Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm
2024-11-13 16:03:06 +01:00
111 changed files with 2176 additions and 1313 deletions
+13 -8
View File
@@ -942,8 +942,9 @@ services:
path: /var/lib/mysql
- commands:
- /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled
-alertmanager.utf8-strict-mode-enabled
environment: {}
image: grafana/mimir-alpine:r304-3872ccb
image: grafana/mimir-alpine:r316-55f47f8
name: mimir_backend
- environment: {}
image: redis:6.2.11-alpine
@@ -1383,8 +1384,9 @@ services:
path: /var/lib/mysql
- commands:
- /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled
-alertmanager.utf8-strict-mode-enabled
environment: {}
image: grafana/mimir-alpine:r304-3872ccb
image: grafana/mimir-alpine:r316-55f47f8
name: mimir_backend
- environment: {}
image: redis:6.2.11-alpine
@@ -2455,8 +2457,9 @@ services:
path: /var/lib/mysql
- commands:
- /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled
-alertmanager.utf8-strict-mode-enabled
environment: {}
image: grafana/mimir-alpine:r304-3872ccb
image: grafana/mimir-alpine:r316-55f47f8
name: mimir_backend
- environment: {}
image: redis:6.2.11-alpine
@@ -3039,8 +3042,9 @@ services:
path: /var/lib/mysql
- commands:
- /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled
-alertmanager.utf8-strict-mode-enabled
environment: {}
image: grafana/mimir-alpine:r304-3872ccb
image: grafana/mimir-alpine:r316-55f47f8
name: mimir_backend
- environment: {}
image: redis:6.2.11-alpine
@@ -4978,8 +4982,9 @@ services:
path: /var/lib/mysql
- commands:
- /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled
-alertmanager.utf8-strict-mode-enabled
environment: {}
image: grafana/mimir-alpine:r304-3872ccb
image: grafana/mimir-alpine:r316-55f47f8
name: mimir_backend
- environment: {}
image: redis:6.2.11-alpine
@@ -5451,7 +5456,7 @@ steps:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM plugins/slack
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM python:3.8
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM postgres:12.3-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/mimir-alpine:r304-3872ccb
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/mimir-alpine:r316-55f47f8
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:5.7.39
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:8.0.32
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM redis:6.2.11-alpine
@@ -5489,7 +5494,7 @@ steps:
- trivy --exit-code 1 --severity HIGH,CRITICAL plugins/slack
- trivy --exit-code 1 --severity HIGH,CRITICAL python:3.8
- trivy --exit-code 1 --severity HIGH,CRITICAL postgres:12.3-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/mimir-alpine:r304-3872ccb
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/mimir-alpine:r316-55f47f8
- trivy --exit-code 1 --severity HIGH,CRITICAL mysql:5.7.39
- trivy --exit-code 1 --severity HIGH,CRITICAL mysql:8.0.32
- trivy --exit-code 1 --severity HIGH,CRITICAL redis:6.2.11-alpine
@@ -5735,6 +5740,6 @@ kind: secret
name: gcr_credentials
---
kind: signature
hmac: 250d3c2ae9ddf7cbfd2a9fed01fe81312a882ba09b8f8725f4704629da00a774
hmac: bb28bcd274c9d2ce724db12659ed6e3dcc461f8a07ae0eb9385a64ca5daad4de
...
@@ -1,63 +0,0 @@
diff --git a/cjs/history.js b/cjs/history.js
index fcd8ebab613c6d87b9ac824feb30ab1080cf0ef2..4df20d5cb2f9ba5fc8777899aada53f49399560b 100644
--- a/cjs/history.js
+++ b/cjs/history.js
@@ -103,16 +103,6 @@ function createLocation(path, state, key, currentLocation) {
if (state !== undefined && location.state === undefined) location.state = state;
}
- try {
- location.pathname = decodeURI(location.pathname);
- } catch (e) {
- if (e instanceof URIError) {
- throw new URIError('Pathname "' + location.pathname + '" could not be decoded. ' + 'This is likely caused by an invalid percent-encoding.');
- } else {
- throw e;
- }
- }
-
if (key) location.key = key;
if (currentLocation) {
diff --git a/esm/history.js b/esm/history.js
index df67820fe3eed558c44fca07a82b0cd409d46720..e0e0d4f69a407e8de782b3fdf8297d42708e110a 100644
--- a/esm/history.js
+++ b/esm/history.js
@@ -80,16 +80,6 @@ function createLocation(path, state, key, currentLocation) {
if (state !== undefined && location.state === undefined) location.state = state;
}
- try {
- location.pathname = decodeURI(location.pathname);
- } catch (e) {
- if (e instanceof URIError) {
- throw new URIError('Pathname "' + location.pathname + '" could not be decoded. ' + 'This is likely caused by an invalid percent-encoding.');
- } else {
- throw e;
- }
- }
-
if (key) location.key = key;
if (currentLocation) {
diff --git a/umd/history.js b/umd/history.js
index 80e4ff66c44a2a71d4f842cc05a252e48dd18e9a..f8f4901be95e48c66f5626fbf051747a2ffbe41d 100644
--- a/umd/history.js
+++ b/umd/history.js
@@ -207,16 +207,6 @@
if (state !== undefined && location.state === undefined) location.state = state;
}
- try {
- location.pathname = decodeURI(location.pathname);
- } catch (e) {
- if (e instanceof URIError) {
- throw new URIError('Pathname "' + location.pathname + '" could not be decoded. ' + 'This is likely caused by an invalid percent-encoding.');
- } else {
- throw e;
- }
- }
-
if (key) location.key = key;
if (currentLocation) {
+16
View File
@@ -1,3 +1,19 @@
<!-- 11.3.0+security-01 START -->
# 11.3.0+security-01 (2024-11-12)
### Bug fixes
- **MigrationAssistant:** Fix Migration Assistant issue [CVE-2024-9476]
<!-- 11.3.0+security-01 END -->
<!-- 11.2.3+security-01 START -->
# 11.2.3+security-01 (2024-11-12)
- **MigrationAssistant:** Fix Migration Assistant issue [CVE-2024-9476]
<!-- 11.2.3+security-01 END -->
<!-- 10.4.12 START -->
# 10.4.12 (2024-11-08)
@@ -1,9 +1,10 @@
mimir_backend:
image: grafana/mimir-alpine:r304-3872ccb
image: grafana/mimir-alpine:r316-55f47f8
container_name: mimir_backend
command:
- -target=backend
- -alertmanager.grafana-alertmanager-compatibility-enabled
- -alertmanager.utf8-strict-mode-enabled
nginx:
environment:
- NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx
+1 -3
View File
@@ -428,9 +428,7 @@ SELECT hostname FROM my_host WHERE hostname LIKE '$__searchFilter'
### Using Variables in Queries
From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically. If your template variables are strings, do not wrap them in quotes in where clauses.
From Grafana 4.7.0, template variable values are only quoted when the template variable is a `multi-value`.
Template variable values are only quoted when the template variable is a `multi-value`.
If the variable is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values.
@@ -43,7 +43,7 @@ With all of these dynamic elements, there's almost no limit to what a canvas can
We'd love your feedback on the canvas visualization. Please check out the [open Github issues](https://github.com/grafana/grafana/issues?page=1&q=is%3Aopen+is%3Aissue+label%3Aarea%2Fpanel%2Fcanvas) and [submit a new feature request](https://github.com/grafana/grafana/issues/new?assignees=&labels=type%2Ffeature-request,area%2Fpanel%2Fcanvas&title=Canvas:&projects=grafana-dataviz&template=1-feature_requests.md) as needed.
{{< /admonition >}}
## Configure a bar gauge visualization
## Configure a canvas visualization
The following video shows you how to create and configure a canvas visualization:
@@ -222,6 +222,7 @@ Experimental features might be changed or removed without prior notice.
| `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode |
| `zipkinBackendMigration` | Enables querying Zipkin data source without the proxy |
| `enableSCIM` | Enables SCIM support for user and group management |
| `crashDetection` | Enables browser crash detection reporting to Faro. |
## Development feature toggles
@@ -17,12 +17,14 @@ weight: 200
Grafana provides many ways to authenticate users. Some authentication integrations also enable syncing user permissions and org memberships.
The following table shows all supported authentication providers and the features available for them. [Team sync]({{< relref "../configure-team-sync" >}}) and [active sync]({{< relref "./enhanced-ldap#active-ldap-synchronization" >}}) are only available in Grafana Enterprise.
The following table shows all supported authentication methods and the features available for them. [Team sync]({{< relref "../configure-team-sync" >}}) and [active sync]({{< relref "./enhanced-ldap#active-ldap-synchronization" >}}) are only available in Grafana Enterprise.
| Provider | Multi Org Mapping | Enforce Sync | Role Mapping | Grafana Admin Mapping | Team Sync | Allowed groups | Active Sync | Skip OrgRole mapping | Auto Login | Single Logout |
| Authentication method | Multi Org Mapping | Enforce Sync | Role Mapping | Grafana Admin Mapping | Team Sync | Allowed groups | Active Sync | Skip OrgRole mapping | Auto Login | Single Logout |
| :---------------------------------------------------- | :---------------- | :----------- | :----------- | :-------------------- | :-------- | :------------- | :---------- | :------------------- | :--------- | :------------ |
| [Anonymous access]({{< relref "./anonymous-auth" >}}) | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| [Auth Proxy]({{< relref "./auth-proxy" >}}) | no | yes | yes | no | yes | no | N/A | no | N/A | N/A |
| [Azure AD OAuth]({{< relref "./azuread" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
| [Basic auth]({{< relref "./grafana" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A |
| [Generic OAuth]({{< relref "./generic-oauth" >}}) | yes | yes | yes | yes | yes | no | N/A | yes | yes | yes |
| [GitHub OAuth]({{< relref "./github" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
| [GitLab OAuth]({{< relref "./gitlab" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
@@ -33,17 +35,9 @@ The following table shows all supported authentication providers and the feature
| [LDAP]({{< relref "./ldap" >}}) | yes | yes | yes | yes | yes | yes | yes | no | N/A | N/A |
| [JWT Proxy]({{< relref "./jwt" >}}) | no | yes | yes | yes | no | no | N/A | no | N/A | N/A |
N/A = Not applicable
Fields explanation:
## Auth Proxy
| Feature | Supported? |
| :---------------- | :--------- |
| Multi Org Mapping | no |
| Enforce Sync | N/A |
| Role Mapping | yes |
**Multi Org Mapping:** Able to add a user and role map him to multiple orgs
**Multi Org Mapping:** Able to add a user and map roles to multiple organizations
**Enforce Sync:** If the information provided by the identity provider is empty, does the integration skip setting that user’s field or does it enforce a default.
@@ -82,15 +76,9 @@ If users want to use the same email address with multiple identity providers (fo
To enable this option, refer to the [Enable email lookup](#enable-email-lookup) section.
## Grafana Auth
## Login and short-lived tokens
Grafana of course has a built in user authentication system with password authentication enabled by default. You can
disable authentication by enabling anonymous access. You can also hide the login form and only allow login through an auth
provider (listed above). There are also options for allowing self sign up.
### Login and short-lived tokens
> The following applies when using Grafana's built in user authentication, LDAP (without Auth proxy) or OAuth integration.
> The following applies when using Grafana's basic authentication, LDAP (without Auth proxy) or OAuth integration.
Grafana uses short-lived tokens as a mechanism for verifying authenticated users.
These short-lived tokens are rotated on an interval specified by `token_rotation_interval_minutes` for active authenticated users.
@@ -99,11 +87,6 @@ Inactive authenticated users will remain logged in for a duration specified by `
This means that a user can close a Grafana window and return before `now + login_maximum_inactive_lifetime_duration` to continue their session.
This is true as long as the time since last user login is less than `login_maximum_lifetime_duration`.
#### Remote logout
You can logout from other devices by removing login sessions from the bottom of your profile page. If you are
a Grafana admin user, you can also do the same for any user from the Server Admin / Edit User view.
## Settings
Example:
@@ -130,50 +113,7 @@ api_key_max_seconds_to_live = -1
oauth_allow_insecure_email_lookup = false
```
### Anonymous authentication
You can make Grafana accessible without any login required by enabling anonymous access in the configuration file.
Example:
```bash
[auth.anonymous]
enabled = true
{{< admonition type="note" >}}
Enabling anonymous access is a disallowed configuration setting on Hosted Grafana and not recommended due [security implications](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/#implications-of-enabling-anonymous-access-to-dashboards).
For sharing dashboards with a wider audience, consider using the [public dashboard feature](https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/) instead.
{{< /admonition >}}
# Organization name that should be used for unauthenticated users
org_name = Main Org.
# Role for unauthenticated users, other valid values are `Editor` and `Admin`
org_role = Viewer
```
If you change your organization name in the Grafana UI, this setting needs to be updated to match the new name.
### Basic authentication
Basic auth is enabled by default and works with the built-in Grafana user-password authentication system and LDAP
authentication integration.
To disable basic auth:
```bash
[auth.basic]
enabled = false
```
### Disable login form
Hide the Grafana login form using the below configuration settings.
```bash
[auth]
disable_login_form = true
```
## Extended authentication settings
### Enable email lookup
@@ -244,6 +184,11 @@ Example for Generic OAuth:
signout_redirect_url =
```
### Remote logout
You can log out from other devices by removing login sessions from the bottom of your profile page. If you are
a Grafana admin user, you can also do the same for any user from the Server Admin / Edit User view.
### Protected roles
{{% admonition type="note" %}}
@@ -0,0 +1,61 @@
---
aliases:
- ../../../auth/anonymous-auth/
description: Learn how to configure anonymous access in Grafana
labels:
products:
- enterprise
- oss
menuTitle: Anonymous access
title: Configure anonymous access
weight: 250
---
# Anonymous authentication
You can make Grafana accessible without any login required by enabling anonymous access in the configuration file.
{{< admonition type="note" >}}
Anonymous users are charged as active users in Grafana Enterprise
{{< /admonition >}}
## Anonymous devices
The anonymous devices feature enhances the management and monitoring of anonymous access within your Grafana instance. This feature is part of ongoing efforts to provide more control and transparency over anonymous usage.
Users can now view anonymous usage statistics, including the count of devices and users over the last 30 days.
- Go to **Administration -> Users** to access the anonymous devices tab.
- A new stat for the usage stats page -> Usage & Stats page shows the active anonymous devices last 30 days.
The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access.
To display anonymous users and devices for versions 10.2, 10.3, 10.4, you need to enable the feature toggle `displayAnonymousStats`
```bash
[feature_toggles]
enable = displayAnonymousStats
```
## Configuration
Example:
```bash
[auth.anonymous]
enabled = true
# Organization name that should be used for unauthenticated users
org_name = Main Org.
# Role for unauthenticated users, other valid values are `Editor` and `Admin`
org_role = Viewer
# Hide the Grafana version text from the footer and help tooltip for unauthenticated users (default: false)
hide_version = true
# Setting this limits the number of anonymous devices in your instance. Any new anonymous devices added after the limit has been reached will be denied access.
device_limit =
```
If you change your organization name in the Grafana UI this setting needs to be updated to match the new name.
@@ -461,7 +461,7 @@ The following table outlines the various Azure AD/Entra ID configuration options
| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | |
| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` |
| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `openid email profile` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Azure AD/Entra ID login. Only existing Grafana users can log in with generic OAuth if set to `false`. | `true` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Azure AD/Entra ID login. Only existing Grafana users can log in with Azure AD/Entra ID if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Map roles]({{< relref "#map-roles" >}}). | `false` |
| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
@@ -470,7 +470,7 @@ The following table outlines the various Azure AD/Entra ID configuration options
| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` |
| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure Azure AD/Entra ID to include the `groups` claim following [Configure group membership claims on the Azure Portal]({{< relref "#configure-group-membership-claims-on-the-azure-portal" >}}). | |
| `allowed_organizations` | No | Yes | List of comma- or space-separated Azure tenant identifiers. The user should be a member of at least one tenant to log in. | |
| `allowed_domains` | No | Yes | List comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` |
| `tls_client_cert` | No | No | The path to the certificate. | |
| `tls_client_key` | No | No | The path to the key. | |
@@ -382,7 +382,7 @@ The following table outlines the various generic OAuth2 configuration options. Y
| `auth_style` | No | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` |
| `scopes` | No | List of comma- or space-separated OAuth2 scopes. | `user:email` |
| `empty_scopes` | No | Set to `true` to use an empty scope during authentication. | `false` |
| `allow_sign_up` | No | Controls Grafana user creation through the generic OAuth2 login. Only existing Grafana users can log in with generic OAuth if set to `false`. | `true` |
| `allow_sign_up` | No | Controls Grafana user creation through the generic OAuth2 login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` |
| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `id_token_attribute_name` | No | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` |
| `login_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login]({{< relref "#configure-login" >}}). | |
@@ -398,7 +398,7 @@ The following table outlines the various generic OAuth2 configuration options. Y
| `groups_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | |
| `allowed_groups` | No | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | |
| `allowed_organizations` | No | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | |
| `allowed_domains` | No | List comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `allowed_domains` | No | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `team_ids` | No | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | |
| `team_ids_attribute_path` | No | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | |
| `teams_url` | No | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | |
@@ -7,14 +7,14 @@ labels:
- cloud
- enterprise
- oss
menuTitle: Google OAuth2
title: Configure Google OAuth2 authentication
menuTitle: Google OAuth
title: Configure Google OAuth authentication
weight: 1100
---
# Configure Google OAuth2 authentication
# Configure Google OAuth authentication
To enable Google OAuth2 you must register your application with Google. Google will generate a client ID and secret key for you to use.
To enable Google OAuth you must register your application with Google. Google will generate a client ID and secret key for you to use.
{{% admonition type="note" %}}
If Users use the same email address in Google that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information.
@@ -25,13 +25,21 @@ If Users use the same email address in Google that they use with other authentic
First, you need to create a Google OAuth Client:
1. Go to https://console.developers.google.com/apis/credentials.
1. Create a new project if you don't have one already.
1. Enter a project name. The **Organization** and **Location** fields should both be set to your organization's information.
1. In **OAuth consent screen** select the **External** User Type. Click **CREATE**.
1. Fill out the requested information using the URL of your Grafana Cloud instance.
1. Accept the defaults, or customize the consent screen options.
1. Click **Create Credentials**, then click **OAuth Client ID** in the drop-down menu
1. Enter the following:
- Application Type: Web Application
- Name: Grafana
- Authorized JavaScript Origins: https://grafana.mycompany.com
- Authorized Redirect URLs: https://grafana.mycompany.com/login/google
- Replace https://grafana.mycompany.com with the URL of your Grafana instance.
- **Application Type**: Web application
- **Name**: Grafana
- **Authorized JavaScript origins**: `https://<YOUR_GRAFANA_URL>`
- **Authorized redirect URIs**: `https://<YOUR_GRAFANA_URL>/login/google`
- Replace `<YOUR_GRAFANA_URL>` with the URL of your Grafana instance.
{{< admonition type="note" >}}
The URL you enter is the one for your Grafana instance home page, not your Grafana Cloud portal URL.
{{< /admonition >}}
1. Click Create
1. Copy the Client ID and Client Secret from the 'OAuth Client' modal
@@ -41,7 +49,7 @@ First, you need to create a Google OAuth Client:
Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle.
{{% /admonition %}}
As a Grafana Admin, you can configure Google OAuth2 client from within Grafana using the Google UI. To do this, navigate to **Administration > Authentication > Google** page and fill in the form. If you have a current configuration in the Grafana configuration file then the form will be pre-populated with those values otherwise the form will contain default values.
As a Grafana Admin, you can configure Google OAuth client from within Grafana using the Google UI. To do this, navigate to **Administration > Authentication > Google** page and fill in the form. If you have a current configuration in the Grafana configuration file then the form will be pre-populated with those values otherwise the form will contain default values.
After you have filled in the form, click **Save**. If the save was successful, Grafana will apply the new configurations.
@@ -130,7 +138,9 @@ introduces "proof key for code exchange" (PKCE) which provides
additional protection against some forms of authorization code
interception attacks. PKCE will be required in [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-03).
> You can disable PKCE in Grafana by setting `use_pkce` to `false` in the`[auth.google]` section.
{{% admonition type="note" %}}
You can disable PKCE in Grafana by setting `use_pkce` to `false` in the`[auth.google]` section.
{{% /admonition %}}
#### Configure refresh token
@@ -183,7 +193,9 @@ to a comma or space separated list of groups.
Google groups are referenced by the group email key. For example, `developers@google.com`.
> Note: Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` scopes configuration to retrieve groups
{{% admonition type="note" %}}
Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` scopes configuration to retrieve groups.
{{% /admonition %}}
#### Configure role mapping
@@ -196,8 +208,9 @@ If no valid role is found, the user is assigned the role specified by [the `auto
You can disable this default role assignment by setting `role_attribute_strict = true`. This setting denies user access if no role or an invalid role is returned after evaluating the `role_attribute_path` and the `org_mapping` expressions.
To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads.
> By default skip_org_role_sync is enabled. skip_org_role_sync will default to false in Grafana v10.3.0 and later versions.
{{% admonition type="note" %}}
By default skip_org_role_sync is enabled. skip_org_role_sync will default to false in Grafana v10.3.0 and later versions.
{{% /admonition %}}
##### Role mapping examples
@@ -237,7 +250,9 @@ role_attribute_path = contains(groups[*], 'example-group@google.com') && 'Editor
skip_org_role_sync = false
```
> Note: Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` scopes configuration to retrieve groups
{{% admonition type="note" %}}
Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` scopes configuration to retrieve groups.
{{% /admonition %}}
###### Map server administrator role
@@ -258,3 +273,39 @@ In this example, all users will be assigned `Viewer` role regardless of the user
role_attribute_path = "'Viewer'"
skip_org_role_sync = false
```
## Configuration options
The following table outlines the various Google OAuth configuration options. You can apply these options as environment variables, similar to any other configuration within Grafana. For more information, refer to [Override configuration with environment variables]({{< relref "../../../configure-grafana#override-configuration-with-environment-variables" >}}).
| Setting | Required | Supported on Cloud | Description | Default |
| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `enabled` | No | Yes | Enables Google authentication. | `false` |
| `name` | No | Yes | Name that refers to the Google authentication from the Grafana user interface. | `Google` |
| `icon` | No | Yes | Icon used for the Google authentication in the Grafana user interface. | `google` |
| `client_id` | Yes | Yes | Client ID of the App. | |
| `client_secret` | Yes | Yes | Client secret of the App. | |
| `auth_url` | Yes | Yes | Authorization endpoint of the Google OAuth provider. | `https://accounts.google.com/o/oauth2/v2/auth` |
| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | `https://oauth2.googleapis.com/token` |
| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | `https://openidconnect.googleapis.com/v1/userinfo` |
| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` |
| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `openid email profile` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Google login. Only existing Grafana users can log in with Google if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `hosted_domain` | No | Yes | Specifies the domain to restrict access to users from that domain. This value is appended to the authorization request using the `hd` parameter. | |
| `validate_hd` | No | Yes | Set to `false` to disable the validation of the `hd` parameter from the Google ID token. For more informatiion, refer to [Enable Google OAuth in Grafana]({{< relref "#enable-google-oauth-in-grafana" >}}). | `true` |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` |
| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `org_mapping` | No | No | List of comma- or space-separated `<ExternalOrgName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `allow_assign_grafana_admin` | No | No | Set to `true` to automatically sync the Grafana server administrator role. When enabled, if the Google user's App role is `GrafanaAdmin`, Grafana grants the user server administrator privileges and the organization administrator role. If disabled, the user will only receive the organization administrator role. For more details on user role mapping, refer to [Map roles]({{< relref "#map-roles" >}}). | `false` |
| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` |
| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure Google to include the `groups` claim following [Configure allowed groups]({{< relref "#configure-allowed-groups" >}}). | |
| `allowed_organizations` | No | Yes | List of comma- or space-separated Azure tenant identifiers. The user should be a member of at least one tenant to log in. | |
| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` |
| `tls_client_cert` | No | No | The path to the certificate. | |
| `tls_client_key` | No | No | The path to the key. | |
| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | |
| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` |
| `use_refresh_token` | No | Yes | Enables the use of refresh tokens and checks for access token expiration. When enabled, Grafana automatically adds the `promp=consent` and `access_type=offline` parameters to the authorization request. | `true` |
| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | |
@@ -1,127 +1,32 @@
---
aliases:
- ../../../auth/grafana/
description: Grafana OAuthentication Guide
description: Learn how to configure basic authentication in Grafana
labels:
products:
- enterprise
- oss
menuTitle: Basic auth
title: Configure Grafana authentication
title: Configure basic authentication
weight: 200
---
## Configure Grafana authentication
# Configure basic authentication
Grafana of course has a built in user authentication system with password authentication enabled by default. You can
disable authentication by enabling anonymous access. You can also hide login form and only allow login through an auth
provider (listed above). There is also options for allowing self sign up.
Grafana provides a basic authentication system with password authentication enabled by default. This document details configuration options to manage and enhance basic authentication.
### Login and short-lived tokens
## Disable basic authentication
> The following applies when using Grafana's built in user authentication, LDAP (without Auth proxy) or OAuth integration.
Grafana uses short-lived tokens as a mechanism for verifying authenticated users.
These short-lived tokens are rotated each `token_rotation_interval_minutes` for an active authenticated user.
An active authenticated user that gets it token rotated will extend the `login_maximum_inactive_lifetime_duration` time from "now" that Grafana will remember the user.
This means that a user can close its browser and come back before `now + login_maximum_inactive_lifetime_duration` and still being authenticated.
This is true as long as the time since user login is less than `login_maximum_lifetime_duration`.
#### Remote logout
You can logout from other devices by removing login sessions from the bottom of your profile page. If you are
a Grafana admin user you can also do the same for any user from the Server Admin / Edit User view.
## Settings
Example:
```bash
[auth]
# Login cookie name
login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
login_maximum_inactive_lifetime_duration = 7d
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
login_maximum_lifetime_duration = 30d
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
token_rotation_interval_minutes = 10
# The maximum lifetime (seconds) an api key can be used. If it is set all the api keys should have limited lifetime that is lower than this value.
api_key_max_seconds_to_live = -1
```
### Anonymous authentication
You can make Grafana accessible without any login required by enabling anonymous access in the configuration file. For more information, refer to [Anonymous authentication]({{< relref "../../configure-authentication#anonymous-authentication" >}}).
#### Anonymous devices
The anonymous devices feature enhances the management and monitoring of anonymous access within your Grafana instance. This feature is part of ongoing efforts to provide more control and transparency over anonymous usage.
Users can now view anonymous usage statistics, including the count of devices and users over the last 30 days.
- Go to **Administration -> Users** to access the anonymous devices tab.
- A new stat for the usage stats page -> Usage & Stats page shows the active anonymous devices last 30 days.
The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access.
To display anonymous users and devices for versions 10.2, 10.3, 10.4, you need to enable the feature toggle `displayAnonymousStats`
```bash
[feature_toggles]
enable = displayAnonymousStats
```
#### Anonymous users
{{< admonition type="note" >}}
Anonymous users are charged as active users in Grafana Enterprise
{{< /admonition >}}
#### Configuration
Example:
```bash
[auth.anonymous]
enabled = true
# Organization name that should be used for unauthenticated users
org_name = Main Org.
# Role for unauthenticated users, other valid values are `Editor` and `Admin`
org_role = Viewer
# Hide the Grafana version text from the footer and help tooltip for unauthenticated users (default: false)
hide_version = true
# Setting this limits the number of anonymous devices in your instance. Any new anonymous devices added after the limit has been reached will be denied access.
device_limit =
```
If you change your organization name in the Grafana UI this setting needs to be updated to match the new name.
### Basic authentication
Basic auth is enabled by default and works with the built in Grafana user password authentication system and LDAP
authentication integration.
To disable basic auth:
To disable basic authentication, use the following configuration:
```bash
[auth.basic]
enabled = false
```
### Strong password policy
## Password policy
By default, the password policy for all basic auth users is set to a minimum of four characters. You can enable a stronger password policy with the `password_policy` configuration option.
By default, Grafana’s password policy requires a minimum of four characters for basic auth users. For a stronger password policy, enable the `password_policy` configuration option.
With the `password_policy` option enabled, new and updated passwords must meet the following criteria:
@@ -137,63 +42,16 @@ password_policy = true
```
{{% admonition type="note" %}}
Existing passwords that don't comply with the new password policy will not be impacted until the user updates their password.
Existing passwords that do not comply with the new password policy will not be affected until the user updates their password.
{{% /admonition %}}
### Disable login form
## Disable login form
You can hide the Grafana login form using the below configuration settings.
To hide the Grafana login form, use the following configuration setting:
```bash
[auth]
disable_login_form = true
```
### Automatic OAuth login
Set to true to attempt login with specific OAuth provider automatically, skipping the login screen.
This setting is ignored if multiple auth providers are configured to use auto login.
Defaults to `false`.
```bash
[auth.generic_oauth]
auto_login = true
```
### Hide sign-out menu
Set the option detailed below to true to hide sign-out menu link. Useful if you use an auth proxy or JWT authentication.
```bash
[auth]
disable_signout_menu = true
```
### URL redirect after signing out
The URL to redirect the user to after signing out from Grafana can be configured under `[auth]` or under a specific OAuth provider section (for example, `[auth.generic_oauth]`). The URL configured under a specific OAuth provider section takes precedence over the URL configured in `[auth]` section. This can, for example, enable signout from the OAuth provider.
```bash
[auth.generic_oauth]
signout_redirect_url =
[auth]
signout_redirect_url =
```
### Protected roles
{{% admonition type="note" %}}
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud]({{< relref "../../../../introduction/grafana-cloud" >}}).
{{% /admonition %}}
By default, after you configure an authorization provider, Grafana will adopt existing users into the new authentication scheme. For example, if you have created a user with basic authentication having the login `jsmith@example.com`, then set up SAML authentication where `jsmith@example.com` is an account, the user's authentication type will be changed to SAML if they perform a SAML sign-in.
You can disable this user adoption for certain roles using the `protected_roles` property:
```bash
[auth.security]
protected_roles = server_admins org_admins
```
The value of `protected_roles` should be a list of roles to protect, separated by spaces. Valid roles are `viewers`, `editors`, `org_admins`, `server_admins`, and `all` (a superset of the other roles).
This can be helpful in setups where authentication is handled entirely through external mechanisms or single sign-on (SSO).
@@ -281,6 +281,6 @@ The following table outlines the various Okta OIDC configuration options. You ca
| `org_mapping` | No | List of comma- or space-separated `<ExternalOrgName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` |
| `allowed_groups` | No | List of comma- or space-separated groups. The user should be a member of at least one group to log in. | |
| `allowed_domains` | No | List comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `allowed_domains` | No | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` |
| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. | `false` |
@@ -284,68 +284,31 @@ Grafana supports user authentication through Okta, which is useful when you want
1. Log in to the [Okta portal](https://login.okta.com/).
1. Go to the Admin Console in your Okta organization by clicking **Admin** in the upper-right corner. If you are in the Developer Console, then click **Developer Console** in the upper-left corner and then click **Classic UI** to switch over to the Admin Console.
1. In the Admin Console, navigate to **Applications** > **Applications**.
1. Click **Add Application**.
1. Click **Create New App** to start the Application Integration Wizard.
1. Choose **Web** as a platform.
1. Select **SAML 2.0** in the Sign on method section.
1. Click **Create App Integration** to start the Application Integration Wizard.
1. Choose **SAML 2.0** as the **Sign-in method**.
1. Click **Create**.
1. On the **General Settings** tab, enter a name for your Grafana integration. You can also upload a logo.
1. On the **Configure SAML** tab, enter the SAML information related to your Grafana instance:
- In the **Single sign on URL** field, use the `/saml/acs` endpoint URL of your Grafana instance, for example, `https://grafana.example.com/saml/acs`.
- In the **Audience URI (SP Entity ID)** field, use the `/saml/metadata` endpoint URL, for example, `https://grafana.example.com/saml/metadata`.
- In the **Audience URI (SP Entity ID)** field, use the `/saml/metadata` endpoint URL, by default it is the `/saml/metadata` endpoint of your Grafana instance (for example `https://example.grafana.com/saml/metadata`). This could be configured differently, but the value here must match the `entity_id` setting of the SAML settings of Grafana.
- Leave the default values for **Name ID format** and **Application username**.
{{% admonition type="note" %}}
If you plan to enable SAML Single Logout, consider setting the **Name ID format** to `EmailAddress` or `Persistent`. This must match the `name_id_format` setting of the Grafana instance.
{{% /admonition %}}
- In the **ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter the SAML attributes to be shared with Grafana. The attribute names in Okta need to match exactly what is defined within Grafana, for example:
| Attribute name (in Grafana) | Name and value (in Okta profile) |
| --------------------------- | -------------------------------------------------- |
| Login | Login `user.login` |
| Email | Email `user.email` |
| DisplayName | DisplayName `user.firstName + " " + user.lastName` |
| Attribute name (in Grafana) | Name and value (in Okta profile) | Grafana configuration (under `auth.saml`) |
| --------------------------- | ---------------------------------------------------- | ----------------------------------------- |
| Login | Login - `user.login` | `assertion_attribute_login = Login` |
| Email | Email - `user.email` | `assertion_attribute_email = Email` |
| DisplayName | DisplayName - `user.firstName + " " + user.lastName` | `assertion_attribute_name = DisplayName` |
- In the **GROUP ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter a group attribute name (for example, `Group`) and set filter to `Matches regex .*` to return all user groups.
- In the **GROUP ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter a group attribute name (for example, `Group`, ensure it matches the `asssertion_attribute_groups` setting in Grafana) and set filter to `Matches regex .*` to return all user groups.
1. Click **Next**.
1. On the final Feedback tab, fill out the form and then click **Finish**.
## Configure SAML authentication in Grafana
The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../../../configure-grafana#override-configuration-with-environment-variables" >}}).
| Setting | Required | Description | Default |
| ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| `enabled` | No | Whether SAML authentication is allowed | `false` |
| `name` | No | Name used to refer to the SAML authentication in the Grafana user interface. | `SAML` |
| `single_logout` | No | Whether SAML Single Logout enabled | `false` |
| `allow_sign_up` | No | Whether to allow new Grafana user creation through SAML login. If set to `false`, then only existing Grafana users can log in with SAML. | `true` |
| `auto_login` | No | Whether SAML auto login is enabled | `false` |
| `allow_idp_initiated` | No | Whether SAML IdP-initiated login is allowed | `false` |
| `certificate` or `certificate_path` | Yes | Base64-encoded string or Path for the SP X.509 certificate | |
| `private_key` or `private_key_path` | Yes | Base64-encoded string or Path for the SP private key | |
| `signature_algorithm` | No | Signature algorithm used for signing requests to the IdP. Supported values are rsa-sha1, rsa-sha256, rsa-sha512. | |
| `idp_metadata`, `idp_metadata_path`, or `idp_metadata_url` | Yes | Base64-encoded string, Path or URL for the IdP SAML metadata XML | |
| `max_issue_delay` | No | Duration, since the IdP issued a response and the SP is allowed to process it | `90s` |
| `metadata_valid_duration` | No | Duration, for how long the SP metadata is valid | `48h` |
| `relay_state` | No | Relay state for IdP-initiated login. Should match relay state configured in IdP | |
| `assertion_attribute_name` | No | Friendly name or name of the attribute within the SAML assertion to use as the user name. Alternatively, this can be a template with variables that match the names of attributes within the SAML assertion. | `displayName` |
| `assertion_attribute_login` | No | Friendly name or name of the attribute within the SAML assertion to use as the user login handle | `mail` |
| `assertion_attribute_email` | No | Friendly name or name of the attribute within the SAML assertion to use as the user email | `mail` |
| `assertion_attribute_groups` | No | Friendly name or name of the attribute within the SAML assertion to use as the user groups | |
| `assertion_attribute_role` | No | Friendly name or name of the attribute within the SAML assertion to use as the user roles | |
| `assertion_attribute_org` | No | Friendly name or name of the attribute within the SAML assertion to use as the user organization | |
| `allowed_organizations` | No | List of comma- or space-separated organizations. User should be a member of at least one organization to log in. | |
| `org_mapping` | No | List of comma- or space-separated Organization:OrgId:Role mappings. Organization can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. | |
| `role_values_none` | No | List of comma- or space-separated roles which will be mapped into the None role | |
| `role_values_viewer` | No | List of comma- or space-separated roles which will be mapped into the Viewer role | |
| `role_values_editor` | No | List of comma- or space-separated roles which will be mapped into the Editor role | |
| `role_values_admin` | No | List of comma- or space-separated roles which will be mapped into the Admin role | |
| `role_values_grafana_admin` | No | List of comma- or space-separated roles which will be mapped into the Grafana Admin (Super Admin) role | |
| `name_id_format` | No | The Name ID Format to request within the SAML assertion | `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` |
| `client_id` | No | Client ID of the IdP service application used to retrieve more information about the user from the IdP. | |
| `client_secret` | No | Client secret of the IdP service application used to retrieve more information about the user from the IdP. | |
| `token_url` | No | URL to retrieve the access token from the IdP. | |
| `force_use_graph_api` | No | Whether to use the IdP service application retrieve more information about the user from the IdP. | `false` |
### Signature algorithm
The SAML standard recommends using a digital signature for some types of messages, like authentication or logout requests. If the `signature_algorithm` option is configured, Grafana will put a digital signature into SAML requests. Supported signature types are `rsa-sha1`, `rsa-sha256`, `rsa-sha512`. This option should match your IdP configuration, otherwise, signature validation will fail. Grafana uses key and certificate configured with `private_key` and `certificate` options for signing SAML requests.
@@ -358,10 +321,13 @@ By default, this is set to `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`
The following list includes valid configuration field values:
- `urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified`
- `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`
- `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`
- `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent`
| `name_id_format` value in the configuration file or Terraform | `Name identifier format` on the UI |
| ------------------------------------------------------------- | ---------------------------------- |
| `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` | Default |
| `urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified` | Unspecified |
| `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` | Email address |
| `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent` | Persistent |
| `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` | Transient |
### IdP metadata
@@ -721,3 +687,43 @@ cookie_secure = true
```
Ensure cookie_secure is set to true to ensure that cookies are only sent over HTTPS.
## Configure SAML authentication in Grafana
The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../../../configure-grafana#override-configuration-with-environment-variables" >}}).
| Setting | Required | Description | Default |
| ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| `enabled` | No | Whether SAML authentication is allowed. | `false` |
| `name` | No | Name used to refer to the SAML authentication in the Grafana user interface. | `SAML` |
| `entity_id` | No | The entity ID of the service provider. This is the unique identifier of the service provider. | `https://{Grafana URL}/saml/metadata` |
| `single_logout` | No | Whether SAML Single Logout is enabled. | `false` |
| `allow_sign_up` | No | Whether to allow new Grafana user creation through SAML login. If set to `false`, then only existing Grafana users can log in with SAML. | `true` |
| `auto_login` | No | Whether SAML auto login is enabled. | `false` |
| `allow_idp_initiated` | No | Whether SAML IdP-initiated login is allowed. | `false` |
| `certificate` or `certificate_path` | Yes | Base64-encoded string or Path for the SP X.509 certificate. | |
| `private_key` or `private_key_path` | Yes | Base64-encoded string or Path for the SP private key. | |
| `signature_algorithm` | No | Signature algorithm used for signing requests to the IdP. Supported values are rsa-sha1, rsa-sha256, rsa-sha512. | |
| `idp_metadata`, `idp_metadata_path`, or `idp_metadata_url` | Yes | Base64-encoded string, Path or URL for the IdP SAML metadata XML. | |
| `max_issue_delay` | No | Maximum time allowed between the issuance of an AuthnRequest by the SP and the processing of the Response. | `90s` |
| `metadata_valid_duration` | No | Duration for which the SP metadata remains valid. | `48h` |
| `relay_state` | No | Relay state for IdP-initiated login. This should match the relay state configured in the IdP. | |
| `assertion_attribute_name` | No | Friendly name or name of the attribute within the SAML assertion to use as the user name. Alternatively, this can be a template with variables that match the names of attributes within the SAML assertion. | `displayName` |
| `assertion_attribute_login` | No | Friendly name or name of the attribute within the SAML assertion to use as the user login handle. | `mail` |
| `assertion_attribute_email` | No | Friendly name or name of the attribute within the SAML assertion to use as the user email. | `mail` |
| `assertion_attribute_groups` | No | Friendly name or name of the attribute within the SAML assertion to use as the user groups. | |
| `assertion_attribute_role` | No | Friendly name or name of the attribute within the SAML assertion to use as the user roles. | |
| `assertion_attribute_org` | No | Friendly name or name of the attribute within the SAML assertion to use as the user organization | |
| `allowed_organizations` | No | List of comma- or space-separated organizations. User should be a member of at least one organization to log in. | |
| `org_mapping` | No | List of comma- or space-separated Organization:OrgId:Role mappings. Organization can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. | |
| `role_values_none` | No | List of comma- or space-separated roles which will be mapped into the None role. | |
| `role_values_viewer` | No | List of comma- or space-separated roles which will be mapped into the Viewer role. | |
| `role_values_editor` | No | List of comma- or space-separated roles which will be mapped into the Editor role. | |
| `role_values_admin` | No | List of comma- or space-separated roles which will be mapped into the Admin role. | |
| `role_values_grafana_admin` | No | List of comma- or space-separated roles which will be mapped into the Grafana Admin (Super Admin) role. | |
| `skip_org_role_sync` | No | Whether to skip organization role synchronization. | `false` |
| `name_id_format` | No | Specifies the format of the requested NameID element in the SAML AuthnRequest. | `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` |
| `client_id` | No | Client ID of the IdP service application used to retrieve more information about the user from the IdP. (Microsoft Entra ID only) | |
| `client_secret` | No | Client secret of the IdP service application used to retrieve more information about the user from the IdP. (Microsoft Entra ID only) | |
| `token_url` | No | URL to retrieve the access token from the IdP. (Microsoft Entra ID only) | |
| `force_use_graph_api` | No | Whether to use the IdP service application retrieve more information about the user from the IdP. (Microsoft Entra ID only) | `false` |
@@ -0,0 +1,78 @@
---
description: Learn how to use group attribute sync to synchronize between groups in your authentication provider and Grafana RBAC roles.
labels:
products:
- cloud
- enterprise
title: Configure group attribute sync
weight: 1000
---
# Configure group attribute sync
Group attribute sync allows you to manage user permissions in Grafana based on group membership sourced from the user's identity provider (IdP).
Groups are mapped to [fixed](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control#fixed-roles) and [custom](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control#custom-roles) role-based access control roles in Grafana.
> **Note:** Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{% admonition type="note" %}}
This feature is behind the `groupAttributeSync` feature toggle.
You can enable feature toggles through configuration file or environment variables. See configuration [docs](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#feature_toggles) for details.
{{% /admonition %}}
When a user logs in, Grafana checks the user's external group memberships and the configured group to role mappings to assign the corresponding roles to the user.
If the user's group memberships change or a new mapping is created, the user's role assignments are updated the next time the user logs in.
If a group mapping is removed, the role assignment to users for the group mapping is revoked immediately.
Role mappings are tied to organizations, so you can have different mappings for different organizations.
## Create role mappings for a new group
For information about creating group mappings via the API, refer to [create group mappings reference](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/group_attribute_sync#create-group-mappings).
### Before you begin
Ensure you have permission to create and update group mappings. By default, the organization administrator role is required to create and edit group mappings. For more information about user permissions, refer to [roles and permissions](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions).
### To create mappings between an external group and RBAC roles
1. Sign in to Grafana and click **Administration** in the left-side menu.
1. Click **Users and access**.
1. Click **External group sync**.
1. Click **New**.
1. Insert the group identifier for the group that you want to map.
1. Use the role picker to select the roles that you want to map to the group and click **Update**.
1. Click **Save**.
## Update role mappings for a group
For information about updating group mappings via the API, refer to [update group mappings reference](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/group_attribute_sync#update-group-mappings).
### Before you begin
Ensure you have permission to update group mappings. By default, the organization administrator role is required to edit group mappings. For more information about user permissions, refer to [roles and permissions](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions).
### To update role mappings for an external group
1. Sign in to Grafana and click **Administration** in the left-side menu.
1. Click **Users and access**.
1. Click **External group sync**.
1. Find the group whose mappings you want to update.
1. Click on the role picker corresponding to the group and select the roles that you want to map.
1. Click **Apply**.
## Remove role mappings for a group
For information about deleting group mappings via the API, refer to [delete group mappings reference](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/group_attribute_sync#delete-group-mappings).
### Before you begin
Ensure you have permission to update group mappings. By default, the organization administrator role is required to edit group mappings. For more information about user permissions, refer to [roles and permissions](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions).
### To remove role mappings for an external group
1. Sign in to Grafana and click **Administration** in the left-side menu.
1. Click **Users and access**.
1. Click **External group sync**.
1. Find the group whose mappings you want to remove.
1. Click on the trash bin icon corresponding to the group mappings you want to remove.
@@ -277,3 +277,33 @@ _Available in public preview in all editions of Grafana_
[The SSO settings API](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/sso-settings/) has been updated to include support for LDAP settings. This feature is experimental behind the feature flag `ssoSettingsLDAP`.
You will soon be able to configure LDAP from the UI and Terraform.
### Reduce number of required fields from the SAML form
<!-- #proj-grafana-sso-config -->
_Generally available in Grafana Enterprise and Grafana Cloud Pro and Advanced_
The private key and certificate fields are no longer mandatory in the SAML form. To configure SAML without providing a private key and a certificate you have to opt out from using signed requests.
{{< figure src="/media/docs/grafana/screenshot-grafana-11-2-saml-sign-requests.png" alt="Sign requests in SAML config form" >}}
### Generate SAML certificate and private key
<!-- #proj-grafana-sso-config -->
_Generally available in Grafana Enterprise and Grafana Cloud Pro_
You can generate a new certificate and private key for SAML directly from the UI form. Click on the **Generate key and certificate** button from the **Sign requests** tab in the SAML form and then fill in the information you want to be embedded in your generated certificate.
{{< video-embed src="/media/docs/grafana/screen-recording-11-2-generate-saml-certificate.mp4" >}}
### OpenID Connect Discovery URL for Generic OAuth
<!-- #proj-grafana-sso-config -->
_Generally available in all editions of Grafana_
The OpenID Connect Discovery URL is available in the Generic OAuth form. The info extracted from this URL will be used to populate the Auth URL, Token URL and API URL fields.
{{< video-embed src="/media/docs/grafana/screen-recording-11-2-openid-discovery-url.mp4" >}}
+14 -15
View File
@@ -69,9 +69,9 @@
"releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/"
},
"devDependencies": {
"@babel/core": "7.25.9",
"@babel/preset-env": "7.25.9",
"@babel/runtime": "7.25.9",
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/runtime": "7.26.0",
"@betterer/betterer": "5.4.0",
"@betterer/cli": "5.4.0",
"@betterer/eslint": "5.4.0",
@@ -95,7 +95,7 @@
"@swc/core": "1.4.2",
"@swc/helpers": "0.5.15",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/angular": "1.8.9",
@@ -164,7 +164,8 @@
"chrome-remote-interface": "0.33.2",
"codeowners": "^5.1.1",
"copy-webpack-plugin": "12.0.2",
"core-js": "3.38.1",
"core-js": "3.39.0",
"crashme": "0.0.15",
"css-loader": "7.1.2",
"css-minimizer-webpack-plugin": "6.0.0",
"cypress": "13.10.0",
@@ -173,12 +174,12 @@
"esbuild": "0.24.0",
"esbuild-loader": "4.2.2",
"esbuild-plugin-browserslist": "^0.15.0",
"eslint": "9.12.0",
"eslint": "9.14.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-jest-dom": "^5.4.0",
"eslint-plugin-jsdoc": "50.4.3",
"eslint-plugin-jsdoc": "50.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-lodash": "8.0.0",
"eslint-plugin-no-barrel-files": "^1.1.1",
@@ -223,9 +224,9 @@
"react-test-renderer": "18.2.0",
"redux-mock-store": "1.5.5",
"rimraf": "6.0.1",
"rudder-sdk-js": "2.48.23",
"rudder-sdk-js": "2.48.26",
"sass": "1.79.5",
"sass-loader": "16.0.2",
"sass-loader": "16.0.3",
"smtp-tester": "^2.1.0",
"style-loader": "4.0.0",
"stylelint": "16.9.0",
@@ -253,7 +254,7 @@
"@emotion/react": "11.13.3",
"@fingerprintjs/fingerprintjs": "^3.4.2",
"@floating-ui/react": "0.26.27",
"@formatjs/intl-durationformat": "^0.5.0",
"@formatjs/intl-durationformat": "^0.6.0",
"@glideapps/glide-data-grid": "^6.0.0",
"@grafana/aws-sdk": "0.5.0",
"@grafana/azure-sdk": "0.0.5",
@@ -296,7 +297,7 @@
"@react-aria/overlays": "3.23.4",
"@react-aria/utils": "3.25.3",
"@react-awesome-query-builder/ui": "6.6.3",
"@reduxjs/toolkit": "2.2.8",
"@reduxjs/toolkit": "2.3.0",
"@testing-library/react-hooks": "^8.0.1",
"@visx/event": "3.3.0",
"@visx/gradient": "3.3.0",
@@ -380,7 +381,7 @@
"react-router": "5.3.3",
"react-router-dom": "5.3.3",
"react-router-dom-v5-compat": "^6.26.1",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-split-pane": "0.1.92",
"react-table": "7.8.0",
"react-transition-group": "4.4.5",
@@ -426,8 +427,6 @@
"debug@npm:^0.7.4": "2.6.9",
"slate-dev-environment@^0.2.2": "patch:slate-dev-environment@npm:0.2.5#.yarn/patches/slate-dev-environment-npm-0.2.5-9aeb7da7b5.patch",
"react-split-pane@0.1.92": "patch:react-split-pane@npm:0.1.92#.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch",
"history@4.10.1": "patch:history@npm%3A4.10.1#./.yarn/patches/history-npm-4.10.1-ee217563ae.patch",
"history@^4.9.0": "patch:history@npm%3A4.10.1#./.yarn/patches/history-npm-4.10.1-ee217563ae.patch",
"redux": "^5.0.0",
"@storybook/blocks@npm:8.1.6": "patch:@storybook/blocks@npm%3A8.1.6#~/.yarn/patches/@storybook-blocks-npm-8.1.6-892f57a6d7.patch",
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch",
@@ -728,6 +728,35 @@ describe('calculateField transformer w/ timeseries', () => {
});
});
it('calculates fixed, trailing moving average with missing values', async () => {
const cfg = {
id: DataTransformerID.calculateField,
options: {
mode: CalculateFieldMode.WindowFunctions,
window: {
windowAlignment: WindowAlignment.Trailing,
field: 'x',
windowSize: 2,
windowSizeMode: WindowSizeMode.Fixed,
reducer: ReducerID.mean,
},
},
};
const series = toDataFrame({
fields: [{ name: 'x', type: FieldType.number, values: [1, undefined, 3, 4, 5] }],
});
await expect(transformDataFrame([cfg], [series])).toEmitValuesWith((received) => {
const data = received[0][0];
//console.log(data.fields);
expect(data.fields.length).toEqual(2);
expect(data.fields[1].values).toEqual([1, 1, 3, 3.5, 4.5]);
});
});
it('throws error when calculating moving average if window size < 1', async () => {
const cfg = {
id: DataTransformerID.calculateField,
@@ -357,8 +357,11 @@ function getTrailingWindowValues(frame: DataFrame, reducer: ReducerID, selectedF
sum += currentValue;
if (i > window - 1) {
sum -= selectedField.values[i - window];
count--;
const value = selectedField.values[i - window];
if (value != null) {
sum -= value;
count--;
}
}
}
vals.push(count === 0 ? 0 : sum / count);
@@ -236,4 +236,5 @@ export interface FeatureToggles {
enableExtensionsAdminPage?: boolean;
zipkinBackendMigration?: boolean;
enableSCIM?: boolean;
crashDetection?: boolean;
}
+1 -1
View File
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@typescript-eslint/types": "^8.9.0",
"eslint": "9.12.0",
"eslint": "9.14.0",
"tslib": "2.7.0"
},
"private": true
+2 -2
View File
@@ -56,8 +56,8 @@
"tslib": "2.7.0"
},
"devDependencies": {
"@babel/core": "7.25.9",
"@babel/preset-env": "7.25.9",
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.25.9",
"@grafana/tsconfig": "^2.0.0",
"@rollup/plugin-node-resolve": "15.3.0",
+1 -1
View File
@@ -34,7 +34,7 @@
"build": "yarn generate && rollup -c rollup.config.ts --configPlugin esbuild"
},
"devDependencies": {
"@babel/core": "7.25.9",
"@babel/core": "7.26.0",
"@grafana/tsconfig": "^2.0.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.0",
@@ -24,7 +24,7 @@
"@grafana/runtime": "11.4.0-pre",
"@grafana/schema": "11.4.0-pre",
"@grafana/ui": "11.4.0-pre",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-use": "17.5.1",
"rxjs": "7.8.1",
"tslib": "2.7.0"
+1 -1
View File
@@ -11,7 +11,7 @@
"@swc/core": "1.4.2",
"@types/eslint": "9.6.1",
"copy-webpack-plugin": "12.0.2",
"eslint": "9.12.0",
"eslint": "9.14.0",
"eslint-webpack-plugin": "4.2.0",
"fork-ts-checker-webpack-plugin": "9.0.2",
"glob": "11.0.0",
+7 -7
View File
@@ -50,7 +50,7 @@
"@lezer/highlight": "1.2.1",
"@lezer/lr": "1.4.2",
"@prometheus-io/lezer-promql": "0.55.1",
"@reduxjs/toolkit": "2.2.8",
"@reduxjs/toolkit": "2.3.0",
"d3": "7.9.0",
"date-fns": "3.6.0",
"debounce-promise": "3.1.2",
@@ -65,7 +65,7 @@
"pluralize": "8.0.0",
"prismjs": "1.29.0",
"react-highlight-words": "0.20.0",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-use": "17.5.1",
"react-window": "1.8.10",
"rxjs": "7.8.1",
@@ -83,7 +83,7 @@
"@swc/core": "1.4.2",
"@swc/helpers": "0.5.15",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/d3": "7.4.3",
@@ -106,11 +106,11 @@
"copy-webpack-plugin": "12.0.2",
"css-loader": "7.1.2",
"esbuild": "0.24.0",
"eslint": "9.12.0",
"eslint": "9.14.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsdoc": "50.4.3",
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-jsdoc": "50.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-lodash": "8.0.0",
"eslint-plugin-react": "7.37.2",
@@ -131,7 +131,7 @@
"rollup-plugin-esbuild": "6.1.1",
"rollup-plugin-node-externals": "^7.1.3",
"sass": "1.79.5",
"sass-loader": "16.0.2",
"sass-loader": "16.0.3",
"style-loader": "4.0.0",
"testing-library-selector": "0.3.1",
"ts-node": "10.9.2",
@@ -12,7 +12,6 @@ import { config } from '@grafana/runtime';
import {
AsyncSelect,
Button,
CustomScrollbar,
FormatOptionLabelMeta,
getSelectStyles,
Icon,
@@ -22,6 +21,7 @@ import {
useStyles2,
useTheme2,
} from '@grafana/ui';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { PrometheusDatasource } from '../../datasource';
import { truncateResult } from '../../language_utils';
@@ -245,15 +245,9 @@ export function MetricSelect({
style={{ maxHeight: Math.round(maxHeight * 0.9) }}
aria-label="Select options menu"
>
<CustomScrollbar
scrollRefCallback={innerRef}
autoHide={false}
autoHeightMax="inherit"
hideHorizontalTrack
showScrollIndicators
>
<ScrollContainer ref={innerRef} showScrollIndicators>
{children}
</CustomScrollbar>
</ScrollContainer>
{optionsLoaded && (
<div className={styles.customMenuFooter}>
<div>
+1 -1
View File
@@ -25,7 +25,7 @@
"lodash": "4.17.21",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-use": "17.5.1",
"react-virtualized-auto-sizer": "1.0.24",
"rxjs": "7.8.1",
+5 -5
View File
@@ -99,7 +99,7 @@
"react-inlinesvg": "3.0.2",
"react-loading-skeleton": "3.5.0",
"react-router-dom-v5-compat": "^6.26.1",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-table": "7.8.0",
"react-transition-group": "4.4.5",
"react-use": "17.5.1",
@@ -114,7 +114,7 @@
"uuid": "9.0.1"
},
"devDependencies": {
"@babel/core": "7.25.9",
"@babel/core": "7.26.0",
"@faker-js/faker": "^9.0.0",
"@grafana/tsconfig": "^2.0.0",
"@rollup/plugin-node-resolve": "15.3.0",
@@ -135,7 +135,7 @@
"@storybook/react-webpack5": "^8.1.6",
"@storybook/theming": "^8.1.6",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/chance": "1.1.6",
@@ -161,7 +161,7 @@
"@types/uuid": "9.0.8",
"chance": "1.1.12",
"common-tags": "1.8.2",
"core-js": "3.38.1",
"core-js": "3.39.0",
"css-loader": "7.1.2",
"csstype": "3.1.3",
"esbuild": "0.24.0",
@@ -179,7 +179,7 @@
"rollup-plugin-esbuild": "6.1.1",
"rollup-plugin-node-externals": "^7.1.3",
"rollup-plugin-svg-import": "3.0.0",
"sass-loader": "16.0.2",
"sass-loader": "16.0.3",
"storybook": "^8.1.6",
"storybook-dark-mode": "^4.0.1",
"style-loader": "4.0.0",
@@ -41,7 +41,7 @@ export const useComboboxFloat = (
const preferredMaxHeight = availableHeight - POPOVER_PADDING;
const width = Math.max(preferredMaxWidth, 0);
const height = Math.min(Math.max(preferredMaxHeight, OPTION_HEIGHT), POPOVER_MAX_HEIGHT);
const height = Math.min(Math.max(preferredMaxHeight, OPTION_HEIGHT * 6), POPOVER_MAX_HEIGHT);
setPopoverMaxSize({ width, height });
},
+29 -6
View File
@@ -7,7 +7,6 @@ import (
"github.com/grafana/authlib/authz"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/grafana/authlib/claims"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel"
"google.golang.org/grpc"
@@ -37,7 +36,6 @@ func WithLogger(logger log.Logger) ClientOption {
type Client struct {
logger log.Logger
openfga openfgav1.OpenFGAServiceClient
authz authzv1.AuthzServiceClient
authzext authzextv1.AuthzExtentionServiceClient
tenantID string
@@ -59,7 +57,6 @@ func NewClient(ctx context.Context, cc grpc.ClientConnInterface, cfg *setting.Cf
func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption) (*Client, error) {
c := &Client{
openfga: openfgav1.NewOpenFGAServiceClient(cc),
authz: authzv1.NewAuthzServiceClient(cc),
authzext: authzextv1.NewAuthzExtentionServiceClient(cc),
}
@@ -103,7 +100,7 @@ func (c *Client) Compile(ctx context.Context, id claims.AuthInfo, req authz.List
ctx, span := tracer.Start(ctx, "authz.zanzana.client.Compile")
defer span.End()
_, err := c.authzext.List(ctx, &authzextv1.ListRequest{
res, err := c.authzext.List(ctx, &authzextv1.ListRequest{
Subject: id.GetUID(),
Group: req.Group,
Verb: utils.VerbList,
@@ -115,8 +112,34 @@ func (c *Client) Compile(ctx context.Context, id claims.AuthInfo, req authz.List
return nil, err
}
// FIXME: implement checker
return func(namespace, name, folder string) bool { return false }, nil
return newItemChecker(res), nil
}
func newItemChecker(res *authzextv1.ListResponse) authz.ItemChecker {
// if we can see all resource of this type we can just return a function that always return true
if res.GetAll() {
return func(_, _, _ string) bool { return true }
}
folders := make(map[string]struct{}, len(res.Folders))
for _, f := range res.Folders {
folders[f] = struct{}{}
}
items := make(map[string]struct{}, len(res.Items))
for _, i := range res.Items {
items[i] = struct{}{}
}
return func(_, name, folder string) bool {
if _, ok := items[name]; ok {
return true
}
if _, ok := folders[folder]; ok {
return true
}
return false
}
}
func (c *Client) List(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (*authzextv1.ListResponse, error) {
+8 -5
View File
@@ -175,7 +175,7 @@ func (cma *CloudMigrationAPI) GetSessionList(c *contextmodel.ReqContext) respons
ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.GetSessionList")
defer span.End()
sl, err := cma.cloudMigrationService.GetSessionList(ctx)
sl, err := cma.cloudMigrationService.GetSessionList(ctx, c.OrgID)
if err != nil {
span.SetStatus(codes.Error, "session list error")
span.RecordError(err)
@@ -208,7 +208,7 @@ func (cma *CloudMigrationAPI) GetSession(c *contextmodel.ReqContext) response.Re
return response.Error(http.StatusBadRequest, "invalid session uid", err)
}
s, err := cma.cloudMigrationService.GetSession(ctx, uid)
s, err := cma.cloudMigrationService.GetSession(ctx, c.OrgID, uid)
if err != nil {
span.SetStatus(codes.Error, "session not found")
span.RecordError(err)
@@ -245,8 +245,9 @@ func (cma *CloudMigrationAPI) CreateSession(c *contextmodel.ReqContext) response
return response.ErrOrFallback(http.StatusBadRequest, "bad request data", err)
}
s, err := cma.cloudMigrationService.CreateSession(ctx, cloudmigration.CloudMigrationSessionRequest{
s, err := cma.cloudMigrationService.CreateSession(ctx, c.SignedInUser, cloudmigration.CloudMigrationSessionRequest{
AuthToken: cmd.AuthToken,
OrgID: c.SignedInUser.OrgID,
})
if err != nil {
span.SetStatus(codes.Error, "session creation error")
@@ -285,7 +286,7 @@ func (cma *CloudMigrationAPI) DeleteSession(c *contextmodel.ReqContext) response
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
}
_, err := cma.cloudMigrationService.DeleteSession(ctx, uid)
_, err := cma.cloudMigrationService.DeleteSession(ctx, c.OrgID, c.SignedInUser, uid)
if err != nil {
span.SetStatus(codes.Error, "session delete error")
span.RecordError(err)
@@ -365,6 +366,7 @@ func (cma *CloudMigrationAPI) GetSnapshot(c *contextmodel.ReqContext) response.R
SessionUID: sessUid,
ResultPage: c.QueryInt("resultPage"),
ResultLimit: c.QueryInt("resultLimit"),
OrgID: c.SignedInUser.OrgID,
}
if q.ResultLimit == 0 {
q.ResultLimit = 100
@@ -448,6 +450,7 @@ func (cma *CloudMigrationAPI) GetSnapshotList(c *contextmodel.ReqContext) respon
Limit: c.QueryInt("limit"),
Page: c.QueryInt("page"),
Sort: c.Query("sort"),
OrgID: c.SignedInUser.OrgID,
}
if q.Limit == 0 {
q.Limit = 100
@@ -508,7 +511,7 @@ func (cma *CloudMigrationAPI) UploadSnapshot(c *contextmodel.ReqContext) respons
return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err)
}
if err := cma.cloudMigrationService.UploadSnapshot(ctx, sessUid, snapshotUid); err != nil {
if err := cma.cloudMigrationService.UploadSnapshot(ctx, c.OrgID, c.SignedInUser, sessUid, snapshotUid); err != nil {
span.SetStatus(codes.Error, "error uploading snapshot")
span.RecordError(err)
@@ -16,14 +16,14 @@ type Service interface {
ValidateToken(ctx context.Context, mig CloudMigrationSession) error
DeleteToken(ctx context.Context, uid string) error
CreateSession(ctx context.Context, req CloudMigrationSessionRequest) (*CloudMigrationSessionResponse, error)
GetSession(ctx context.Context, migUID string) (*CloudMigrationSession, error)
DeleteSession(ctx context.Context, migUID string) (*CloudMigrationSession, error)
GetSessionList(context.Context) (*CloudMigrationSessionListResponse, error)
CreateSession(ctx context.Context, signedInUser *user.SignedInUser, req CloudMigrationSessionRequest) (*CloudMigrationSessionResponse, error)
GetSession(ctx context.Context, orgID int64, migUID string) (*CloudMigrationSession, error)
DeleteSession(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, migUID string) (*CloudMigrationSession, error)
GetSessionList(ctx context.Context, orgID int64) (*CloudMigrationSessionListResponse, error)
CreateSnapshot(ctx context.Context, signedInUser *user.SignedInUser, sessionUid string) (*CloudMigrationSnapshot, error)
GetSnapshot(ctx context.Context, query GetSnapshotsQuery) (*CloudMigrationSnapshot, error)
GetSnapshotList(ctx context.Context, query ListSnapshotsQuery) ([]CloudMigrationSnapshot, error)
UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error
UploadSnapshot(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, sessionUid string, snapshotUid string) error
CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error
}
@@ -362,10 +362,10 @@ func (s *Service) DeleteToken(ctx context.Context, tokenID string) error {
return nil
}
func (s *Service) GetSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {
func (s *Service) GetSession(ctx context.Context, orgID int64, uid string) (*cloudmigration.CloudMigrationSession, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSession")
defer span.End()
migration, err := s.store.GetMigrationSessionByUID(ctx, uid)
migration, err := s.store.GetMigrationSessionByUID(ctx, orgID, uid)
if err != nil {
return nil, err
}
@@ -373,11 +373,11 @@ func (s *Service) GetSession(ctx context.Context, uid string) (*cloudmigration.C
return migration, nil
}
func (s *Service) GetSessionList(ctx context.Context) (*cloudmigration.CloudMigrationSessionListResponse, error) {
func (s *Service) GetSessionList(ctx context.Context, orgID int64) (*cloudmigration.CloudMigrationSessionListResponse, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSessionList")
defer span.End()
values, err := s.store.GetCloudMigrationSessionList(ctx)
values, err := s.store.GetCloudMigrationSessionList(ctx, orgID)
if err != nil {
return nil, fmt.Errorf("retrieving session list from store: %w", err)
}
@@ -394,7 +394,7 @@ func (s *Service) GetSessionList(ctx context.Context) (*cloudmigration.CloudMigr
return &cloudmigration.CloudMigrationSessionListResponse{Sessions: migrations}, nil
}
func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
func (s *Service) CreateSession(ctx context.Context, signedInUser *user.SignedInUser, cmd cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.CreateSession")
defer span.End()
@@ -408,7 +408,7 @@ func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMig
return nil, cloudmigration.ErrTokenInvalid.Errorf("token could not be decoded") // don't want to leak info here
}
migration := token.ToMigration()
migration := token.ToMigration(cmd.OrgID)
// validate token against GMS before saving
if err := s.ValidateToken(ctx, migration); err != nil {
return nil, err
@@ -419,7 +419,7 @@ func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMig
return nil, cloudmigration.ErrSessionCreationFailure.Errorf("error creating migration")
}
s.report(ctx, &migration, gmsclient.EventConnect, 0, nil)
s.report(ctx, &migration, gmsclient.EventConnect, 0, nil, signedInUser.UserUID)
return &cloudmigration.CloudMigrationSessionResponse{
UID: cm.UID,
@@ -429,18 +429,18 @@ func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMig
}, nil
}
func (s *Service) DeleteSession(ctx context.Context, sessionUID string) (*cloudmigration.CloudMigrationSession, error) {
func (s *Service) DeleteSession(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, sessionUID string) (*cloudmigration.CloudMigrationSession, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.DeleteSession")
defer span.End()
session, snapshots, err := s.store.DeleteMigrationSessionByUID(ctx, sessionUID)
session, snapshots, err := s.store.DeleteMigrationSessionByUID(ctx, orgID, sessionUID)
if err != nil {
s.report(ctx, session, gmsclient.EventDisconnect, 0, err)
s.report(ctx, session, gmsclient.EventDisconnect, 0, err, signedInUser.UserUID)
return nil, fmt.Errorf("deleting migration from db for session %v: %w", sessionUID, err)
}
err = s.deleteLocalFiles(snapshots)
s.report(ctx, session, gmsclient.EventDisconnect, 0, err)
s.report(ctx, session, gmsclient.EventDisconnect, 0, err, signedInUser.UserUID)
return session, nil
}
@@ -451,7 +451,7 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
defer span.End()
// fetch session for the gms auth token
session, err := s.store.GetMigrationSessionByUID(ctx, sessionUid)
session, err := s.store.GetMigrationSessionByUID(ctx, signedInUser.GetOrgID(), sessionUid)
if err != nil {
return nil, fmt.Errorf("fetching migration session for uid %s: %w", sessionUid, err)
}
@@ -506,7 +506,7 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
asyncCtx, cancelFunc := context.WithCancel(asyncCtx)
s.cancelFunc = cancelFunc
s.report(asyncCtx, session, gmsclient.EventStartBuildingSnapshot, 0, nil)
s.report(asyncCtx, session, gmsclient.EventStartBuildingSnapshot, 0, nil, signedInUser.UserUID)
start := time.Now()
err := s.buildSnapshot(asyncCtx, signedInUser, initResp.MaxItemsPerPartition, initResp.Metadata, snapshot)
@@ -527,7 +527,7 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
}
span.SetStatus(codes.Ok, "snapshot built")
s.report(asyncCtx, session, gmsclient.EventDoneBuildingSnapshot, time.Since(start), err)
s.report(asyncCtx, session, gmsclient.EventDoneBuildingSnapshot, time.Since(start), err, signedInUser.UserUID)
}()
return &snapshot, nil
@@ -538,13 +538,13 @@ func (s *Service) GetSnapshot(ctx context.Context, query cloudmigration.GetSnaps
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSnapshot")
defer span.End()
sessionUid, snapshotUid := query.SessionUID, query.SnapshotUID
snapshot, err := s.store.GetSnapshotByUID(ctx, sessionUid, snapshotUid, query.ResultPage, query.ResultLimit)
orgID, sessionUid, snapshotUid := query.OrgID, query.SessionUID, query.SnapshotUID
snapshot, err := s.store.GetSnapshotByUID(ctx, orgID, sessionUid, snapshotUid, query.ResultPage, query.ResultLimit)
if err != nil {
return nil, fmt.Errorf("fetching snapshot for uid %s: %w", snapshotUid, err)
}
session, err := s.store.GetMigrationSessionByUID(ctx, sessionUid)
session, err := s.store.GetMigrationSessionByUID(ctx, orgID, sessionUid)
if err != nil {
return nil, fmt.Errorf("fetching session for uid %s: %w", sessionUid, err)
}
@@ -594,7 +594,7 @@ func (s *Service) GetSnapshot(ctx context.Context, query cloudmigration.GetSnaps
}
// Refresh the snapshot after the update
snapshot, err = s.store.GetSnapshotByUID(ctx, sessionUid, snapshotUid, query.ResultPage, query.ResultLimit)
snapshot, err = s.store.GetSnapshotByUID(ctx, orgID, sessionUid, snapshotUid, query.ResultPage, query.ResultLimit)
if err != nil {
return nil, fmt.Errorf("fetching snapshot for uid %s: %w", snapshotUid, err)
}
@@ -685,7 +685,7 @@ func (s *Service) GetSnapshotList(ctx context.Context, query cloudmigration.List
return snapshotList, nil
}
func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error {
func (s *Service) UploadSnapshot(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, sessionUid string, snapshotUid string) error {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.UploadSnapshot",
trace.WithAttributes(
attribute.String("sessionUid", sessionUid),
@@ -695,7 +695,7 @@ func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapsho
defer span.End()
// fetch session for the gms auth token
session, err := s.store.GetMigrationSessionByUID(ctx, sessionUid)
session, err := s.store.GetMigrationSessionByUID(ctx, orgID, sessionUid)
if err != nil {
return fmt.Errorf("fetching migration session for uid %s: %w", sessionUid, err)
}
@@ -703,6 +703,7 @@ func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapsho
snapshot, err := s.GetSnapshot(ctx, cloudmigration.GetSnapshotsQuery{
SnapshotUID: snapshotUid,
SessionUID: sessionUid,
OrgID: orgID,
})
if err != nil {
return fmt.Errorf("fetching snapshot with uid %s: %w", snapshotUid, err)
@@ -739,7 +740,7 @@ func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapsho
asyncCtx, s.cancelFunc = context.WithCancel(asyncCtx)
s.report(asyncCtx, session, gmsclient.EventStartUploadingSnapshot, 0, nil)
s.report(asyncCtx, session, gmsclient.EventStartUploadingSnapshot, 0, nil, signedInUser.UserUID)
start := time.Now()
err := s.uploadSnapshot(asyncCtx, session, snapshot, uploadUrl)
@@ -759,7 +760,7 @@ func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapsho
}
}
s.report(asyncCtx, session, gmsclient.EventDoneUploadingSnapshot, time.Since(start), err)
s.report(asyncCtx, session, gmsclient.EventDoneUploadingSnapshot, time.Since(start), err, signedInUser.UserUID)
}()
return nil
@@ -807,6 +808,7 @@ func (s *Service) report(
t gmsclient.LocalEventType,
d time.Duration,
evtErr error,
userUID string,
) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.report")
defer span.End()
@@ -831,6 +833,7 @@ func (s *Service) report(
e := gmsclient.EventRequestDTO{
Event: t,
LocalID: id,
UserUID: userUID,
}
if d != 0 {
@@ -29,19 +29,19 @@ func (s *NoopServiceImpl) ValidateToken(ctx context.Context, cm cloudmigration.C
return cloudmigration.ErrMigrationDisabled
}
func (s *NoopServiceImpl) GetSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {
func (s *NoopServiceImpl) GetSession(ctx context.Context, orgID int64, uid string) (*cloudmigration.CloudMigrationSession, error) {
return nil, cloudmigration.ErrFeatureDisabledError
}
func (s *NoopServiceImpl) GetSessionList(ctx context.Context) (*cloudmigration.CloudMigrationSessionListResponse, error) {
func (s *NoopServiceImpl) GetSessionList(ctx context.Context, orgID int64) (*cloudmigration.CloudMigrationSessionListResponse, error) {
return nil, cloudmigration.ErrFeatureDisabledError
}
func (s *NoopServiceImpl) CreateSession(ctx context.Context, cm cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
func (s *NoopServiceImpl) CreateSession(ctx context.Context, signedInUser *user.SignedInUser, cm cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
return nil, cloudmigration.ErrMigrationDisabled
}
func (s *NoopServiceImpl) DeleteSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {
func (s *NoopServiceImpl) DeleteSession(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, uid string) (*cloudmigration.CloudMigrationSession, error) {
return nil, cloudmigration.ErrFeatureDisabledError
}
@@ -57,7 +57,7 @@ func (s *NoopServiceImpl) GetSnapshotList(ctx context.Context, query cloudmigrat
return nil, cloudmigration.ErrFeatureDisabledError
}
func (s *NoopServiceImpl) UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error {
func (s *NoopServiceImpl) UploadSnapshot(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, sessionUid string, snapshotUid string) error {
return cloudmigration.ErrFeatureDisabledError
}
@@ -2,6 +2,7 @@ package cloudmigrationimpl
import (
"context"
"errors"
"maps"
"os"
"path/filepath"
@@ -48,7 +49,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func Test_NoopServiceDoesNothing(t *testing.T) {
@@ -83,8 +83,8 @@ func Test_CreateGetAndDeleteToken(t *testing.T) {
func Test_GetSnapshotStatusFromGMS(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
gmsClientMock := &gmsClientMock{}
s.gmsClient = gmsClientMock
gmsClientFake := &gmsClientMock{}
s.gmsClient = gmsClientFake
// Insert a session and snapshot into the database before we start
createTokenResp, err := s.CreateToken(context.Background())
@@ -112,7 +112,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, cloudmigration.SnapshotStatusCreating, snapshot.Status)
assert.Never(t, func() bool { return gmsClientMock.GetSnapshotStatusCallCount() > 0 }, time.Second, 10*time.Millisecond)
assert.Never(t, func() bool { return gmsClientFake.GetSnapshotStatusCallCount() > 0 }, time.Second, 10*time.Millisecond)
// Make the status pending processing and ensure GMS gets called
err = s.store.UpdateSnapshot(context.Background(), cloudmigration.UpdateSnapshotCmd{
@@ -123,12 +123,13 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
assert.NoError(t, err)
cleanupFunc := func() {
gmsClientMock.getStatusCalled = 0
gmsClientMock.getSnapshotResponse = nil
if s.cancelFunc != nil {
s.cancelFunc()
}
gmsClientFake = &gmsClientMock{}
s.gmsClient = gmsClientFake
err = s.store.UpdateSnapshot(context.Background(), cloudmigration.UpdateSnapshotCmd{
UID: uid,
SessionID: sess.UID,
@@ -152,7 +153,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
}
t.Run("test case: gms snapshot initialized", func(t *testing.T) {
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateInitialized,
}
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
@@ -161,12 +162,12 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
})
assert.NoError(t, err)
require.Eventually(t, checkStatusSync(cloudmigration.SnapshotStatusPendingProcessing), time.Second, 10*time.Millisecond)
assert.Equal(t, 1, gmsClientMock.GetSnapshotStatusCallCount())
assert.Equal(t, 1, gmsClientFake.GetSnapshotStatusCallCount())
t.Cleanup(cleanupFunc)
})
t.Run("test case: gms snapshot processing", func(t *testing.T) {
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateProcessing,
}
_, err := s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
@@ -175,12 +176,12 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
})
assert.NoError(t, err)
require.Eventually(t, checkStatusSync(cloudmigration.SnapshotStatusProcessing), time.Second, 10*time.Millisecond)
assert.Equal(t, 1, gmsClientMock.GetSnapshotStatusCallCount())
assert.Equal(t, 1, gmsClientFake.GetSnapshotStatusCallCount())
t.Cleanup(cleanupFunc)
})
t.Run("test case: gms snapshot finished", func(t *testing.T) {
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateFinished,
}
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
@@ -189,13 +190,13 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
})
assert.NoError(t, err)
require.Eventually(t, checkStatusSync(cloudmigration.SnapshotStatusFinished), time.Second, 10*time.Millisecond)
assert.Equal(t, 1, gmsClientMock.GetSnapshotStatusCallCount())
assert.Equal(t, 1, gmsClientFake.GetSnapshotStatusCallCount())
t.Cleanup(cleanupFunc)
})
t.Run("test case: gms snapshot canceled", func(t *testing.T) {
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateCanceled,
}
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
@@ -204,13 +205,13 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
})
assert.NoError(t, err)
require.Eventually(t, checkStatusSync(cloudmigration.SnapshotStatusCanceled), time.Second, 10*time.Millisecond)
assert.Equal(t, 1, gmsClientMock.GetSnapshotStatusCallCount())
assert.Equal(t, 1, gmsClientFake.GetSnapshotStatusCallCount())
t.Cleanup(cleanupFunc)
})
t.Run("test case: gms snapshot error", func(t *testing.T) {
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateError,
}
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
@@ -219,13 +220,13 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
})
assert.NoError(t, err)
require.Eventually(t, checkStatusSync(cloudmigration.SnapshotStatusError), time.Second, 10*time.Millisecond)
assert.Equal(t, 1, gmsClientMock.GetSnapshotStatusCallCount())
assert.Equal(t, 1, gmsClientFake.GetSnapshotStatusCallCount())
t.Cleanup(cleanupFunc)
})
t.Run("test case: gms snapshot unknown", func(t *testing.T) {
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateUnknown,
}
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
@@ -235,7 +236,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
assert.NoError(t, err)
// snapshot status should remain unchanged
require.Eventually(t, func() bool { return gmsClientMock.GetSnapshotStatusCallCount() == 1 }, time.Second, 10*time.Millisecond)
require.Eventually(t, func() bool { return gmsClientFake.GetSnapshotStatusCallCount() == 1 }, time.Second, 10*time.Millisecond)
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
SnapshotUID: uid,
SessionUID: sess.UID,
@@ -249,7 +250,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
t.Run("GMS results applied to local snapshot", func(t *testing.T) {
t.Skip("This test is flaky and needs to be fixed")
gmsClientMock.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
gmsClientFake.getSnapshotResponse = &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateFinished,
Results: []cloudmigration.CloudMigrationResource{
{
@@ -268,7 +269,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
SessionUID: sess.UID,
})
assert.NoError(t, err)
require.Eventually(t, func() bool { return gmsClientMock.GetSnapshotStatusCallCount() == 1 }, time.Second, 10*time.Millisecond)
require.Eventually(t, func() bool { return gmsClientFake.GetSnapshotStatusCallCount() == 1 }, time.Second, 10*time.Millisecond)
snapshot, err = s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{
SnapshotUID: uid,
@@ -358,22 +359,19 @@ func Test_OnlyQueriesStatusFromGMSWhenRequired(t *testing.T) {
func Test_DeletedDashboardsNotMigrated(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
/** NOTE: this is not used at the moment since we changed the service
// modify what the mock returns for just this test case
dashMock := s.dashboardService.(*dashboards.FakeDashboardService)
dashMock.On("GetAllDashboards", mock.Anything).Return(
[]*dashboards.Dashboard{
{
UID: "1",
Data: simplejson.New(),
},
{
UID: "2",
Data: simplejson.New(),
Deleted: time.Now(),
},
{UID: "1", OrgID: 1, Data: simplejson.New()},
{UID: "2", OrgID: 1, Data: simplejson.New(), Deleted: time.Now()},
},
nil,
)
*/
data, err := s.getMigrationDataJSON(context.TODO(), &user.SignedInUser{OrgID: 1})
assert.NoError(t, err)
@@ -551,12 +549,13 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) {
func TestDeleteSession(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
user := &user.SignedInUser{UserUID: "user123"}
t.Run("when deleting a session that does not exist in the database, it returns an error", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
session, err := s.DeleteSession(ctx, "invalid-session-uid")
session, err := s.DeleteSession(ctx, 2, user, "invalid-session-uid")
require.Nil(t, session)
require.Error(t, err)
})
@@ -571,19 +570,20 @@ func TestDeleteSession(t *testing.T) {
cmd := cloudmigration.CloudMigrationSessionRequest{
AuthToken: createTokenResp.Token,
OrgID: 3,
}
createResp, err := s.CreateSession(ctx, cmd)
createResp, err := s.CreateSession(ctx, user, cmd)
require.NoError(t, err)
require.NotEmpty(t, createResp.UID)
require.NotEmpty(t, createResp.Slug)
deletedSession, err := s.DeleteSession(ctx, createResp.UID)
deletedSession, err := s.DeleteSession(ctx, cmd.OrgID, user, createResp.UID)
require.NoError(t, err)
require.NotNil(t, deletedSession)
require.Equal(t, deletedSession.UID, createResp.UID)
notFoundSession, err := s.GetSession(ctx, deletedSession.UID)
notFoundSession, err := s.GetSession(ctx, cmd.OrgID, deletedSession.UID)
require.ErrorIs(t, err, cloudmigration.ErrMigrationNotFound)
require.Nil(t, notFoundSession)
})
@@ -600,7 +600,7 @@ func TestReportEvent(t *testing.T) {
s.gmsClient = gmsMock
require.NotPanics(t, func() {
s.report(ctx, nil, gmsclient.EventConnect, time.Minute, nil)
s.report(ctx, nil, gmsclient.EventConnect, time.Minute, nil, "user123")
})
require.Zero(t, gmsMock.reportEventCalled)
@@ -616,7 +616,7 @@ func TestReportEvent(t *testing.T) {
s.gmsClient = gmsMock
require.NotPanics(t, func() {
s.report(ctx, &cloudmigration.CloudMigrationSession{}, gmsclient.EventConnect, time.Minute, nil)
s.report(ctx, &cloudmigration.CloudMigrationSession{}, gmsclient.EventConnect, time.Minute, nil, "user123")
})
require.Equal(t, 1, gmsMock.reportEventCalled)
@@ -699,6 +699,7 @@ func TestGetParentNames(t *testing.T) {
folders []folder.CreateFolderCommand
dashboards []dashboards.Dashboard
libraryElements []libraryElement
alertRules []alertRule
expectedParentNames map[cloudmigration.MigrateDataType][]string
}{
{
@@ -719,10 +720,15 @@ func TestGetParentNames(t *testing.T) {
{UID: "libraryElementUID-0", FolderUID: &libraryElementFolderUID},
{UID: "libraryElementUID-1"},
},
alertRules: []alertRule{
{UID: "alertRuleUID-0", FolderUID: ""},
{UID: "alertRuleUID-1", FolderUID: "folderUID-B"},
},
expectedParentNames: map[cloudmigration.MigrateDataType][]string{
cloudmigration.DashboardDataType: {"", "Folder A", "Folder B"},
cloudmigration.FolderDataType: {"Folder A"},
cloudmigration.LibraryElementDataType: {"Folder A"},
cloudmigration.AlertRuleType: {"Folder B"},
},
},
}
@@ -730,7 +736,7 @@ func TestGetParentNames(t *testing.T) {
for _, tc := range testcases {
s.folderService = &foldertest.FakeService{ExpectedFolders: tc.fakeFolders}
dataUIDsToParentNamesByType, err := s.getParentNames(ctx, user, tc.dashboards, tc.folders, tc.libraryElements)
dataUIDsToParentNamesByType, err := s.getParentNames(ctx, user, tc.dashboards, tc.folders, tc.libraryElements, tc.alertRules)
require.NoError(t, err)
for dataType, expectedParentNames := range tc.expectedParentNames {
@@ -784,8 +790,7 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool) cloudmigration.Servi
sqlStore := db.InitTestDB(t)
secretsService := secretsfakes.NewFakeSecretsService()
rr := routing.NewRouteRegister()
spanRecorder := tracetest.NewSpanRecorder()
tracer := tracing.InitializeTracerForTest(tracing.WithSpanProcessor(spanRecorder))
tracer := tracing.InitializeTracerForTest()
mockFolder := &foldertest.FakeService{
ExpectedFolder: &folder.Folder{UID: "folderUID", Title: "Folder"},
}
@@ -870,6 +875,21 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool) cloudmigration.Servi
LastApplied: time.Now().Unix(),
}))
// Insert test data for dashboard test, should be removed later when we move GetAllDashboardsByOrgId() to the dashboard service
_, err = sqlStore.GetSqlxSession().Exec(context.Background(), `
INSERT INTO
dashboard (id, org_id, data, deleted, slug, title, created, version, updated )
VALUES
(1, 1, '{}', null, 'asdf', 'ghjk', '2024-03-27 15:30:43.000' , '1','2024-03-27 15:30:43.000' ),
(2, 1, '{}', '2024-03-27 15:30:43.000','qwert', 'yuio', '2024-03-27 15:30:43.000' , '2','2024-03-27 15:30:43.000'),
(3, 2, '{}', null, 'asdf', 'ghjk', '2024-03-27 15:30:43.000' , '1','2024-03-27 15:30:43.000' ),
(4, 2, '{}', '2024-03-27 15:30:43.000','qwert', 'yuio', '2024-03-27 15:30:43.000' , '2','2024-03-27 15:30:43.000');
`,
)
if err != nil {
require.NoError(t, err)
}
s, err := ProvideService(
cfg,
httpclient.NewProvider(),
@@ -923,6 +943,9 @@ func (m *gmsClientMock) GetSnapshotStatus(_ context.Context, _ cloudmigration.Cl
m.mu.Lock()
m.getStatusCalled++
m.mu.Unlock()
if m.getSnapshotResponse == nil {
return nil, errors.New("no response set")
}
return m.getSnapshotResponse, nil
}
@@ -45,7 +45,7 @@ func (m FakeServiceImpl) DeleteToken(_ context.Context, _ string) error {
return nil
}
func (m FakeServiceImpl) CreateSession(_ context.Context, _ cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
func (m FakeServiceImpl) CreateSession(_ context.Context, _ *user.SignedInUser, _ cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
if m.ReturnError {
return nil, cloudmigration.ErrSessionCreationFailure
}
@@ -57,21 +57,21 @@ func (m FakeServiceImpl) CreateSession(_ context.Context, _ cloudmigration.Cloud
}, nil
}
func (m FakeServiceImpl) GetSession(_ context.Context, _ string) (*cloudmigration.CloudMigrationSession, error) {
func (m FakeServiceImpl) GetSession(_ context.Context, _ int64, _ string) (*cloudmigration.CloudMigrationSession, error) {
if m.ReturnError {
return nil, fmt.Errorf("mock error")
}
return &cloudmigration.CloudMigrationSession{UID: "fake"}, nil
}
func (m FakeServiceImpl) DeleteSession(_ context.Context, _ string) (*cloudmigration.CloudMigrationSession, error) {
func (m FakeServiceImpl) DeleteSession(_ context.Context, _ int64, _ *user.SignedInUser, _ string) (*cloudmigration.CloudMigrationSession, error) {
if m.ReturnError {
return nil, fmt.Errorf("mock error")
}
return &cloudmigration.CloudMigrationSession{UID: "fake"}, nil
}
func (m FakeServiceImpl) GetSessionList(_ context.Context) (*cloudmigration.CloudMigrationSessionListResponse, error) {
func (m FakeServiceImpl) GetSessionList(_ context.Context, _ int64) (*cloudmigration.CloudMigrationSessionListResponse, error) {
if m.ReturnError {
return nil, fmt.Errorf("mock error")
}
@@ -154,7 +154,7 @@ func (m FakeServiceImpl) GetSnapshotList(ctx context.Context, query cloudmigrati
return cloudSnapshots, nil
}
func (m FakeServiceImpl) UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error {
func (m FakeServiceImpl) UploadSnapshot(ctx context.Context, _ int64, _ *user.SignedInUser, sessionUid string, snapshotUid string) error {
if m.ReturnError {
return fmt.Errorf("mock error")
}
@@ -44,7 +44,7 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
defer span.End()
// Data sources
dataSources, err := s.getDataSourceCommands(ctx)
dataSources, err := s.getDataSourceCommands(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get datasources", "err", err)
return nil, err
@@ -195,7 +195,7 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
}
// Obtain the names of parent elements for Dashboard and Folders data types
parentNamesByType, err := s.getParentNames(ctx, signedInUser, dashs, folders, libraryElements)
parentNamesByType, err := s.getParentNames(ctx, signedInUser, dashs, folders, libraryElements, alertRules)
if err != nil {
s.log.Error("Failed to get parent folder names", "err", err)
}
@@ -208,17 +208,17 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
return migrationData, nil
}
func (s *Service) getDataSourceCommands(ctx context.Context) ([]datasources.AddDataSourceCommand, error) {
func (s *Service) getDataSourceCommands(ctx context.Context, signedInUser *user.SignedInUser) ([]datasources.AddDataSourceCommand, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getDataSourceCommands")
defer span.End()
dataSources, err := s.dsService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{})
dataSources, err := s.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{OrgID: signedInUser.GetOrgID()})
if err != nil {
s.log.Error("Failed to get all datasources", "err", err)
return nil, err
}
result := []datasources.AddDataSourceCommand{}
result := make([]datasources.AddDataSourceCommand, 0, len(dataSources))
for _, dataSource := range dataSources {
// Decrypt secure json to send raw credentials
decryptedData, err := s.secretsService.DecryptJsonData(ctx, dataSource.SecureJsonData)
@@ -253,7 +253,7 @@ func (s *Service) getDashboardAndFolderCommands(ctx context.Context, signedInUse
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getDashboardAndFolderCommands")
defer span.End()
dashs, err := s.dashboardService.GetAllDashboards(ctx)
dashs, err := s.store.GetAllDashboardsByOrgId(ctx, signedInUser.GetOrgID())
if err != nil {
return nil, nil, err
}
@@ -279,20 +279,21 @@ func (s *Service) getDashboardAndFolderCommands(ctx context.Context, signedInUse
folders, err := s.folderService.GetFolders(ctx, folder.GetFoldersQuery{
UIDs: folderUids,
SignedInUser: signedInUser,
OrgID: signedInUser.GetOrgID(),
WithFullpathUIDs: true,
})
if err != nil {
return nil, nil, err
}
folderCmds := make([]folder.CreateFolderCommand, len(folders))
for i, f := range folders {
folderCmds[i] = folder.CreateFolderCommand{
folderCmds := make([]folder.CreateFolderCommand, 0, len(folders))
for _, f := range folders {
folderCmds = append(folderCmds, folder.CreateFolderCommand{
UID: f.UID,
Title: f.Title,
Description: f.Description,
ParentUID: f.ParentUID,
}
})
}
return dashboardCmds, folderCmds, nil
@@ -641,6 +642,7 @@ func (s *Service) getFolderNamesForFolderUIDs(ctx context.Context, signedInUser
folders, err := s.folderService.GetFolders(ctx, folder.GetFoldersQuery{
UIDs: folderUIDs,
SignedInUser: signedInUser,
OrgID: signedInUser.GetOrgID(),
WithFullpathUIDs: true,
})
if err != nil {
@@ -660,16 +662,26 @@ func (s *Service) getFolderNamesForFolderUIDs(ctx context.Context, signedInUser
// getParentNames finds the parent names for resources and returns a map of data type: {data UID : parentName}
// for dashboards, folders and library elements - the parent is the parent folder
func (s *Service) getParentNames(ctx context.Context, signedInUser *user.SignedInUser, dashboards []dashboards.Dashboard, folders []folder.CreateFolderCommand, libraryElements []libraryElement) (map[cloudmigration.MigrateDataType]map[string](string), error) {
parentNamesByType := make(map[cloudmigration.MigrateDataType]map[string](string))
func (s *Service) getParentNames(
ctx context.Context,
signedInUser *user.SignedInUser,
dashboards []dashboards.Dashboard,
folders []folder.CreateFolderCommand,
libraryElements []libraryElement,
alertRules []alertRule,
) (map[cloudmigration.MigrateDataType]map[string](string), error) {
parentNamesByType := make(map[cloudmigration.MigrateDataType]map[string]string)
for _, dataType := range currentMigrationTypes {
parentNamesByType[dataType] = make(map[string]string)
}
// Obtain list of unique folderUIDs
parentFolderUIDsSet := make(map[string]struct{}, len(dashboards)+len(folders)+len(libraryElements))
parentFolderUIDsSet := make(map[string]struct{})
for _, dashboard := range dashboards {
parentFolderUIDsSet[dashboard.FolderUID] = struct{}{}
// we dont need the root folder
if dashboard.FolderUID != "" {
parentFolderUIDsSet[dashboard.FolderUID] = struct{}{}
}
}
for _, f := range folders {
parentFolderUIDsSet[f.ParentUID] = struct{}{}
@@ -679,6 +691,11 @@ func (s *Service) getParentNames(ctx context.Context, signedInUser *user.SignedI
parentFolderUIDsSet[*libraryElement.FolderUID] = struct{}{}
}
}
for _, alertRule := range alertRules {
if alertRule.FolderUID != "" {
parentFolderUIDsSet[alertRule.FolderUID] = struct{}{}
}
}
parentFolderUIDsSlice := make([]string, 0, len(parentFolderUIDsSet))
for parentFolderUID := range parentFolderUIDsSet {
parentFolderUIDsSlice = append(parentFolderUIDsSlice, parentFolderUID)
@@ -703,6 +720,11 @@ func (s *Service) getParentNames(ctx context.Context, signedInUser *user.SignedI
parentNamesByType[cloudmigration.LibraryElementDataType][libraryElement.UID] = foldersUIDsToFolderName[*libraryElement.FolderUID]
}
}
for _, alertRule := range alertRules {
if alertRule.FolderUID != "" {
parentNamesByType[cloudmigration.AlertRuleType][alertRule.UID] = foldersUIDsToFolderName[alertRule.FolderUID]
}
}
return parentNamesByType, err
}
@@ -4,24 +4,29 @@ import (
"context"
"github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/services/dashboards"
)
type store interface {
CreateMigrationSession(ctx context.Context, session cloudmigration.CloudMigrationSession) (*cloudmigration.CloudMigrationSession, error)
GetMigrationSessionByUID(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error)
GetCloudMigrationSessionList(ctx context.Context) ([]*cloudmigration.CloudMigrationSession, error)
GetMigrationSessionByUID(ctx context.Context, orgID int64, uid string) (*cloudmigration.CloudMigrationSession, error)
GetCloudMigrationSessionList(ctx context.Context, orgID int64) ([]*cloudmigration.CloudMigrationSession, error)
// DeleteMigrationSessionByUID deletes the migration session, and all the related snapshot and resources.
// the work is done in a transaction.
DeleteMigrationSessionByUID(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, []cloudmigration.CloudMigrationSnapshot, error)
DeleteMigrationSessionByUID(ctx context.Context, orgID int64, uid string) (*cloudmigration.CloudMigrationSession, []cloudmigration.CloudMigrationSnapshot, error)
CreateSnapshot(ctx context.Context, snapshot cloudmigration.CloudMigrationSnapshot) (string, error)
UpdateSnapshot(ctx context.Context, snapshot cloudmigration.UpdateSnapshotCmd) error
GetSnapshotByUID(ctx context.Context, sessUid, id string, resultPage int, resultLimit int) (*cloudmigration.CloudMigrationSnapshot, error)
GetSnapshotByUID(ctx context.Context, orgID int64, sessUid, id string, resultPage int, resultLimit int) (*cloudmigration.CloudMigrationSnapshot, error)
GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error)
DeleteSnapshot(ctx context.Context, snapshotUid string) error
CreateUpdateSnapshotResources(ctx context.Context, snapshotUid string, resources []cloudmigration.CloudMigrationResource) error
GetSnapshotResources(ctx context.Context, snapshotUid string, page int, limit int) ([]cloudmigration.CloudMigrationResource, error)
GetSnapshotResourceStats(ctx context.Context, snapshotUid string) (*cloudmigration.SnapshotResourceStats, error)
DeleteSnapshotResources(ctx context.Context, snapshotUid string) error
// Deleted because were not used externally
// - DeleteSnapshot(ctx context.Context, snapshotUid string) error
// - CreateUpdateSnapshotResources(ctx context.Context, snapshotUid string, resources []cloudmigration.CloudMigrationResource) error
// - GetSnapshotResources(ctx context.Context, snapshotUid string, page int, limit int) ([]cloudmigration.CloudMigrationResource, error)
// - GetSnapshotResourceStats(ctx context.Context, snapshotUid string) (*cloudmigration.SnapshotResourceStats, error)
// - DeleteSnapshotResources(ctx context.Context, snapshotUid string) error
// TODO move this function dashboards/databases/databases.go
GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error)
}
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/secrets"
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -29,10 +30,10 @@ const (
GetSnapshotListSortingLatest = "latest"
)
func (ss *sqlStore) GetMigrationSessionByUID(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {
func (ss *sqlStore) GetMigrationSessionByUID(ctx context.Context, orgID int64, uid string) (*cloudmigration.CloudMigrationSession, error) {
var cm cloudmigration.CloudMigrationSession
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
exist, err := sess.Where("uid=?", uid).Get(&cm)
exist, err := sess.Where("org_id=? AND uid=?", orgID, uid).Get(&cm)
if err != nil {
return err
}
@@ -74,11 +75,10 @@ func (ss *sqlStore) CreateMigrationSession(ctx context.Context, migration cloudm
return &migration, nil
}
func (ss *sqlStore) GetCloudMigrationSessionList(ctx context.Context) ([]*cloudmigration.CloudMigrationSession, error) {
func (ss *sqlStore) GetCloudMigrationSessionList(ctx context.Context, orgID int64) ([]*cloudmigration.CloudMigrationSession, error) {
var migrations = make([]*cloudmigration.CloudMigrationSession, 0)
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
sess.OrderBy("created DESC")
return sess.Find(&migrations)
return sess.Where("org_id=?", orgID).OrderBy("created DESC").Find(&migrations)
})
if err != nil {
return nil, err
@@ -95,10 +95,10 @@ func (ss *sqlStore) GetCloudMigrationSessionList(ctx context.Context) ([]*cloudm
return migrations, nil
}
func (ss *sqlStore) DeleteMigrationSessionByUID(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, []cloudmigration.CloudMigrationSnapshot, error) {
func (ss *sqlStore) DeleteMigrationSessionByUID(ctx context.Context, orgID int64, uid string) (*cloudmigration.CloudMigrationSession, []cloudmigration.CloudMigrationSnapshot, error) {
var c cloudmigration.CloudMigrationSession
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
exist, err := sess.Where("uid=?", uid).Get(&c)
exist, err := sess.Where("org_id=? AND uid=?", orgID, uid).Get(&c)
if err != nil {
return err
}
@@ -124,11 +124,11 @@ func (ss *sqlStore) DeleteMigrationSessionByUID(ctx context.Context, uid string)
err = ss.db.InTransaction(ctx, func(ctx context.Context) error {
for _, snapshot := range snapshots {
err := ss.DeleteSnapshotResources(ctx, snapshot.UID)
err := ss.deleteSnapshotResources(ctx, snapshot.UID)
if err != nil {
return fmt.Errorf("deleting snapshot resource from db: %w", err)
}
err = ss.DeleteSnapshot(ctx, snapshot.UID)
err = ss.deleteSnapshot(ctx, orgID, snapshot.UID)
if err != nil {
return fmt.Errorf("deleting snapshot from db: %w", err)
}
@@ -214,7 +214,7 @@ func (ss *sqlStore) UpdateSnapshot(ctx context.Context, update cloudmigration.Up
// Update resources if set
if len(update.Resources) > 0 {
if err := ss.CreateUpdateSnapshotResources(ctx, update.UID, update.Resources); err != nil {
if err := ss.createUpdateSnapshotResources(ctx, update.UID, update.Resources); err != nil {
return err
}
}
@@ -224,7 +224,7 @@ func (ss *sqlStore) UpdateSnapshot(ctx context.Context, update cloudmigration.Up
return err
}
func (ss *sqlStore) DeleteSnapshot(ctx context.Context, snapshotUid string) error {
func (ss *sqlStore) deleteSnapshot(ctx context.Context, orgID int64, snapshotUid string) error {
return ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
_, err := sess.Delete(cloudmigration.CloudMigrationSnapshot{
UID: snapshotUid,
@@ -233,9 +233,16 @@ func (ss *sqlStore) DeleteSnapshot(ctx context.Context, snapshotUid string) erro
})
}
func (ss *sqlStore) GetSnapshotByUID(ctx context.Context, sessionUid, uid string, resultPage int, resultLimit int) (*cloudmigration.CloudMigrationSnapshot, error) {
func (ss *sqlStore) GetSnapshotByUID(ctx context.Context, orgID int64, sessionUid, uid string, resultPage int, resultLimit int) (*cloudmigration.CloudMigrationSnapshot, error) {
// first we check if the session exists, using orgId and sessionUid
session, err := ss.GetMigrationSessionByUID(ctx, orgID, sessionUid)
if err != nil || session == nil {
return nil, err
}
// now we get the snapshot
var snapshot cloudmigration.CloudMigrationSnapshot
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
err = ss.db.WithDbSession(ctx, func(sess *db.Session) error {
exist, err := sess.Where("session_uid=? AND uid=?", sessionUid, uid).Get(&snapshot)
if err != nil {
return err
@@ -257,11 +264,11 @@ func (ss *sqlStore) GetSnapshotByUID(ctx context.Context, sessionUid, uid string
snapshot.EncryptionKey = []byte(secret)
}
resources, err := ss.GetSnapshotResources(ctx, uid, resultPage, resultLimit)
resources, err := ss.getSnapshotResources(ctx, uid, resultPage, resultLimit)
if err == nil {
snapshot.Resources = resources
}
stats, err := ss.GetSnapshotResourceStats(ctx, uid)
stats, err := ss.getSnapshotResourceStats(ctx, uid)
if err == nil {
snapshot.StatsRollup = *stats
}
@@ -274,7 +281,9 @@ func (ss *sqlStore) GetSnapshotByUID(ctx context.Context, sessionUid, uid string
func (ss *sqlStore) GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error) {
var snapshots = make([]cloudmigration.CloudMigrationSnapshot, 0)
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
sess.Join("INNER", "cloud_migration_session", "cloud_migration_session.uid = cloud_migration_snapshot.session_uid")
sess.Join("INNER", "cloud_migration_session",
"cloud_migration_session.uid = cloud_migration_snapshot.session_uid AND cloud_migration_session.org_id = ?", query.OrgID,
)
if query.Limit != GetAllSnapshots {
offset := (query.Page - 1) * query.Limit
sess.Limit(query.Limit, offset)
@@ -298,7 +307,7 @@ func (ss *sqlStore) GetSnapshotList(ctx context.Context, query cloudmigration.Li
snapshot.EncryptionKey = []byte(secret)
}
if stats, err := ss.GetSnapshotResourceStats(ctx, snapshot.UID); err != nil {
if stats, err := ss.getSnapshotResourceStats(ctx, snapshot.UID); err != nil {
return nil, err
} else {
snapshot.StatsRollup = *stats
@@ -310,7 +319,7 @@ func (ss *sqlStore) GetSnapshotList(ctx context.Context, query cloudmigration.Li
// CreateUpdateSnapshotResources either updates a migration resource for a snapshot, or creates it if it does not exist
// If the uid is not known, it uses snapshot_uid + resource_uid as a lookup
func (ss *sqlStore) CreateUpdateSnapshotResources(ctx context.Context, snapshotUid string, resources []cloudmigration.CloudMigrationResource) error {
func (ss *sqlStore) createUpdateSnapshotResources(ctx context.Context, snapshotUid string, resources []cloudmigration.CloudMigrationResource) error {
return ss.db.InTransaction(ctx, func(ctx context.Context) error {
sql := "UPDATE cloud_migration_resource SET status=?, error_string=?, error_code=? WHERE uid=? OR (snapshot_uid=? AND resource_uid=?)"
err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
@@ -344,7 +353,7 @@ func (ss *sqlStore) CreateUpdateSnapshotResources(ctx context.Context, snapshotU
})
}
func (ss *sqlStore) GetSnapshotResources(ctx context.Context, snapshotUid string, page int, limit int) ([]cloudmigration.CloudMigrationResource, error) {
func (ss *sqlStore) getSnapshotResources(ctx context.Context, snapshotUid string, page int, limit int) ([]cloudmigration.CloudMigrationResource, error) {
if page < 1 {
page = 1
}
@@ -366,7 +375,7 @@ func (ss *sqlStore) GetSnapshotResources(ctx context.Context, snapshotUid string
return resources, nil
}
func (ss *sqlStore) GetSnapshotResourceStats(ctx context.Context, snapshotUid string) (*cloudmigration.SnapshotResourceStats, error) {
func (ss *sqlStore) getSnapshotResourceStats(ctx context.Context, snapshotUid string) (*cloudmigration.SnapshotResourceStats, error) {
typeCounts := make([]struct {
Count int `json:"count"`
Type string `json:"type"`
@@ -413,7 +422,7 @@ func (ss *sqlStore) GetSnapshotResourceStats(ctx context.Context, snapshotUid st
return stats, nil
}
func (ss *sqlStore) DeleteSnapshotResources(ctx context.Context, snapshotUid string) error {
func (ss *sqlStore) deleteSnapshotResources(ctx context.Context, snapshotUid string) error {
return ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
_, err := sess.Delete(cloudmigration.CloudMigrationResource{
SnapshotUID: snapshotUid,
@@ -456,3 +465,19 @@ func (ss *sqlStore) decryptToken(ctx context.Context, cm *cloudmigration.CloudMi
return nil
}
// TODO move this function dashboards/databases/databases.go
func (ss *sqlStore) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error) {
//ctx, span := tracer.Start(ctx, "dashboards.database.GetAllDashboardsByOrgId")
//defer span.End()
var dashs = make([]*dashboards.Dashboard, 0)
err := ss.db.WithDbSession(ctx, func(session *db.Session) error {
// "deleted IS NULL" is to avoid deleted dashboards
return session.Where("org_id = ? AND deleted IS NULL", orgID).Find(&dashs)
})
if err != nil {
return nil, err
}
return dashs, nil
}
@@ -25,7 +25,7 @@ func Test_GetAllCloudMigrationSessions(t *testing.T) {
ctx := context.Background()
t.Run("get all cloud_migration_session entries", func(t *testing.T) {
value, err := s.GetCloudMigrationSessionList(ctx)
value, err := s.GetCloudMigrationSessionList(ctx, 1)
require.NoError(t, err)
require.Equal(t, 3, len(value))
for _, m := range value {
@@ -54,6 +54,7 @@ func Test_CreateMigrationSession(t *testing.T) {
cm := cloudmigration.CloudMigrationSession{
AuthToken: encodeToken("token"),
Slug: "fake_stack",
OrgID: 3,
StackID: 1234,
RegionSlug: "fake_slug",
ClusterSlug: "fake_cluster_slug",
@@ -63,7 +64,7 @@ func Test_CreateMigrationSession(t *testing.T) {
require.NotEmpty(t, sess.ID)
require.NotEmpty(t, sess.UID)
getRes, err := s.GetMigrationSessionByUID(ctx, sess.UID)
getRes, err := s.GetMigrationSessionByUID(ctx, 3, sess.UID)
require.NoError(t, err)
require.Equal(t, sess.ID, getRes.ID)
require.Equal(t, sess.UID, getRes.UID)
@@ -80,13 +81,15 @@ func Test_GetMigrationSessionByUID(t *testing.T) {
ctx := context.Background()
t.Run("find session by uid", func(t *testing.T) {
uid := "qwerty"
mig, err := s.GetMigrationSessionByUID(ctx, uid)
orgId := int64(1)
mig, err := s.GetMigrationSessionByUID(ctx, orgId, uid)
require.NoError(t, err)
require.Equal(t, uid, mig.UID)
require.Equal(t, orgId, mig.OrgID)
})
t.Run("returns error if session is not found by uid", func(t *testing.T) {
_, err := s.GetMigrationSessionByUID(ctx, "fake_uid_1234")
_, err := s.GetMigrationSessionByUID(ctx, 1, "fake_uid_1234")
require.ErrorIs(t, cloudmigration.ErrMigrationNotFound, err)
})
}
@@ -115,7 +118,10 @@ func Test_SnapshotManagement(t *testing.T) {
ctx := context.Background()
t.Run("tests the snapshot lifecycle", func(t *testing.T) {
session, err := s.CreateMigrationSession(ctx, cloudmigration.CloudMigrationSession{})
session, err := s.CreateMigrationSession(ctx, cloudmigration.CloudMigrationSession{
OrgID: 1,
AuthToken: encodeToken("token"),
})
require.NoError(t, err)
// create a snapshot
@@ -129,7 +135,7 @@ func Test_SnapshotManagement(t *testing.T) {
require.NotEmpty(t, snapshotUid)
//retrieve it from the db
snapshot, err := s.GetSnapshotByUID(ctx, session.UID, snapshotUid, 0, 0)
snapshot, err := s.GetSnapshotByUID(ctx, 1, session.UID, snapshotUid, 0, 0)
require.NoError(t, err)
require.Equal(t, cloudmigration.SnapshotStatusCreating, snapshot.Status)
@@ -138,22 +144,22 @@ func Test_SnapshotManagement(t *testing.T) {
require.NoError(t, err)
//retrieve it again
snapshot, err = s.GetSnapshotByUID(ctx, session.UID, snapshotUid, 0, 0)
snapshot, err = s.GetSnapshotByUID(ctx, 1, session.UID, snapshotUid, 0, 0)
require.NoError(t, err)
require.Equal(t, cloudmigration.SnapshotStatusCreating, snapshot.Status)
// lists snapshots and ensures it's in there
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: session.UID, Page: 1, Limit: 100})
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: session.UID, OrgID: 1, Page: 1, Limit: 100})
require.NoError(t, err)
require.Len(t, snapshots, 1)
require.Equal(t, *snapshot, snapshots[0])
// delete snapshot
err = s.DeleteSnapshot(ctx, snapshotUid)
err = s.deleteSnapshot(ctx, 1, snapshotUid)
require.NoError(t, err)
// now we expect not to find the snapshot
snapshot, err = s.GetSnapshotByUID(ctx, session.UID, snapshotUid, 0, 0)
snapshot, err = s.GetSnapshotByUID(ctx, 1, session.UID, snapshotUid, 0, 0)
require.ErrorIs(t, err, cloudmigration.ErrSnapshotNotFound)
require.Nil(t, snapshot)
})
@@ -165,12 +171,12 @@ func Test_SnapshotResources(t *testing.T) {
t.Run("tests CRUD of snapshot resources", func(t *testing.T) {
// Get the default rows from the test
resources, err := s.GetSnapshotResources(ctx, "poiuy", 0, 100)
resources, err := s.getSnapshotResources(ctx, "poiuy", 0, 100)
assert.NoError(t, err)
assert.Len(t, resources, 3)
// create a new resource and update an existing resource
err = s.CreateUpdateSnapshotResources(ctx, "poiuy", []cloudmigration.CloudMigrationResource{
err = s.createUpdateSnapshotResources(ctx, "poiuy", []cloudmigration.CloudMigrationResource{
{
Type: cloudmigration.DatasourceDataType,
RefID: "mi39fj",
@@ -184,7 +190,7 @@ func Test_SnapshotResources(t *testing.T) {
assert.NoError(t, err)
// Get resources again
resources, err = s.GetSnapshotResources(ctx, "poiuy", 0, 100)
resources, err = s.getSnapshotResources(ctx, "poiuy", 0, 100)
assert.NoError(t, err)
assert.Len(t, resources, 4)
// ensure existing resource was updated
@@ -203,7 +209,7 @@ func Test_SnapshotResources(t *testing.T) {
}
// check stats
stats, err := s.GetSnapshotResourceStats(ctx, "poiuy")
stats, err := s.getSnapshotResourceStats(ctx, "poiuy")
assert.NoError(t, err)
assert.Equal(t, map[cloudmigration.MigrateDataType]int{
cloudmigration.DatasourceDataType: 2,
@@ -217,10 +223,10 @@ func Test_SnapshotResources(t *testing.T) {
assert.Equal(t, 4, stats.Total)
// delete snapshot resources
err = s.DeleteSnapshotResources(ctx, "poiuy")
err = s.deleteSnapshotResources(ctx, "poiuy")
assert.NoError(t, err)
// make sure they're gone
resources, err = s.GetSnapshotResources(ctx, "poiuy", 0, 100)
resources, err = s.getSnapshotResources(ctx, "poiuy", 0, 100)
assert.NoError(t, err)
assert.Len(t, resources, 0)
})
@@ -233,7 +239,7 @@ func TestGetSnapshotList(t *testing.T) {
ctx := context.Background()
t.Run("returns list of snapshots that belong to a session", func(t *testing.T) {
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, Page: 1, Limit: 100})
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, OrgID: 1, Page: 1, Limit: 100})
require.NoError(t, err)
ids := make([]string, 0)
@@ -246,7 +252,7 @@ func TestGetSnapshotList(t *testing.T) {
})
t.Run("returns only one snapshot that belongs to a session", func(t *testing.T) {
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, Page: 1, Limit: 1})
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, OrgID: 1, Page: 1, Limit: 1})
require.NoError(t, err)
assert.Len(t, snapshots, 1)
})
@@ -258,7 +264,7 @@ func TestGetSnapshotList(t *testing.T) {
})
t.Run("returns paginated snapshot that belongs to a session", func(t *testing.T) {
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, Page: 2, Limit: 1})
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, OrgID: 1, Page: 2, Limit: 1})
require.NoError(t, err)
ids := make([]string, 0)
@@ -271,7 +277,7 @@ func TestGetSnapshotList(t *testing.T) {
})
t.Run("returns desc sorted list of snapshots that belong to a session", func(t *testing.T) {
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, Page: 1, Limit: 100, Sort: "latest"})
snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUID, OrgID: 1, Page: 1, Limit: 100, Sort: "latest"})
require.NoError(t, err)
ids := make([]string, 0)
@@ -291,7 +297,7 @@ func TestGetSnapshotList(t *testing.T) {
t.Run("if the session is deleted, snapshots can't be retrieved anymore", func(t *testing.T) {
// Delete the session.
_, _, err := s.DeleteMigrationSessionByUID(ctx, sessionUID)
_, _, err := s.DeleteMigrationSessionByUID(ctx, 1, sessionUID)
require.NoError(t, err)
// Fetch the snapshots that belong to the deleted session.
@@ -363,15 +369,17 @@ func setUpTest(t *testing.T) (*sqlstore.SQLStore, *sqlStore) {
// insert cloud migration test data
_, err := testDB.GetSqlxSession().Exec(ctx, `
INSERT INTO
cloud_migration_session (id, uid, auth_token, slug, stack_id, region_slug, cluster_slug, created, updated)
cloud_migration_session (id, uid, org_id, auth_token, slug, stack_id, region_slug, cluster_slug, created, updated)
VALUES
(1,'qwerty', ?, '11111', 11111, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000'),
(2,'asdfgh', ?, '22222', 22222, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000'),
(3,'zxcvbn', ?, '33333', 33333, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000');
(1,'qwerty', 1, ?, '11111', 11111, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000'),
(2,'asdfgh', 1, ?, '22222', 22222, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000'),
(3,'zxcvbn', 1, ?, '33333', 33333, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000'),
(4,'zxcvbn_org2', 2, ?, '33333', 33333, 'test', 'test', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000');
`,
encodeToken("12345"),
encodeToken("6789"),
encodeToken("777"),
encodeToken("0987"),
)
require.NoError(t, err)
@@ -380,9 +388,10 @@ func setUpTest(t *testing.T) (*sqlstore.SQLStore, *sqlStore) {
INSERT INTO
cloud_migration_snapshot (session_uid, uid, created, updated, finished, status)
VALUES
('qwerty', 'poiuy', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"),
('qwerty', 'poiuy', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"),
('qwerty', 'lkjhg', '2024-03-26 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"),
('zxcvbn', 'mnbvvc', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished");
('zxcvbn', 'mnbvvc', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"),
('zxcvbn_org2', 'mnbvvc_org2', '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished");
`,
)
require.NoError(t, err)
@@ -400,7 +409,8 @@ func setUpTest(t *testing.T) (*sqlstore.SQLStore, *sqlStore) {
('mnbvde', 'poiuy', 'DATASOURCE', 'jf38gh', 'OK', ''),
('qwerty', 'poiuy', 'DASHBOARD', 'ejcx4d', 'ERROR', 'fake error'),
('zxcvbn', 'poiuy', 'FOLDER', 'fi39fj', 'PENDING', ''),
('4fi9sd', '39fi39', 'FOLDER', 'fi39fj', 'OK', '');
('4fi9sd', '39fi39', 'FOLDER', 'fi39fj', 'OK', ''),
('4fi9ee', 'mnbvvc_org2', 'DATASOURCE', 'fi39asd', 'OK', '');
`,
)
require.NoError(t, err)
@@ -50,6 +50,7 @@ type EventRequestDTO struct {
Event LocalEventType `json:"event"`
Error string `json:"error"`
DurationIfFinished time.Duration `json:"duration"`
UserUID string `json:"userUid"`
}
type LocalEventType string
+7 -1
View File
@@ -21,6 +21,7 @@ var (
// CloudMigrationSession represents a configured migration token
type CloudMigrationSession struct {
ID int64 `xorm:"pk autoincr 'id'"`
OrgID int64 `xorm:"org_id"`
UID string `xorm:"uid"`
AuthToken string
Slug string
@@ -144,6 +145,8 @@ type CloudMigrationRunList struct {
type CloudMigrationSessionRequest struct {
AuthToken string
// OrgId in the on prem instance
OrgID int64
}
type CloudMigrationSessionResponse struct {
@@ -159,6 +162,7 @@ type CloudMigrationSessionListResponse struct {
type GetSnapshotsQuery struct {
SnapshotUID string
OrgID int64
SessionUID string
ResultPage int
ResultLimit int
@@ -166,6 +170,7 @@ type GetSnapshotsQuery struct {
type ListSnapshotsQuery struct {
SessionUID string
OrgID int64
Page int
Limit int
Sort string
@@ -189,13 +194,14 @@ type Base64EncodedTokenPayload struct {
Instance Base64HGInstance
}
func (p Base64EncodedTokenPayload) ToMigration() CloudMigrationSession {
func (p Base64EncodedTokenPayload) ToMigration(orgID int64) CloudMigrationSession {
return CloudMigrationSession{
AuthToken: p.Token,
Slug: p.Instance.Slug,
StackID: p.Instance.StackID,
RegionSlug: p.Instance.RegionSlug,
ClusterSlug: p.Instance.ClusterSlug,
OrgID: orgID,
}
}
+7
View File
@@ -1628,6 +1628,13 @@ var (
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
},
{
Name: "crashDetection",
Description: "Enables browser crash detection reporting to Faro.",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityTracesAndProfilingSquad,
FrontendOnly: true,
},
}
)
+1
View File
@@ -217,3 +217,4 @@ exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,fals
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
zipkinBackendMigration,experimental,@grafana/oss-big-tent,false,false,false
enableSCIM,experimental,@grafana/identity-access-team,false,false,false
crashDetection,experimental,@grafana/observability-traces-and-profiling,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
217 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
218 zipkinBackendMigration experimental @grafana/oss-big-tent false false false
219 enableSCIM experimental @grafana/identity-access-team false false false
220 crashDetection experimental @grafana/observability-traces-and-profiling false false true
+4
View File
@@ -878,4 +878,8 @@ const (
// FlagEnableSCIM
// Enables SCIM support for user and group management
FlagEnableSCIM = "enableSCIM"
// FlagCrashDetection
// Enables browser crash detection reporting to Faro.
FlagCrashDetection = "crashDetection"
)
+14 -1
View File
@@ -855,6 +855,19 @@
"expression": "true"
}
},
{
"metadata": {
"name": "crashDetection",
"resourceVersion": "1730381712885",
"creationTimestamp": "2024-10-31T13:35:12Z"
},
"spec": {
"description": "Enables browser crash detection reporting to Faro.",
"stage": "experimental",
"codeowner": "@grafana/observability-traces-and-profiling",
"frontend": true
}
},
{
"metadata": {
"name": "dashboardNewLayouts",
@@ -3470,4 +3483,4 @@
}
}
]
}
}
+6 -1
View File
@@ -150,7 +150,12 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, decryptFn Decrypt
return c.Do(req.WithContext(ctx))
}
senderLogger := log.New("ngalert.sender.external-alertmanager")
s, err := sender.NewExternalAlertmanagerSender(senderLogger, prometheus.NewRegistry(), sender.WithDoFunc(doFunc))
s, err := sender.NewExternalAlertmanagerSender(
senderLogger,
prometheus.NewRegistry(),
sender.WithDoFunc(doFunc),
sender.WithUTF8Labels(),
)
if err != nil {
return nil, err
}
@@ -785,16 +785,18 @@ func TestIntegrationRemoteAlertmanagerAlerts(t *testing.T) {
require.Equal(t, 0, len(alertGroups))
// Let's create two active alerts and one expired one.
alert1 := genAlert(true, map[string]string{"test_1": "test_1", "empty": "", alertingModels.NamespaceUIDLabel: "test_1"})
alert2 := genAlert(true, map[string]string{"test_2": "test_2", "empty": "", alertingModels.NamespaceUIDLabel: "test_2"})
alert3 := genAlert(false, map[string]string{"test_3": "test_3", "empty": "", alertingModels.NamespaceUIDLabel: "test_3"})
// UTF-8 label names should be preserved.
utf8LabelName := "test utf-8 label 😳"
alert1 := genAlert(true, map[string]string{utf8LabelName: "test_1", "empty": "", alertingModels.NamespaceUIDLabel: "test_1"})
alert2 := genAlert(true, map[string]string{utf8LabelName: "test_2", "empty": "", alertingModels.NamespaceUIDLabel: "test_2"})
alert3 := genAlert(false, map[string]string{utf8LabelName: "test_3", "empty": "", alertingModels.NamespaceUIDLabel: "test_3"})
postableAlerts := apimodels.PostableAlerts{
PostableAlerts: []amv2.PostableAlert{alert1, alert2, alert3},
}
err = am.PutAlerts(context.Background(), postableAlerts)
require.NoError(t, err)
// We should have two alerts and one group now.
// We should eventually have two active alerts.
require.Eventually(t, func() bool {
alerts, err = am.GetAlerts(context.Background(), true, true, true, []string{}, "")
require.NoError(t, err)
@@ -806,15 +808,15 @@ func TestIntegrationRemoteAlertmanagerAlerts(t *testing.T) {
require.Equal(t, 1, len(alertGroups))
// Labels with empty values and the namespace UID label should be removed.
// UTF-8 label names should remain unchanged.
for _, a := range alertGroups {
require.NotContains(t, a.Labels, "empty")
require.NotContains(t, a.Labels, alertingModels.NamespaceUIDLabel)
require.Len(t, a.Alerts, 2)
for _, a := range a.Alerts {
require.NotContains(t, a.Labels, "empty")
require.NotContains(t, a.Labels, alertingModels.NamespaceUIDLabel)
require.Contains(t, a.Labels, utf8LabelName)
}
}
// Filtering by `test_1=test_1` should return one alert.
alerts, err = am.GetAlerts(context.Background(), true, true, true, []string{"test_1=test_1"}, "")
require.NoError(t, err)
require.Equal(t, 1, len(alerts))
}
func TestIntegrationRemoteAlertmanagerReceivers(t *testing.T) {
+20 -4
View File
@@ -37,8 +37,9 @@ type ExternalAlertmanager struct {
manager *Manager
sdCancel context.CancelFunc
sdManager *discovery.Manager
sanitizeLabelSetFn func(lbls models.LabelSet) labels.Labels
sdCancel context.CancelFunc
sdManager *discovery.Manager
}
type ExternalAMcfg struct {
@@ -57,6 +58,20 @@ func WithDoFunc(doFunc doFunc) Option {
}
}
// WithUTF8Labels skips sanitizing labels and annotations before sending alerts to the external Alertmanager(s).
// It assumes UTF-8 label names are supported by the Alertmanager(s).
func WithUTF8Labels() Option {
return func(s *ExternalAlertmanager) {
s.sanitizeLabelSetFn = func(lbls models.LabelSet) labels.Labels {
ls := make(labels.Labels, 0, len(lbls))
for k, v := range lbls {
ls = append(ls, labels.Label{Name: k, Value: v})
}
return ls
}
}
}
func (cfg *ExternalAMcfg) SHA256() string {
return asSHA256([]string{cfg.headerString(), cfg.URL})
}
@@ -87,6 +102,7 @@ func NewExternalAlertmanagerSender(l log.Logger, reg prometheus.Registerer, opts
sdCancel: sdCancel,
}
s.sanitizeLabelSetFn = s.sanitizeLabelSet
s.manager = NewManager(
// Injecting a new registry here means these metrics are not exported.
// Once we fix the individual Alertmanager metrics we should fix this scenario too.
@@ -240,8 +256,8 @@ func buildNotifierConfig(alertmanagers []ExternalAMcfg) (*config.Config, map[str
func (s *ExternalAlertmanager) alertToNotifierAlert(alert models.PostableAlert) *Alert {
// Prometheus alertmanager has stricter rules for annotations/labels than grafana's internal alertmanager, so we sanitize invalid keys.
return &Alert{
Labels: s.sanitizeLabelSet(alert.Alert.Labels),
Annotations: s.sanitizeLabelSet(alert.Annotations),
Labels: s.sanitizeLabelSetFn(alert.Alert.Labels),
Annotations: s.sanitizeLabelSetFn(alert.Annotations),
StartsAt: time.Time(alert.StartsAt),
EndsAt: time.Time(alert.EndsAt),
GeneratorURL: alert.Alert.GeneratorURL.String(),
+12 -9
View File
@@ -124,16 +124,19 @@ func (st *Manager) Run(ctx context.Context) error {
}
func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader, instanceReader InstanceReader) {
logger := st.log.FromContext(ctx)
if st.instanceStore == nil {
st.log.Info("Skip warming the state because instance store is not configured")
logger.Info("Skip warming the state because instance store is not configured")
return
}
startTime := time.Now()
st.log.Info("Warming state cache for startup")
logger.Info("Warming state cache for startup")
orgIds, err := instanceReader.FetchOrgIds(ctx)
if err != nil {
st.log.Error("Unable to fetch orgIds", "error", err)
logger.Error("Unable to fetch orgIds", "error", err)
}
statesCount := 0
@@ -144,7 +147,7 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader, instanceRea
}
alertRules, err := rulesReader.ListAlertRules(ctx, &ruleCmd)
if err != nil {
st.log.Error("Unable to fetch previous state", "error", err)
logger.Error("Unable to fetch previous state", "error", err)
}
ruleByUID := make(map[string]*ngModels.AlertRule, len(alertRules))
@@ -158,7 +161,7 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader, instanceRea
// We will not enforce this here, but it's convenient to emit the warning here as we load up all the rules.
for name, size := range groupSizes {
if st.rulesPerRuleGroupLimit > 0 && size > st.rulesPerRuleGroupLimit {
st.log.Warn(
logger.Warn(
"Large rule group was loaded. Large groups are discouraged and changes to them may be disallowed in the future.",
"limit", st.rulesPerRuleGroupLimit,
"actual", size,
@@ -173,7 +176,7 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader, instanceRea
}
alertInstances, err := instanceReader.ListAlertInstances(ctx, &cmd)
if err != nil {
st.log.Error("Unable to fetch previous state", "error", err)
logger.Error("Unable to fetch previous state", "error", err)
}
for _, entry := range alertInstances {
@@ -195,7 +198,7 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader, instanceRea
if entry.ResultFingerprint != "" {
fp, err := strconv.ParseUint(entry.ResultFingerprint, 16, 64)
if err != nil {
st.log.Error("Failed to parse result fingerprint of alert instance", "error", err, "ruleUID", entry.RuleUID)
logger.Error("Failed to parse result fingerprint of alert instance", "error", err, "ruleUID", entry.RuleUID)
}
resultFp = data.Fingerprint(fp)
}
@@ -215,12 +218,12 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader, instanceRea
ResolvedAt: entry.ResolvedAt,
LastSentAt: entry.LastSentAt,
}
st.cache.getOrAdd(state, st.log)
st.cache.getOrAdd(state, logger)
statesCount++
}
}
st.log.Info("State cache has been initialized", "states", statesCount, "duration", time.Since(startTime))
logger.Info("State cache has been initialized", "states", statesCount, "duration", time.Since(startTime))
}
func (st *Manager) Get(orgID int64, alertRuleUID string, stateId data.Fingerprint) *State {
@@ -66,7 +66,7 @@ func addCloudMigrationsMigrations(mg *Migrator) {
}))
// --- v2 - asynchronous workflow refactor
sessionTable := Table{
migrationSessionTable := Table{
Name: "cloud_migration_session",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
@@ -99,7 +99,7 @@ func addCloudMigrationsMigrations(mg *Migrator) {
},
}
addTableReplaceMigrations(mg, migrationTable, sessionTable, 2, map[string]string{
addTableReplaceMigrations(mg, migrationTable, migrationSessionTable, 2, map[string]string{
"id": "id",
"uid": "uid",
"auth_token": "auth_token",
@@ -171,6 +171,11 @@ func addCloudMigrationsMigrations(mg *Migrator) {
Nullable: true,
}))
// -- Adds org_id column for for all elements - defaults to 1 (default org)
mg.AddMigration("add cloud_migration_session.org_id column", NewAddColumnMigration(migrationSessionTable, &Column{
Name: "org_id", Type: DB_BigInt, Nullable: false, Default: "1",
}))
mg.AddMigration("add cloud_migration_resource.error_code column", NewAddColumnMigration(migrationResourceTable, &Column{
Name: "error_code",
Type: DB_Text,
@@ -33,6 +33,7 @@ func (s *SAMLStrategy) loadSAMLSettings() map[string]any {
section := s.settingsProvider.Section("auth.saml")
result := map[string]any{
"enabled": section.KeyValue("enabled").MustBool(false),
"entity_id": section.KeyValue("entity_id").MustString(""),
"name": section.KeyValue("name").MustString("SAML"),
"single_logout": section.KeyValue("single_logout").MustBool(false),
"allow_sign_up": section.KeyValue("allow_sign_up").MustBool(false),
@@ -15,6 +15,7 @@ var (
iniSAMLContent = `
[auth.saml]
enabled = true
entity_id = custom-entity-id
single_logout = true
name = "SAML Test"
allow_sign_up = true
@@ -54,6 +55,7 @@ var (
expectedSAMLInfo = map[string]any{
"enabled": true,
"entity_id": "custom-entity-id",
"single_logout": true,
"allow_sign_up": true,
"auto_login": true,
+8 -1
View File
@@ -7,7 +7,9 @@ import (
"math"
"time"
"github.com/google/uuid"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/storage"
"k8s.io/klog/v2"
@@ -50,6 +52,9 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
if obj.GetResourceVersion() != "" {
return nil, storage.ErrResourceVersionSetOnCreate
}
if obj.GetUID() == "" {
obj.SetUID(types.UID(uuid.NewString()))
}
obj.SetGenerateName("") // Clear the random name field
obj.SetResourceVersion("")
@@ -95,7 +100,9 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
if previous.GetUID() == "" {
klog.Errorf("object is missing UID: %s, %s", obj.GetGroupVersionKind().String(), obj.GetName())
} else if obj.GetUID() != previous.GetUID() {
klog.Errorf("object UID mismatch: %s, was:%s, now: %s", obj.GetGroupVersionKind().String(), previous.GetName(), obj.GetUID())
if obj.GetUID() != "" {
klog.Errorf("object UID mismatch: %s, was:%s, now: %s", obj.GetGroupVersionKind().String(), previous.GetName(), obj.GetUID())
}
obj.SetUID(previous.GetUID())
}
+26
View File
@@ -0,0 +1,26 @@
package resource
import (
"context"
"github.com/grafana/authlib/authz"
"github.com/grafana/authlib/claims"
)
type staticAuthzClient struct {
allowed bool
}
// Check implements authz.AccessClient.
func (c *staticAuthzClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) {
return authz.CheckResponse{Allowed: c.allowed}, nil
}
// Compile implements authz.AccessClient.
func (c *staticAuthzClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) {
return func(namespace string, name, folder string) bool {
return c.allowed
}, nil
}
var _ authz.AccessClient = &staticAuthzClient{}
-14
View File
@@ -8,10 +8,6 @@ import (
)
type WriteAccessHooks struct {
// Check if a user has access to write folders
// When this is nil, no resources can have folders configured
Folder func(ctx context.Context, user claims.AuthInfo, uid string) bool
// When configured, this will make sure a user is allowed to save to a given origin
Origin func(ctx context.Context, user claims.AuthInfo, origin string) bool
}
@@ -24,16 +20,6 @@ type LifecycleHooks interface {
Stop(context.Context) error
}
func (a *WriteAccessHooks) CanWriteFolder(ctx context.Context, user claims.AuthInfo, uid string) error {
if a.Folder == nil {
return fmt.Errorf("writing folders is not supported")
}
if !a.Folder(ctx, user, uid) {
return fmt.Errorf("not allowed to write resource to folder")
}
return nil
}
func (a *WriteAccessHooks) CanWriteOrigin(ctx context.Context, user claims.AuthInfo, uid string) error {
if a.Origin == nil || uid == "UI" {
return nil // default to OK
+142 -14
View File
@@ -17,6 +17,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/authlib/authz"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -140,7 +141,10 @@ type ResourceServerOptions struct {
// Check if a user has access to write folders
// When this is nil, no resources can have folders configured
WriteAccess WriteAccessHooks
WriteHooks WriteAccessHooks
// Link RBAC
AccessClient authz.AccessClient
// Callbacks for startup and shutdown
Lifecycle LifecycleHooks
@@ -161,6 +165,10 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
return nil, fmt.Errorf("missing Backend implementation")
}
if opts.AccessClient == nil {
opts.AccessClient = &staticAuthzClient{allowed: true} // everything OK
}
if opts.Diagnostics == nil {
opts.Diagnostics = &noopService{}
}
@@ -209,7 +217,8 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
index: opts.Index,
blob: blobstore,
diagnostics: opts.Diagnostics,
access: opts.WriteAccess,
access: opts.AccessClient,
writeHooks: opts.WriteHooks,
lifecycle: opts.Lifecycle,
now: opts.Now,
ctx: ctx,
@@ -228,7 +237,8 @@ type server struct {
blob BlobSupport
index ResourceIndexServer
diagnostics DiagnosticsServer
access WriteAccessHooks
access authz.AccessClient
writeHooks WriteAccessHooks
lifecycle LifecycleHooks
now func() int64
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
@@ -303,6 +313,8 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
}
if obj.GetUID() == "" {
// TODO! once https://github.com/grafana/grafana/pull/96086 is deployed everywhere
// return nil, NewBadRequestError("object is missing UID")
s.log.Error("object is missing UID", "key", key)
}
@@ -310,6 +322,13 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
s.log.Error("object must not include a resource version", "key", key)
}
check := authz.CheckRequest{
Verb: "create",
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
}
event := &WriteEvent{
Value: value,
Key: key,
@@ -319,6 +338,7 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
event.Type = WatchEvent_ADDED
} else {
event.Type = WatchEvent_MODIFIED
check.Verb = "update"
temp := &unstructured.Unstructured{}
err = temp.UnmarshalJSON(oldValue)
@@ -362,19 +382,24 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
return nil, err
}
folder := obj.GetFolder()
if folder != "" {
err = s.access.CanWriteFolder(ctx, user, folder)
if err != nil {
return nil, AsErrorResult(err)
check.Folder = obj.GetFolder()
check.Name = key.Name
a, err := s.access.Check(ctx, user, check)
if err != nil {
return nil, AsErrorResult(err)
}
if !a.Allowed {
return nil, &ErrorResult{
Code: http.StatusForbidden,
}
}
origin, err := obj.GetOriginInfo()
if err != nil {
return nil, NewBadRequestError("invalid origin info")
}
if origin != nil {
err = s.access.CanWriteOrigin(ctx, user, origin.Name)
err = s.writeHooks.CanWriteOrigin(ctx, user, origin.Name)
if err != nil {
return nil, AsErrorResult(err)
}
@@ -404,7 +429,7 @@ func (s *server) Create(ctx context.Context, req *CreateRequest) (*CreateRespons
if found != nil && len(found.Value) > 0 {
rsp.Error = &ErrorResult{
Code: http.StatusConflict,
Message: "key already exists",
Message: "key already exists", // TODO?? soft delete replace?
}
return rsp, nil
}
@@ -414,6 +439,7 @@ func (s *server) Create(ctx context.Context, req *CreateRequest) (*CreateRespons
rsp.Error = e
return rsp, nil
}
var err error
rsp.ResourceVersion, err = s.backend.WriteEvent(ctx, *event)
if err != nil {
@@ -489,6 +515,14 @@ func (s *server) Delete(ctx context.Context, req *DeleteRequest) (*DeleteRespons
if req.ResourceVersion < 0 {
return nil, apierrors.NewBadRequest("update must include the previous version")
}
user, ok := claims.From(ctx)
if !ok || user == nil {
rsp.Error = &ErrorResult{
Message: "no user found in context",
Code: http.StatusUnauthorized,
}
return rsp, nil
}
latest := s.backend.ReadResource(ctx, &ReadRequest{
Key: req.Key,
@@ -502,6 +536,25 @@ func (s *server) Delete(ctx context.Context, req *DeleteRequest) (*DeleteRespons
return rsp, nil
}
access, err := s.access.Check(ctx, user, authz.CheckRequest{
Verb: "delete",
Group: req.Key.Group,
Resource: req.Key.Resource,
Namespace: req.Key.Namespace,
Name: req.Key.Name,
Folder: latest.Folder,
})
if err != nil {
rsp.Error = AsErrorResult(err)
return rsp, nil
}
if !access.Allowed {
rsp.Error = &ErrorResult{
Code: http.StatusForbidden,
}
return rsp, nil
}
now := metav1.NewTime(time.UnixMilli(s.now()))
event := WriteEvent{
Key: req.Key,
@@ -513,7 +566,7 @@ func (s *server) Delete(ctx context.Context, req *DeleteRequest) (*DeleteRespons
return nil, apierrors.NewBadRequest("unable to get user")
}
marker := &DeletedMarker{}
err := json.Unmarshal(latest.Value, marker)
err = json.Unmarshal(latest.Value, marker)
if err != nil {
return nil, apierrors.NewBadRequest(
fmt.Sprintf("unable to read previous object, %v", err))
@@ -549,6 +602,14 @@ func (s *server) Read(ctx context.Context, req *ReadRequest) (*ReadResponse, err
if err := s.Init(ctx); err != nil {
return nil, err
}
user, ok := claims.From(ctx)
if !ok || user == nil {
return &ReadResponse{
Error: &ErrorResult{
Message: "no user found in context",
Code: http.StatusUnauthorized,
}}, nil
}
// if req.Key.Group == "" {
// status, _ := AsErrorResult(apierrors.NewBadRequest("missing group"))
@@ -559,7 +620,24 @@ func (s *server) Read(ctx context.Context, req *ReadRequest) (*ReadResponse, err
}
rsp := s.backend.ReadResource(ctx, req)
// TODO, check folder permissions etc
a, err := s.access.Check(ctx, user, authz.CheckRequest{
Verb: "get",
Group: req.Key.Group,
Resource: req.Key.Resource,
Namespace: req.Key.Namespace,
Name: req.Key.Name,
Folder: rsp.Folder,
})
if err != nil {
return &ReadResponse{Error: AsErrorResult(err)}, nil
}
if !a.Allowed {
return &ReadResponse{
Error: &ErrorResult{
Code: http.StatusForbidden,
}}, nil
}
return &ReadResponse{
ResourceVersion: rsp.ResourceVersion,
Value: rsp.Value,
@@ -571,6 +649,15 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
ctx, span := s.tracer.Start(ctx, "storage_server.List")
defer span.End()
user, ok := claims.From(ctx)
if !ok || user == nil {
return &ListResponse{
Error: &ErrorResult{
Message: "no user found in context",
Code: http.StatusUnauthorized,
}}, nil
}
if err := s.Init(ctx); err != nil {
return nil, err
}
@@ -580,19 +667,37 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
maxPageBytes := 1024 * 1024 * 2 // 2mb/page
pageBytes := 0
rsp := &ListResponse{}
key := req.Options.Key
checker, err := s.access.Compile(ctx, user, authz.ListRequest{
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
})
if err != nil {
return &ListResponse{Error: AsErrorResult(err)}, nil
}
if checker == nil {
return &ListResponse{Error: &ErrorResult{
Code: http.StatusForbidden,
}}, nil
}
rv, err := s.backend.ListIterator(ctx, req, func(iter ListIterator) error {
for iter.Next() {
if err := iter.Error(); err != nil {
return err
}
// TODO: add authz filters
item := &ResourceWrapper{
ResourceVersion: iter.ResourceVersion(),
Value: iter.Value(),
}
if !checker(iter.Namespace(), iter.Name(), iter.Folder()) {
continue
}
pageBytes += len(item.Value)
rsp.Items = append(rsp.Items, item)
if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes {
@@ -642,6 +747,7 @@ func (s *server) initWatcher() error {
return err
}
//nolint:gocyclo
func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error {
ctx := srv.Context()
@@ -649,6 +755,24 @@ func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error {
return err
}
user, ok := claims.From(ctx)
if !ok || user == nil {
return apierrors.NewUnauthorized("no user found in context")
}
key := req.Options.Key
checker, err := s.access.Compile(ctx, user, authz.ListRequest{
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
})
if err != nil {
return err
}
if checker == nil {
return apierrors.NewUnauthorized("not allowed to list anything") // ?? or a single error?
}
// Start listening -- this will buffer any changes that happen while we backfill.
// If events are generated faster than we can process them, then some events will be dropped.
// TODO: Think of a way to allow the client to catch up.
@@ -722,6 +846,10 @@ func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error {
}
s.log.Debug("Server Broadcasting", "type", event.Type, "rv", event.ResourceVersion, "previousRV", event.PreviousRV, "group", event.Key.Group, "namespace", event.Key.Namespace, "resource", event.Key.Resource, "name", event.Key.Name)
if event.ResourceVersion > since && matchesQueryKey(req.Options.Key, event.Key) {
if !checker(event.Key.Namespace, event.Key.Name, event.Folder) {
continue
}
value := event.Value
// remove the delete marker stored in the value for deleted objects
if event.Type == WatchEvent_DELETED {
+6 -3
View File
@@ -8,13 +8,14 @@ import (
"testing"
"time"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/stretchr/testify/require"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func TestSimpleServer(t *testing.T) {
@@ -56,6 +57,7 @@ func TestSimpleServer(t *testing.T) {
"kind": "Playlist",
"metadata": {
"name": "fdgsv37qslr0ga",
"uid": "xyz",
"namespace": "default",
"annotations": {
"grafana.app/originName": "elsewhere",
@@ -172,6 +174,7 @@ func TestSimpleServer(t *testing.T) {
"metadata": {
"name": "fdgsv37qslr0ga",
"namespace": "default",
"uid": "xyz",
"annotations": {
"grafana.app/originName": "elsewhere",
"grafana.app/originPath": "path/to/item",
+19 -12
View File
@@ -5,14 +5,16 @@ import (
"os"
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
infraDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
"github.com/prometheus/client_golang/prometheus"
)
// Creates a new ResourceServer
@@ -48,17 +50,6 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, fea
opts.Diagnostics = store
opts.Lifecycle = store
if features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) {
opts.WriteAccess = resource.WriteAccessHooks{
Folder: func(ctx context.Context, user claims.AuthInfo, uid string) bool {
// #TODO build on the logic here
// #TODO only enable write access when the resource being written in the folder
// is another folder
return true
},
}
}
if features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) {
opts.Index = resource.NewResourceIndexServer(cfg, tracer)
}
@@ -70,6 +61,22 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, fea
// Initialize the indexer if one is configured
if opts.Index != nil {
// TODO: Create a proper identity for the indexer
orgId := int64(1)
ctx = identity.WithRequester(ctx, &identity.StaticRequester{
Type: claims.TypeServiceAccount, // system:apiserver
UserID: 1,
OrgID: int64(1),
Name: "admin",
Login: "admin",
OrgRole: identity.RoleAdmin,
IsGrafanaAdmin: true,
Permissions: map[int64]map[string][]string{
orgId: {
"*": {"*"}, // all resources, all scopes
},
},
})
_, err = rs.(resource.ResourceIndexer).Index(ctx)
if err != nil {
return nil, err
@@ -1,10 +1,12 @@
package azuremonitor
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@@ -27,16 +29,46 @@ type httpServiceProxy struct {
logger log.Logger
}
func (s *httpServiceProxy) writeErrorResponse(rw http.ResponseWriter, statusCode int, message string) error {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(statusCode)
// Attempt to locate JSON portion in error message
re := regexp.MustCompile(`\{.*?\}`)
jsonPart := re.FindString(message)
var jsonData map[string]interface{}
if unmarshalErr := json.Unmarshal([]byte(jsonPart), &jsonData); unmarshalErr != nil {
errorMsg, _ := json.Marshal(map[string]string{"error": "Invalid JSON format in error message"})
_, err := rw.Write(errorMsg)
if err != nil {
return fmt.Errorf("unable to write HTTP response: %v", err)
}
return unmarshalErr
}
// Extract relevant fields for a formatted error message
errorType, _ := jsonData["error"].(string)
errorDescription, _ := jsonData["error_description"].(string)
if errorType == "" {
errorType = "UnknownError"
}
formattedError := fmt.Sprintf("%s: %s", errorType, errorDescription)
errorMsg, _ := json.Marshal(map[string]string{"error": formattedError})
_, err := rw.Write(errorMsg)
if err != nil {
return fmt.Errorf("unable to write HTTP response: %v", err)
}
return nil
}
func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) {
res, err := cli.Do(req)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
_, err = rw.Write([]byte(fmt.Sprintf("unexpected error %v", err)))
if err != nil {
return nil, fmt.Errorf("unable to write HTTP response: %v", err)
}
return nil, err
return nil, s.writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("unexpected error: %v", err))
}
defer func() {
if err := res.Body.Close(); err != nil {
s.logger.Warn("Failed to close response body", "err", err)
@@ -52,6 +84,7 @@ func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *ht
}
return nil, err
}
rw.WriteHeader(res.StatusCode)
_, err = rw.Write(body)
if err != nil {
@@ -64,7 +97,8 @@ func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *ht
rw.Header().Add(k, v)
}
}
// Returning the response write for testing purposes
// Return the ResponseWriter for testing purposes
return rw, nil
}
+24 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -131,13 +132,30 @@ func callResource(ctx context.Context, req *backend.CallResourceRequest, sender
api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, tracer, false)
rawLokiResponse, err := api.RawQuery(ctx, lokiURL)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
plog.Error("Failed resource call from loki", "err", err, "url", lokiURL)
return err
var rawLokiResponse RawLokiResponse
var err error
// suggestions is a resource endpoint that will return label and label value suggestions based
// on queries and the existing scope. By moving this to the backend we can use the logql parser to
// rewrite queries safely.
if req.Method == http.MethodPost && strings.EqualFold(req.Path, "suggestions") {
rawLokiResponse, err = GetSuggestions(ctx, api, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
plog.FromContext(ctx).Error("Failed to get suggestions from loki", "err", err)
return err
}
} else {
rawLokiResponse, err = api.RawQuery(ctx, lokiURL)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
plog.Error("Failed resource call from loki", "err", err, "url", lokiURL)
return err
}
}
respHeaders := map[string][]string{
"content-type": {"application/json"},
}
+71
View File
@@ -1,12 +1,83 @@
package loki
import (
"context"
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/promlib/models"
"github.com/grafana/grafana/pkg/tsdb/loki/kinds/dataquery"
"github.com/grafana/loki/v3/pkg/logql/syntax"
"github.com/prometheus/prometheus/promql/parser"
)
// SuggestionRequest is the request body for the GetSuggestions resource.
type SuggestionRequest struct {
// LabelName, if provided, will result in label values being returned for the given label name.
LabelName string `json:"labelName"`
Query string `json:"query"`
Scopes []models.ScopeFilter `json:"scopes"`
AdhocFilters []models.ScopeFilter `json:"adhocFilters"`
// Start and End are proxied directly to the prometheus endpoint (which is rfc3339 | unix_timestamp)
Start string `json:"start"`
End string `json:"end"`
}
// GetSuggestions returns label names or label values for the given queries and scopes.
func GetSuggestions(ctx context.Context, lokiAPI *LokiAPI, req *backend.CallResourceRequest) (RawLokiResponse, error) {
sugReq := SuggestionRequest{}
err := json.Unmarshal(req.Body, &sugReq)
if err != nil {
return RawLokiResponse{}, fmt.Errorf("error unmarshalling suggestion request: %v", err)
}
values := url.Values{}
if sugReq.Query != "" {
// the query is used to find label/labelvalues the duration and interval does not matter.
// If the user want to filter values based on time it should used the `start` and `end` fields
interpolatedQuery := interpolateVariables(sugReq.Query, time.Minute, time.Minute, dataquery.LokiQueryTypeRange, time.Minute)
if len(sugReq.Scopes) > 0 {
rewrittenQuery, err := ApplyScopes(interpolatedQuery, sugReq.Scopes)
if err == nil {
values.Add("query", rewrittenQuery)
} else {
values.Add("query", interpolatedQuery)
}
}
} else if len(sugReq.Scopes) > 0 {
matchers, err := models.FiltersToMatchers(sugReq.Scopes, sugReq.AdhocFilters)
if err != nil {
return RawLokiResponse{}, fmt.Errorf("error converting filters to matchers: %v", err)
}
vs := parser.VectorSelector{LabelMatchers: matchers}
values.Add("query", vs.String())
}
if sugReq.Start != "" {
values.Add("start", sugReq.Start)
}
if sugReq.End != "" {
values.Add("end", sugReq.End)
}
var path string
if sugReq.LabelName != "" {
path = "/loki/api/v1/label/" + url.QueryEscape(sugReq.LabelName) + "/values?" + values.Encode()
} else {
path = "/loki/api/v1/labels?" + values.Encode()
}
return lokiAPI.RawQuery(ctx, path)
}
// ApplyScopes applies the given scope filters to the given raw expression.
func ApplyScopes(rawExpr string, scopeFilters []models.ScopeFilter) (string, error) {
if len(scopeFilters) == 0 {
+5
View File
@@ -56,6 +56,7 @@ import { AppChromeService } from './core/components/AppChrome/AppChromeService';
import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/components/OptionsUI/registry';
import { PluginPage } from './core/components/Page/PluginPage';
import { GrafanaContextType, useChromeHeaderHeight, useReturnToPreviousInternal } from './core/context/GrafanaContext';
import { initializeCrashDetection } from './core/crash';
import { initIconCache } from './core/icons/iconBundle';
import { initializeI18n } from './core/internationalization';
import { setMonacoEnv } from './core/monacoEnv';
@@ -267,6 +268,10 @@ export class GrafanaApp {
initializeScopes();
if (config.featureToggles.crashDetection) {
initializeCrashDetection();
}
const root = createRoot(document.getElementById('reactRoot')!);
root.render(
createElement(AppWrapper, {
@@ -1,8 +1,9 @@
import { css, cx } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { Button, CustomScrollbar, Stack, TextLink, useStyles2, useTheme2 } from '@grafana/ui';
import { Button, Stack, TextLink, useStyles2, useTheme2 } from '@grafana/ui';
import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { OrgRole, Role } from 'app/types';
import { BuiltinRoleSelector } from './BuiltinRoleSelector';
@@ -217,14 +218,11 @@ export const RolePickerMenu = ({
)}
>
<div className={customStyles.menu} aria-label="Role picker menu">
<CustomScrollbar
autoHide={false}
autoHeightMax={`${MENU_MAX_HEIGHT}px`}
hideHorizontalTrack
hideVerticalTrack
<ScrollContainer
maxHeight={`${MENU_MAX_HEIGHT}px`}
// NOTE: this is a way to force hiding of the scrollbar
// the scrollbar makes the mouseEvents drop
className={cx(customStyles.hideScrollBar)}
scrollbarWidth="none"
>
{showBasicRole && (
<div className={customStyles.menuSection}>
@@ -254,7 +252,7 @@ export const RolePickerMenu = ({
showOnLeftSubMenu={menuLeft}
/>
))}
</CustomScrollbar>
</ScrollContainer>
<div className={customStyles.menuButtonRow}>
<Stack justifyContent="flex-end">
<Button size="sm" fill="text" onClick={onClearInternal} disabled={updateDisabled}>
@@ -1,7 +1,8 @@
import { cx } from '@emotion/css';
import { Button, CustomScrollbar, Stack, useStyles2, useTheme2 } from '@grafana/ui';
import { Button, Stack, useStyles2, useTheme2 } from '@grafana/ui';
import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { Role } from 'app/types';
import { RoleMenuOption } from './RoleMenuOption';
@@ -41,7 +42,7 @@ export const RolePickerSubMenu = ({
className={cx(customStyles.subMenu, { [customStyles.subMenuLeft]: showOnLeft })}
aria-label="Role picker submenu"
>
<CustomScrollbar autoHide={false} autoHeightMax={`${MENU_MAX_HEIGHT}px`} hideHorizontalTrack>
<ScrollContainer maxHeight={`${MENU_MAX_HEIGHT}px`}>
<div className={styles.optionBody}>
{options.map((option, i) => (
<RoleMenuOption
@@ -62,7 +63,7 @@ export const RolePickerSubMenu = ({
/>
))}
</div>
</CustomScrollbar>
</ScrollContainer>
<div className={customStyles.subMenuButtonRow}>
<Stack justifyContent="flex-end">
<Button size="sm" fill="text" onClick={onClearInternal}>
+7
View File
@@ -0,0 +1,7 @@
import { initClientWorker } from 'crashme';
initClientWorker({
dbName: 'grafana.crashes',
// How often the tab will report its state
pingInterval: 1000,
});
+58
View File
@@ -0,0 +1,58 @@
import { LogContext } from '@grafana/faro-core/dist/types/api/logs/types';
export interface ChromePerformanceMemory {
totalJSHeapSize: number;
usedJSHeapSize: number;
jsHeapSizeLimit: number;
}
export interface ChromePerformance {
memory: ChromePerformanceMemory;
}
function isChromePerformanceMemory(memory: unknown): memory is ChromePerformanceMemory {
if (!memory || typeof memory !== 'object') {
return false;
}
return 'totalJSHeapSize' in memory && 'usedJSHeapSize' in memory && 'jsHeapSizeLimit' in memory;
}
export function isChromePerformance(performance: unknown): performance is ChromePerformance {
if (!performance || typeof performance !== 'object') {
return false;
}
return 'memory' in performance && isChromePerformanceMemory(performance.memory);
}
/**
* Ensures the context is a flat object with strings (required by Faro)
*/
export function prepareContext(context: Object): LogContext {
const preparedContext: LogContext = {};
function prepare(value: object | string | number, propertyName: string) {
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
throw new Error('Array values are not supported.');
} else {
for (const key in value) {
if (value.hasOwnProperty(key)) {
// @ts-ignore
prepare(value[key], propertyName ? `${propertyName}_${key}` : key);
}
}
}
} else if (typeof value === 'string') {
preparedContext[propertyName] = value;
} else if (typeof value === 'number') {
if (Number.isInteger(value)) {
preparedContext[propertyName] = value.toString();
} else {
preparedContext[propertyName] = value.toFixed(4);
}
}
}
prepare(context, 'crash');
return preparedContext;
}
+8
View File
@@ -0,0 +1,8 @@
import { initDetectorWorker } from 'crashme';
initDetectorWorker({
dbName: 'grafana.crashes',
interval: 5000,
crashThreshold: 5000,
staleThreshold: 5000,
});
+81
View File
@@ -0,0 +1,81 @@
import { initCrashDetection } from 'crashme';
import { BaseStateReport } from 'crashme/dist/types';
import { nanoid } from 'nanoid';
import { config, createMonitoringLogger } from '@grafana/runtime';
import { contextSrv } from '../services/context_srv';
import { isChromePerformance, prepareContext } from './crash.utils';
const logger = createMonitoringLogger('core.crash-detection');
interface GrafanaCrashReport extends BaseStateReport {
app: {
version: string;
url: string;
};
user: {
email: string;
login: string;
name: string;
};
memory?: {
heapUtilization: number;
limitUtilization: number;
usedJSHeapSize: number;
totalJSHeapSize: number;
jsHeapSizeLimit: number;
};
}
export function initializeCrashDetection() {
initCrashDetection<GrafanaCrashReport>({
id: nanoid(5),
dbName: 'grafana.crashes',
createClientWorker(): Worker {
return new Worker(new URL('./client.worker', import.meta.url));
},
createDetectorWorker(): SharedWorker {
return new SharedWorker(new URL('./detector.worker', import.meta.url));
},
reportCrash: async (report) => {
const preparedContext = prepareContext(report);
logger.logWarning('browser crash detected', preparedContext);
return true;
},
reportStaleTab: async (report) => {
const preparedContext = prepareContext(report);
logger.logWarning('stale browser tab detected', preparedContext);
return true;
},
updateInfo: (info) => {
info.app = {
version: config.buildInfo.version,
url: window.location.href,
};
info.user = {
email: contextSrv.user.email,
login: contextSrv.user.login,
name: contextSrv.user.name,
};
if (isChromePerformance(performance)) {
info.memory = {
heapUtilization: performance.memory.usedJSHeapSize / performance.memory.totalJSHeapSize,
limitUtilization: performance.memory.totalJSHeapSize / performance.memory.jsHeapSizeLimit,
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
};
}
},
});
}
@@ -36,6 +36,7 @@ const getTemplatePreviewContent = async () =>
const templatesSelectorTestId = 'existing-templates-selector';
describe('Edit contact point', () => {
jest.retryTimes(2);
it('can edit a contact point with existing template field values', async () => {
const { user } = renderEditContactPoint('lotsa-emails');
@@ -72,5 +73,5 @@ describe('Edit contact point', () => {
await user.click(screen.getByRole('button', { name: /save contact point/i }));
expect(await screen.findByText(/redirected/i)).toBeInTheDocument();
});
}, 600000);
});
@@ -1,17 +1,20 @@
import { produce } from 'immer';
import { useEffect } from 'react';
import { Validate } from 'react-hook-form';
import { useDispatch } from 'app/types';
import { AlertManagerCortexConfig } from '../../../../../plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { templatesApi } from '../../api/templateApi';
import { useAsync } from '../../hooks/useAsync';
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
import {
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroupList,
} from '../../openapi/templatesApi.gen';
import { updateAlertManagerConfigAction } from '../../state/actions';
import {
addNotificationTemplateAction,
deleteNotificationTemplateAction,
updateNotificationTemplateAction,
} from '../../reducers/alertmanager/notificationTemplates';
import { K8sAnnotations, PROVENANCE_NONE } from '../../utils/k8s/constants';
import { getAnnotation, getK8sNamespace, shouldUseK8sApi } from '../../utils/k8s/utils';
import { ensureDefine } from '../../utils/templates';
@@ -30,6 +33,7 @@ export interface NotificationTemplate {
}
const { useGetAlertmanagerConfigurationQuery, useLazyGetAlertmanagerConfigurationQuery } = alertmanagerApi;
const {
useListNamespacedTemplateGroupQuery,
useLazyReadNamespacedTemplateGroupQuery,
@@ -146,39 +150,17 @@ interface CreateTemplateParams {
}
export function useCreateNotificationTemplate({ alertmanager }: BaseAlertmanagerArgs) {
const dispatch = useDispatch();
const [fetchAmConfig] = useLazyGetAlertmanagerConfigurationQuery();
const [createNamespacedTemplateGroup] = useCreateNamespacedTemplateGroupMutation();
const [updateAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
const k8sApiSupported = shouldUseK8sApi(alertmanager);
async function createUsingConfigFileApi({ templateValues }: CreateTemplateParams) {
const amConfig = await fetchAmConfig(alertmanager).unwrap();
// wrap content in "define" if it's not already wrapped, in case user did not do it/
// it's not obvious that this is needed for template to work
const content = ensureDefine(templateValues.title, templateValues.content);
const createUsingConfigFileApi = useAsync(({ templateValues }: CreateTemplateParams) => {
const action = addNotificationTemplateAction({ template: templateValues });
return updateAlertmanagerConfiguration(action);
});
const targetTemplateExists = amConfig.template_files?.[templateValues.title] !== undefined;
if (targetTemplateExists) {
throw new Error('target template already exists');
}
const updatedConfig = produce(amConfig, (draft) => {
draft.template_files[templateValues.title] = content;
draft.alertmanager_config.templates = [...(draft.alertmanager_config.templates ?? []), templateValues.title];
});
return dispatch(
updateAlertManagerConfigAction({
alertManagerSourceName: alertmanager,
newConfig: updatedConfig,
oldConfig: amConfig,
})
).unwrap();
}
async function createUsingK8sApi({ templateValues }: CreateTemplateParams) {
const createUsingK8sApi = useAsync(({ templateValues }: CreateTemplateParams) => {
const content = ensureDefine(templateValues.title, templateValues.content);
return createNamespacedTemplateGroup({
@@ -188,7 +170,7 @@ export function useCreateNotificationTemplate({ alertmanager }: BaseAlertmanager
metadata: {},
},
}).unwrap();
}
});
return k8sApiSupported ? createUsingK8sApi : createUsingConfigFileApi;
}
@@ -199,43 +181,17 @@ interface UpdateTemplateParams {
}
export function useUpdateNotificationTemplate({ alertmanager }: BaseAlertmanagerArgs) {
const dispatch = useDispatch();
const [fetchAmConfig] = useLazyGetAlertmanagerConfigurationQuery();
const [replaceNamespacedTemplateGroup] = useReplaceNamespacedTemplateGroupMutation();
const [updateAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
const k8sApiSupported = shouldUseK8sApi(alertmanager);
async function updateUsingConfigFileApi({ template, patch }: UpdateTemplateParams) {
const amConfig = await fetchAmConfig(alertmanager).unwrap();
// wrap content in "define" if it's not already wrapped, in case user did not do it/
// it's not obvious that this is needed for template to work
const content = ensureDefine(patch.title, patch.content);
const updateUsingConfigFileApi = useAsync(({ template, patch }: UpdateTemplateParams) => {
const action = updateNotificationTemplateAction({ name: template.title, template: patch });
return updateAlertmanagerConfiguration(action);
});
const originalName = template.title; // For ConfigFile API name is the same as uid
const nameChanged = originalName !== patch.title;
// TODO Maybe we could simplify or extract this logic
const updatedConfig = produce(amConfig, (draft) => {
if (nameChanged) {
delete draft.template_files[originalName];
draft.alertmanager_config.templates = draft.alertmanager_config.templates?.filter((t) => t !== originalName);
}
draft.template_files[patch.title] = content;
draft.alertmanager_config.templates = [...(draft.alertmanager_config.templates ?? []), patch.title];
});
return dispatch(
updateAlertManagerConfigAction({
alertManagerSourceName: alertmanager,
newConfig: updatedConfig,
oldConfig: amConfig,
})
).unwrap();
}
async function updateUsingK8sApi({ template, patch }: UpdateTemplateParams) {
const updateUsingK8sApi = useAsync(({ template, patch }: UpdateTemplateParams) => {
const content = ensureDefine(patch.title, patch.content);
return replaceNamespacedTemplateGroup({
@@ -246,43 +202,30 @@ export function useUpdateNotificationTemplate({ alertmanager }: BaseAlertmanager
metadata: { name: template.uid },
},
}).unwrap();
}
});
return k8sApiSupported ? updateUsingK8sApi : updateUsingConfigFileApi;
}
export function useDeleteNotificationTemplate({ alertmanager }: BaseAlertmanagerArgs) {
const dispatch = useDispatch();
const [fetchAmConfig] = useLazyGetAlertmanagerConfigurationQuery();
const [deleteNamespacedTemplateGroup] = useDeleteNamespacedTemplateGroupMutation();
const [updateAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
async function deleteUsingConfigFileApi({ uid }: { uid: string }) {
const amConfig = await fetchAmConfig(alertmanager).unwrap();
const deleteUsingConfigAPI = useAsync(async ({ uid }: { uid: string }) => {
const action = deleteNotificationTemplateAction({ name: uid });
return updateAlertmanagerConfiguration(action);
});
const updatedConfig = produce(amConfig, (draft) => {
delete draft.template_files[uid];
draft.alertmanager_config.templates = draft.alertmanager_config.templates?.filter((t) => t !== uid);
});
return dispatch(
updateAlertManagerConfigAction({
alertManagerSourceName: alertmanager,
newConfig: updatedConfig,
oldConfig: amConfig,
})
).unwrap();
}
async function deleteUsingK8sApi({ uid }: { uid: string }) {
const deleteUsingK8sApi = useAsync(({ uid }: { uid: string }) => {
return deleteNamespacedTemplateGroup({
namespace: getK8sNamespace(),
name: uid,
ioK8SApimachineryPkgApisMetaV1DeleteOptions: {},
}).unwrap();
}
});
const k8sApiSupported = shouldUseK8sApi(alertmanager);
return k8sApiSupported ? deleteUsingK8sApi : deleteUsingConfigFileApi;
return k8sApiSupported ? deleteUsingK8sApi : deleteUsingConfigAPI;
}
interface ValidateNotificationTemplateParams {
@@ -10,18 +10,18 @@ import { GrafanaTheme2 } from '@grafana/data';
import { isFetchError, locationService } from '@grafana/runtime';
import {
Alert,
Box,
Button,
Drawer,
Dropdown,
FieldSet,
InlineField,
Input,
LinkButton,
Menu,
useStyles2,
Stack,
useSplitter,
Drawer,
InlineField,
Box,
useStyles2,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
@@ -93,8 +93,8 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props)
const appNotification = useAppNotification();
const createNewTemplate = useCreateNotificationTemplate({ alertmanager });
const updateTemplate = useUpdateNotificationTemplate({ alertmanager });
const [createNewTemplate] = useCreateNotificationTemplate({ alertmanager });
const [updateTemplate] = useUpdateNotificationTemplate({ alertmanager });
const { titleIsUnique } = useValidateNotificationTemplate({ alertmanager, originalTemplate });
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
@@ -149,9 +149,9 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props)
try {
if (!originalTemplate) {
await createNewTemplate({ templateValues: values });
await createNewTemplate.execute({ templateValues: values });
} else {
await updateTemplate({ template: originalTemplate, patch: values });
await updateTemplate.execute({ template: originalTemplate, patch: values });
}
appNotification.success('Template saved', `Template ${values.title} has been saved`);
locationService.push(returnLink);
@@ -30,7 +30,7 @@ interface Props {
export const TemplatesTable = ({ alertManagerName, templates }: Props) => {
const appNotification = useAppNotification();
const deleteTemplate = useDeleteNotificationTemplate({ alertmanager: alertManagerName });
const [deleteTemplate] = useDeleteNotificationTemplate({ alertmanager: alertManagerName });
const tableStyles = useStyles2(getAlertTableStyles);
@@ -39,7 +39,7 @@ export const TemplatesTable = ({ alertManagerName, templates }: Props) => {
const onDeleteTemplate = async () => {
if (templateToDelete) {
try {
await deleteTemplate({ uid: templateToDelete.uid });
await deleteTemplate.execute({ uid: templateToDelete.uid });
appNotification.success('Template deleted', `Template ${templateToDelete.title} has been deleted`);
} catch (error) {
appNotification.error('Error deleting template', `Error deleting template ${templateToDelete.title}`);
@@ -5,6 +5,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
import { alertmanagerApi } from '../api/alertmanagerApi';
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates';
import { receiversReducer } from '../reducers/alertmanager/receivers';
import { useAlertmanager } from '../state/AlertmanagerContext';
@@ -26,7 +27,12 @@ export const initialAlertmanagerConfiguration: AlertManagerCortexConfig = {
template_files: {},
};
const configurationReducer = reduceReducers(initialAlertmanagerConfiguration, muteTimingsReducer, receiversReducer);
const configurationReducer = reduceReducers(
initialAlertmanagerConfiguration,
muteTimingsReducer,
receiversReducer,
notificationTemplatesReducer
);
/**
* This hook will make sure we are always applying actions that mutate the Alertmanager configuration
@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`notification templates should add a new notification template 1`] = `
{
"alertmanager_config": {
"templates": [
"foo",
],
},
"template_files": {
"foo": "{{ define "foo" }}
foo
{{ end }}",
},
}
`;
exports[`notification templates should allow renaming a notification template 1`] = `
{
"alertmanager_config": {
"templates": [
"rename-me-copy",
],
},
"template_files": {
"rename-me-copy": "{{ define "rename-me-copy" }}
rename me, please
{{ end }}",
},
}
`;
exports[`notification templates should remove a notification template 1`] = `
{
"alertmanager_config": {
"templates": [],
},
"template_files": {},
}
`;
exports[`notification templates should update a notification template without renaming 1`] = `
{
"alertmanager_config": {
"templates": [
"update-me",
],
},
"template_files": {
"update-me": "{{ define "update-me" }}
update me, please
{{ end }}",
},
}
`;
@@ -0,0 +1,119 @@
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { TemplateFormValues } from '../../components/receivers/TemplateForm';
import {
addNotificationTemplateAction,
deleteNotificationTemplateAction,
notificationTemplatesReducer,
updateNotificationTemplateAction,
} from './notificationTemplates';
describe('notification templates', () => {
it('should add a new notification template', () => {
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {},
template_files: {},
};
const newNotificationTemplate: TemplateFormValues = {
title: 'foo',
content: 'foo',
};
const action = addNotificationTemplateAction({ template: newNotificationTemplate });
expect(notificationTemplatesReducer(initialConfig, action)).toMatchSnapshot();
});
it('should not add a new notification template if the name already exists', () => {
const name = 'existing';
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: { templates: [name] },
template_files: {
[name]: 'foo',
},
};
const newNotificationTemplate: TemplateFormValues = {
title: name,
content: 'foo',
};
const action = addNotificationTemplateAction({ template: newNotificationTemplate });
expect(() => notificationTemplatesReducer(initialConfig, action)).toThrow(/already exists/);
});
it('should update a notification template without renaming', () => {
const name = 'update-me';
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {
templates: [name],
},
template_files: {
[name]: 'update me',
},
};
const action = updateNotificationTemplateAction({ name, template: { title: name, content: 'update me, please' } });
expect(notificationTemplatesReducer(initialConfig, action)).toMatchSnapshot();
});
it('should not update if target does not exist', () => {
const name = 'update-me';
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {},
template_files: {},
};
const action = updateNotificationTemplateAction({ name, template: { title: name, content: 'update me, please' } });
expect(() => notificationTemplatesReducer(initialConfig, action)).toThrow(/did not find it/);
});
it('should not update if renaming and new template name exist', () => {
const name = 'rename-me';
const name2 = 'rename-me-2';
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {
templates: [name, name2],
},
template_files: {
[name]: 'foo',
[name2]: 'bar',
},
};
const action = updateNotificationTemplateAction({ name, template: { title: name2, content: 'foo' } });
expect(() => notificationTemplatesReducer(initialConfig, action)).toThrow(/duplicate/i);
});
it('should allow renaming a notification template', () => {
const name = 'rename-me';
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {
templates: [name],
},
template_files: {
[name]: 'rename me',
},
};
const action = updateNotificationTemplateAction({
name,
template: { title: 'rename-me-copy', content: 'rename me, please' },
});
expect(notificationTemplatesReducer(initialConfig, action)).toMatchSnapshot();
});
it('should remove a notification template', () => {
const name = 'delete-me';
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {
templates: [name],
},
template_files: {
[name]: 'delete me please',
},
};
const action = deleteNotificationTemplateAction({ name });
expect(notificationTemplatesReducer(initialConfig, action)).toMatchSnapshot();
});
});
@@ -0,0 +1,83 @@
import { createAction, createReducer } from '@reduxjs/toolkit';
import { remove, toArray, unset } from 'lodash';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { TemplateFormValues } from '../../components/receivers/TemplateForm';
import { ensureDefine } from '../../utils/templates';
export const addNotificationTemplateAction = createAction<{ template: TemplateFormValues }>('notificationTemplate/add');
export const updateNotificationTemplateAction = createAction<{
name: string;
template: TemplateFormValues;
}>('notificationTemplate/update');
export const deleteNotificationTemplateAction = createAction<{ name: string }>('notificationTemplate/delete');
const initialState: AlertManagerCortexConfig = {
alertmanager_config: {},
template_files: {},
};
/**
* This reducer will manage action related to notification templates and make sure all operations on the alertmanager
* configuration happen immutably and only mutate what they need.
*/
export const notificationTemplatesReducer = createReducer(initialState, (builder) => {
builder
.addCase(addNotificationTemplateAction, (draft, { payload }) => {
const { alertmanager_config = {}, template_files = {} } = draft;
const { template } = payload;
const targetTemplateExists = template_files[template.title] !== undefined;
if (targetTemplateExists) {
throw new Error('target template already exists');
}
// wrap content in "define" if it's not already wrapped, in case user did not do it/
// it's not obvious that this is needed for template to work
const content = ensureDefine(template.title, template.content);
// add the template to the list of template files
template_files[template.title] = content;
// add the template to the alertmanager_config
alertmanager_config.templates = toArray(alertmanager_config.templates).concat(template.title);
})
.addCase(updateNotificationTemplateAction, (draft, { payload }) => {
const { alertmanager_config = {}, template_files = {} } = draft;
const { name, template } = payload;
const renaming = name !== template.title;
const targetExists = template_files[name] !== undefined;
if (!targetExists) {
throw new Error(`Expected notification template ${name} to exist, but did not find it in the config`);
}
// wrap content in "define" if it's not already wrapped, in case user did not do it/
// it's not obvious that this is needed for template to work
const content = ensureDefine(template.title, template.content);
if (renaming) {
const oldName = name;
const newName = template.title;
const targetExists = template_files[newName] !== undefined;
if (targetExists) {
throw new Error(`Duplicate template name ${newName}`);
}
unset(template_files, oldName);
remove(alertmanager_config.templates ?? [], (templateName) => templateName === oldName);
alertmanager_config.templates = toArray(alertmanager_config.templates).concat(template.title);
}
template_files[template.title] = content;
})
.addCase(deleteNotificationTemplateAction, (draft, { payload }) => {
const { name } = payload;
const { alertmanager_config = {}, template_files = {} } = draft;
unset(template_files, name);
remove(alertmanager_config.templates ?? [], (templateName) => templateName === name);
});
});
@@ -11,7 +11,8 @@ import {
SceneObjectUrlValues,
VizPanel,
} from '@grafana/scenes';
import { Container, CustomScrollbar, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { Container, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { getConfig } from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
@@ -83,11 +84,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
<TabsBar hideBorder={true} className={styles.tabsBar}>
{tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))}
</TabsBar>
<CustomScrollbar className={styles.scroll}>
<ScrollContainer backgroundColor="primary">
<TabContent className={styles.tabContent}>
<Container>{currentTab && <currentTab.Component model={currentTab} />}</Container>
</TabContent>
</CustomScrollbar>
</ScrollContainer>
</div>
);
}
@@ -128,8 +129,5 @@ function getStyles(theme: GrafanaTheme2) {
flexShrink: 0,
paddingLeft: theme.spacing(2),
}),
scroll: css({
background: theme.colors.background.primary,
}),
};
}
@@ -1,85 +0,0 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { GrafanaTheme2, PanelPluginMeta } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { CustomScrollbar, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { onCreateNewWidgetPanel } from 'app/features/dashboard/utils/dashboard';
import { VizTypePickerPlugin } from 'app/features/panel/components/VizTypePicker/VizTypePickerPlugin';
import { filterPluginList, getWidgetPluginMeta } from 'app/features/panel/state/util';
import { useSelector } from 'app/types';
export const AddWidgetModal = () => {
const styles = useStyles2(getStyles);
const [searchQuery, setSearchQuery] = useState('');
const dashboard = useSelector((state) => state.dashboard.getModel());
const widgetsList: PanelPluginMeta[] = useMemo(() => {
return getWidgetPluginMeta();
}, []);
const filteredWidgetsTypes = useMemo((): PanelPluginMeta[] => {
return filterPluginList(widgetsList, searchQuery);
}, [widgetsList, searchQuery]);
const onDismiss = () => {
locationService.partial({ addWidget: null });
};
return (
<Modal
title="Select widget type"
closeOnEscape
closeOnBackdropClick
isOpen
className={styles.modal}
onClickBackdrop={onDismiss}
onDismiss={onDismiss}
>
<Input
type="search"
autoFocus
className={styles.searchInput}
value={searchQuery}
prefix={<Icon name="search" />}
placeholder="Search widget"
onChange={(e) => {
setSearchQuery(e.currentTarget.value);
}}
/>
<CustomScrollbar>
<div className={styles.grid}>
{filteredWidgetsTypes.map((plugin, index) => (
<VizTypePickerPlugin
disabled={false}
key={plugin.id}
isCurrent={false}
plugin={plugin}
onClick={(e) => {
const id = onCreateNewWidgetPanel(dashboard!, plugin.id);
locationService.partial({ editPanel: id, addWidget: null });
}}
/>
))}
</div>
</CustomScrollbar>
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
modal: css({
width: '65%',
maxWidth: '960px',
[theme.breakpoints.down('md')]: {
width: '100%',
},
}),
searchInput: css({
marginBottom: theme.spacing(2),
}),
grid: css({
display: 'grid',
gridGap: theme.spacing(1),
}),
});
@@ -6,8 +6,9 @@ import { GrafanaTheme2, ScopedVars } from '@grafana/data';
import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardLink } from '@grafana/schema';
import { CustomScrollbar, Dropdown, Icon, Button, Menu, useStyles2 } from '@grafana/ui';
import { Dropdown, Icon, Button, Menu, useStyles2 } from '@grafana/ui';
import { ButtonLinkProps, LinkButton } from '@grafana/ui/src/components/Button';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchItem } from 'app/features/search/types';
@@ -36,7 +37,7 @@ function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) {
return (
<Menu>
<div className={styles.dropdown}>
<CustomScrollbar>
<ScrollContainer maxHeight="inherit">
{resolvedLinks.map((resolvedLink, index) => {
return (
<Menu.Item
@@ -49,7 +50,7 @@ function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) {
/>
);
})}
</CustomScrollbar>
</ScrollContainer>
</div>
</Menu>
);
@@ -157,7 +158,6 @@ function getStyles(theme: GrafanaTheme2) {
dropdown: css({
maxWidth: 'max(30vw, 300px)',
maxHeight: '70vh',
overflowY: 'auto',
}),
button: css({
color: theme.colors.text.primary,
@@ -118,7 +118,7 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
if (pageId === PluginTabIds.USAGE && pluginConfig) {
return (
<div>
<div className={styles.wrap}>
<PluginUsage plugin={pluginConfig?.meta} />
</div>
);
@@ -140,6 +140,10 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
}
export const getStyles = (theme: GrafanaTheme2) => ({
wrap: css({
width: '100%',
height: '50vh',
}),
readme: css({
'& img': {
maxWidth: '100%',
@@ -115,6 +115,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
// Needed due to block formatting context
tabContent: css({
paddingLeft: '5px',
width: '100%',
}),
};
};
@@ -88,7 +88,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
return {
wrap: css({
width: '100%',
height: '100%',
height: '90%',
}),
info: css({
paddingBottom: '30px',
@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { Version } from '../types';
@@ -8,6 +9,13 @@ import { Version } from '../types';
import { VersionInstallButton } from './VersionInstallButton';
describe('VersionInstallButton', () => {
const originalConfig = { ...config };
afterEach(() => {
config.featureToggles = {
...originalConfig.featureToggles,
};
config.pluginCatalogPreinstalledPlugins = originalConfig.pluginCatalogPreinstalledPlugins;
});
it('should show install when no version is installed', () => {
const version: Version = {
version: '',
@@ -103,6 +111,72 @@ describe('VersionInstallButton', () => {
const el = screen.getByText('Installed');
expect(el).toBeVisible();
});
it('should hide the upgrade button if preinstalled and pinned', () => {
const version: Version = {
version: '1.0.1',
createdAt: '',
isCompatible: false,
grafanaDependency: null,
};
const installedVersion = '1.0.0';
config.featureToggles.preinstallAutoUpdate = true;
config.pluginCatalogPreinstalledPlugins = [{ id: 'test', version: '1.0.0' }];
renderWithStore(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={'test'}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
expect(screen.getByText('Upgrade')).not.toBeVisible();
});
it('should hide the downgrade button if preinstalled and pinned', () => {
const version: Version = {
version: '1.0.0',
createdAt: '',
isCompatible: false,
grafanaDependency: null,
};
const installedVersion = '1.0.1';
config.featureToggles.preinstallAutoUpdate = true;
config.pluginCatalogPreinstalledPlugins = [{ id: 'test', version: '1.0.1' }];
renderWithStore(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={'test'}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
expect(screen.getByText('Downgrade')).not.toBeVisible();
});
it('should hide the downgrade button if preinstalled', () => {
const version: Version = {
version: '1.0.0',
createdAt: '',
isCompatible: false,
grafanaDependency: null,
};
const installedVersion = '1.0.1';
config.featureToggles.preinstallAutoUpdate = true;
config.pluginCatalogPreinstalledPlugins = [{ id: 'test', version: '' }];
renderWithStore(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={'test'}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
expect(screen.getByText('Downgrade')).not.toBeVisible();
});
});
function renderWithStore(component: JSX.Element) {
@@ -3,10 +3,11 @@ import { useEffect, useState } from 'react';
import { gt } from 'semver';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { Badge, Button, ConfirmModal, Icon, Spinner, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isPreinstalledPlugin } from '../helpers';
import { useInstall } from '../state/hooks';
import { Version } from '../types';
@@ -89,11 +90,22 @@ export const VersionInstallButton = ({
};
let label = 'Downgrade';
let hidden = false;
const isPreinstalled = isPreinstalledPlugin(pluginId);
if (!installedVersion) {
label = 'Install';
} else if (gt(version.version, installedVersion)) {
label = 'Upgrade';
if (isPreinstalled.withVersion) {
// Hide button if the plugin is preinstalled with a specific version
hidden = true;
}
} else {
if (isPreinstalled.found && Boolean(config.featureToggles.preinstallAutoUpdate)) {
// Hide the downgrade button if the plugin is preinstalled since it will be auto-updated
hidden = true;
}
}
return (
@@ -106,6 +118,7 @@ export const VersionInstallButton = ({
variant={latestCompatibleVersion === version.version ? 'primary' : 'secondary'}
onClick={onInstallClick}
className={styles.button}
hidden={hidden}
>
{label} {isInstalling ? <Spinner className={styles.spinner} inline size="sm" /> : getIcon(label)}
</Button>
@@ -7,6 +7,7 @@ import {
shouldLoadPluginInFrontendSandbox,
setSandboxEnabledCheck,
isPluginFrontendSandboxEnabled,
isPluginFrontendSandboxEligible,
} from './sandbox_plugin_loader_registry';
jest.mock('@grafana/runtime', () => ({
@@ -89,4 +90,11 @@ describe('Sandbox eligibility checks', () => {
expect(customCheck).toHaveBeenCalledWith({ pluginId: 'test-plugin' });
expect(result).toBe(false);
});
test('isPluginFrontendSandboxEligible returns false for plugins with internal signature', async () => {
//@ts-expect-error We don't publicly export the internal signature
getPluginSettingsMock.mockResolvedValue({ ...fakePlugin, signature: 'internal' });
const result = await isPluginFrontendSandboxEligible({ pluginId: 'test-plugin' });
expect(result).toBe(false);
});
});
@@ -64,7 +64,7 @@ export async function isPluginFrontendSandboxEligible({
try {
// don't run grafana-signed plugins in sandbox
const pluginMeta = await getPluginSettings(pluginId, { showErrorAlert: false });
if (pluginMeta.signatureType === PluginSignatureType.grafana) {
if (pluginMeta.signatureType === PluginSignatureType.grafana || pluginMeta.signature === 'internal') {
return false;
}
} catch (e) {
@@ -16,7 +16,8 @@ import {
import { selectors } from '@grafana/e2e-selectors';
import { getDataSourceSrv, locationService } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Button, CustomScrollbar, HorizontalGroup, InlineFormLabel, Modal, stylesFactory } from '@grafana/ui';
import { Button, HorizontalGroup, InlineFormLabel, Modal, stylesFactory } from '@grafana/ui';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import config from 'app/core/config';
import { backendSrv } from 'app/core/services/backend_srv';
@@ -331,7 +332,7 @@ export class QueryGroup extends PureComponent<Props, State> {
const styles = getStyles();
return (
<CustomScrollbar autoHeightMin="100%" scrollRefCallback={this.setScrollRef}>
<ScrollContainer minHeight="100%" ref={this.setScrollRef}>
<div className={styles.innerWrapper}>
{this.renderTopSection(styles)}
{dsSettings && (
@@ -346,7 +347,7 @@ export class QueryGroup extends PureComponent<Props, State> {
</>
)}
</div>
</CustomScrollbar>
</ScrollContainer>
);
}
}
@@ -2,7 +2,8 @@ import { css, cx } from '@emotion/css';
import { Dictionary } from 'lodash';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, CustomScrollbar, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { Checkbox, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import { t } from 'app/core/internationalization';
import { ScopesTree } from './ScopesTree';
@@ -129,12 +130,12 @@ export function ScopesTreeItem({
if (isLastExpandedNode) {
return (
<CustomScrollbar
autoHeightMin={`${Math.min(5, nodes.length) * 30}px`}
autoHeightMax={nodeReason === NodeReason.Persisted ? `${Math.min(5, nodes.length) * 30}px` : '100%'}
<ScrollContainer
minHeight={`${Math.min(5, nodes.length) * 30}px`}
maxHeight={nodeReason === NodeReason.Persisted ? `${Math.min(5, nodes.length) * 30}px` : '100%'}
>
{children}
</CustomScrollbar>
</ScrollContainer>
);
}
@@ -31,7 +31,7 @@ export class SortByScene extends SceneObjectBase<SortBySceneState> {
options: [
{
value: 'outliers',
label: 'Outlying Values',
label: 'Outlying series',
description: 'Prioritizes values that show distinct behavior from others within the same label',
},
{
@@ -33,7 +33,7 @@ export type ExtractedRecordingRules = {
*/
async function fetchRecordingRuleGroups(datasourceSettings: DataSourceInstanceSettings<DataSourceJsonData>) {
const recordingRuleUrl = `api/prometheus/${datasourceSettings.uid}/api/v1/rules`;
const recordingRules: BackendSrvRequest = { url: recordingRuleUrl };
const recordingRules: BackendSrvRequest = { url: recordingRuleUrl, showErrorAlert: false, showSuccessAlert: false };
const { data } = await lastValueFrom<
FetchResponse<{
data: { groups: RecordingRuleGroup[] };
@@ -4,9 +4,10 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
import { CustomScrollbar, FieldValidationMessage, Portal, TextArea, useTheme2 } from '@grafana/ui';
import { FieldValidationMessage, Portal, TextArea, useTheme2 } from '@grafana/ui';
import { DataLinkSuggestions } from '@grafana/ui/src/components/DataLinks/DataLinkSuggestions';
import { Input } from '@grafana/ui/src/components/Input/Input';
import { ScrollContainer } from '@grafana/ui/src/unstable';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
const ERROR_TOOLTIP_OFFSET = 8;
@@ -61,6 +62,7 @@ export const SuggestionsInput = ({
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [variableValue, setVariableValue] = useState<string>(value.toString());
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [inputHeight, setInputHeight] = useState<number>(0);
const [startPos, setStartPos] = useState<number>(0);
@@ -70,6 +72,10 @@ export const SuggestionsInput = ({
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollTop);
}, [scrollTop]);
// the order of middleware is important!
const middleware = [
flip({
@@ -189,10 +195,10 @@ export const SuggestionsInput = ({
{showingSuggestions && (
<Portal>
<div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}>
<CustomScrollbar
scrollTop={scrollTop}
autoHeightMax="300px"
setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)}
<ScrollContainer
maxHeight="300px"
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop ?? 0)}
ref={scrollRef}
>
{/* This suggestion component has a specialized name,
but is rather generalistic in implementation,
@@ -205,7 +211,7 @@ export const SuggestionsInput = ({
onClose={() => setShowingSuggestions(false)}
activeIndex={suggestionsIndex}
/>
</CustomScrollbar>
</ScrollContainer>
</div>
</Portal>
)}
@@ -3,17 +3,8 @@ import { useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import {
Button,
Card,
Collapse,
CustomScrollbar,
Field,
Input,
LoadingPlaceholder,
Select,
useStyles2,
} from '@grafana/ui';
import { Button, Card, Collapse, Field, Input, LoadingPlaceholder, Select, useStyles2 } from '@grafana/ui';
import { ScrollContainer } from '@grafana/ui/src/unstable';
import AzureLogAnalyticsDatasource from '../../azure_log_analytics/azure_log_analytics_datasource';
import {
@@ -171,7 +162,7 @@ const AzureCheatSheet = (props: AzureCheatSheetProps) => {
return totalQueries;
}, 0)}
</div>
<CustomScrollbar showScrollIndicators={true} autoHeightMax="350px">
<ScrollContainer showScrollIndicators maxHeight="350px">
{Object.keys(visibleQueries).map((category: string) => {
if (visibleQueries[category]!.length) {
return (
@@ -186,14 +177,14 @@ const AzureCheatSheet = (props: AzureCheatSheetProps) => {
return (
<Card className={styles.card} key={query.id}>
<Card.Heading>{query.displayName}</Card.Heading>
<CustomScrollbar showScrollIndicators={true} autoHeightMax="100px">
<ScrollContainer showScrollIndicators maxHeight="100px">
<RawQuery
aria-label={`${query.displayName} raw query`}
query={query.body}
lang={lang}
className={styles.rawQuery}
/>
</CustomScrollbar>
</ScrollContainer>
<Card.Actions>
<Button
size="sm"
@@ -224,7 +215,7 @@ const AzureCheatSheet = (props: AzureCheatSheetProps) => {
}
return;
})}
</CustomScrollbar>
</ScrollContainer>
</div>
) : (
<LoadingPlaceholder text="Loading..." />
@@ -19,7 +19,7 @@
"prismjs": "1.29.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-use": "17.5.1",
"rxjs": "7.8.1",
"tslib": "2.7.0"
@@ -20,7 +20,7 @@
"prismjs": "1.29.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-use": "17.5.1",
"rxjs": "7.8.1",
"tslib": "2.7.0"
@@ -22,7 +22,7 @@
"devDependencies": {
"@grafana/plugin-configs": "11.4.0-pre",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
@@ -15,7 +15,7 @@
"micro-memoize": "^4.1.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-use": "17.5.1",
"rxjs": "7.8.1",
"tslib": "2.7.0",
@@ -15,7 +15,7 @@
"logfmt": "^1.3.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select": "5.8.2",
"react-select": "5.8.3",
"react-window": "1.8.10",
"rxjs": "7.8.1",
"stream-browserify": "3.0.0",
@@ -25,7 +25,7 @@
"devDependencies": {
"@grafana/plugin-configs": "workspace:*",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",

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