Merge branch 'main' into serge/prevent-panel-hovel-header-crop-with-scenes
This commit is contained in:
commit
d8df97cda9
77 files changed
+4020
-1430
No files matched your search
@@ -820,9 +820,6 @@ exports[`better eslint`] = {
|
||||
"packages/grafana-ui/src/components/Dropdown/Dropdown.story.tsx:5381": [
|
||||
[0, 0, 0, "\'VerticalGroup\' import from \'../Layout/Layout\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/FilterPill/FilterPill.story.tsx:5381": [
|
||||
[0, 0, 0, "\'HorizontalGroup\' import from \'../Layout/Layout\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Forms/Checkbox.story.tsx:5381": [
|
||||
[0, 0, 0, "\'VerticalGroup\' import from \'../Layout/Layout\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
|
||||
],
|
||||
@@ -867,9 +864,6 @@ exports[`better eslint`] = {
|
||||
"packages/grafana-ui/src/components/Menu/Menu.story.tsx:5381": [
|
||||
[0, 0, 0, "\'VerticalGroup\' import from \'../Layout/Layout\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Modal/Modal.tsx:5381": [
|
||||
[0, 0, 0, "\'HorizontalGroup\' import from \'../Layout/Layout\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Modal/ModalsContext.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
|
||||
@@ -172,7 +172,7 @@ make test-go-integration-postgres
|
||||
|
||||
### Run end-to-end tests
|
||||
|
||||
The end to end tests in Grafana use [Cypress](https://www.cypress.io/) and [Playwright](https://playwright.dev/) to run automated scripts in a browser. Read more about our Cypress [e2e framework](/contribute/style-guides/e2e.md).
|
||||
Grafana uses [Cypress](https://www.cypress.io/) to end-to-end test core features. Core plugins use [Playwright](https://playwright.dev/) to run automated end-to-end tests. You can find more information on how to add end-to-end tests to your core plugin [here](./style-guides/e2e-plugins.md)
|
||||
|
||||
#### Running Cypress tests
|
||||
|
||||
|
||||
@@ -22,4 +22,4 @@ The above commands use some utils scripts under [_\<repo-root>/e2e_](../../e2e)
|
||||
|
||||
## Test suites
|
||||
|
||||
All the integration tests are located at _\<repo-root>/e2e/suite\<x>/specs_. The page objects and reusable flows are in the [_\<repo-root>/packages/grafana-e2e_](../../packages/grafana-e2e) package.
|
||||
All the integration tests are located at _\<repo-root>/e2e/suite\<x>/specs_.
|
||||
@@ -1,28 +1,35 @@
|
||||
# End-to-End Tests for plugins
|
||||
# end-to-end tests for plugins
|
||||
|
||||
Be sure that you've read the [generalized E2E document](e2e.md).
|
||||
When end-to-end testing Grafana plugins, it's recommended to use the [`@grafana/plugin-e2e`](https://www.npmjs.com/package/@grafana/plugin-e2e?activeTab=readme) testing tool. `@grafana/plugin-e2e` extends [`@playwright/test`](https://playwright.dev/) capabilities with relevant fixtures, models, and expect matchers; enabling comprehensive end-to-end testing of Grafana plugins across multiple versions of Grafana. For information on how to get started with Plugin end-to-end testing and Playwright, checkout the [Get started](https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/get-started) guide.
|
||||
|
||||
## Adding end-to-end tests for a core plugin
|
||||
|
||||
Playwright end-to-end tests for plugins should be added to the [`e2e/plugin-e2e`](https://github.com/grafana/grafana/tree/main/e2e/plugin-e2e) directory.
|
||||
|
||||
1. Add a new directory that has the name as your plugin [`here`](https://github.com/grafana/grafana/tree/main/e2e/plugin-e2e). This is where your plugin tests will be kept.
|
||||
|
||||
2. Playwright uses [projects](https://playwright.dev/docs/test-projects) to logically group tests together. All tests in a project share the same configuration.
|
||||
In the [Playwright config file](https://github.com/grafana/grafana/blob/main/playwright.config.ts), add a new project item. Make sure the `name` and the `testDir` sub directory matches the name of the directory that contains your plugin tests.
|
||||
Adding `'authenticate'` to the list of dependencies and specifying `'playwright/.auth/admin.json'` as storage state will ensure all tests in your project will start already authenticated as an admin user. If you wish to use a different role for and perhaps test RBAC for some of your tests, please refer to the plugin-e2e [documentation](https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/use-authentication).
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'mysql',
|
||||
testDir: path.join(testDirRoot, '/mysql'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
```
|
||||
|
||||
3. Update the [CODEOWNERS](https://github.com/grafana/grafana/blob/main/.github/CODEOWNERS/#L315) file so that your team is owner of the tests in the directory you added in step 1.
|
||||
|
||||
## Commands
|
||||
|
||||
- `yarn test:e2e` will run [Grafana's E2E utility](../../packages/grafana-e2e) against an already running Grafana server.
|
||||
- `yarn test:e2e:update` will run `test:e2e` but instead of asserting that screenshots match their expected fixtures, they'll be replaced with new ones.
|
||||
- `yarn e2e:playwright` will run all Playwright tests. Optionally, you can provide the `--project mysql` argument to run tests in a certain project.
|
||||
|
||||
Your running Grafana instance can be targeted by setting the `CYPRESS_BASE_URL`, `CYPRESS_USERNAME` and `CYPRESS_PASSWORD` environment variableS:
|
||||
The script above assumes you have Grafana running on `localhost:3000`. You may change this by providing environment variables.
|
||||
|
||||
```shell
|
||||
CYPRESS_BASE_URL=https://localhost:3000 CYPRESS_USERNAME=admin CYPRESS_PASSWORD=admin yarn test:e2e
|
||||
```
|
||||
|
||||
## Test suites
|
||||
|
||||
All tests are located at _\<repo-root>/cypress/integration_ by default.
|
||||
|
||||
## Things to test
|
||||
|
||||
- Add data source (if applicable)
|
||||
- Add panel
|
||||
- Edit panel
|
||||
- Annotations (if applicable)
|
||||
- Aliases (if applicable)
|
||||
- Template variables
|
||||
- "Explore" view
|
||||
`HOST=127.0.0.1 PORT=3001 yarn e2e:playwright`
|
||||
@@ -1,13 +1,13 @@
|
||||
# End-to-End tests
|
||||
|
||||
Grafana Labs uses a minimal [homegrown solution](../../packages/grafana-e2e) built on top of [Cypress](https://cypress.io) for its end-to-end (E2E) tests.
|
||||
Grafana Labs uses a minimal [homegrown solution](../../e2e/utils/index.ts) built on top of [Cypress](https://cypress.io) for its end-to-end (E2E) tests.
|
||||
|
||||
Important notes:
|
||||
|
||||
- We generally store all element identifiers ([CSS selectors](https://mdn.io/docs/Web/CSS/CSS_Selectors)) within the framework for reuse and maintainability.
|
||||
- We generally do not use stubs or mocks as to fully simulate a real user.
|
||||
- Cypress' promises [do not behave as you'd expect](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Mixing-Async-and-Sync-code).
|
||||
- [Testing core Grafana](e2e-core.md) is slightly different than [testing plugins](e2e-plugins.md).
|
||||
- [Testing core Grafana](e2e-core.md) is different than [testing plugins](e2e-plugins.md) - core Grafana uses Cypress whereas plugins use [Playwright test](https://playwright.dev/).
|
||||
|
||||
## Framework structure
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ Team LBAC is available in private preview on Grafana Cloud for Loki created with
|
||||
|
||||
## Before you begin
|
||||
|
||||
To be able to use Team LBAC rules, you need to enable the feature toggle `teamHTTPHeaders` on your Grafana instance. Contact support to enable the feature toggle for you.
|
||||
To be able to use Team LBAC rules, you need to enable the feature toggle `teamHttpHeaders` on your Grafana instance. Contact support to enable the feature toggle for you.
|
||||
|
||||
- Be sure that you have the permission setup to create a loki tenant in Grafana Cloud
|
||||
- Be sure that you have admin data source permissions for Grafana.
|
||||
|
||||
+135
-134
@@ -29,140 +29,141 @@ To learn more about the Grafana resources to which you can apply RBAC, refer to
|
||||
|
||||
The following list contains role-based access control actions.
|
||||
|
||||
| Action | Applicable scope | Description |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alert.instances.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alerts and silences in data sources that support alerting. |
|
||||
| `alert.instances.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. |
|
||||
| `alert.instances:create` | n/a | Create silences in the current organization. |
|
||||
| `alert.instances:read` | n/a | Read alerts and silences in the current organization. |
|
||||
| `alert.instances:write` | n/a | Update and expire silences in the current organization. |
|
||||
| `alert.notifications.external:read` | `datasources:*`<br>`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. |
|
||||
| `alert.notifications.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. |
|
||||
| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. |
|
||||
| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. |
|
||||
| `alert.rules.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) |
|
||||
| `alert.rules.external:write` | `datasources:*`<br>`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). |
|
||||
| `alert.rules:create` | `folders:*`<br>`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.rules:delete` | `folders:*`<br>`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.rules:read` | `folders:*`<br>`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.rules:write` | `folders:*`<br>`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. |
|
||||
| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. |
|
||||
| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. |
|
||||
| `annotations:create` | `annotations:*`<br>`annotations:type:*` | Create annotations. |
|
||||
| `annotations:delete` | `annotations:*`<br>`annotations:type:*` | Delete annotations. |
|
||||
| `annotations:read` | `annotations:*`<br>`annotations:type:*` | Read annotations and annotation tags. |
|
||||
| `annotations:write` | `annotations:*`<br>`annotations:type:*` | Update annotations. |
|
||||
| `apikeys:create` | n/a | Create API keys. |
|
||||
| `apikeys:read` | `apikeys:*`<br>`apikeys:id:*` | Read API keys. |
|
||||
| `apikeys:delete` | `apikeys:*`<br>`apikeys:id:*` | Delete API keys. |
|
||||
| `dashboards:create` | `folders:*`<br>`folders:uid:*` | Create dashboards in one or more folders and their subfolders. |
|
||||
| `dashboards:delete` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Delete one or more dashboards. |
|
||||
| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. |
|
||||
| `dashboards.permissions:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read permissions for one or more dashboards. |
|
||||
| `dashboards.permissions:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update permissions for one or more dashboards. |
|
||||
| `dashboards:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read one or more dashboards. |
|
||||
| `dashboards:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update one or more dashboards. |
|
||||
| `dashboards.public:write` | `dashboards:*`<br>`dashboards:uid:*` | Write public dashboard configuration. |
|
||||
| `datasources.caching:read` | `datasources:*`<br>`datasources:uid:*` | Read data source query caching settings. |
|
||||
| `datasources.caching:write` | `datasources:*`<br>`datasources:uid:*` | Update data source query caching settings. |
|
||||
| `datasources:create` | n/a | Create data sources. |
|
||||
| `datasources:delete` | `datasources:*`<br>`datasources:uid:*` | Delete data sources. |
|
||||
| `datasources:explore` | n/a | Enable access to the **Explore** tab. |
|
||||
| `datasources.id:read` | `datasources:*`<br>`datasources:uid:*` | Read data source IDs. |
|
||||
| `datasources.insights:read` | n/a | Read data sources insights data. |
|
||||
| `datasources.permissions:read` | `datasources:*`<br>`datasources:uid:*` | List data source permissions. |
|
||||
| `datasources.permissions:write` | `datasources:*`<br>`datasources:uid:*` | Update data source permissions. |
|
||||
| `datasources:query` | `datasources:*`<br>`datasources:uid:*` | Query data sources. |
|
||||
| `datasources:read` | `datasources:*`<br>`datasources:uid:*` | List data sources. |
|
||||
| `datasources:write` | `datasources:*`<br>`datasources:uid:*` | Update data sources. |
|
||||
| `featuremgmt.read` | n/a | Read feature toggles. |
|
||||
| `featuremgmt.write` | n/a | Write feature toggles. |
|
||||
| `folders.permissions:read` | `folders:*`<br>`folders:uid:*` | Read permissions for one or more folders and their subfolders. |
|
||||
| `folders.permissions:write` | `folders:*`<br>`folders:uid:*` | Update permissions for one or more folders and their subfolders. |
|
||||
| `folders:create` | n/a | Create folders in the root level. If granted together with `folders:write`, also allows creating subfolders under all folders that the user can update. |
|
||||
| `folders:delete` | `folders:*`<br>`folders:uid:*` | Delete one or more folders and their subfolders. |
|
||||
| `folders:read` | `folders:*`<br>`folders:uid:*` | Read one or more folders and their subfolders. |
|
||||
| `folders:write` | `folders:*`<br>`folders:uid:*` | Update one or more folders and their subfolders. If granted together with `folders:create` permission, also allows creating subfolders under these folders. |
|
||||
| `ldap.config:reload` | n/a | Reload the LDAP configuration. |
|
||||
| `ldap.status:read` | n/a | Verify the availability of the LDAP server or servers. |
|
||||
| `ldap.user:read` | n/a | Read users via LDAP. |
|
||||
| `ldap.user:sync` | n/a | Sync users via LDAP. |
|
||||
| `library.panels:create` | `folders:*` <br> `folders:uid:*` | Create a library panel in one or more folders and their subfolders. |
|
||||
| `library.panels:read` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Read one or more library panels. |
|
||||
| `library.panels:write` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Update one or more library panels. |
|
||||
| `library.panels:delete` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Delete one or more library panels. |
|
||||
| `licensing.reports:read` | n/a | Get custom permission reports. |
|
||||
| `licensing:delete` | n/a | Delete the license token. |
|
||||
| `licensing:read` | n/a | Read licensing information. |
|
||||
| `licensing:write` | n/a | Update the license token. |
|
||||
| `org.users:write` | `users:*` <br> `users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. |
|
||||
| `org.users:add` | `users:*` <br> `users:id:*` | Add a user to an organization or invite a new user to an organization. |
|
||||
| `org.users:read` | `users:*` <br> `users:id:*` | Get user profiles within an organization. |
|
||||
| `org.users:remove` | `users:*` <br> `users:id:*` | Remove a user from an organization. |
|
||||
| `orgs.preferences:read` | n/a | Read organization preferences. |
|
||||
| `orgs.preferences:write` | n/a | Update organization preferences. |
|
||||
| `orgs.quotas:read` | n/a | Read organization quotas. |
|
||||
| `orgs.quotas:write` | n/a | Update organization quotas. |
|
||||
| `orgs:create` | n/a | Create an organization. |
|
||||
| `orgs:delete` | n/a | Delete one or more organizations. |
|
||||
| `orgs:read` | n/a | Read one or more organizations. |
|
||||
| `orgs:write` | n/a | Update one or more organizations. |
|
||||
| `plugins.app:access` | `plugins:*` <br> `plugins:id:*` | Access one or more application plugins (still enforcing the organization role) |
|
||||
| `plugins:install` | n/a | Install and uninstall plugins. |
|
||||
| `plugins:write` | `plugins:*` <br> `plugins:id:*` | Edit settings for one or more plugins. |
|
||||
| `provisioning:reload` | `provisioners:*` | Reload provisioning files. To find the exact scope for specific provisioner, see [Scope definitions]({{< relref "#scope-definitions" >}}). |
|
||||
| `reports:create` | n/a | Create reports. |
|
||||
| `reports:write` | `reports:*` <br> `reports:id:*` | Update reports. |
|
||||
| `reports.settings:read` | n/a | Read report settings. |
|
||||
| `reports.settings:write` | n/a | Update report settings. |
|
||||
| `reports:delete` | `reports:*` <br> `reports:id:*` | Delete reports. |
|
||||
| `reports:read` | `reports:*` <br> `reports:id:*` | List all available reports or get a specific report. |
|
||||
| `reports:send` | `reports:*` <br> `reports:id:*` | Send a report email. |
|
||||
| `roles:delete` | `permissions:type:delegate` | Delete a custom role. |
|
||||
| `roles:read` | `roles:*` <br> `roles:uid:*` | List roles and read a specific role with its permissions. |
|
||||
| `roles:write` | `permissions:type:delegate` | Create or update a custom role. |
|
||||
| `roles:write` | `permissions:type:escalate` | Reset basic roles to their default permissions. |
|
||||
| `server.stats:read` | n/a | Read Grafana instance statistics. |
|
||||
| `server.usagestats.report:read` | n/a | View usage statistics report. |
|
||||
| `serviceaccounts:write` | `serviceaccounts:*` | Create Grafana service accounts. |
|
||||
| `serviceaccounts:create` | n/a | Update Grafana service accounts. |
|
||||
| `serviceaccounts:delete` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Delete Grafana service accounts. |
|
||||
| `serviceaccounts:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service accounts. |
|
||||
| `serviceaccounts.permissions:write` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. |
|
||||
| `serviceaccounts.permissions:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. |
|
||||
| `settings:read` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) |
|
||||
| `settings:write` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update any Grafana configuration settings that can be [updated at runtime]({{< relref "../../../../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}). |
|
||||
| `support.bundles:create` | n/a | Create support bundles. |
|
||||
| `support.bundles:delete` | n/a | Delete support bundles. |
|
||||
| `support.bundles:read` | n/a | List and download support bundles. |
|
||||
| `status:accesscontrol` | `services:accesscontrol` | Get access-control enabled status. |
|
||||
| `teams.permissions:read` | `teams:*`<br>`teams:id:*` | Read members and Team Sync setup for teams. |
|
||||
| `teams.permissions:write` | `teams:*`<br>`teams:id:*` | Add, remove and update members and manage Team Sync setup for teams. |
|
||||
| `teams.roles:add` | `permissions:type:delegate` | Assign a role to a team. |
|
||||
| `teams.roles:read` | `teams:*`<br>`teams:id:*` | List roles assigned directly to a team. |
|
||||
| `teams.roles:remove` | `permissions:type:delegate` | Unassign a role from a team. |
|
||||
| `teams:create` | n/a | Create teams. |
|
||||
| `teams:delete` | `teams:*`<br>`teams:id:*` | Delete one or more teams. |
|
||||
| `teams:read` | `teams:*`<br>`teams:id:*` | Read one or more teams and team preferences. To list teams through the UI one of the following permissions is required in addition to `teams:read`: `teams:write`, `teams.permissions:read` or `teams.permissions:write`. |
|
||||
| `teams:write` | `teams:*`<br>`teams:id:*` | Update one or more teams and team preferences. |
|
||||
| `users.authtoken:read` | `global.users:*` <br> `global.users:id:*` | List authentication tokens that are assigned to a user. |
|
||||
| `users.authtoken:write` | `global.users:*` <br> `global.users:id:*` | Update authentication tokens that are assigned to a user. |
|
||||
| `users.password:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s password. |
|
||||
| `users.permissions:read` | `users:*` | List permissions of a user. |
|
||||
| `users.permissions:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s organization-level permissions. |
|
||||
| `users.quotas:read` | `global.users:*` <br> `global.users:id:*` | List a user’s quotas. |
|
||||
| `users.quotas:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s quotas. |
|
||||
| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user or a service account. |
|
||||
| `users.roles:read` | `users:*` | List roles assigned directly to a user or a service account. |
|
||||
| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user or a service account. |
|
||||
| `users:create` | n/a | Create a user. |
|
||||
| `users:delete` | `global.users:*` <br> `global.users:id:*` | Delete a user. |
|
||||
| `users:disable` | `global.users:*` <br> `global.users:id:*` | Disable a user. |
|
||||
| `users:enable` | `global.users:*` <br> `global.users:id:*` | Enable a user. |
|
||||
| `users:logout` | `global.users:*` <br> `global.users:id:*` | Sign out a user. |
|
||||
| `users:read` | `global.users:*` | Read or search user profiles. |
|
||||
| `users:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s profile. |
|
||||
| Action | Applicable scope | Description |
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alert.instances.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alerts and silences in data sources that support alerting. |
|
||||
| `alert.instances.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. |
|
||||
| `alert.instances:create` | n/a | Create silences in the current organization. |
|
||||
| `alert.instances:read` | n/a | Read alerts and silences in the current organization. |
|
||||
| `alert.instances:write` | n/a | Update and expire silences in the current organization. |
|
||||
| `alert.notifications.external:read` | `datasources:*`<br>`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. |
|
||||
| `alert.notifications.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. |
|
||||
| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. |
|
||||
| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. |
|
||||
| `alert.rules.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) |
|
||||
| `alert.rules.external:write` | `datasources:*`<br>`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). |
|
||||
| `alert.rules:create` | `folders:*`<br>`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.rules:delete` | `folders:*`<br>`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.rules:read` | `folders:*`<br>`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.rules:write` | `folders:*`<br>`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
|
||||
| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. |
|
||||
| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. |
|
||||
| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. |
|
||||
| `alert.provisioning.provenance:write` | n/a | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources |
|
||||
| `annotations:create` | `annotations:*`<br>`annotations:type:*` | Create annotations. |
|
||||
| `annotations:delete` | `annotations:*`<br>`annotations:type:*` | Delete annotations. |
|
||||
| `annotations:read` | `annotations:*`<br>`annotations:type:*` | Read annotations and annotation tags. |
|
||||
| `annotations:write` | `annotations:*`<br>`annotations:type:*` | Update annotations. |
|
||||
| `apikeys:create` | n/a | Create API keys. |
|
||||
| `apikeys:read` | `apikeys:*`<br>`apikeys:id:*` | Read API keys. |
|
||||
| `apikeys:delete` | `apikeys:*`<br>`apikeys:id:*` | Delete API keys. |
|
||||
| `dashboards:create` | `folders:*`<br>`folders:uid:*` | Create dashboards in one or more folders and their subfolders. |
|
||||
| `dashboards:delete` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Delete one or more dashboards. |
|
||||
| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. |
|
||||
| `dashboards.permissions:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read permissions for one or more dashboards. |
|
||||
| `dashboards.permissions:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update permissions for one or more dashboards. |
|
||||
| `dashboards:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read one or more dashboards. |
|
||||
| `dashboards:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update one or more dashboards. |
|
||||
| `dashboards.public:write` | `dashboards:*`<br>`dashboards:uid:*` | Write public dashboard configuration. |
|
||||
| `datasources.caching:read` | `datasources:*`<br>`datasources:uid:*` | Read data source query caching settings. |
|
||||
| `datasources.caching:write` | `datasources:*`<br>`datasources:uid:*` | Update data source query caching settings. |
|
||||
| `datasources:create` | n/a | Create data sources. |
|
||||
| `datasources:delete` | `datasources:*`<br>`datasources:uid:*` | Delete data sources. |
|
||||
| `datasources:explore` | n/a | Enable access to the **Explore** tab. |
|
||||
| `datasources.id:read` | `datasources:*`<br>`datasources:uid:*` | Read data source IDs. |
|
||||
| `datasources.insights:read` | n/a | Read data sources insights data. |
|
||||
| `datasources.permissions:read` | `datasources:*`<br>`datasources:uid:*` | List data source permissions. |
|
||||
| `datasources.permissions:write` | `datasources:*`<br>`datasources:uid:*` | Update data source permissions. |
|
||||
| `datasources:query` | `datasources:*`<br>`datasources:uid:*` | Query data sources. |
|
||||
| `datasources:read` | `datasources:*`<br>`datasources:uid:*` | List data sources. |
|
||||
| `datasources:write` | `datasources:*`<br>`datasources:uid:*` | Update data sources. |
|
||||
| `featuremgmt.read` | n/a | Read feature toggles. |
|
||||
| `featuremgmt.write` | n/a | Write feature toggles. |
|
||||
| `folders.permissions:read` | `folders:*`<br>`folders:uid:*` | Read permissions for one or more folders and their subfolders. |
|
||||
| `folders.permissions:write` | `folders:*`<br>`folders:uid:*` | Update permissions for one or more folders and their subfolders. |
|
||||
| `folders:create` | n/a | Create folders in the root level. If granted together with `folders:write`, also allows creating subfolders under all folders that the user can update. |
|
||||
| `folders:delete` | `folders:*`<br>`folders:uid:*` | Delete one or more folders and their subfolders. |
|
||||
| `folders:read` | `folders:*`<br>`folders:uid:*` | Read one or more folders and their subfolders. |
|
||||
| `folders:write` | `folders:*`<br>`folders:uid:*` | Update one or more folders and their subfolders. If granted together with `folders:create` permission, also allows creating subfolders under these folders. |
|
||||
| `ldap.config:reload` | n/a | Reload the LDAP configuration. |
|
||||
| `ldap.status:read` | n/a | Verify the availability of the LDAP server or servers. |
|
||||
| `ldap.user:read` | n/a | Read users via LDAP. |
|
||||
| `ldap.user:sync` | n/a | Sync users via LDAP. |
|
||||
| `library.panels:create` | `folders:*` <br> `folders:uid:*` | Create a library panel in one or more folders and their subfolders. |
|
||||
| `library.panels:read` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Read one or more library panels. |
|
||||
| `library.panels:write` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Update one or more library panels. |
|
||||
| `library.panels:delete` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Delete one or more library panels. |
|
||||
| `licensing.reports:read` | n/a | Get custom permission reports. |
|
||||
| `licensing:delete` | n/a | Delete the license token. |
|
||||
| `licensing:read` | n/a | Read licensing information. |
|
||||
| `licensing:write` | n/a | Update the license token. |
|
||||
| `org.users:write` | `users:*` <br> `users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. |
|
||||
| `org.users:add` | `users:*` <br> `users:id:*` | Add a user to an organization or invite a new user to an organization. |
|
||||
| `org.users:read` | `users:*` <br> `users:id:*` | Get user profiles within an organization. |
|
||||
| `org.users:remove` | `users:*` <br> `users:id:*` | Remove a user from an organization. |
|
||||
| `orgs.preferences:read` | n/a | Read organization preferences. |
|
||||
| `orgs.preferences:write` | n/a | Update organization preferences. |
|
||||
| `orgs.quotas:read` | n/a | Read organization quotas. |
|
||||
| `orgs.quotas:write` | n/a | Update organization quotas. |
|
||||
| `orgs:create` | n/a | Create an organization. |
|
||||
| `orgs:delete` | n/a | Delete one or more organizations. |
|
||||
| `orgs:read` | n/a | Read one or more organizations. |
|
||||
| `orgs:write` | n/a | Update one or more organizations. |
|
||||
| `plugins.app:access` | `plugins:*` <br> `plugins:id:*` | Access one or more application plugins (still enforcing the organization role) |
|
||||
| `plugins:install` | n/a | Install and uninstall plugins. |
|
||||
| `plugins:write` | `plugins:*` <br> `plugins:id:*` | Edit settings for one or more plugins. |
|
||||
| `provisioning:reload` | `provisioners:*` | Reload provisioning files. To find the exact scope for specific provisioner, see [Scope definitions]({{< relref "#scope-definitions" >}}). |
|
||||
| `reports:create` | n/a | Create reports. |
|
||||
| `reports:write` | `reports:*` <br> `reports:id:*` | Update reports. |
|
||||
| `reports.settings:read` | n/a | Read report settings. |
|
||||
| `reports.settings:write` | n/a | Update report settings. |
|
||||
| `reports:delete` | `reports:*` <br> `reports:id:*` | Delete reports. |
|
||||
| `reports:read` | `reports:*` <br> `reports:id:*` | List all available reports or get a specific report. |
|
||||
| `reports:send` | `reports:*` <br> `reports:id:*` | Send a report email. |
|
||||
| `roles:delete` | `permissions:type:delegate` | Delete a custom role. |
|
||||
| `roles:read` | `roles:*` <br> `roles:uid:*` | List roles and read a specific role with its permissions. |
|
||||
| `roles:write` | `permissions:type:delegate` | Create or update a custom role. |
|
||||
| `roles:write` | `permissions:type:escalate` | Reset basic roles to their default permissions. |
|
||||
| `server.stats:read` | n/a | Read Grafana instance statistics. |
|
||||
| `server.usagestats.report:read` | n/a | View usage statistics report. |
|
||||
| `serviceaccounts:write` | `serviceaccounts:*` | Create Grafana service accounts. |
|
||||
| `serviceaccounts:create` | n/a | Update Grafana service accounts. |
|
||||
| `serviceaccounts:delete` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Delete Grafana service accounts. |
|
||||
| `serviceaccounts:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service accounts. |
|
||||
| `serviceaccounts.permissions:write` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. |
|
||||
| `serviceaccounts.permissions:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. |
|
||||
| `settings:read` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) |
|
||||
| `settings:write` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update any Grafana configuration settings that can be [updated at runtime]({{< relref "../../../../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}). |
|
||||
| `support.bundles:create` | n/a | Create support bundles. |
|
||||
| `support.bundles:delete` | n/a | Delete support bundles. |
|
||||
| `support.bundles:read` | n/a | List and download support bundles. |
|
||||
| `status:accesscontrol` | `services:accesscontrol` | Get access-control enabled status. |
|
||||
| `teams.permissions:read` | `teams:*`<br>`teams:id:*` | Read members and Team Sync setup for teams. |
|
||||
| `teams.permissions:write` | `teams:*`<br>`teams:id:*` | Add, remove and update members and manage Team Sync setup for teams. |
|
||||
| `teams.roles:add` | `permissions:type:delegate` | Assign a role to a team. |
|
||||
| `teams.roles:read` | `teams:*`<br>`teams:id:*` | List roles assigned directly to a team. |
|
||||
| `teams.roles:remove` | `permissions:type:delegate` | Unassign a role from a team. |
|
||||
| `teams:create` | n/a | Create teams. |
|
||||
| `teams:delete` | `teams:*`<br>`teams:id:*` | Delete one or more teams. |
|
||||
| `teams:read` | `teams:*`<br>`teams:id:*` | Read one or more teams and team preferences. To list teams through the UI one of the following permissions is required in addition to `teams:read`: `teams:write`, `teams.permissions:read` or `teams.permissions:write`. |
|
||||
| `teams:write` | `teams:*`<br>`teams:id:*` | Update one or more teams and team preferences. |
|
||||
| `users.authtoken:read` | `global.users:*` <br> `global.users:id:*` | List authentication tokens that are assigned to a user. |
|
||||
| `users.authtoken:write` | `global.users:*` <br> `global.users:id:*` | Update authentication tokens that are assigned to a user. |
|
||||
| `users.password:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s password. |
|
||||
| `users.permissions:read` | `users:*` | List permissions of a user. |
|
||||
| `users.permissions:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s organization-level permissions. |
|
||||
| `users.quotas:read` | `global.users:*` <br> `global.users:id:*` | List a user’s quotas. |
|
||||
| `users.quotas:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s quotas. |
|
||||
| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user or a service account. |
|
||||
| `users.roles:read` | `users:*` | List roles assigned directly to a user or a service account. |
|
||||
| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user or a service account. |
|
||||
| `users:create` | n/a | Create a user. |
|
||||
| `users:delete` | `global.users:*` <br> `global.users:id:*` | Delete a user. |
|
||||
| `users:disable` | `global.users:*` <br> `global.users:id:*` | Disable a user. |
|
||||
| `users:enable` | `global.users:*` <br> `global.users:id:*` | Enable a user. |
|
||||
| `users:logout` | `global.users:*` <br> `global.users:id:*` | Sign out a user. |
|
||||
| `users:read` | `global.users:*` | Read or search user profiles. |
|
||||
| `users:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s profile. |
|
||||
|
||||
### Grafana OnCall action definitions (beta)
|
||||
|
||||
|
||||
+8
-7
@@ -23,13 +23,13 @@ The following tables list permissions associated with basic and fixed roles.
|
||||
|
||||
## Basic role assignments
|
||||
|
||||
| Basic role | Associated fixed roles | Description |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Grafana Admin | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. |
|
||||
| Admin | `fixed:reports:reader`<br>`fixed:reports:writer`<br>`fixed:datasources:reader`<br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:reader`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:reader`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:reader`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:reader`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:reader`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:apikeys:reader`<br>`fixed:apikeys:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. |
|
||||
| Editor | `fixed:datasources:explorer`<br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled<br>`fixed:alerting:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:general.writer` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. |
|
||||
| Viewer | `fixed:datasources.id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:general.reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. |
|
||||
| No Basic Role | | Default [No Basic Role]({{< relref "../#basic-roles" >}}) |
|
||||
| Basic role | Associated fixed roles | Description |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Grafana Admin | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. |
|
||||
| Admin | `fixed:reports:reader`<br>`fixed:reports:writer`<br>`fixed:datasources:reader`<br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:reader`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:reader`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:reader`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:reader`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:reader`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:apikeys:reader`<br>`fixed:apikeys:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer`<br>`fixed:alerting.provisioning.status:writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. |
|
||||
| Editor | `fixed:datasources:explorer`<br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled<br>`fixed:alerting:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:general.writer`<br>`fixed:alerting.provisioning.status:writer` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. |
|
||||
| Viewer | `fixed:datasources.id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:general.reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. |
|
||||
| No Basic Role | | Default [No Basic Role]({{< relref "../#basic-roles" >}}) |
|
||||
|
||||
## Fixed role definitions
|
||||
|
||||
@@ -45,6 +45,7 @@ The following tables list permissions associated with basic and fixed roles.
|
||||
| `fixed:alerting:reader` | All permissions from `fixed:alerting.rules:reader` <br>`fixed:alerting.instances:reader`<br>`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) |
|
||||
| `fixed:alerting.provisioning.secrets:reader` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) |
|
||||
| `fixed:alerting.provisioning:writer` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) |
|
||||
| `fixed:alerting.provisioning.status:writer` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) |
|
||||
| `fixed:annotations.dashboard:writer` | `annotations:write` <br>`annotations.create`<br> `annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. |
|
||||
| `fixed:annotations:reader` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. |
|
||||
| `fixed:annotations:writer` | All permissions from `fixed:annotations:reader` <br>`annotations:write` <br>`annotations.create`<br> `annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. |
|
||||
|
||||
@@ -757,6 +757,40 @@ deleteMuteTimes:
|
||||
name: mti_1
|
||||
```
|
||||
|
||||
## Template variable interpolation
|
||||
|
||||
Provisioning interpolates environment variables using the `$variable` syntax.
|
||||
|
||||
```yaml
|
||||
contactPoints:
|
||||
- orgId: 1
|
||||
name: My Contact Email Point
|
||||
receivers:
|
||||
- uid: 1
|
||||
type: email
|
||||
settings:
|
||||
addresses: $EMAIL
|
||||
```
|
||||
|
||||
In this example, provisioning replaces `$EMAIL` with the value of the `EMAIL` environment variable or an empty string if it is not present. For more information, refer to [Using environment variables in the Provision documentation][provisioning_env_vars].
|
||||
|
||||
In alerting resources, most properties support template variable interpolation, with a few exceptions:
|
||||
|
||||
- Alert rule annotations: `groups[].rules[].annotations`
|
||||
- Alert rule time range: `groups[].rules[].relativeTimeRange`
|
||||
- Alert rule query model: `groups[].rules[].data.model`
|
||||
- Mute timings name: `muteTimes[].name`
|
||||
- Mute timings time intervals: `muteTimes[].time_intervals[]`
|
||||
- Notification template name: `templates[].name`
|
||||
- Notification template content: `templates[].template`
|
||||
|
||||
Note for properties that support interpolation, you may unexpectedly substitute template variables when not intended. To avoid this, you can escape the `$variable` with `$$variable`.
|
||||
|
||||
For example, when provisioning a `subject` property in a `contactPoints.receivers.settings` object that is meant to use the `$labels` variable.
|
||||
|
||||
1. `subject: '{{ $labels }}'` will interpolate, incorrectly defining the subject as `subject: '{{ }}'`.
|
||||
1. `subject: '{{ $$labels }}'` will not interpolate, correctly defining the subject as `subject: '{{ $labels }}'`.
|
||||
|
||||
## More examples
|
||||
|
||||
For more examples on the concept of this guide:
|
||||
@@ -783,6 +817,7 @@ For more examples on the concept of this guide:
|
||||
[export_mute_timings]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-mute-timings"
|
||||
|
||||
[provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/provisioning"
|
||||
[provisioning_env_vars]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/provisioning#using-environment-variables"
|
||||
|
||||
[reload-provisioning-configurations]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/developers/http_api/admin#reload-provisioning-configurations"
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ If you set Google OAuth configuration using `api_url,` you might be using the le
|
||||
|
||||
You can turn off this feature through the configuration toggle `validate_hd `. Anyone using the legacy Google OAuth configuration should turn off this validation if the ID Token response doesn't have the HD parameter.
|
||||
|
||||
[GitHub issue](https://github.com/grafana/grafana/pull/83229)
|
||||
[GitHub PR](https://github.com/grafana/grafana/pull/83229)
|
||||
|
||||
### Changes to how the panel view URL is generated for repeated panels
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
title: Explore Metrics
|
||||
aliases:
|
||||
description: This topic describes the Explore Metrics feature
|
||||
weight: 200
|
||||
---
|
||||
|
||||
# Grafana Explore Metrics
|
||||
|
||||
Grafana Explore Metrics is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics.
|
||||
|
||||
{{% admonition type="warning" %}}
|
||||
Explore Metrics is currently in [private preview](/docs/release-life-cycle/). Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available.
|
||||
{{% /admonition %}}
|
||||
|
||||
With Explore Metrics, you can:
|
||||
|
||||
- easily slice and dice metrics based on their labels, so you can immediately see anomalies and identify issues
|
||||
- see the right visualization for your metric based on its type (gauge vs. counter, for example) without building it yourself
|
||||
- surface other metrics relevant to the current metric
|
||||
- “explore in a drawer” - expand a drawer over a dashboard with more content so you don’t lose your place
|
||||
- view a history of user steps when navigating through metrics and their filters
|
||||
- easily pivot to other related telemetry, including logs or traces
|
||||
|
||||
You can access Explore Metrics either as a standalone experience or as part of Grafana dashboards.
|
||||
|
||||
## Standalone experience
|
||||
|
||||
To access Explore Metrics as a standalone experience:
|
||||
|
||||
1. Click the arrow next to **Explore** in the Grafana left-side menu and click **Metrics**. You are taken to an overview page that shows recent metrics, bookmarks, and the option to select a new metric exploration.
|
||||
1. To get started with a new exploration, click **+ New metric exploration**.
|
||||
1. Select **Prometheus** or any Prometheus-compatible data source available in the drop-down menu under **Data source**.
|
||||
1. Click **+ Add label** to select a label-value pair from the drop-down menu. You can add multiple label-value pairs. A label type will appear above the selected label with a drop-down list of options from which to choose. For example, if you select the label `container` a drop-down list of available containers appears.
|
||||
1. You can also search for metrics using keywords under **Search metrics** in the search bar.
|
||||
1. Use the time picker to select a date and time range from the drop-down menu or use an absolute time range.
|
||||
1. Click the down arrow next to the **Refresh** icon to set a refresh rate from the drop-down menu. The default is `Off`.
|
||||
1. Click the **Settings** icon and toggle **Always keep selected metric graph in-view** to keep your main graph always in view on the Breakdown drill-down tab.
|
||||
|
||||
The **History** button in the upper left corner tracks every step navigating through metric exploration.
|
||||
|
||||

|
||||
|
||||
### Metrics exploration
|
||||
|
||||
To further explore a metric, click **Select** in the upper right corner of the metric visualization.
|
||||
|
||||

|
||||
|
||||
- The **Overview** tab provides a description for each metric, as well as the metric `type` and `unit` associated with the metric. It also provides a list of labels associated with the metric. Click on any label to view drill-down visualizations.
|
||||
- The **Breakdown** tab depicts time series visualizations for each of the label-value pairs for the selected metric. You can further drill down on each label and click **Add to filter** to add the label/value pair into your filters. You can also change the **View** from grid to rows.
|
||||
- The **Related metrics** tab depicts related metrics with relevant key words. You can repeat the drill down process for any related metric. Toggle **SHow previews** to preview visualizations.
|
||||
|
||||
Once you have gathered your metrics exploration data you can:
|
||||
|
||||
- Click the **Explore** icon on the right side to open the graph in Explore, where you can modify the query or add the graph to a dashboard or incident.
|
||||
- Click the **Share** icon on the right side to copy the metric drill down URL to the clipboard so it can be shared.
|
||||
- Click the **Star** icon on the right side to bookmark and save the metrics exploration.
|
||||
|
||||
## Dashboard experience
|
||||
|
||||
To access Explore Metrics via a dashboard:
|
||||
|
||||
1. Navigate to your dashboard.
|
||||
1. Select a time series panel.
|
||||
1. Click the panel menu in the upper right and select **Explore Metrics**. If there are multiple metrics, click on the one you want to explore.
|
||||
1. You will see a slide out drawer with the Metrics Experience, starting with the drill down. You can access the standalone experience by clicking **Open** in the upper right.
|
||||
@@ -343,11 +343,11 @@ GET /api/v1/provisioning/alert-rules/:uid/export
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Source | Type | Go type | Separator | Required | Default | Description |
|
||||
| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| UID | `path` | string | `string` | | ✓ | | Alert rule UID |
|
||||
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
|
||||
| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
|
||||
| Name | Source | Type | Go type | Separator | Required | Default | Description |
|
||||
| -------- | ------- | ------- | -------- | --------- | :------: | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| UID | `path` | string | `string` | | ✓ | | Alert rule UID |
|
||||
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
|
||||
| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml, json or hcl. Accept header can also be used, but the query parameter will take precedence. |
|
||||
|
||||
#### All responses
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ keywords:
|
||||
- '11.0'
|
||||
- '11.0-preview'
|
||||
title: Upgrade to Grafana v11.0-preview
|
||||
menuTitle: Upgrade to v11.0
|
||||
menuTitle: Upgrade to v11.0-preview
|
||||
weight: 1200
|
||||
---
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ weight: -42
|
||||
|
||||
# What’s new in Grafana v11.0-preview
|
||||
|
||||
Welcome to Grafana 11.0-preview! This preview release contains some notable improvements, most notably, the ability to explore your metrics without queries. We've taken strides to improve the dashboard experience with subfolders becoming generally available and the addition of enhanced flowcharting capabilities. We've also migrated the dashboard viewing experience so that it's using our Scenes library.
|
||||
Welcome to Grafana 11.0-preview! This release contains some major improvements: most notably, the ability to explore your Prometheus metrics and Loki logs without writing any PromQL or LogQL, using Explore Metrics and Explore Logs. The dashboard experience is better than ever with edit mode for dashboards, AI-generated dashboard names and descriptions, and general availability for subfolders. You can also take advantage of improvements to the canvas and table visualizations, new transformations, a revamp of the Alert Rule page, and more.
|
||||
|
||||
Why "preview?" The Grafana 11.0 stable release is planned for this May. This is an early release to coincide with [Grafanacon 2024](https://grafana.com/about/events/grafanacon/2024/), so that you can try the new functionality early. To understand the differences between preview and GA releases, review the [release life cycle](https://grafana.com/docs/release-life-cycle/).
|
||||
|
||||
For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v11.0-preview, check out our [Upgrade Guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v11.0/).
|
||||
|
||||
@@ -46,20 +48,48 @@ Use full URLs for links. When linking to versioned docs, replace the version wit
|
||||
<!--Learn how to upload images here: https://grafana.com/docs/writers-toolkit/write/image-guidelines/#where-to-store-media-assets-->
|
||||
<!---->
|
||||
|
||||
## Explore Metrics and Logs
|
||||
|
||||
### Explore Metrics
|
||||
|
||||
<!-- #proj-datatrails-dev, PM: Jay Goodson, Engineering: Darren Janeczek, André Pereira, Design: Catherine Gui -->
|
||||
|
||||
_Public preview in all editions of Grafana_
|
||||
|
||||
Explore Metrics is a query-less experience for browsing Prometheus-compatible metrics. Search or filter to find a metric. Quickly find related metrics - all in just a few clicks. No PromQL to be found anywhere! With Explore Metrics, you can:
|
||||
|
||||
- easily slice and dice metrics based on their labels, so you can see anomalies right away
|
||||
- See the right visualization for your metric based on its type (e.g. gauge vs. counter) without writing it yourself
|
||||
- surface other metrics relevant to the current metric
|
||||
- “explore in a drawer” - expand a drawer over a dashboard with more content, so you don’t lose your place
|
||||
- view a history of user steps when navigating through metrics and their filters
|
||||
- easily pivot to other related telemetry - IE, logs or traces
|
||||
|
||||
… all without writing any queries!
|
||||
|
||||
To learn more, refer to the Explore Metrics [documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/explore/explore-metrics/) as well as the following video demo:
|
||||
|
||||
{{< youtube id="JbaPufQs5LY" >}}
|
||||
|
||||
### Explore Logs
|
||||
|
||||
<!-- Slack: #proj-lokiapp-dev, PM: Steven Dungan, Engineering: Andrew Stucky, Cyril Tovena, Design: Joan Wortman -->
|
||||
|
||||
_Experimental in Grafana Open Source and Enterprise_
|
||||
|
||||
Explore Logs is a queryless experience for exploring Loki logs - no LogQL required! The primary interaction modes are point-and-click based on log volume, similar to Explore Metrics.
|
||||
|
||||
Highlights:
|
||||
|
||||
- View log volume and log line samples when you first land in Explore Logs (no more "blank screen!")
|
||||
- Explore additional labels and detected fields in a similar way, focusing on volume and distribution; add them to your "query" to refine your logs search without needing LogQL
|
||||
– See common patterns in your log lines, to easily filter out noise or focus in on anomalies
|
||||
- For power users, an easy way to hop into the familiar Explore while preserving context
|
||||
|
||||
Explore Logs is Open Source, and experimental - some papercuts are to be expected. Give it a try and let us know what you think!
|
||||
|
||||
## Dashboards and visualizations
|
||||
|
||||
### Scenes for viewers
|
||||
|
||||
<!-- #grafana-dashboards, Dominik Prokop, Natalia Bernarte -->
|
||||
|
||||
_Generally available in all editions of Grafana_
|
||||
|
||||
Dashboards, when accessed by users with the Viewer role, are now using the Scenes library. Those users shouldn't see any difference in the dashboards apart from two small changes to the user interface (UI): the variables UI has slightly changed and the time picker is now part of the dashboard container.
|
||||
|
||||
Dashboards aren't affected for users in other roles.
|
||||
|
||||
This is the first step towards a more robust and dynamic dashboarding system that we'll be releasing in the upcoming months.
|
||||
|
||||
### Scenes powered Dashboards
|
||||
|
||||
<!-- #grafana-dashboards -->
|
||||
@@ -83,6 +113,20 @@ We moved the time picker into the dashboard canvas and now, together with templa
|
||||
|
||||
If you want to learn more, in detail, about all the improvements we've made, don't miss our blog post.
|
||||
|
||||
<!-- {{< youtube id="ECahW01W5gs" >}} commented out until team decides if there will be changes to the video -->
|
||||
|
||||
### Scenes for viewers
|
||||
|
||||
<!-- #grafana-dashboards, Dominik Prokop, Natalia Bernarte -->
|
||||
|
||||
_Generally available in all editions of Grafana_
|
||||
|
||||
Dashboards, when accessed by users with the Viewer role, are now using the Scenes library. Those users shouldn't see any difference in the dashboards apart from two small changes to the user interface (UI): the variables UI has slightly changed and the time picker is now part of the dashboard container.
|
||||
|
||||
Dashboards aren't affected for users in other roles.
|
||||
|
||||
This is the first step towards a more robust and dynamic dashboarding system that we'll be releasing in the upcoming months.
|
||||
|
||||
### Subfolders
|
||||
|
||||
<!-- #wg-nested-folders -->
|
||||
@@ -109,6 +153,8 @@ To correct this, take the following steps:
|
||||
|
||||
If you use file provisioning, you can upgrade and update the routes at the same time.
|
||||
|
||||
{{< youtube id="R9mehA0EssU" >}}
|
||||
|
||||
### Use AI to generate titles and descriptions for panels and dashboards
|
||||
|
||||
<!-- Ivan Ortega -->
|
||||
@@ -123,19 +169,7 @@ When enabled, look for the **✨ Auto generate** option next to the **Title** an
|
||||
|
||||

|
||||
|
||||
### Substring matcher added to the filter by value transformation
|
||||
|
||||
<!-- #grafana-dataviz -->
|
||||
|
||||
_Generally available in Grafana Cloud and Open Source_
|
||||
|
||||
This update to the **Filter data by values** transformation simplifies data filtering by enabling partial string matching on field values thanks to two new matchers: **Contains substring** and **Does not contain substring**. With the substring matcher built into the **Filter data by values** transformation, you can efficiently filter large datasets, displaying relevant information with speed and precision. Whether you're searching for keywords, product names, or user IDs, this feature streamlines the process, saving time and effort while ensuring accurate data output.
|
||||
|
||||
In the **Filter data by values** transformation, simply add a condition, choose a field, choose your matcher, and then input the string to match against.
|
||||
|
||||
This update will be rolled out to customers over the next few weeks.
|
||||
|
||||
{{< video-embed src="/media/docs/grafana/substring-matcher.mp4" >}}
|
||||
{{< youtube id="s61WHREHuYE" >}}
|
||||
|
||||
### Improvements to the canvas visualization
|
||||
|
||||
@@ -164,6 +198,8 @@ We've updated data links so that you can add them to almost all elements or elem
|
||||
This update doesn't apply to the drone and button elements.
|
||||
{{< /admonition >}}
|
||||
|
||||
{{< youtube id="0iO2gqv0XNA" >}}
|
||||
|
||||
[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/canvas/)
|
||||
|
||||
### Infinite panning for the canvas visualization
|
||||
@@ -192,6 +228,8 @@ This feature is useful for a wide variety of use cases including mapping status
|
||||
|
||||
To learn more, refer to the [documentation for the Colored background cell type](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/table/#color-background-gradient-or-solid).
|
||||
|
||||
{{< youtube id="PLfADTtCnmg" >}}
|
||||
|
||||
### Set threshold colors in the Config from query transformation
|
||||
|
||||
<!-- #grafana-dataviz" -->
|
||||
@@ -202,6 +240,20 @@ You now have the ability to customize specific colors for individual thresholds
|
||||
|
||||
This feature addresses a common pain point highlighted by users. With customizable threshold colors, you now have greater control over your data representation, fostering more insightful and impactful analyses across diverse datasets.
|
||||
|
||||
### Substring matcher added to the Filter by value transformation
|
||||
|
||||
<!-- #grafana-dataviz -->
|
||||
|
||||
_Generally available in Grafana Cloud and Open Source_
|
||||
|
||||
This update to the **Filter data by values** transformation simplifies data filtering by enabling partial string matching on field values thanks to two new matchers: **Contains substring** and **Does not contain substring**. With the substring matcher built into the **Filter data by values** transformation, you can efficiently filter large datasets, displaying relevant information with speed and precision. Whether you're searching for keywords, product names, or user IDs, this feature streamlines the process, saving time and effort while ensuring accurate data output.
|
||||
|
||||
In the **Filter data by values** transformation, simply add a condition, choose a field, choose your matcher, and then input the string to match against.
|
||||
|
||||
This update will be rolled out to customers over the next few weeks.
|
||||
|
||||
{{< video-embed src="/media/docs/grafana/substring-matcher.mp4" >}}
|
||||
|
||||
## Reporting
|
||||
|
||||
### PDF export improvements
|
||||
@@ -218,6 +270,8 @@ This update also fixes all [caveats](https://grafana.com/docs/grafana/<GRAFANA_V
|
||||
|
||||
To try out this update, enable the `newPDFRendering` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/).
|
||||
|
||||
{{< youtube id="ypk1usnE7D8" >}}
|
||||
|
||||
## Alerting
|
||||
|
||||
### Keep Last State for Grafana Managed Alerting
|
||||
@@ -258,42 +312,6 @@ Debug or audit using the alert rule metadata and view the alert rule annotations
|
||||
|
||||
## Data sources
|
||||
|
||||
### Explore Metrics
|
||||
|
||||
<!-- #proj-datatrails-dev, PM: Jay Goodson, Engineering: Darren Janeczek, André Pereira, Design: Catherine Gui -->
|
||||
|
||||
_Generally available in all editions of Grafana_
|
||||
|
||||
Explore Metrics is a query-less experience for browsing Prometheus-compatible metrics. Search for or filter to find a metric. Quickly find related metrics - all in just a few clicks. No PromQL to be found anywhere! With Explore Metrics, you can:
|
||||
|
||||
- easily slice and dice metrics based on their labels, so you can see anomalies right away
|
||||
- See the right visualization for your metric based on its type (e.g. gauge vs. counter) without writing it yourself
|
||||
- surface other metrics relevant to the current metric
|
||||
- “explore in a drawer” - expand a drawer over a dashboard with more content, so you don’t lose your place
|
||||
- view a history of user steps when navigating through metrics and their filters
|
||||
- easily pivot to other related telemetry - IE, logs or traces
|
||||
|
||||
… all without writing any queries!
|
||||
|
||||
<!--To learn more, refer to the Explore Metrics [documentation](http://grafana.com/docs/grafana/<GRAFANA_VERSION>/explore/metrics-explore/) as well as the following video demo (forthcoming):-->
|
||||
|
||||
### Explore Logs
|
||||
|
||||
<!-- Slack: #proj-lokiapp-dev, PM: Steven Dungan, Engineering: Andrew Stucky, Cyril Tovena, Design: Joan Wortman -->
|
||||
|
||||
_Experimental in Grafana Open Source and Enterprise_
|
||||
|
||||
Explore Logs is a queryless experience for exploring Loki logs - no LogQL required! The primary interaction modes are point-and-click based on log volume, similar to Explore Metrics.
|
||||
|
||||
Highlights:
|
||||
|
||||
- View log volume and log line samples when you first land in Explore Logs (no more "blank screen!")
|
||||
- Explore additional labels and detected fields in a similar way, focusing on volume and distribution; add them to your "query" to refine your logs search without needing LogQL
|
||||
– See common patterns in your log lines, to easily filter out noise or focus in on anomalies
|
||||
- For power users, an easy way to hop into the familiar Explore while preserving context
|
||||
|
||||
Explore Logs is Open Source, and in preview - some papercuts are to be expected. Give it a try and let us know what you think!
|
||||
|
||||
### Azure Monitor: Current User authentication
|
||||
|
||||
<!-- #grafana-partner-datasources, @Andreas -->
|
||||
|
||||
@@ -112,6 +112,41 @@ describe('Dashboard time zone support', () => {
|
||||
// Need to wait for 2 calls as there's 2 panels
|
||||
cy.wait(['@dataQuery', '@dataQuery']);
|
||||
|
||||
e2e.components.Panels.Panel.title('Panel with relative time override')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.contains('[role="row"]', '00:00:00').should('be.visible');
|
||||
});
|
||||
|
||||
e2e.components.Panels.Panel.title('Panel in timezone')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.contains('[role="row"]', '00:00:00').should('be.visible');
|
||||
});
|
||||
|
||||
// Test UTC timezone
|
||||
e2e.flows.setTimeRange({
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
zone: 'Coordinated Universal Time',
|
||||
});
|
||||
// Need to wait for 2 calls as there's 2 panels
|
||||
cy.wait(['@dataQuery', '@dataQuery']);
|
||||
|
||||
e2e.components.Panels.Panel.title('Panel with relative time override')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.contains('[role="row"]', '00:00:00').should('be.visible');
|
||||
});
|
||||
|
||||
// Today so far, still in UTC timezone
|
||||
e2e.flows.setTimeRange({
|
||||
from: 'now/d',
|
||||
to: 'now',
|
||||
});
|
||||
// Need to wait for 2 calls as there's 2 panels
|
||||
cy.wait(['@dataQuery', '@dataQuery']);
|
||||
|
||||
e2e.components.Panels.Panel.title('Panel with relative time override')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
|
||||
@@ -126,10 +126,6 @@ export const dateTimeForTimeZone = (
|
||||
input?: DateTimeInput,
|
||||
formatInput?: FormatInput
|
||||
): DateTime => {
|
||||
if (timezone === 'utc') {
|
||||
return toUtc(input, formatInput);
|
||||
}
|
||||
|
||||
if (timezone && timezone !== 'browser') {
|
||||
let result: moment.Moment;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Grafana End-to-End Test library
|
||||
|
||||
> **@grafana/e2e is currently in BETA**.
|
||||
|
||||
This package contains an API wrapper built on top of [Cypress](https://www.cypress.io) that simplifies creating end-to-end tests for Grafana. More information can be found [here](https://github.com/grafana/grafana/blob/main/contribute/style-guides/e2e.md).
|
||||
> [!CAUTION]
|
||||
> This package is deprecated.
|
||||
> If you'd like to write end-to-end tests for a Grafana plugin (core or external), use the [`@grafana/plugin-e2e`](https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/introduction) package.
|
||||
@@ -2,7 +2,7 @@ import { Meta, StoryFn } from '@storybook/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { getAvailableIcons } from '../../types';
|
||||
import { HorizontalGroup } from '../Layout/Layout';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
|
||||
import { FilterPill } from './FilterPill';
|
||||
import mdx from './FilterPill.mdx';
|
||||
@@ -30,11 +30,11 @@ export const Example = () => {
|
||||
const elements = ['Singapore', 'Paris', 'Stockholm', 'New York', 'London'];
|
||||
|
||||
return (
|
||||
<HorizontalGroup>
|
||||
<Stack>
|
||||
{elements.map((item) => (
|
||||
<FilterPill key={item} label={item} selected={item === selected} onClick={() => setSelected(item)} />
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStyles2 } from '../../themes';
|
||||
import { IconName } from '../../types';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
import { HorizontalGroup } from '../Layout/Layout';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
|
||||
import { ModalHeader } from './ModalHeader';
|
||||
import { getModalStyles } from './getModalStyles';
|
||||
@@ -105,23 +105,23 @@ function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode;
|
||||
if (leftItems) {
|
||||
return (
|
||||
<div className={styles.modalButtonRow}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup justify="flex-start" spacing="md">
|
||||
<Stack justifyContent="space-between">
|
||||
<Stack justifyContent="flex-start" gap={2}>
|
||||
{leftItems}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup justify="flex-end" spacing="md">
|
||||
</Stack>
|
||||
<Stack justifyContent="flex-end" gap={2}>
|
||||
{children}
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modalButtonRow}>
|
||||
<HorizontalGroup justify="flex-end" spacing="md" wrap={true}>
|
||||
<Stack justifyContent="flex-end" gap={2} wrap="wrap">
|
||||
{children}
|
||||
</HorizontalGroup>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ type LegacyStorage interface {
|
||||
// - rest.CollectionDeleter
|
||||
type DualWriter struct {
|
||||
Storage
|
||||
legacy LegacyStorage
|
||||
Legacy LegacyStorage
|
||||
}
|
||||
|
||||
type DualWriterMode int
|
||||
@@ -80,15 +80,17 @@ var CurrentMode = Mode2
|
||||
|
||||
// NewDualWriter returns a new DualWriter.
|
||||
func NewDualWriter(legacy LegacyStorage, storage Storage) *DualWriter {
|
||||
//TODO: replace this with
|
||||
// SelectDualWriter(CurrentMode, legacy, storage)
|
||||
return &DualWriter{
|
||||
Storage: storage,
|
||||
legacy: legacy,
|
||||
Legacy: legacy,
|
||||
}
|
||||
}
|
||||
|
||||
// Create overrides the default behavior of the Storage and writes to both the LegacyStorage and Storage.
|
||||
func (d *DualWriter) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
if legacy, ok := d.legacy.(rest.Creater); ok {
|
||||
if legacy, ok := d.Legacy.(rest.Creater); ok {
|
||||
created, err := legacy.Create(ctx, obj, createValidation, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -113,7 +115,7 @@ func (d *DualWriter) Create(ctx context.Context, obj runtime.Object, createValid
|
||||
|
||||
// Update overrides the default behavior of the Storage and writes to both the LegacyStorage and Storage.
|
||||
func (d *DualWriter) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
if legacy, ok := d.legacy.(rest.Updater); ok {
|
||||
if legacy, ok := d.Legacy.(rest.Updater); ok {
|
||||
// Get the previous version from k8s storage (the one)
|
||||
old, err := d.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
@@ -168,7 +170,7 @@ func (d *DualWriter) Delete(ctx context.Context, name string, deleteValidation r
|
||||
// Delete from storage *first* so the item is still exists if a failure happens
|
||||
obj, async, err := d.Storage.Delete(ctx, name, deleteValidation, options)
|
||||
if err == nil {
|
||||
if legacy, ok := d.legacy.(rest.GracefulDeleter); ok {
|
||||
if legacy, ok := d.Legacy.(rest.GracefulDeleter); ok {
|
||||
obj, async, err = legacy.Delete(ctx, name, deleteValidation, options)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +181,7 @@ func (d *DualWriter) Delete(ctx context.Context, name string, deleteValidation r
|
||||
func (d *DualWriter) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) {
|
||||
out, err := d.Storage.DeleteCollection(ctx, deleteValidation, options, listOptions)
|
||||
if err == nil {
|
||||
if legacy, ok := d.legacy.(rest.CollectionDeleter); ok {
|
||||
if legacy, ok := d.Legacy.(rest.CollectionDeleter); ok {
|
||||
out, err = legacy.DeleteCollection(ctx, deleteValidation, options, listOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type DualWriterMode1 struct {
|
||||
DualWriter
|
||||
}
|
||||
|
||||
var errNoCreaterMethod = errors.New("legacy storage rest.Creater is missing")
|
||||
|
||||
// NewDualWriterMode1 returns a new DualWriter in mode 1.
|
||||
// Mode 1 represents writing to and reading from LegacyStorage.
|
||||
func NewDualWriterMode1(legacy LegacyStorage, storage Storage) *DualWriterMode1 {
|
||||
@@ -21,9 +24,10 @@ func NewDualWriterMode1(legacy LegacyStorage, storage Storage) *DualWriterMode1
|
||||
|
||||
// Create overrides the default behavior of the DualWriter and writes only to LegacyStorage.
|
||||
func (d *DualWriterMode1) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
legacy, ok := d.legacy.(rest.Creater)
|
||||
legacy, ok := d.Legacy.(rest.Creater)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("legacy storage rest.Creater is missing")
|
||||
klog.FromContext(ctx).Error(errNoCreaterMethod, "legacy storage rest.Creater is missing")
|
||||
return nil, errNoCreaterMethod
|
||||
}
|
||||
|
||||
return legacy.Create(ctx, obj, createValidation, options)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/klog"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type DualWriterMode2 struct {
|
||||
@@ -23,13 +23,14 @@ func NewDualWriterMode2(legacy LegacyStorage, storage Storage) *DualWriterMode2
|
||||
|
||||
// Create overrides the default behavior of the DualWriter and writes to LegacyStorage and Storage.
|
||||
func (d *DualWriterMode2) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
legacy, ok := d.legacy.(rest.Creater)
|
||||
legacy, ok := d.Legacy.(rest.Creater)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("legacy storage rest.Creater is missing")
|
||||
}
|
||||
|
||||
created, err := legacy.Create(ctx, obj, createValidation, options)
|
||||
if err != nil {
|
||||
klog.FromContext(ctx).Error(err, "unable to create object in legacy storage", "mode", 2)
|
||||
return created, err
|
||||
}
|
||||
|
||||
@@ -42,12 +43,14 @@ func (d *DualWriterMode2) Create(ctx context.Context, obj runtime.Object, create
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
|
||||
// create method expects an empty resource version
|
||||
accessor.SetResourceVersion("")
|
||||
accessor.SetUID("")
|
||||
|
||||
rsp, err := d.Storage.Create(ctx, c, createValidation, options)
|
||||
if err != nil {
|
||||
klog.Error("unable to create object in duplicate storage", "error", err, "mode", Mode2)
|
||||
klog.FromContext(ctx).Error(err, "unable to create object in Storage", "mode", 2)
|
||||
}
|
||||
return rsp, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/klog"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type DualWriterMode3 struct {
|
||||
@@ -22,18 +22,19 @@ func NewDualWriterMode3(legacy LegacyStorage, storage Storage) *DualWriterMode3
|
||||
|
||||
// Create overrides the default behavior of the DualWriter and writes to LegacyStorage and Storage.
|
||||
func (d *DualWriterMode3) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
legacy, ok := d.legacy.(rest.Creater)
|
||||
legacy, ok := d.Legacy.(rest.Creater)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("legacy storage rest.Creater is missing")
|
||||
}
|
||||
|
||||
created, err := d.Storage.Create(ctx, obj, createValidation, options)
|
||||
if err != nil {
|
||||
klog.FromContext(ctx).Error(err, "unable to create object in Storage", "mode", 3)
|
||||
return created, err
|
||||
}
|
||||
|
||||
if _, err := legacy.Create(ctx, obj, createValidation, options); err != nil {
|
||||
klog.Error("unable to create object in legacy storage", "error", err)
|
||||
klog.FromContext(ctx).Error(err, "unable to create object in legacy storage", "mode", 3)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -4,14 +4,15 @@ const (
|
||||
// All includes all modules necessary for Grafana to run as a standalone server
|
||||
All string = "all"
|
||||
|
||||
Core string = "core"
|
||||
GrafanaAPIServer string = "grafana-apiserver"
|
||||
StorageServer string = "storage-server"
|
||||
Core string = "core"
|
||||
GrafanaAPIServer string = "grafana-apiserver"
|
||||
StorageServer string = "storage-server"
|
||||
InstrumentationServer string = "instrumentation-server"
|
||||
)
|
||||
|
||||
var dependencyMap = map[string][]string{
|
||||
GrafanaAPIServer: {},
|
||||
StorageServer: {},
|
||||
GrafanaAPIServer: {InstrumentationServer},
|
||||
StorageServer: {InstrumentationServer},
|
||||
Core: {},
|
||||
All: {Core},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type instrumentationService struct {
|
||||
*services.BasicService
|
||||
httpServ *http.Server
|
||||
log log.Logger
|
||||
errChan chan error
|
||||
}
|
||||
|
||||
func NewInstrumentationService(log log.Logger) (*instrumentationService, error) {
|
||||
s := &instrumentationService{log: log}
|
||||
s.BasicService = services.NewBasicService(s.start, s.running, s.stop)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *instrumentationService) start(ctx context.Context) error {
|
||||
s.httpServ = s.newInstrumentationServer(ctx)
|
||||
s.errChan = make(chan error)
|
||||
go func() {
|
||||
s.errChan <- s.httpServ.ListenAndServe()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *instrumentationService) running(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-s.errChan:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *instrumentationService) stop(failureReason error) error {
|
||||
s.log.Info("stopping instrumentation server", "reason", failureReason)
|
||||
if err := s.httpServ.Shutdown(context.Background()); err != nil {
|
||||
s.log.Error("failed to shutdown instrumentation server", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *instrumentationService) newInstrumentationServer(ctx context.Context) *http.Server {
|
||||
router := http.NewServeMux()
|
||||
router.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
srv := &http.Server{
|
||||
// 5s timeout for header reads to avoid Slowloris attacks (https://thetooth.io/blog/slowloris-attack/)
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
Addr: ":3000", // TODO - make configurable?
|
||||
Handler: router,
|
||||
BaseContext: func(_ net.Listener) context.Context { return ctx },
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunInstrumentationService(t *testing.T) {
|
||||
s, err := NewInstrumentationService(log.New("test-logger"))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = services.StartAndAwaitRunning(ctx, s)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCounter := prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "test_counter",
|
||||
})
|
||||
err = prometheus.Register(testCounter)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCounter.Inc()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
client := http.Client{}
|
||||
res, err := client.Get("http://localhost:3000/metrics")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
|
||||
b, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
resp := string(b[len(b)-16:])
|
||||
assert.Equal(t, "\ntest_counter 1\n", resp)
|
||||
|
||||
err = res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = services.StopAndAwaitTerminated(ctx, s)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/dskit/services"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/modules"
|
||||
@@ -117,6 +116,14 @@ func (s *ModuleServer) Run() error {
|
||||
|
||||
m := modules.New(s.cfg.Target)
|
||||
|
||||
// only run the instrumentation server module if were not running a module that already contains an http server
|
||||
m.RegisterInvisibleModule(modules.InstrumentationServer, func() (services.Service, error) {
|
||||
if m.IsModuleEnabled(modules.All) || m.IsModuleEnabled(modules.Core) {
|
||||
return services.NewBasicService(nil, nil, nil).WithName(modules.InstrumentationServer), nil
|
||||
}
|
||||
return NewInstrumentationService(s.log)
|
||||
})
|
||||
|
||||
m.RegisterModule(modules.Core, func() (services.Service, error) {
|
||||
return NewService(s.cfg, s.opts, s.apiOpts)
|
||||
})
|
||||
@@ -131,7 +138,7 @@ func (s *ModuleServer) Run() error {
|
||||
//}
|
||||
|
||||
m.RegisterModule(modules.StorageServer, func() (services.Service, error) {
|
||||
return storageServer.ProvideService(s.cfg, s.features)
|
||||
return storageServer.ProvideService(s.cfg, s.features, s.log)
|
||||
})
|
||||
|
||||
m.RegisterModule(modules.All, nil)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cuelang.org/go/pkg/regexp"
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/modules"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
dbType := os.Getenv("GRAFANA_TEST_DB")
|
||||
if dbType == "" {
|
||||
t.Skip("skipping - GRAFANA_TEST_DB not defined")
|
||||
}
|
||||
if dbType == "sqlite3" {
|
||||
t.Skip("skipping - sqlite not supported for storage server target")
|
||||
}
|
||||
|
||||
testdb := db.InitTestDB(t)
|
||||
cfg := testdb.Cfg
|
||||
cfg.GRPCServerNetwork = "tcp"
|
||||
cfg.GRPCServerAddress = "localhost:10000"
|
||||
addStorageServerToConfig(t, cfg, dbType)
|
||||
cfg.Target = []string{modules.StorageServer}
|
||||
|
||||
ms, err := InitializeModuleServer(cfg, Options{}, api.ServerOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err = ms.Run()
|
||||
if err.Error() != "context canceled" {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond) // wait for http server to be running
|
||||
|
||||
client := http.Client{}
|
||||
res, err := client.Get("http://localhost:3000/metrics")
|
||||
require.NoError(t, err)
|
||||
err = res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
|
||||
err = ms.Shutdown(context.Background(), "test over")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func addStorageServerToConfig(t *testing.T, cfg *setting.Cfg, dbType string) {
|
||||
s, err := cfg.Raw.NewSection("entity_api")
|
||||
require.NoError(t, err)
|
||||
_, err = s.NewKey("db_type", dbType)
|
||||
require.NoError(t, err)
|
||||
|
||||
if dbType == "postgres" {
|
||||
_, _ = s.NewKey("db_host", "localhost")
|
||||
_, _ = s.NewKey("db_name", "grafanatest")
|
||||
_, _ = s.NewKey("db_user", "grafanatest")
|
||||
_, _ = s.NewKey("db_pass", "grafanatest")
|
||||
} else {
|
||||
// cant use localhost as hostname in drone tests for mysql, so need to parse it from connection string
|
||||
sec, err := cfg.Raw.GetSection("database")
|
||||
require.NoError(t, err)
|
||||
connString := sec.Key("connection_string").String()
|
||||
matches, err := regexp.FindSubmatch("(.+):(.+)@tcp\\((.+):(\\d+)\\)/(.+)\\?", connString)
|
||||
require.NoError(t, err)
|
||||
_, _ = s.NewKey("db_host", matches[3])
|
||||
_, _ = s.NewKey("db_name", matches[5])
|
||||
_, _ = s.NewKey("db_user", matches[1])
|
||||
_, _ = s.NewKey("db_pass", matches[2])
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/star/starimpl"
|
||||
"github.com/grafana/grafana/pkg/services/stats/statsimpl"
|
||||
"github.com/grafana/grafana/pkg/services/store"
|
||||
entityStore "github.com/grafana/grafana/pkg/services/store/entity"
|
||||
entityDB "github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash"
|
||||
@@ -336,6 +337,7 @@ var wireBasicSet = wire.NewSet(
|
||||
dbimpl.ProvideEntityDB,
|
||||
wire.Bind(new(entityDB.EntityDBInterface), new(*dbimpl.EntityDB)),
|
||||
sqlstash.ProvideSQLEntityServer,
|
||||
wire.Bind(new(entityStore.EntityStoreServer), new(sqlstash.SqlEntityServer)),
|
||||
resolver.ProvideEntityReferenceResolver,
|
||||
teamimpl.ProvideService,
|
||||
teamapi.ProvideTeamAPI,
|
||||
|
||||
@@ -440,6 +440,10 @@ const (
|
||||
ActionAlertingInstanceUpdate = "alert.instances:write"
|
||||
ActionAlertingInstanceRead = "alert.instances:read"
|
||||
|
||||
ActionAlertingSilencesRead = "alert.silences:read"
|
||||
ActionAlertingSilencesCreate = "alert.silences:create"
|
||||
ActionAlertingSilencesWrite = "alert.silences:write"
|
||||
|
||||
// Alerting Notification policies actions
|
||||
ActionAlertingNotificationsRead = "alert.notifications:read"
|
||||
ActionAlertingNotificationsWrite = "alert.notifications:write"
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
@@ -28,6 +27,7 @@ import (
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
"k8s.io/apiserver/pkg/storage/storagebackend"
|
||||
"k8s.io/apiserver/pkg/storage/storagebackend/factory"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
entityStore "github.com/grafana/grafana/pkg/services/store/entity"
|
||||
)
|
||||
@@ -171,21 +171,13 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption
|
||||
|
||||
if opts.Predicate.Field != nil {
|
||||
// check for metadata.name field selector
|
||||
if v, ok := opts.Predicate.Field.RequiresExactMatch("metadata.name"); ok {
|
||||
if k.Name != "" && k.Name != v {
|
||||
return nil, apierrors.NewBadRequest("name field selector does not match key")
|
||||
}
|
||||
|
||||
if v, ok := opts.Predicate.Field.RequiresExactMatch("metadata.name"); ok && k.Name == "" {
|
||||
// just watch the specific key if we have a name field selector
|
||||
k.Name = v
|
||||
}
|
||||
|
||||
// check for metadata.namespace field selector
|
||||
if v, ok := opts.Predicate.Field.RequiresExactMatch("metadata.namespace"); ok {
|
||||
if k.Namespace != "" && k.Namespace != v {
|
||||
return nil, apierrors.NewBadRequest("namespace field selector does not match key")
|
||||
}
|
||||
|
||||
if v, ok := opts.Predicate.Field.RequiresExactMatch("metadata.namespace"); ok && k.Namespace == "" {
|
||||
// just watch the specific namespace if we have a namespace field selector
|
||||
k.Namespace = v
|
||||
}
|
||||
@@ -209,12 +201,30 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption
|
||||
}
|
||||
|
||||
req := &entityStore.EntityWatchRequest{
|
||||
Action: entityStore.EntityWatchRequest_START,
|
||||
Key: []string{
|
||||
k.String(),
|
||||
},
|
||||
Labels: map[string]string{},
|
||||
WithBody: true,
|
||||
WithStatus: true,
|
||||
Labels: map[string]string{},
|
||||
WithBody: true,
|
||||
WithStatus: true,
|
||||
SendInitialEvents: false,
|
||||
AllowWatchBookmarks: opts.Predicate.AllowWatchBookmarks,
|
||||
}
|
||||
|
||||
if opts.ResourceVersion != "" {
|
||||
rv, err := strconv.ParseInt(opts.ResourceVersion, 10, 64)
|
||||
if err != nil {
|
||||
return nil, apierrors.NewBadRequest(fmt.Sprintf("invalid resource version: %s", opts.ResourceVersion))
|
||||
}
|
||||
|
||||
req.Since = rv
|
||||
}
|
||||
|
||||
if opts.SendInitialEvents == nil && req.Since == 0 {
|
||||
req.SendInitialEvents = true
|
||||
} else if opts.SendInitialEvents != nil {
|
||||
req.SendInitialEvents = *opts.SendInitialEvents
|
||||
}
|
||||
|
||||
if requirements.Folder != nil {
|
||||
@@ -233,24 +243,35 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption
|
||||
}
|
||||
}
|
||||
|
||||
if opts.ResourceVersion != "" {
|
||||
rv, err := strconv.ParseInt(opts.ResourceVersion, 10, 64)
|
||||
if err != nil {
|
||||
return nil, apierrors.NewBadRequest(fmt.Sprintf("invalid resource version: %s", opts.ResourceVersion))
|
||||
client, err := s.store.Watch(ctx)
|
||||
if err != nil {
|
||||
// if the context was canceled, just return a new empty watch
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.EOF) {
|
||||
return watch.NewEmptyWatch(), nil
|
||||
}
|
||||
|
||||
req.Since = rv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.store.Watch(ctx, req)
|
||||
err = client.Send(req)
|
||||
if err != nil {
|
||||
err2 := client.CloseSend()
|
||||
if err2 != nil {
|
||||
klog.Errorf("watch close failed: %s\n", err2)
|
||||
}
|
||||
|
||||
// if the context was canceled, just return a new empty watch
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.EOF) {
|
||||
return watch.NewEmptyWatch(), nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reporter := apierrors.NewClientErrorReporter(500, "WATCH", "")
|
||||
|
||||
decoder := &Decoder{
|
||||
client: result,
|
||||
client: client,
|
||||
newFunc: s.newFunc,
|
||||
opts: opts,
|
||||
codec: s.codec,
|
||||
@@ -613,49 +634,148 @@ type Decoder struct {
|
||||
}
|
||||
|
||||
func (d *Decoder) Decode() (action watch.EventType, object runtime.Object, err error) {
|
||||
decode:
|
||||
for {
|
||||
err := d.client.Context().Err()
|
||||
if err != nil {
|
||||
klog.Errorf("client: context error: %s\n", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
resp, err := d.client.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
log.Printf("watch is done")
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
if grpcStatus.Code(err) == grpcCodes.Canceled {
|
||||
log.Printf("watch was canceled")
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error receiving result: %s", err)
|
||||
klog.Errorf("client: error receiving result: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
if resp.Entity == nil {
|
||||
klog.Errorf("client: received nil entity\n")
|
||||
continue decode
|
||||
}
|
||||
|
||||
obj := d.newFunc()
|
||||
|
||||
err = entityToResource(resp.Entity, obj, d.codec)
|
||||
if err != nil {
|
||||
log.Printf("error decoding entity: %s", err)
|
||||
return watch.Error, nil, err
|
||||
if resp.Entity.Action == entityStore.Entity_BOOKMARK {
|
||||
// here k8s expects an empty object with just resource version and k8s.io/initial-events-end annotation
|
||||
accessor, err := meta.Accessor(obj)
|
||||
if err != nil {
|
||||
klog.Errorf("error getting object accessor: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
accessor.SetResourceVersion(fmt.Sprintf("%d", resp.Entity.ResourceVersion))
|
||||
accessor.SetAnnotations(map[string]string{"k8s.io/initial-events-end": "true"})
|
||||
return watch.Bookmark, obj, nil
|
||||
}
|
||||
|
||||
// apply any predicates not handled in storage
|
||||
matches, err := d.opts.Predicate.Matches(obj)
|
||||
err = entityToResource(resp.Entity, obj, d.codec)
|
||||
if err != nil {
|
||||
log.Printf("error matching object: %s", err)
|
||||
klog.Errorf("error decoding entity: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
|
||||
var watchAction watch.EventType
|
||||
switch resp.Entity.Action {
|
||||
case entityStore.Entity_CREATED:
|
||||
// apply any predicates not handled in storage
|
||||
matches, err := d.opts.Predicate.Matches(obj)
|
||||
if err != nil {
|
||||
klog.Errorf("error matching object: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
if !matches {
|
||||
continue decode
|
||||
}
|
||||
|
||||
watchAction = watch.Added
|
||||
case entityStore.Entity_UPDATED:
|
||||
watchAction = watch.Modified
|
||||
|
||||
// apply any predicates not handled in storage
|
||||
matches, err := d.opts.Predicate.Matches(obj)
|
||||
if err != nil {
|
||||
klog.Errorf("error matching object: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
// if we have a previous object, check if it matches
|
||||
prevMatches := false
|
||||
prevObj := d.newFunc()
|
||||
if resp.Previous != nil {
|
||||
err = entityToResource(resp.Previous, prevObj, d.codec)
|
||||
if err != nil {
|
||||
klog.Errorf("error decoding entity: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
// apply any predicates not handled in storage
|
||||
prevMatches, err = d.opts.Predicate.Matches(prevObj)
|
||||
if err != nil {
|
||||
klog.Errorf("error matching object: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if !matches {
|
||||
if !prevMatches {
|
||||
continue decode
|
||||
}
|
||||
|
||||
// if the object didn't match, send a Deleted event
|
||||
watchAction = watch.Deleted
|
||||
|
||||
// here k8s expects the previous object but with the new resource version
|
||||
obj = prevObj
|
||||
|
||||
accessor, err := meta.Accessor(obj)
|
||||
if err != nil {
|
||||
klog.Errorf("error getting object accessor: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
accessor.SetResourceVersion(fmt.Sprintf("%d", resp.Entity.ResourceVersion))
|
||||
} else if !prevMatches {
|
||||
// if the object didn't previously match, send an Added event
|
||||
watchAction = watch.Added
|
||||
}
|
||||
case entityStore.Entity_DELETED:
|
||||
watchAction = watch.Deleted
|
||||
|
||||
// if we have a previous object, return that in the deleted event
|
||||
if resp.Previous != nil {
|
||||
err = entityToResource(resp.Previous, obj, d.codec)
|
||||
if err != nil {
|
||||
klog.Errorf("error decoding entity: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
// here k8s expects the previous object but with the new resource version
|
||||
accessor, err := meta.Accessor(obj)
|
||||
if err != nil {
|
||||
klog.Errorf("error getting object accessor: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
|
||||
accessor.SetResourceVersion(fmt.Sprintf("%d", resp.Entity.ResourceVersion))
|
||||
}
|
||||
|
||||
// apply any predicates not handled in storage
|
||||
matches, err := d.opts.Predicate.Matches(obj)
|
||||
if err != nil {
|
||||
klog.Errorf("error matching object: %s", err)
|
||||
return watch.Error, nil, err
|
||||
}
|
||||
if !matches {
|
||||
continue decode
|
||||
}
|
||||
default:
|
||||
watchAction = watch.Error
|
||||
}
|
||||
@@ -665,7 +785,10 @@ func (d *Decoder) Decode() (action watch.EventType, object runtime.Object, err e
|
||||
}
|
||||
|
||||
func (d *Decoder) Close() {
|
||||
_ = d.client.CloseSend()
|
||||
err := d.client.CloseSend()
|
||||
if err != nil {
|
||||
klog.Errorf("error closing watch stream: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ watch.Decoder = (*Decoder)(nil)
|
||||
@@ -0,0 +1,166 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
var _ storage.Interface = &RequestInfoWrapper{}
|
||||
|
||||
type RequestInfoWrapper struct {
|
||||
store storage.Interface
|
||||
gr schema.GroupResource
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) setRequestInfo(ctx context.Context, key string) (context.Context, error) {
|
||||
pkey, err := convertToParsedKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx = appcontext.WithUser(ctx, &user.SignedInUser{
|
||||
Login: "admin",
|
||||
UserID: 1,
|
||||
OrgID: 1,
|
||||
})
|
||||
|
||||
return request.WithRequestInfo(ctx, &request.RequestInfo{
|
||||
APIGroup: pkey.Group,
|
||||
APIVersion: "v1",
|
||||
Resource: pkey.Resource,
|
||||
Subresource: "",
|
||||
Namespace: pkey.Namespace,
|
||||
Name: pkey.Name,
|
||||
Parts: strings.Split(key, "/"),
|
||||
IsResourceRequest: true,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) Create(ctx context.Context, key string, obj runtime.Object, out runtime.Object, ttl uint64) error {
|
||||
ctx, err := r.setRequestInfo(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.store.Create(ctx, key, obj, out, ttl)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) Delete(ctx context.Context, key string, out runtime.Object, preconditions *storage.Preconditions, validateDeletion storage.ValidateObjectFunc, cachedExistingObject runtime.Object) error {
|
||||
ctx, err := r.setRequestInfo(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.store.Delete(ctx, key, out, preconditions, validateDeletion, cachedExistingObject)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) Watch(ctx context.Context, key string, opts storage.ListOptions) (watch.Interface, error) {
|
||||
ctx, err := r.setRequestInfo(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.store.Watch(ctx, key, opts)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) Get(ctx context.Context, key string, opts storage.GetOptions, objPtr runtime.Object) error {
|
||||
ctx, err := r.setRequestInfo(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.store.Get(ctx, key, opts, objPtr)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) GetList(ctx context.Context, key string, opts storage.ListOptions, listObj runtime.Object) error {
|
||||
ctx, err := r.setRequestInfo(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.store.GetList(ctx, key, opts, listObj)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) GuaranteedUpdate(ctx context.Context, key string, destination runtime.Object, ignoreNotFound bool, preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, cachedExistingObject runtime.Object) error {
|
||||
ctx, err := r.setRequestInfo(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.store.GuaranteedUpdate(ctx, key, destination, ignoreNotFound, preconditions, tryUpdate, cachedExistingObject)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) Count(key string) (int64, error) {
|
||||
return r.store.Count(key)
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) Versioner() storage.Versioner {
|
||||
return r.store.Versioner()
|
||||
}
|
||||
|
||||
func (r *RequestInfoWrapper) RequestWatchProgress(ctx context.Context) error {
|
||||
return r.store.RequestWatchProgress(ctx)
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
Group string
|
||||
Resource string
|
||||
Namespace string
|
||||
Name string
|
||||
}
|
||||
|
||||
func convertToParsedKey(key string) (*Key, error) {
|
||||
// NOTE: the following supports the watcher tests that run against v1/pods
|
||||
// Other than that, there are ambiguities in the key format that only field selector
|
||||
// when set to use metadata.name can be used to bring clarity in the 3-segment case
|
||||
|
||||
// Cases handled below:
|
||||
// namespace scoped:
|
||||
// /<resource>/[<namespace>]/[<name>]
|
||||
// /<resource>/[<namespace>]
|
||||
//
|
||||
// cluster scoped:
|
||||
// /<resource>/[<name>]
|
||||
// /<resource>
|
||||
k := &Key{}
|
||||
|
||||
if !strings.HasPrefix(key, "/") {
|
||||
key = "/" + key
|
||||
}
|
||||
|
||||
parts := strings.SplitN(key, "/", 5)
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid key format: %s", key)
|
||||
}
|
||||
|
||||
k.Resource = parts[1]
|
||||
if len(parts) < 3 {
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// figure out whether the key is namespace scoped or cluster scoped
|
||||
if isTestNs(parts[2]) {
|
||||
k.Namespace = parts[2]
|
||||
if len(parts) >= 4 {
|
||||
k.Name = parts[3]
|
||||
}
|
||||
} else {
|
||||
k.Name = parts[2]
|
||||
}
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func isTestNs(part string) bool {
|
||||
return strings.HasPrefix(part, "test-ns-") || strings.HasPrefix(part, "ns-") || strings.Index(part, "-ns") > 0
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/storage/etcd3/watcher_test.go
|
||||
// Provenance-includes-license: Apache-2.0
|
||||
// Provenance-includes-copyright: The Kubernetes Authors.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/apitesting"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"k8s.io/apiserver/pkg/apis/example"
|
||||
examplev1 "k8s.io/apiserver/pkg/apis/example/v1"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
"k8s.io/apiserver/pkg/storage/storagebackend"
|
||||
"k8s.io/apiserver/pkg/storage/storagebackend/factory"
|
||||
storagetesting "k8s.io/apiserver/pkg/storage/testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/storage/entity"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
entityStore "github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
)
|
||||
|
||||
var scheme = runtime.NewScheme()
|
||||
var codecs = serializer.NewCodecFactory(scheme)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func createTestContext(t *testing.T) (entityStore.EntityStoreClient, factory.DestroyFunc) {
|
||||
t.Helper()
|
||||
|
||||
grafDir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrpcServer,
|
||||
featuremgmt.FlagUnifiedStorage,
|
||||
},
|
||||
AppModeProduction: false, // required for migrations to run
|
||||
GRPCServerAddress: "127.0.0.1:0", // :0 for choosing the port automatically
|
||||
})
|
||||
|
||||
cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{Config: cfgPath, HomePath: grafDir})
|
||||
assert.NoError(t, err)
|
||||
|
||||
featureManager, err := featuremgmt.ProvideManagerService(cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
featureToggles := featuremgmt.ProvideToggles(featureManager)
|
||||
|
||||
db := sqlstore.InitTestDBWithMigration(t, nil, sqlstore.InitTestDBOpt{EnsureDefaultOrgAndUser: false})
|
||||
require.NoError(t, err)
|
||||
|
||||
eDB, err := dbimpl.ProvideEntityDB(db, cfg, featureToggles)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eDB.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
store, err := sqlstash.ProvideSQLEntityServer(eDB)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := entityStore.NewEntityStoreClientLocal(store)
|
||||
|
||||
return client, func() { store.Stop() }
|
||||
}
|
||||
|
||||
func init() {
|
||||
metav1.AddToGroupVersion(scheme, metav1.SchemeGroupVersion)
|
||||
utilruntime.Must(example.AddToScheme(scheme))
|
||||
utilruntime.Must(examplev1.AddToScheme(scheme))
|
||||
}
|
||||
|
||||
type setupOptions struct {
|
||||
codec runtime.Codec
|
||||
newFunc func() runtime.Object
|
||||
newListFunc func() runtime.Object
|
||||
prefix string
|
||||
resourcePrefix string
|
||||
groupResource schema.GroupResource
|
||||
}
|
||||
|
||||
type setupOption func(*setupOptions, *testing.T)
|
||||
|
||||
func withDefaults(options *setupOptions, t *testing.T) {
|
||||
options.codec = apitesting.TestCodec(codecs, examplev1.SchemeGroupVersion)
|
||||
options.newFunc = newPod
|
||||
options.newListFunc = newPodList
|
||||
options.prefix = t.TempDir()
|
||||
options.resourcePrefix = "/pods"
|
||||
options.groupResource = schema.GroupResource{Resource: "pods"}
|
||||
}
|
||||
|
||||
var _ setupOption = withDefaults
|
||||
|
||||
func testSetup(t *testing.T, opts ...setupOption) (context.Context, storage.Interface, factory.DestroyFunc, error) {
|
||||
setupOpts := setupOptions{}
|
||||
opts = append([]setupOption{withDefaults}, opts...)
|
||||
for _, opt := range opts {
|
||||
opt(&setupOpts, t)
|
||||
}
|
||||
|
||||
config := storagebackend.NewDefaultConfig(setupOpts.prefix, setupOpts.codec)
|
||||
|
||||
client, destroyFunc := createTestContext(t)
|
||||
|
||||
store, _, err := entity.NewStorage(
|
||||
config.ForResource(setupOpts.groupResource),
|
||||
setupOpts.groupResource,
|
||||
client,
|
||||
setupOpts.codec,
|
||||
func(obj runtime.Object) (string, error) {
|
||||
return storage.NamespaceKeyFunc(setupOpts.resourcePrefix, obj)
|
||||
},
|
||||
setupOpts.newFunc,
|
||||
setupOpts.newListFunc,
|
||||
storage.DefaultNamespaceScopedAttr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
wrappedStore := &RequestInfoWrapper{
|
||||
store: store,
|
||||
gr: setupOpts.groupResource,
|
||||
}
|
||||
|
||||
return ctx, wrappedStore, destroyFunc, nil
|
||||
}
|
||||
|
||||
func TestIntegrationWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatch(ctx, t, store)
|
||||
}
|
||||
|
||||
func TestIntegrationClusterScopedWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestClusterScopedWatch(ctx, t, store)
|
||||
}
|
||||
|
||||
func TestIntegrationNamespaceScopedWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestNamespaceScopedWatch(ctx, t, store)
|
||||
}
|
||||
|
||||
func TestIntegrationDeleteTriggerWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestDeleteTriggerWatch(ctx, t, store)
|
||||
}
|
||||
|
||||
func TestIntegrationWatchFromZero(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatchFromZero(ctx, t, store, nil)
|
||||
}
|
||||
|
||||
// TestWatchFromNonZero tests that
|
||||
// - watch from non-0 should just watch changes after given version
|
||||
func TestIntegrationWatchFromNonZero(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatchFromNonZero(ctx, t, store)
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO this times out, we need to buffer events
|
||||
func TestIntegrationDelayedWatchDelivery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestDelayedWatchDelivery(ctx, t, store)
|
||||
}
|
||||
*/
|
||||
|
||||
/* func TestIntegrationWatchError(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, _ := testSetup(t)
|
||||
storagetesting.RunTestWatchError(ctx, t, &storeWithPrefixTransformer{store})
|
||||
} */
|
||||
|
||||
func TestIntegrationWatchContextCancel(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatchContextCancel(ctx, t, store)
|
||||
}
|
||||
|
||||
func TestIntegrationWatcherTimeout(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatcherTimeout(ctx, t, store)
|
||||
}
|
||||
|
||||
func TestIntegrationWatchDeleteEventObjectHaveLatestRV(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatchDeleteEventObjectHaveLatestRV(ctx, t, store)
|
||||
}
|
||||
|
||||
// TODO: enable when we support flow control and priority fairness
|
||||
/* func TestIntegrationWatchInitializationSignal(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatchInitializationSignal(ctx, t, store)
|
||||
} */
|
||||
|
||||
/* func TestIntegrationProgressNotify(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunOptionalTestProgressNotify(ctx, t, store)
|
||||
} */
|
||||
|
||||
// TestWatchDispatchBookmarkEvents makes sure that
|
||||
// setting allowWatchBookmarks query param against
|
||||
// etcd implementation doesn't have any effect.
|
||||
func TestIntegrationWatchDispatchBookmarkEvents(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunTestWatchDispatchBookmarkEvents(ctx, t, store, false)
|
||||
}
|
||||
|
||||
func TestIntegrationSendInitialEventsBackwardCompatibility(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunSendInitialEventsBackwardCompatibility(ctx, t, store)
|
||||
}
|
||||
|
||||
// TODO this test times out
|
||||
func TestIntegrationEtcdWatchSemantics(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunWatchSemantics(ctx, t, store)
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO this test times out
|
||||
func TestIntegrationEtcdWatchSemanticInitialEventsExtended(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
assert.NoError(t, err)
|
||||
storagetesting.RunWatchSemanticInitialEventsExtended(ctx, t, store)
|
||||
}
|
||||
*/
|
||||
|
||||
func newPod() runtime.Object {
|
||||
return &example.Pod{}
|
||||
}
|
||||
|
||||
func newPodList() runtime.Object {
|
||||
return &example.PodList{}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
// IConnection is interface for LDAP connection manipulation
|
||||
@@ -99,7 +100,7 @@ func (server *Server) Dial() error {
|
||||
var certPool *x509.CertPool
|
||||
if server.Config.RootCACert != "" {
|
||||
certPool = x509.NewCertPool()
|
||||
for _, caCertFile := range strings.Split(server.Config.RootCACert, " ") {
|
||||
for _, caCertFile := range util.SplitString(server.Config.RootCACert) {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `caCertFile` comes from ldap config.
|
||||
pem, err := os.ReadFile(caCertFile)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package accesscontrol
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type genericService struct {
|
||||
ac accesscontrol.AccessControl
|
||||
}
|
||||
|
||||
// HasAccess returns true if the identity.Requester has all permissions specified by the evaluator. Returns error if access control backend could not evaluate permissions
|
||||
func (r genericService) HasAccess(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
return r.ac.Evaluate(ctx, user, evaluator)
|
||||
}
|
||||
|
||||
// HasAccessOrError returns nil if the identity.Requester has enough permissions to pass the accesscontrol.Evaluator. Otherwise, returns authorization error that contains action that was performed
|
||||
func (r genericService) HasAccessOrError(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator, action func() string) error {
|
||||
has, err := r.HasAccess(ctx, user, evaluator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
return NewAuthorizationErrorWithPermissions(action(), evaluator)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -22,32 +22,15 @@ const (
|
||||
)
|
||||
|
||||
type RuleService struct {
|
||||
ac accesscontrol.AccessControl
|
||||
genericService
|
||||
}
|
||||
|
||||
func NewRuleService(ac accesscontrol.AccessControl) *RuleService {
|
||||
return &RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
}
|
||||
|
||||
// HasAccess returns true if the identity.Requester has all permissions specified by the evaluator. Returns error if access control backend could not evaluate permissions
|
||||
func (r *RuleService) HasAccess(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
return r.ac.Evaluate(ctx, user, evaluator)
|
||||
}
|
||||
|
||||
// HasAccessOrError returns nil if the identity.Requester has enough permissions to pass the accesscontrol.Evaluator. Otherwise, returns authorization error that contains action that was performed
|
||||
func (r *RuleService) HasAccessOrError(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator, action func() string) error {
|
||||
has, err := r.HasAccess(ctx, user, evaluator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
return NewAuthorizationErrorWithPermissions(action(), evaluator)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getReadFolderAccessEvaluator constructs accesscontrol.Evaluator that checks all permissions required to read rules in specific folder
|
||||
func getReadFolderAccessEvaluator(folderUID string) accesscontrol.Evaluator {
|
||||
return accesscontrol.EvalAll(
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestAuthorizeRuleChanges(t *testing.T) {
|
||||
for _, missing := range permissionCombinations {
|
||||
ac := &recordingAccessControlFake{}
|
||||
srv := RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
err := srv.AuthorizeRuleChanges(context.Background(), createUserWithPermissions(missing), groupChanges)
|
||||
|
||||
@@ -369,7 +369,7 @@ func TestAuthorizeRuleChanges(t *testing.T) {
|
||||
},
|
||||
}
|
||||
srv := RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
err := srv.AuthorizeRuleChanges(context.Background(), createUserWithPermissions(permissions), groupChanges)
|
||||
require.NoError(t, err)
|
||||
@@ -414,7 +414,7 @@ func TestCheckDatasourcePermissionsForRule(t *testing.T) {
|
||||
|
||||
ac := &recordingAccessControlFake{}
|
||||
svc := RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
|
||||
eval := svc.AuthorizeDatasourceAccessForRule(context.Background(), createUserWithPermissions(permissions), rule)
|
||||
@@ -430,7 +430,7 @@ func TestCheckDatasourcePermissionsForRule(t *testing.T) {
|
||||
},
|
||||
}
|
||||
svc := RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
|
||||
result := svc.AuthorizeDatasourceAccessForRule(context.Background(), createUserWithPermissions(nil), rule)
|
||||
@@ -460,7 +460,7 @@ func Test_authorizeAccessToRuleGroup(t *testing.T) {
|
||||
}
|
||||
ac := &recordingAccessControlFake{}
|
||||
svc := RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
|
||||
result := svc.AuthorizeAccessToRuleGroup(context.Background(), createUserWithPermissions(permissions), rules)
|
||||
@@ -493,7 +493,7 @@ func Test_authorizeAccessToRuleGroup(t *testing.T) {
|
||||
ac := &recordingAccessControlFake{}
|
||||
|
||||
svc := RuleService{
|
||||
ac: ac,
|
||||
genericService{ac: ac},
|
||||
}
|
||||
|
||||
result := svc.AuthorizeAccessToRuleGroup(context.Background(), createUserWithPermissions(permissions), rules)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package accesscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
)
|
||||
|
||||
const (
|
||||
instancesRead = ac.ActionAlertingInstanceRead
|
||||
instancesCreate = ac.ActionAlertingInstanceCreate
|
||||
instancesWrite = ac.ActionAlertingInstanceUpdate
|
||||
silenceRead = ac.ActionAlertingSilencesRead
|
||||
silenceCreate = ac.ActionAlertingSilencesCreate
|
||||
silenceWrite = ac.ActionAlertingSilencesWrite
|
||||
)
|
||||
|
||||
var (
|
||||
// asserts full read-only access to silences
|
||||
readAllSilencesEvaluator = ac.EvalPermission(instancesRead)
|
||||
// shortcut assertion that to check if user can read silences
|
||||
readSomeSilenceEvaluator = ac.EvalAny(ac.EvalPermission(instancesRead), ac.EvalPermission(silenceRead))
|
||||
// asserts whether user has read access to rules in a specific folder
|
||||
readRuleSilenceEvaluator = func(folderUID string) ac.Evaluator {
|
||||
return ac.EvalAny(
|
||||
ac.EvalPermission(instancesRead),
|
||||
ac.EvalPermission(silenceRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)),
|
||||
)
|
||||
}
|
||||
|
||||
// shortcut assertion to check if user can create any silence
|
||||
createAnySilenceEvaluator = ac.EvalAll(ac.EvalPermission(instancesCreate), readAllSilencesEvaluator)
|
||||
// asserts that user has access to create general silences, the ones that can match alerts created by one or many rules
|
||||
createGeneralSilenceEvaluator = ac.EvalAll(ac.EvalPermission(instancesCreate), readSomeSilenceEvaluator)
|
||||
// shortcut assertion to check if user can create silences at all
|
||||
createSomeRuleSilenceEvaluator = ac.EvalAll(
|
||||
readSomeSilenceEvaluator,
|
||||
ac.EvalAny(
|
||||
ac.EvalPermission(instancesCreate),
|
||||
ac.EvalPermission(silenceCreate)),
|
||||
)
|
||||
// asserts that user has access to create silences in a specific folder
|
||||
createRuleSilenceEvaluator = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAll(
|
||||
ac.EvalAny(
|
||||
ac.EvalPermission(instancesCreate),
|
||||
ac.EvalPermission(silenceCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(uid)),
|
||||
),
|
||||
readRuleSilenceEvaluator(uid),
|
||||
)
|
||||
}
|
||||
|
||||
// shortcut assertion to check if user can update any silence
|
||||
updateAnySilenceEvaluator = ac.EvalAll(ac.EvalPermission(instancesWrite), readAllSilencesEvaluator)
|
||||
// asserts that user has access to update general silences
|
||||
updateGeneralSilenceEvaluator = ac.EvalAll(ac.EvalPermission(instancesWrite), readSomeSilenceEvaluator)
|
||||
// asserts that user has access to update silences at all
|
||||
updateSomeRuleSilenceEvaluator = ac.EvalAll(
|
||||
readSomeSilenceEvaluator,
|
||||
ac.EvalAny(
|
||||
ac.EvalPermission(instancesWrite),
|
||||
ac.EvalPermission(silenceWrite)),
|
||||
)
|
||||
// asserts that user has access to create silences in a specific folder
|
||||
updateRuleSilenceEvaluator = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAll(
|
||||
ac.EvalAny(
|
||||
ac.EvalPermission(instancesWrite),
|
||||
ac.EvalPermission(silenceWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(uid)),
|
||||
),
|
||||
readRuleSilenceEvaluator(uid),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type Silence interface {
|
||||
GetRuleUID() *string
|
||||
}
|
||||
|
||||
type RuleUIDToNamespaceStore interface {
|
||||
GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error)
|
||||
}
|
||||
|
||||
type SilenceService struct {
|
||||
genericService
|
||||
store RuleUIDToNamespaceStore
|
||||
}
|
||||
|
||||
func NewSilenceService(ac ac.AccessControl, store RuleUIDToNamespaceStore) *SilenceService {
|
||||
return &SilenceService{
|
||||
genericService: genericService{
|
||||
ac: ac,
|
||||
},
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// FilterByAccess filters the given list of silences based on the access control permissions of the user.
|
||||
// Global silence (one that is not attached to a particular rule) is considered available to all users.
|
||||
// For silences that are not attached to a rule, are checked against authorization.
|
||||
// This method is more preferred when many silences need to be checked.
|
||||
func (s SilenceService) FilterByAccess(ctx context.Context, user identity.Requester, silences ...Silence) ([]Silence, error) {
|
||||
canAll, err := s.HasAccess(ctx, user, readAllSilencesEvaluator)
|
||||
if err != nil || canAll { // return early if user can either read all silences or there is an error
|
||||
return silences, err
|
||||
}
|
||||
canSome, err := s.HasAccess(ctx, user, readSomeSilenceEvaluator)
|
||||
if err != nil || !canSome {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]Silence, 0, len(silences))
|
||||
silencesByRuleUID := make(map[string][]Silence, len(silences))
|
||||
for _, silence := range silences {
|
||||
ruleUID := silence.GetRuleUID()
|
||||
if ruleUID == nil { // if this is a general silence
|
||||
result = append(result, silence)
|
||||
continue
|
||||
}
|
||||
key := *ruleUID
|
||||
silencesByRuleUID[key] = append(silencesByRuleUID[key], silence)
|
||||
}
|
||||
if len(silencesByRuleUID) == 0 { // if only general silences are provided no need in other checks
|
||||
return result, nil
|
||||
}
|
||||
namespacesByRuleUID, err := s.store.GetNamespacesByRuleUID(ctx, user.GetOrgID(), maps.Keys(silencesByRuleUID)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
namespacesByAccess := make(map[string]bool) // caches results of permissions check for each namespace to avoid repeated checks for the same folder
|
||||
for ruleUID, silence := range silencesByRuleUID {
|
||||
ns, ok := namespacesByRuleUID[ruleUID]
|
||||
if !ok { // this means that there is no rule with such UID.
|
||||
continue
|
||||
}
|
||||
hasAccess, ok := namespacesByAccess[ns]
|
||||
if !ok {
|
||||
hasAccess, err = s.HasAccess(ctx, user, readRuleSilenceEvaluator(ns))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
namespacesByAccess[ns] = hasAccess
|
||||
}
|
||||
if hasAccess {
|
||||
result = append(result, silence...)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuthorizeReadSilence checks if user has access to read a silence
|
||||
func (s SilenceService) AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence Silence) error {
|
||||
canAll, err := s.HasAccess(ctx, user, readAllSilencesEvaluator)
|
||||
if canAll || err != nil { // return early if user can either read all silences or there is error
|
||||
return err
|
||||
}
|
||||
|
||||
can, err := s.HasAccess(ctx, user, readSomeSilenceEvaluator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !can { // User does not have silence permissions at all.
|
||||
return NewAuthorizationErrorWithPermissions("read any silences", readSomeSilenceEvaluator)
|
||||
}
|
||||
ruleUID := silence.GetRuleUID()
|
||||
if ruleUID == nil {
|
||||
return nil // no rule UID means that this is a general silence and at this point the user can read them
|
||||
}
|
||||
|
||||
// otherwise resolve rule key to the action's scope
|
||||
folderUID, err := s.ruleUIDToFolderUID(ctx, user.GetOrgID(), *ruleUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve rule UID to folder UID: %w", err)
|
||||
}
|
||||
if folderUID == "" { // if we did not find folder by rule UID then it does not exist.
|
||||
return NewAuthorizationErrorGeneric(fmt.Sprintf("read silence for rule %s", *ruleUID))
|
||||
}
|
||||
return s.HasAccessOrError(ctx, user, readRuleSilenceEvaluator(folderUID), func() string {
|
||||
return "read silence"
|
||||
})
|
||||
}
|
||||
|
||||
// AuthorizeCreateSilence checks if user has access to create a silence. Returns ErrAuthorizationBase if user is not authorized
|
||||
func (s SilenceService) AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence Silence) error {
|
||||
canAny, err := s.HasAccess(ctx, user, createAnySilenceEvaluator)
|
||||
if err != nil || canAny {
|
||||
// return early if user can either create any silence or there is an error
|
||||
return err
|
||||
}
|
||||
ruleUID := silence.GetRuleUID()
|
||||
if ruleUID == nil {
|
||||
return s.HasAccessOrError(ctx, user, createGeneralSilenceEvaluator, func() string {
|
||||
return "create a general silence"
|
||||
})
|
||||
}
|
||||
// pre-check whether a user has at least some basic permissions before hit the store
|
||||
if err := s.HasAccessOrError(ctx, user, createSomeRuleSilenceEvaluator, func() string { return "create any silences" }); err != nil {
|
||||
return err
|
||||
}
|
||||
folderUID, err := s.ruleUIDToFolderUID(ctx, user.GetOrgID(), *ruleUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve rule UID to folder UID: %w", err)
|
||||
}
|
||||
if folderUID == "" { // if we did not find folder by rule UID then it does not exist.
|
||||
return NewAuthorizationErrorGeneric(fmt.Sprintf("create silence for rule %s", *ruleUID))
|
||||
}
|
||||
return s.HasAccessOrError(ctx, user, createRuleSilenceEvaluator(folderUID), func() string {
|
||||
return fmt.Sprintf("create silence for rule %s", *ruleUID)
|
||||
})
|
||||
}
|
||||
|
||||
// AuthorizeUpdateSilence checks if user has access to update\expire a silence. Returns ErrAuthorizationBase if user is not authorized
|
||||
func (s SilenceService) AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence Silence) error {
|
||||
canAny, err := s.HasAccess(ctx, user, updateAnySilenceEvaluator)
|
||||
if err != nil || canAny {
|
||||
// return early if user can either update any silence or there is an error
|
||||
return err
|
||||
}
|
||||
ruleUID := silence.GetRuleUID()
|
||||
if ruleUID == nil {
|
||||
return s.HasAccessOrError(ctx, user, updateGeneralSilenceEvaluator, func() string {
|
||||
return "update a general silence"
|
||||
})
|
||||
}
|
||||
// pre-check whether a user has at least some basic permissions before hit the store
|
||||
if err := s.HasAccessOrError(ctx, user, updateSomeRuleSilenceEvaluator, func() string { return "update any silences" }); err != nil {
|
||||
return err
|
||||
}
|
||||
folderUID, err := s.ruleUIDToFolderUID(ctx, user.GetOrgID(), *ruleUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve rule UID to folder UID: %w", err)
|
||||
}
|
||||
if folderUID == "" { // if we did not find folder by rule UID then it does not exist.
|
||||
return NewAuthorizationErrorGeneric(fmt.Sprintf("update silence for rule %s", *ruleUID))
|
||||
}
|
||||
return s.HasAccessOrError(ctx, user, updateRuleSilenceEvaluator(folderUID), func() string {
|
||||
return fmt.Sprintf("update silence for rule %s", *ruleUID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s SilenceService) ruleUIDToFolderUID(ctx context.Context, orgID int64, ruleUID string) (string, error) {
|
||||
namespaces, err := s.store.GetNamespacesByRuleUID(ctx, orgID, ruleUID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
uid, ok := namespaces[ruleUID]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package accesscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
|
||||
)
|
||||
|
||||
var orgID = rand.Int63()
|
||||
|
||||
func TestFilterByAccess(t *testing.T) {
|
||||
global := testSilence{ID: "global", RuleUID: nil}
|
||||
ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")}
|
||||
folder1 := "rule-1-folder-uid"
|
||||
folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1)
|
||||
ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")}
|
||||
folder2 := "rule-2-folder-uid"
|
||||
notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")}
|
||||
|
||||
silences := []Silence{
|
||||
global,
|
||||
ruleSilence1,
|
||||
ruleSilence2,
|
||||
notFoundRule,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
expected []Silence
|
||||
expectedDbAccess bool
|
||||
}{
|
||||
{
|
||||
name: "no silence access, empty list",
|
||||
user: newUser(),
|
||||
expected: []Silence{},
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
{
|
||||
name: "instance reader should get all",
|
||||
user: newUser(ac.Permission{Action: instancesRead}),
|
||||
expected: []Silence{
|
||||
global,
|
||||
ruleSilence1,
|
||||
ruleSilence2,
|
||||
notFoundRule,
|
||||
},
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
{
|
||||
name: "silence reader should get global + folder",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}),
|
||||
expected: []Silence{
|
||||
global,
|
||||
ruleSilence1,
|
||||
},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ac := &recordingAccessControlFake{}
|
||||
store := &fakeRuleUIDToNamespaceStore{
|
||||
Response: map[string]string{
|
||||
*ruleSilence1.RuleUID: folder1,
|
||||
*ruleSilence2.RuleUID: folder2,
|
||||
},
|
||||
}
|
||||
svc := NewSilenceService(ac, store)
|
||||
|
||||
actual, err := svc.FilterByAccess(context.Background(), testCase.user, silences...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, testCase.expected, actual)
|
||||
|
||||
if testCase.expectedDbAccess {
|
||||
require.Equal(t, store.Calls, 1)
|
||||
} else {
|
||||
require.Equal(t, store.Calls, 0)
|
||||
}
|
||||
require.NotEmpty(t, ac.EvaluateRecordings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeReadSilence(t *testing.T) {
|
||||
global := testSilence{ID: "global", RuleUID: nil}
|
||||
ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")}
|
||||
folder1 := "rule-1-folder-uid"
|
||||
folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1)
|
||||
ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")}
|
||||
folder2 := "rule-2-folder-uid"
|
||||
notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
silence []testSilence
|
||||
expectedErr error
|
||||
expectedDbAccess bool
|
||||
}{
|
||||
{
|
||||
name: "not authorized without permissions",
|
||||
user: newUser(),
|
||||
silence: []testSilence{global, ruleSilence1, notFoundRule},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
{
|
||||
name: "instance reader can read any silence",
|
||||
user: newUser(ac.Permission{Action: instancesRead}),
|
||||
silence: []testSilence{global, ruleSilence1, notFoundRule},
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
{
|
||||
name: "silence reader can read global",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}),
|
||||
silence: []testSilence{global},
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
{
|
||||
name: "silence reader can read from allowed folder",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}),
|
||||
silence: []testSilence{ruleSilence1},
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence reader cannot read from other folders",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}),
|
||||
silence: []testSilence{ruleSilence2},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence reader cannot read unknown rule",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}),
|
||||
silence: []testSilence{notFoundRule},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for _, silence := range testCase.silence {
|
||||
t.Run(silence.ID, func(t *testing.T) {
|
||||
ac := &recordingAccessControlFake{}
|
||||
store := &fakeRuleUIDToNamespaceStore{
|
||||
Response: map[string]string{
|
||||
*ruleSilence1.RuleUID: folder1,
|
||||
*ruleSilence2.RuleUID: folder2,
|
||||
},
|
||||
}
|
||||
svc := NewSilenceService(ac, store)
|
||||
|
||||
err := svc.AuthorizeReadSilence(context.Background(), testCase.user, silence)
|
||||
if testCase.expectedErr != nil {
|
||||
assert.ErrorIs(t, err, testCase.expectedErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
if testCase.expectedDbAccess {
|
||||
require.Equal(t, store.Calls, 1)
|
||||
} else {
|
||||
require.Equal(t, store.Calls, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeCreateSilence(t *testing.T) {
|
||||
global := testSilence{ID: "global", RuleUID: nil}
|
||||
ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")}
|
||||
folder1 := "rule-1-folder-uid"
|
||||
folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1)
|
||||
ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")}
|
||||
folder2 := "rule-2-folder-uid"
|
||||
folder2Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2)
|
||||
notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")}
|
||||
|
||||
silences := []testSilence{
|
||||
global,
|
||||
ruleSilence1,
|
||||
ruleSilence2,
|
||||
notFoundRule,
|
||||
}
|
||||
|
||||
type override struct {
|
||||
expectedErr error
|
||||
expectedDbAccess bool
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
expectedErr error
|
||||
expectedDbAccess bool
|
||||
overrides map[testSilence]override
|
||||
}{
|
||||
{
|
||||
name: "not authorized without permissions",
|
||||
user: newUser(),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "no create access, instance reader",
|
||||
user: newUser(ac.Permission{Action: instancesRead}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "no create access, silence reader",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceRead, Scope: folder2Scope}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "only create access, instance create",
|
||||
user: newUser(ac.Permission{Action: instancesCreate}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "only create access, silence create",
|
||||
user: newUser(ac.Permission{Action: silenceCreate, Scope: folder1Scope}, ac.Permission{Action: silenceCreate, Scope: folder2Scope}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "instance read + create can do everything",
|
||||
user: newUser(ac.Permission{Action: instancesCreate}, ac.Permission{Action: instancesRead}),
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "instance read + silence create",
|
||||
user: newUser(ac.Permission{Action: silenceCreate, Scope: folder1Scope}, ac.Permission{Action: instancesRead}),
|
||||
overrides: map[testSilence]override{
|
||||
global: {
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
ruleSilence1: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read + instance create",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesCreate}),
|
||||
overrides: map[testSilence]override{
|
||||
global: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
ruleSilence1: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read + create",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceCreate, Scope: folder1Scope}),
|
||||
overrides: map[testSilence]override{
|
||||
global: {
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
ruleSilence1: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for _, silence := range silences {
|
||||
expectedErr := testCase.expectedErr
|
||||
expectedDbAccess := testCase.expectedDbAccess
|
||||
if s, ok := testCase.overrides[silence]; ok {
|
||||
expectedErr = s.expectedErr
|
||||
expectedDbAccess = s.expectedDbAccess
|
||||
}
|
||||
t.Run(silence.ID, func(t *testing.T) {
|
||||
ac := &recordingAccessControlFake{}
|
||||
store := &fakeRuleUIDToNamespaceStore{
|
||||
Response: map[string]string{
|
||||
*ruleSilence1.RuleUID: folder1,
|
||||
*ruleSilence2.RuleUID: folder2,
|
||||
},
|
||||
}
|
||||
svc := NewSilenceService(ac, store)
|
||||
|
||||
err := svc.AuthorizeCreateSilence(context.Background(), testCase.user, silence)
|
||||
if expectedErr != nil {
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
if expectedDbAccess {
|
||||
require.Equal(t, 1, store.Calls)
|
||||
} else {
|
||||
require.Equal(t, 0, store.Calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeUpdateSilence(t *testing.T) {
|
||||
global := testSilence{ID: "global", RuleUID: nil}
|
||||
ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")}
|
||||
folder1 := "rule-1-folder-uid"
|
||||
folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1)
|
||||
ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")}
|
||||
folder2 := "rule-2-folder-uid"
|
||||
folder2Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2)
|
||||
notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")}
|
||||
|
||||
silences := []testSilence{
|
||||
global,
|
||||
ruleSilence1,
|
||||
ruleSilence2,
|
||||
notFoundRule,
|
||||
}
|
||||
|
||||
type override struct {
|
||||
expectedErr error
|
||||
expectedDbAccess bool
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
expectedErr error
|
||||
expectedDbAccess bool
|
||||
overrides map[testSilence]override
|
||||
}{
|
||||
{
|
||||
name: "not authorized without permissions",
|
||||
user: newUser(),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "no write access, instance reader",
|
||||
user: newUser(ac.Permission{Action: instancesRead}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "no write access, silence reader",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceRead, Scope: folder2Scope}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "only write access, instance write",
|
||||
user: newUser(ac.Permission{Action: instancesWrite}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "only write access, silence write",
|
||||
user: newUser(ac.Permission{Action: silenceWrite, Scope: folder1Scope}, ac.Permission{Action: silenceWrite, Scope: folder2Scope}),
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
},
|
||||
{
|
||||
name: "instance read + write can do everything",
|
||||
user: newUser(ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesRead}),
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "instance read + silence write",
|
||||
user: newUser(ac.Permission{Action: silenceWrite, Scope: folder1Scope}, ac.Permission{Action: instancesRead}),
|
||||
overrides: map[testSilence]override{
|
||||
global: {
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
ruleSilence1: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read + instance write",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesWrite}),
|
||||
overrides: map[testSilence]override{
|
||||
global: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
ruleSilence1: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read + write",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceWrite, Scope: folder1Scope}),
|
||||
overrides: map[testSilence]override{
|
||||
global: {
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: false,
|
||||
},
|
||||
ruleSilence1: {
|
||||
expectedErr: nil,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
},
|
||||
expectedErr: ErrAuthorizationBase,
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for _, silence := range silences {
|
||||
expectedErr := testCase.expectedErr
|
||||
expectedDbAccess := testCase.expectedDbAccess
|
||||
if s, ok := testCase.overrides[silence]; ok {
|
||||
expectedErr = s.expectedErr
|
||||
expectedDbAccess = s.expectedDbAccess
|
||||
}
|
||||
t.Run(silence.ID, func(t *testing.T) {
|
||||
ac := &recordingAccessControlFake{}
|
||||
store := &fakeRuleUIDToNamespaceStore{
|
||||
Response: map[string]string{
|
||||
*ruleSilence1.RuleUID: folder1,
|
||||
*ruleSilence2.RuleUID: folder2,
|
||||
},
|
||||
}
|
||||
svc := NewSilenceService(ac, store)
|
||||
|
||||
err := svc.AuthorizeUpdateSilence(context.Background(), testCase.user, silence)
|
||||
if expectedErr != nil {
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
if expectedDbAccess {
|
||||
require.Equal(t, 1, store.Calls)
|
||||
} else {
|
||||
require.Equal(t, 0, store.Calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testSilence struct {
|
||||
ID string
|
||||
RuleUID *string
|
||||
}
|
||||
|
||||
func (t testSilence) GetRuleUID() *string {
|
||||
return t.RuleUID
|
||||
}
|
||||
|
||||
type fakeRuleUIDToNamespaceStore struct {
|
||||
Response map[string]string
|
||||
Calls int
|
||||
}
|
||||
|
||||
func (f *fakeRuleUIDToNamespaceStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) {
|
||||
f.Calls++
|
||||
return f.Response, nil
|
||||
}
|
||||
|
||||
func newUser(permissions ...ac.Permission) identity.Requester {
|
||||
return ac.BackgroundUser("test", orgID, org.RoleNone, permissions)
|
||||
}
|
||||
@@ -210,7 +210,9 @@ func (mg *Migrator) run() (err error) {
|
||||
err := mg.InTransaction(func(sess *xorm.Session) error {
|
||||
err := mg.exec(m, sess)
|
||||
// if we get an sqlite busy/locked error, sleep 100ms and try again
|
||||
if errors.Is(err, sqlite3.ErrLocked) || errors.Is(err, sqlite3.ErrBusy) {
|
||||
cnt := 0
|
||||
for cnt < 3 && (errors.Is(err, sqlite3.ErrLocked) || errors.Is(err, sqlite3.ErrBusy)) {
|
||||
cnt++
|
||||
mg.Logger.Debug("Database locked, sleeping then retrying", "error", err, "sql", sql)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
err = mg.exec(m, sess)
|
||||
|
||||
@@ -122,7 +122,11 @@ func (db *SQLite3) TruncateDBTables(engine *xorm.Engine) error {
|
||||
}
|
||||
}
|
||||
if _, err := sess.Exec("UPDATE sqlite_sequence SET seq = 0 WHERE name != 'dashboard_acl';"); err != nil {
|
||||
return fmt.Errorf("failed to cleanup sqlite_sequence: %w", err)
|
||||
// if we have not created any autoincrement columns in the database this will fail, the error is expected and we can ignore it
|
||||
// we can't discriminate based on code because sqlite returns a generic error code
|
||||
if err.Error() != "no such table: sqlite_sequence" {
|
||||
return fmt.Errorf("failed to cleanup sqlite_sequence: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func newSQLStore(cfg *setting.Cfg, engine *xorm.Engine,
|
||||
// Has to be done in a second phase (after initialization), since other services can register migrations during
|
||||
// the initialization phase.
|
||||
func (ss *SQLStore) Migrate(isDatabaseLockingEnabled bool) error {
|
||||
if ss.dbCfg.SkipMigrations {
|
||||
if ss.dbCfg.SkipMigrations || ss.migrations == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,6 @@ func (i fakeEntityStore) Read(ctx context.Context, r *entity.ReadEntityRequest)
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
|
||||
func (i fakeEntityStore) BatchRead(ctx context.Context, batchR *entity.BatchReadEntityRequest) (*entity.BatchReadEntityResponse, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
|
||||
func (i fakeEntityStore) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
@@ -44,7 +40,7 @@ func (i fakeEntityStore) List(ctx context.Context, r *entity.EntityListRequest)
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
|
||||
func (i fakeEntityStore) Watch(*entity.EntityWatchRequest, entity.EntityStore_WatchServer) error {
|
||||
func (i fakeEntityStore) Watch(entity.EntityStore_WatchServer) error {
|
||||
return fmt.Errorf("unimplemented")
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -92,6 +92,7 @@ message Entity {
|
||||
UPDATED = 2;
|
||||
DELETED = 3;
|
||||
ERROR = 4;
|
||||
BOOKMARK = 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,18 +139,6 @@ message ReadEntityRequest {
|
||||
bool with_status = 5;
|
||||
}
|
||||
|
||||
//------------------------------------------------------
|
||||
// Make many read requests at once (by key+version)
|
||||
//------------------------------------------------------
|
||||
|
||||
message BatchReadEntityRequest {
|
||||
repeated ReadEntityRequest batch = 1;
|
||||
}
|
||||
|
||||
message BatchReadEntityResponse {
|
||||
repeated Entity results = 1;
|
||||
}
|
||||
|
||||
//-----------------------------------------------
|
||||
// Create request/response
|
||||
//-----------------------------------------------
|
||||
@@ -244,9 +233,15 @@ message EntityHistoryRequest {
|
||||
// Entity identifier
|
||||
string key = 1;
|
||||
|
||||
// return the history from before this version
|
||||
int64 before = 2;
|
||||
|
||||
// Maximum number of items to return
|
||||
int64 limit = 3;
|
||||
|
||||
// guid of the entity
|
||||
string guid = 4;
|
||||
|
||||
// Starting from the requested page
|
||||
string next_page_token = 5;
|
||||
|
||||
@@ -350,6 +345,14 @@ message EntityListResponse {
|
||||
//-----------------------------------------------
|
||||
|
||||
message EntityWatchRequest {
|
||||
enum WatchAction {
|
||||
START = 0;
|
||||
STOP = 1;
|
||||
}
|
||||
|
||||
// Start or stop the watch
|
||||
WatchAction action = 8;
|
||||
|
||||
// ResourceVersion of last changes. Empty will default to full history
|
||||
int64 since = 1;
|
||||
|
||||
@@ -370,6 +373,11 @@ message EntityWatchRequest {
|
||||
|
||||
// Return the full status in each payload
|
||||
bool with_status = 7;
|
||||
|
||||
// Return initial events
|
||||
bool send_initial_events = 9;
|
||||
|
||||
bool allow_watch_bookmarks = 10;
|
||||
}
|
||||
|
||||
message EntityWatchResponse {
|
||||
@@ -378,6 +386,9 @@ message EntityWatchResponse {
|
||||
|
||||
// Entity that was created, updated, or deleted
|
||||
Entity entity = 2;
|
||||
|
||||
// previous version of the entity
|
||||
Entity previous = 3;
|
||||
}
|
||||
|
||||
message EntitySummary {
|
||||
@@ -433,11 +444,10 @@ message EntityExternalReference {
|
||||
// The entity store provides a basic CRUD (+watch eventually) interface for generic entitys
|
||||
service EntityStore {
|
||||
rpc Read(ReadEntityRequest) returns (Entity);
|
||||
rpc BatchRead(BatchReadEntityRequest) returns (BatchReadEntityResponse);
|
||||
rpc Create(CreateEntityRequest) returns (CreateEntityResponse);
|
||||
rpc Update(UpdateEntityRequest) returns (UpdateEntityResponse);
|
||||
rpc Delete(DeleteEntityRequest) returns (DeleteEntityResponse);
|
||||
rpc History(EntityHistoryRequest) returns (EntityHistoryResponse);
|
||||
rpc List(EntityListRequest) returns (EntityListResponse);
|
||||
rpc Watch(EntityWatchRequest) returns (stream EntityWatchResponse);
|
||||
rpc Watch(stream EntityWatchRequest) returns (stream EntityWatchResponse);
|
||||
}
|
||||
@@ -19,14 +19,13 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion7
|
||||
|
||||
const (
|
||||
EntityStore_Read_FullMethodName = "/entity.EntityStore/Read"
|
||||
EntityStore_BatchRead_FullMethodName = "/entity.EntityStore/BatchRead"
|
||||
EntityStore_Create_FullMethodName = "/entity.EntityStore/Create"
|
||||
EntityStore_Update_FullMethodName = "/entity.EntityStore/Update"
|
||||
EntityStore_Delete_FullMethodName = "/entity.EntityStore/Delete"
|
||||
EntityStore_History_FullMethodName = "/entity.EntityStore/History"
|
||||
EntityStore_List_FullMethodName = "/entity.EntityStore/List"
|
||||
EntityStore_Watch_FullMethodName = "/entity.EntityStore/Watch"
|
||||
EntityStore_Read_FullMethodName = "/entity.EntityStore/Read"
|
||||
EntityStore_Create_FullMethodName = "/entity.EntityStore/Create"
|
||||
EntityStore_Update_FullMethodName = "/entity.EntityStore/Update"
|
||||
EntityStore_Delete_FullMethodName = "/entity.EntityStore/Delete"
|
||||
EntityStore_History_FullMethodName = "/entity.EntityStore/History"
|
||||
EntityStore_List_FullMethodName = "/entity.EntityStore/List"
|
||||
EntityStore_Watch_FullMethodName = "/entity.EntityStore/Watch"
|
||||
)
|
||||
|
||||
// EntityStoreClient is the client API for EntityStore service.
|
||||
@@ -34,13 +33,12 @@ const (
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type EntityStoreClient interface {
|
||||
Read(ctx context.Context, in *ReadEntityRequest, opts ...grpc.CallOption) (*Entity, error)
|
||||
BatchRead(ctx context.Context, in *BatchReadEntityRequest, opts ...grpc.CallOption) (*BatchReadEntityResponse, error)
|
||||
Create(ctx context.Context, in *CreateEntityRequest, opts ...grpc.CallOption) (*CreateEntityResponse, error)
|
||||
Update(ctx context.Context, in *UpdateEntityRequest, opts ...grpc.CallOption) (*UpdateEntityResponse, error)
|
||||
Delete(ctx context.Context, in *DeleteEntityRequest, opts ...grpc.CallOption) (*DeleteEntityResponse, error)
|
||||
History(ctx context.Context, in *EntityHistoryRequest, opts ...grpc.CallOption) (*EntityHistoryResponse, error)
|
||||
List(ctx context.Context, in *EntityListRequest, opts ...grpc.CallOption) (*EntityListResponse, error)
|
||||
Watch(ctx context.Context, in *EntityWatchRequest, opts ...grpc.CallOption) (EntityStore_WatchClient, error)
|
||||
Watch(ctx context.Context, opts ...grpc.CallOption) (EntityStore_WatchClient, error)
|
||||
}
|
||||
|
||||
type entityStoreClient struct {
|
||||
@@ -60,15 +58,6 @@ func (c *entityStoreClient) Read(ctx context.Context, in *ReadEntityRequest, opt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *entityStoreClient) BatchRead(ctx context.Context, in *BatchReadEntityRequest, opts ...grpc.CallOption) (*BatchReadEntityResponse, error) {
|
||||
out := new(BatchReadEntityResponse)
|
||||
err := c.cc.Invoke(ctx, EntityStore_BatchRead_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *entityStoreClient) Create(ctx context.Context, in *CreateEntityRequest, opts ...grpc.CallOption) (*CreateEntityResponse, error) {
|
||||
out := new(CreateEntityResponse)
|
||||
err := c.cc.Invoke(ctx, EntityStore_Create_FullMethodName, in, out, opts...)
|
||||
@@ -114,22 +103,17 @@ func (c *entityStoreClient) List(ctx context.Context, in *EntityListRequest, opt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *entityStoreClient) Watch(ctx context.Context, in *EntityWatchRequest, opts ...grpc.CallOption) (EntityStore_WatchClient, error) {
|
||||
func (c *entityStoreClient) Watch(ctx context.Context, opts ...grpc.CallOption) (EntityStore_WatchClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &EntityStore_ServiceDesc.Streams[0], EntityStore_Watch_FullMethodName, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &entityStoreWatchClient{stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type EntityStore_WatchClient interface {
|
||||
Send(*EntityWatchRequest) error
|
||||
Recv() (*EntityWatchResponse, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
@@ -138,6 +122,10 @@ type entityStoreWatchClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *entityStoreWatchClient) Send(m *EntityWatchRequest) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *entityStoreWatchClient) Recv() (*EntityWatchResponse, error) {
|
||||
m := new(EntityWatchResponse)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
@@ -151,13 +139,12 @@ func (x *entityStoreWatchClient) Recv() (*EntityWatchResponse, error) {
|
||||
// for forward compatibility
|
||||
type EntityStoreServer interface {
|
||||
Read(context.Context, *ReadEntityRequest) (*Entity, error)
|
||||
BatchRead(context.Context, *BatchReadEntityRequest) (*BatchReadEntityResponse, error)
|
||||
Create(context.Context, *CreateEntityRequest) (*CreateEntityResponse, error)
|
||||
Update(context.Context, *UpdateEntityRequest) (*UpdateEntityResponse, error)
|
||||
Delete(context.Context, *DeleteEntityRequest) (*DeleteEntityResponse, error)
|
||||
History(context.Context, *EntityHistoryRequest) (*EntityHistoryResponse, error)
|
||||
List(context.Context, *EntityListRequest) (*EntityListResponse, error)
|
||||
Watch(*EntityWatchRequest, EntityStore_WatchServer) error
|
||||
Watch(EntityStore_WatchServer) error
|
||||
}
|
||||
|
||||
// UnimplementedEntityStoreServer should be embedded to have forward compatible implementations.
|
||||
@@ -167,9 +154,6 @@ type UnimplementedEntityStoreServer struct {
|
||||
func (UnimplementedEntityStoreServer) Read(context.Context, *ReadEntityRequest) (*Entity, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Read not implemented")
|
||||
}
|
||||
func (UnimplementedEntityStoreServer) BatchRead(context.Context, *BatchReadEntityRequest) (*BatchReadEntityResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchRead not implemented")
|
||||
}
|
||||
func (UnimplementedEntityStoreServer) Create(context.Context, *CreateEntityRequest) (*CreateEntityResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Create not implemented")
|
||||
}
|
||||
@@ -185,7 +169,7 @@ func (UnimplementedEntityStoreServer) History(context.Context, *EntityHistoryReq
|
||||
func (UnimplementedEntityStoreServer) List(context.Context, *EntityListRequest) (*EntityListResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method List not implemented")
|
||||
}
|
||||
func (UnimplementedEntityStoreServer) Watch(*EntityWatchRequest, EntityStore_WatchServer) error {
|
||||
func (UnimplementedEntityStoreServer) Watch(EntityStore_WatchServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method Watch not implemented")
|
||||
}
|
||||
|
||||
@@ -218,24 +202,6 @@ func _EntityStore_Read_Handler(srv interface{}, ctx context.Context, dec func(in
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EntityStore_BatchRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchReadEntityRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EntityStoreServer).BatchRead(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EntityStore_BatchRead_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EntityStoreServer).BatchRead(ctx, req.(*BatchReadEntityRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EntityStore_Create_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateEntityRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -327,15 +293,12 @@ func _EntityStore_List_Handler(srv interface{}, ctx context.Context, dec func(in
|
||||
}
|
||||
|
||||
func _EntityStore_Watch_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(EntityWatchRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(EntityStoreServer).Watch(m, &entityStoreWatchServer{stream})
|
||||
return srv.(EntityStoreServer).Watch(&entityStoreWatchServer{stream})
|
||||
}
|
||||
|
||||
type EntityStore_WatchServer interface {
|
||||
Send(*EntityWatchResponse) error
|
||||
Recv() (*EntityWatchRequest, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
@@ -347,6 +310,14 @@ func (x *entityStoreWatchServer) Send(m *EntityWatchResponse) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *entityStoreWatchServer) Recv() (*EntityWatchRequest, error) {
|
||||
m := new(EntityWatchRequest)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// EntityStore_ServiceDesc is the grpc.ServiceDesc for EntityStore service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -358,10 +329,6 @@ var EntityStore_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "Read",
|
||||
Handler: _EntityStore_Read_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchRead",
|
||||
Handler: _EntityStore_BatchRead_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Create",
|
||||
Handler: _EntityStore_Create_Handler,
|
||||
@@ -388,6 +355,7 @@ var EntityStore_ServiceDesc = grpc.ServiceDesc{
|
||||
StreamName: "Watch",
|
||||
Handler: _EntityStore_Watch_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "entity.proto",
|
||||
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/modules"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/grpc"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,11 +51,14 @@ type service struct {
|
||||
tracing *tracing.TracingService
|
||||
|
||||
authenticator interceptors.Authenticator
|
||||
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func ProvideService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
log log.Logger,
|
||||
) (*service, error) {
|
||||
tracingCfg, err := tracing.ProvideTracingConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -76,6 +79,7 @@ func ProvideService(
|
||||
stopCh: make(chan struct{}),
|
||||
authenticator: authn,
|
||||
tracing: tracing,
|
||||
log: log,
|
||||
}
|
||||
|
||||
// This will be used when running as a dskit service
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
StorageServerMetrics *StorageApiMetrics
|
||||
)
|
||||
|
||||
type StorageApiMetrics struct {
|
||||
OptimisticLockFailed *prometheus.CounterVec
|
||||
}
|
||||
|
||||
func NewStorageMetrics() *StorageApiMetrics {
|
||||
once.Do(func() {
|
||||
StorageServerMetrics = &StorageApiMetrics{
|
||||
OptimisticLockFailed: prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: "storage_server",
|
||||
Name: "optimistic_lock_failed",
|
||||
Help: "count of optimistic locks failed",
|
||||
},
|
||||
[]string{"action"},
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return StorageServerMetrics
|
||||
}
|
||||
|
||||
func (s *StorageApiMetrics) Collect(ch chan<- prometheus.Metric) {
|
||||
s.OptimisticLockFailed.Collect(ch)
|
||||
}
|
||||
|
||||
func (s *StorageApiMetrics) Describe(ch chan<- *prometheus.Desc) {
|
||||
s.OptimisticLockFailed.Describe(ch)
|
||||
}
|
||||
@@ -21,57 +21,115 @@ func (d Direction) String() string {
|
||||
return "ASC"
|
||||
}
|
||||
|
||||
type joinQuery struct {
|
||||
query string
|
||||
args []any
|
||||
}
|
||||
|
||||
type whereClause struct {
|
||||
query string
|
||||
args []any
|
||||
}
|
||||
|
||||
type selectQuery struct {
|
||||
dialect migrator.Dialect
|
||||
fields []string // SELECT xyz
|
||||
from string // FROM object
|
||||
fields []string // SELECT xyz
|
||||
from string // FROM object
|
||||
joins []joinQuery // JOIN object
|
||||
offset int64
|
||||
limit int64
|
||||
oneExtra bool
|
||||
|
||||
where []string
|
||||
args []any
|
||||
where []whereClause
|
||||
|
||||
groupBy []string
|
||||
|
||||
orderBy []string
|
||||
direction []Direction
|
||||
}
|
||||
|
||||
func (q *selectQuery) addWhere(f string, val ...any) {
|
||||
q.args = append(q.args, val...)
|
||||
// if the field contains a question mark, we assume it's a raw where clause
|
||||
if strings.Contains(f, "?") {
|
||||
q.where = append(q.where, f)
|
||||
// otherwise we assume it's a field name
|
||||
} else {
|
||||
q.where = append(q.where, q.dialect.Quote(f)+"=?")
|
||||
func NewSelectQuery(dialect migrator.Dialect, from string) *selectQuery {
|
||||
return &selectQuery{
|
||||
dialect: dialect,
|
||||
from: from,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *selectQuery) addWhereInSubquery(f string, subquery string, subqueryArgs []any) {
|
||||
q.args = append(q.args, subqueryArgs...)
|
||||
q.where = append(q.where, q.dialect.Quote(f)+" IN ("+subquery+")")
|
||||
func (q *selectQuery) From(from string) {
|
||||
q.from = from
|
||||
}
|
||||
|
||||
func (q *selectQuery) addWhereIn(f string, vals []string) {
|
||||
func (q *selectQuery) SetLimit(limit int64) {
|
||||
q.limit = limit
|
||||
}
|
||||
|
||||
func (q *selectQuery) SetOffset(offset int64) {
|
||||
q.offset = offset
|
||||
}
|
||||
|
||||
func (q *selectQuery) SetOneExtra() {
|
||||
q.oneExtra = true
|
||||
}
|
||||
|
||||
func (q *selectQuery) UnsetOneExtra() {
|
||||
q.oneExtra = false
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddFields(f ...string) {
|
||||
for _, field := range f {
|
||||
q.fields = append(q.fields, "t."+q.dialect.Quote(field))
|
||||
}
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddRawFields(f ...string) {
|
||||
q.fields = append(q.fields, f...)
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddJoin(j string, args ...any) {
|
||||
q.joins = append(q.joins, joinQuery{query: j, args: args})
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddWhere(f string, val ...any) {
|
||||
// if the field contains a question mark, we assume it's a raw where clause
|
||||
if strings.Contains(f, "?") {
|
||||
q.where = append(q.where, whereClause{f, val})
|
||||
// otherwise we assume it's a field name
|
||||
} else {
|
||||
q.where = append(q.where, whereClause{"t." + q.dialect.Quote(f) + "=?", val})
|
||||
}
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddWhereInSubquery(f string, subquery string, subqueryArgs []any) {
|
||||
q.where = append(q.where, whereClause{"t." + q.dialect.Quote(f) + " IN (" + subquery + ")", subqueryArgs})
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddWhereIn(f string, vals []any) {
|
||||
count := len(vals)
|
||||
if count > 1 {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString(q.dialect.Quote(f))
|
||||
sb.WriteString("t." + q.dialect.Quote(f))
|
||||
sb.WriteString(" IN (")
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("?")
|
||||
q.args = append(q.args, vals[i])
|
||||
}
|
||||
sb.WriteString(") ")
|
||||
q.where = append(q.where, sb.String())
|
||||
q.where = append(q.where, whereClause{sb.String(), vals})
|
||||
} else if count == 1 {
|
||||
q.addWhere(f, vals[0])
|
||||
q.AddWhere(f, vals[0])
|
||||
}
|
||||
}
|
||||
|
||||
func ToAnyList[T any](input []T) []any {
|
||||
list := make([]any, len(input))
|
||||
for i, v := range input {
|
||||
list[i] = v
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
const sqlLikeEscape = "#"
|
||||
|
||||
var sqlLikeEscapeReplacer = strings.NewReplacer(
|
||||
@@ -85,39 +143,57 @@ func escapeJSONStringSQLLike(s string) string {
|
||||
return sqlLikeEscapeReplacer.Replace(string(b))
|
||||
}
|
||||
|
||||
func (q *selectQuery) addWhereJsonContainsKV(field string, key string, value string) {
|
||||
func (q *selectQuery) AddWhereJsonContainsKV(field string, key string, value string) {
|
||||
escapedKey := escapeJSONStringSQLLike(key)
|
||||
escapedValue := escapeJSONStringSQLLike(value)
|
||||
q.where = append(q.where, q.dialect.Quote(field)+" LIKE ? ESCAPE ?")
|
||||
q.args = append(q.args, "{%"+escapedKey+":"+escapedValue+"%}", sqlLikeEscape)
|
||||
q.where = append(q.where, whereClause{
|
||||
"t." + q.dialect.Quote(field) + " LIKE ? ESCAPE ?",
|
||||
[]any{"{%\"" + escapedKey + "\":\"" + escapedValue + "\"%}", sqlLikeEscape},
|
||||
})
|
||||
}
|
||||
|
||||
func (q *selectQuery) addOrderBy(field string, direction Direction) {
|
||||
func (q *selectQuery) AddGroupBy(f string) {
|
||||
q.groupBy = append(q.groupBy, f)
|
||||
}
|
||||
|
||||
func (q *selectQuery) AddOrderBy(field string, direction Direction) {
|
||||
q.orderBy = append(q.orderBy, field)
|
||||
q.direction = append(q.direction, direction)
|
||||
}
|
||||
|
||||
func (q *selectQuery) toQuery() (string, []any) {
|
||||
args := q.args
|
||||
func (q *selectQuery) ToQuery() (string, []any) {
|
||||
args := []any{}
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString("SELECT ")
|
||||
quotedFields := make([]string, len(q.fields))
|
||||
for i, f := range q.fields {
|
||||
quotedFields[i] = q.dialect.Quote(f)
|
||||
}
|
||||
sb.WriteString(strings.Join(quotedFields, ","))
|
||||
sb.WriteString(strings.Join(q.fields, ","))
|
||||
sb.WriteString(" FROM ")
|
||||
sb.WriteString(q.from)
|
||||
sb.WriteString(" AS t")
|
||||
|
||||
for _, j := range q.joins {
|
||||
sb.WriteString(" " + j.query)
|
||||
args = append(args, j.args...)
|
||||
}
|
||||
|
||||
// Templated where string
|
||||
where := len(q.where)
|
||||
if where > 0 {
|
||||
if len(q.where) > 0 {
|
||||
sb.WriteString(" WHERE ")
|
||||
for i := 0; i < where; i++ {
|
||||
for i, w := range q.where {
|
||||
if i > 0 {
|
||||
sb.WriteString(" AND ")
|
||||
}
|
||||
sb.WriteString(q.where[i])
|
||||
sb.WriteString(w.query)
|
||||
args = append(args, w.args...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(q.groupBy) > 0 {
|
||||
sb.WriteString(" GROUP BY ")
|
||||
for i, f := range q.groupBy {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("t." + q.dialect.Quote(f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,21 +203,19 @@ func (q *selectQuery) toQuery() (string, []any) {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(q.dialect.Quote(f))
|
||||
sb.WriteString("t." + q.dialect.Quote(f))
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(q.direction[i].String())
|
||||
}
|
||||
}
|
||||
|
||||
limit := q.limit
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
q.limit = limit
|
||||
if limit > 0 {
|
||||
if q.oneExtra {
|
||||
limit = limit + 1
|
||||
}
|
||||
sb.WriteString(q.dialect.LimitOffset(limit, q.offset))
|
||||
}
|
||||
if q.oneExtra {
|
||||
limit = limit + 1
|
||||
}
|
||||
sb.WriteString(q.dialect.LimitOffset(limit, q.offset))
|
||||
|
||||
return sb.String(), args
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -168,6 +168,10 @@ providers:
|
||||
Login: "admin",
|
||||
})
|
||||
|
||||
// give provisioner some time since we don't have a way to know when provisioning is complete
|
||||
// TODO https://github.com/grafana/grafana/issues/85617
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
type errorResponseBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@@ -224,6 +224,14 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string
|
||||
|
||||
connStr += fmt.Sprintf(" sslmode='%s'", escape(tlsSettings.Mode))
|
||||
|
||||
// there is an issue with the lib/pq module, the `verify-ca` tls mode
|
||||
// does not work correctly. ( see https://github.com/lib/pq/issues/1106 )
|
||||
// to workaround the problem, if the `verify-ca` mode is chosen,
|
||||
// we disable sslsni.
|
||||
if tlsSettings.Mode == "verify-ca" {
|
||||
connStr += " sslsni=0"
|
||||
}
|
||||
|
||||
// Attach root certificate if provided
|
||||
if tlsSettings.RootCertFile != "" {
|
||||
logger.Debug("Setting server root certificate", "tlsRootCert", tlsSettings.RootCertFile)
|
||||
|
||||
@@ -121,6 +121,8 @@ func TestIntegrationPostgresSnapshots(t *testing.T) {
|
||||
{format: "time_series", name: "fill_value"},
|
||||
{format: "time_series", name: "fill_value_wide"},
|
||||
{format: "table", name: "simple"},
|
||||
{format: "table", name: "multi_stat1"},
|
||||
{format: "table", name: "multi_stat2"},
|
||||
{format: "table", name: "no_rows"},
|
||||
{format: "table", name: "types_numeric"},
|
||||
{format: "table", name: "types_char"},
|
||||
|
||||
@@ -57,6 +57,15 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
|
||||
tlsSettings: tlsSettings{Mode: "verify-full"},
|
||||
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'",
|
||||
},
|
||||
{
|
||||
desc: "verify-ca automatically adds disable-sni",
|
||||
host: "host:1234",
|
||||
user: "user",
|
||||
password: "password",
|
||||
database: "database",
|
||||
tlsSettings: tlsSettings{Mode: "verify-ca"},
|
||||
expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-ca' sslsni=0",
|
||||
},
|
||||
{
|
||||
desc: "TCP/port host",
|
||||
host: "host:1234",
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
//
|
||||
// Frame[0] {
|
||||
// "typeVersion": [
|
||||
// 0,
|
||||
// 0
|
||||
// ],
|
||||
// "executedQueryString": "SELECT t FROM tbl WHERE t='one'; SELECT t FROM tbl WHERE t='three';"
|
||||
// }
|
||||
// Name:
|
||||
// Dimensions: 1 Fields by 2 Rows
|
||||
// +-----------------+
|
||||
// | Name: t |
|
||||
// | Labels: |
|
||||
// | Type: []*string |
|
||||
// +-----------------+
|
||||
// | one |
|
||||
// | three |
|
||||
// +-----------------+
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
{
|
||||
"status": 200,
|
||||
"frames": [
|
||||
{
|
||||
"schema": {
|
||||
"meta": {
|
||||
"typeVersion": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"executedQueryString": "SELECT t FROM tbl WHERE t='one'; SELECT t FROM tbl WHERE t='three';"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"name": "t",
|
||||
"type": "string",
|
||||
"typeInfo": {
|
||||
"frame": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"values": [
|
||||
[
|
||||
"one",
|
||||
"three"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- SELECT t FROM tbl WHERE t='one'; SELECT t FROM tbl WHERE t='three';
|
||||
-- lib/pq allows multiple queries to run, and return results for all the queries
|
||||
CREATE TEMPORARY TABLE tbl (
|
||||
t text
|
||||
);
|
||||
|
||||
INSERT INTO tbl (t) VALUES ('one'),('two'),('three');
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
//
|
||||
// Frame[0] {
|
||||
// "typeVersion": [
|
||||
// 0,
|
||||
// 0
|
||||
// ],
|
||||
// "executedQueryString": "SET TIME ZONE -7; select * from tbl; SET TIME ZONE DEFAULT; select * from tbl;"
|
||||
// }
|
||||
// Name:
|
||||
// Dimensions: 1 Fields by 2 Rows
|
||||
// +---------------------------------+
|
||||
// | Name: d |
|
||||
// | Labels: |
|
||||
// | Type: []*time.Time |
|
||||
// +---------------------------------+
|
||||
// | 2024-04-03 05:43:25 -0700 -0700 |
|
||||
// | 2024-04-03 12:43:25 +0000 UTC |
|
||||
// +---------------------------------+
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
{
|
||||
"status": 200,
|
||||
"frames": [
|
||||
{
|
||||
"schema": {
|
||||
"meta": {
|
||||
"typeVersion": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"executedQueryString": "SET TIME ZONE -7; select * from tbl; SET TIME ZONE DEFAULT; select * from tbl;"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"name": "d",
|
||||
"type": "time",
|
||||
"typeInfo": {
|
||||
"frame": "time.Time",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"values": [
|
||||
[
|
||||
1712148205000,
|
||||
1712148205000
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- SET TIME ZONE -7; select * from tbl; SET TIME ZONE DEFAULT; select * from tbl;
|
||||
-- we are testing that you can run multiple statements in one "query",
|
||||
-- and those statements have effects on each other, in this example
|
||||
-- we load the same database-value in different time-zones,
|
||||
-- we should receive different values.
|
||||
CREATE TEMPORARY TABLE tbl (
|
||||
d timestamp with time zone
|
||||
);
|
||||
|
||||
INSERT INTO tbl (d) VALUES ('2024-04-03T12:43:25UTC')
|
||||
+11
-2
@@ -4,11 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var stringListItemMatcher = regexp.MustCompile(`"[^"]+"|[^,\t\n\v\f\r ]+`)
|
||||
|
||||
// StringsFallback2 returns the first of two not empty strings.
|
||||
func StringsFallback2(val1 string, val2 string) string {
|
||||
return stringsFallback(val1, val2)
|
||||
@@ -28,7 +31,8 @@ func stringsFallback(vals ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// SplitString splits a string by commas or empty spaces.
|
||||
// SplitString splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces.
|
||||
// It supports quoted strings with spaces, e.g. "foo bar", "baz".
|
||||
func SplitString(str string) []string {
|
||||
if len(str) == 0 {
|
||||
return []string{}
|
||||
@@ -44,7 +48,12 @@ func SplitString(str string) []string {
|
||||
return res
|
||||
}
|
||||
|
||||
return strings.Fields(strings.ReplaceAll(str, ",", " "))
|
||||
var result []string
|
||||
matches := stringListItemMatcher.FindAllString(str, -1)
|
||||
for _, match := range matches {
|
||||
result = append(result, strings.Trim(match, "\""))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAgeString returns a string representing certain time from years to minutes.
|
||||
|
||||
@@ -56,7 +56,14 @@ func TestSplitString(t *testing.T) {
|
||||
`["foo", "bar baz"]`: {"foo", "bar baz"},
|
||||
`["foo", "bar \"baz\""]`: {"foo", "bar \"baz\""},
|
||||
` ["foo", "bar baz"]`: {"foo", "bar baz"},
|
||||
`[]`: {},
|
||||
`"foo", "bar", "baz"`: {"foo", "bar", "baz"},
|
||||
`"foo" "bar" "baz"`: {"foo", "bar", "baz"},
|
||||
` "foo" "bar" "baz" `: {"foo", "bar", "baz"},
|
||||
`"foo", "bar baz"`: {"foo", "bar baz"},
|
||||
`"foo", bar "baz"`: {"foo", "bar", "baz"},
|
||||
`"first string", "second string", "third string"`: {"first string", "second string", "third string"},
|
||||
`"first string" "second string" "third string" "fourth string"`: {"first string", "second string", "third string", "fourth string"},
|
||||
`[]`: {},
|
||||
}
|
||||
for input, expected := range tests {
|
||||
assert.EqualValues(t, expected, SplitString(input))
|
||||
|
||||
+6
-6
@@ -65,7 +65,7 @@ describe('contact points', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText('grafana-default-email')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('contact-point')).toHaveLength(4);
|
||||
expect(screen.getAllByTestId('contact-point')).toHaveLength(5);
|
||||
|
||||
// check for available actions – our mock 4 contact points, 1 of them is provisioned
|
||||
expect(screen.getByRole('link', { name: 'add contact point' })).toBeInTheDocument();
|
||||
@@ -73,20 +73,20 @@ describe('contact points', () => {
|
||||
|
||||
// 2 of them are unused by routes in the mock response
|
||||
const unusedBadge = screen.getAllByLabelText('unused');
|
||||
expect(unusedBadge).toHaveLength(2);
|
||||
expect(unusedBadge).toHaveLength(3);
|
||||
|
||||
const viewProvisioned = screen.getByRole('link', { name: 'view-action' });
|
||||
expect(viewProvisioned).toBeInTheDocument();
|
||||
expect(viewProvisioned).not.toBeDisabled();
|
||||
|
||||
const editButtons = screen.getAllByRole('link', { name: 'edit-action' });
|
||||
expect(editButtons).toHaveLength(3);
|
||||
expect(editButtons).toHaveLength(4);
|
||||
editButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const moreActionsButtons = screen.getAllByRole('button', { name: 'more-actions' });
|
||||
expect(moreActionsButtons).toHaveLength(4);
|
||||
expect(moreActionsButtons).toHaveLength(5);
|
||||
moreActionsButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
@@ -115,11 +115,11 @@ describe('contact points', () => {
|
||||
|
||||
// there should be view buttons though
|
||||
const viewButtons = screen.getAllByRole('link', { name: 'view-action' });
|
||||
expect(viewButtons).toHaveLength(4);
|
||||
expect(viewButtons).toHaveLength(5);
|
||||
|
||||
// delete should be disabled in the "more" actions
|
||||
const moreButtons = screen.queryAllByRole('button', { name: 'more-actions' });
|
||||
expect(moreButtons).toHaveLength(4);
|
||||
expect(moreButtons).toHaveLength(5);
|
||||
|
||||
// check if all of the delete buttons are disabled
|
||||
for await (const button of moreButtons) {
|
||||
|
||||
+13
@@ -73,6 +73,19 @@
|
||||
"secureFields": { "token": true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OnCall Conctact point",
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"name": "Oncall-integration",
|
||||
"type": "oncall",
|
||||
"settings": {
|
||||
"url": "https://oncall-endpoint.example.com"
|
||||
},
|
||||
"disableResolveMessage": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+13
@@ -115,6 +115,19 @@ exports[`should be able to test and save a receiver 2`] = `
|
||||
],
|
||||
"name": "Slack with multiple channels",
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "Oncall-integration",
|
||||
"settings": {
|
||||
"url": "https://oncall-endpoint.example.com",
|
||||
},
|
||||
"type": "oncall",
|
||||
},
|
||||
],
|
||||
"name": "OnCall Conctact point",
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
|
||||
+197
@@ -60,6 +60,32 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
||||
"name": "lotsa-emails",
|
||||
"numberOfPolicies": 0,
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "Oncall-integration",
|
||||
"settings": {
|
||||
"url": "https://oncall-endpoint.example.com",
|
||||
},
|
||||
"type": "oncall",
|
||||
Symbol(receiver_status): undefined,
|
||||
Symbol(receiver_metadata): {
|
||||
"description": undefined,
|
||||
"name": "Oncall",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): {
|
||||
"description": "grafana-integration",
|
||||
"externalUrl": "/a/grafana-oncall-app/integrations/ABC123",
|
||||
"icon": "public/img/alerting/oncall_logo.svg",
|
||||
"title": "Grafana OnCall",
|
||||
"warning": undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
"name": "OnCall Conctact point",
|
||||
"numberOfPolicies": 0,
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
@@ -147,3 +173,174 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
||||
"refetchReceivers": [Function],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`useContactPoints when having oncall plugin installed and no alert manager config data should return contact points with oncall metadata 1`] = `
|
||||
{
|
||||
"contactPoints": [
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "grafana-default-email",
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "gilles.demey@grafana.com",
|
||||
"singleEmail": false,
|
||||
},
|
||||
"type": "email",
|
||||
"uid": "xeKQrBrnk",
|
||||
Symbol(receiver_status): {
|
||||
"lastNotifyAttempt": "2023-07-02T21:35:34.841+02:00",
|
||||
"lastNotifyAttemptDuration": "1ms",
|
||||
"lastNotifyAttemptError": "failed to send notification to email addresses: gilles.demey@grafana.com: dial tcp 192.168.1.21:1025: connect: connection refused",
|
||||
"name": "email",
|
||||
"sendResolved": true,
|
||||
},
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications using Grafana server configured SMTP settings",
|
||||
"name": "Email",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): undefined,
|
||||
},
|
||||
],
|
||||
"name": "grafana-default-email",
|
||||
"numberOfPolicies": undefined,
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "lotsa-emails",
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
|
||||
"singleEmail": false,
|
||||
},
|
||||
"type": "email",
|
||||
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
|
||||
Symbol(receiver_status): {
|
||||
"lastNotifyAttempt": "",
|
||||
"lastNotifyAttemptDuration": "",
|
||||
"name": "email",
|
||||
"sendResolved": true,
|
||||
},
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications using Grafana server configured SMTP settings",
|
||||
"name": "Email",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): undefined,
|
||||
},
|
||||
],
|
||||
"name": "lotsa-emails",
|
||||
"numberOfPolicies": undefined,
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "Oncall-integration",
|
||||
"settings": {
|
||||
"url": "https://oncall-endpoint.example.com",
|
||||
},
|
||||
"type": "oncall",
|
||||
Symbol(receiver_status): undefined,
|
||||
Symbol(receiver_metadata): {
|
||||
"description": undefined,
|
||||
"name": "Oncall",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): {
|
||||
"icon": "public/img/alerting/oncall_logo.svg",
|
||||
"title": "Grafana OnCall",
|
||||
},
|
||||
},
|
||||
],
|
||||
"name": "OnCall Conctact point",
|
||||
"numberOfPolicies": undefined,
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "provisioned-contact-point",
|
||||
"provenance": "api",
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "gilles.demey@grafana.com",
|
||||
"singleEmail": false,
|
||||
},
|
||||
"type": "email",
|
||||
"uid": "s8SdCVjnk",
|
||||
Symbol(receiver_status): {
|
||||
"lastNotifyAttempt": "",
|
||||
"lastNotifyAttemptDuration": "",
|
||||
"name": "email",
|
||||
"sendResolved": true,
|
||||
},
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications using Grafana server configured SMTP settings",
|
||||
"name": "Email",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): undefined,
|
||||
},
|
||||
],
|
||||
"name": "provisioned-contact-point",
|
||||
"numberOfPolicies": undefined,
|
||||
},
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "Slack with multiple channels",
|
||||
"secureFields": {
|
||||
"token": true,
|
||||
},
|
||||
"settings": {
|
||||
"recipient": "test-alerts",
|
||||
},
|
||||
"type": "slack",
|
||||
"uid": "c02ad56a-31da-46b9-becb-4348ec0890fd",
|
||||
Symbol(receiver_status): {
|
||||
"lastNotifyAttempt": "",
|
||||
"lastNotifyAttemptDuration": "",
|
||||
"name": "slack",
|
||||
"sendResolved": true,
|
||||
},
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications to Slack",
|
||||
"name": "Slack",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): undefined,
|
||||
},
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "Slack with multiple channels",
|
||||
"secureFields": {
|
||||
"token": true,
|
||||
},
|
||||
"settings": {
|
||||
"recipient": "test-alerts2",
|
||||
},
|
||||
"type": "slack",
|
||||
"uid": "b286a3be-f690-49e2-8605-b075cbace2df",
|
||||
Symbol(receiver_status): {
|
||||
"lastNotifyAttempt": "",
|
||||
"lastNotifyAttemptDuration": "",
|
||||
"name": "slack",
|
||||
"sendResolved": true,
|
||||
},
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications to Slack",
|
||||
"name": "Slack",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): undefined,
|
||||
},
|
||||
],
|
||||
"name": "Slack with multiple channels",
|
||||
"numberOfPolicies": undefined,
|
||||
},
|
||||
],
|
||||
"error": undefined,
|
||||
"isLoading": false,
|
||||
"refetchReceivers": [Function],
|
||||
}
|
||||
`;
|
||||
+47
-3
@@ -2,10 +2,12 @@ import { renderHook, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import alertmanagerMock from 'app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { setupMswServer } from '../../mockApi';
|
||||
import { grantUserPermissions } from '../../mocks';
|
||||
import { ONCALL_INTEGRATION_V2_FEATURE } from '../../api/onCallApi';
|
||||
import { mockApi, setupMswServer } from '../../mockApi';
|
||||
import { grantUserPermissions, onCallPluginMetaMock } from '../../mocks';
|
||||
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
|
||||
|
||||
import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer';
|
||||
@@ -23,6 +25,16 @@ describe('useContactPoints', () => {
|
||||
});
|
||||
|
||||
it('should return contact points with status', async () => {
|
||||
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||
mockApi(server).oncall.features([ONCALL_INTEGRATION_V2_FEATURE]);
|
||||
mockApi(server).oncall.getOnCallIntegrations([
|
||||
{
|
||||
display_name: 'grafana-integration',
|
||||
value: 'ABC123',
|
||||
integration_url: 'https://oncall-endpoint.example.com',
|
||||
},
|
||||
]);
|
||||
mockApi(server).getContactPointsList(receivers);
|
||||
const { result } = renderHook(() => useContactPointsWithStatus(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestProvider>
|
||||
@@ -32,10 +44,42 @@ describe('useContactPoints', () => {
|
||||
</TestProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('when having oncall plugin installed and no alert manager config data', () => {
|
||||
it('should return contact points with oncall metadata', async () => {
|
||||
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||
mockApi(server).oncall.features([ONCALL_INTEGRATION_V2_FEATURE]);
|
||||
mockApi(server).oncall.getOnCallIntegrations([
|
||||
{
|
||||
display_name: 'grafana-integration',
|
||||
value: 'ABC123',
|
||||
integration_url: 'https://oncall-endpoint.example.com',
|
||||
},
|
||||
]);
|
||||
mockApi(server).getContactPointsList(receivers);
|
||||
const { result } = renderHook(
|
||||
() => useContactPointsWithStatus({ includePoliciesCount: false, receiverStatusPollingInterval: 0 }),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<TestProvider>
|
||||
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={'grafana'}>
|
||||
{children}
|
||||
</AlertmanagerProvider>
|
||||
</TestProvider>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const receivers = JSON.parse(JSON.stringify(alertmanagerMock)).alertmanager_config.receivers;
|
||||
@@ -137,12 +137,17 @@ export function enhanceContactPointsWithMetadata(
|
||||
alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? 0),
|
||||
grafana_managed_receiver_configs: receivers.map((receiver, index) => {
|
||||
const isOnCallReceiver = receiver.type === ReceiverTypes.OnCall;
|
||||
// if we don't have alertmanagerConfiguration we can't get the metadata for oncall receivers,
|
||||
// because we don't have the url, as we are not using the alertmanager configuration
|
||||
// but the contact points returned by the read only permissions contact points endpoint (/api/v1/notifications/receivers)
|
||||
return {
|
||||
...receiver,
|
||||
[RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index],
|
||||
[RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver),
|
||||
// if OnCall plugin is installed, we'll add it to the receiver's plugin metadata
|
||||
[RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver ? getOnCallMetadata(onCallIntegrations, receiver) : undefined,
|
||||
[RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver
|
||||
? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration))
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
+7
-2
@@ -16,15 +16,20 @@ export interface ReceiverPluginMetadata {
|
||||
const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[SupportedPlugin.OnCall];
|
||||
const onCallReceiverTitle = 'Grafana OnCall';
|
||||
|
||||
const onCallReceiverMeta: ReceiverPluginMetadata = {
|
||||
export const onCallReceiverMeta: ReceiverPluginMetadata = {
|
||||
title: onCallReceiverTitle,
|
||||
icon: onCallReceiverICon,
|
||||
};
|
||||
|
||||
export function getOnCallMetadata(
|
||||
onCallIntegrations: OnCallIntegrationDTO[] | undefined | null,
|
||||
receiver: GrafanaManagedReceiverConfig
|
||||
receiver: GrafanaManagedReceiverConfig,
|
||||
hasAlertManagerConfigData = true
|
||||
): ReceiverPluginMetadata {
|
||||
if (!hasAlertManagerConfigData) {
|
||||
return onCallReceiverMeta;
|
||||
}
|
||||
|
||||
// oncall status is still loading
|
||||
if (onCallIntegrations === undefined) {
|
||||
return onCallReceiverMeta;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AlertManagerCortexConfig,
|
||||
AlertmanagerReceiver,
|
||||
EmailConfig,
|
||||
GrafanaManagedContactPoint,
|
||||
GrafanaManagedReceiverConfig,
|
||||
MatcherOperator,
|
||||
Route,
|
||||
@@ -218,6 +219,9 @@ export function mockApi(server: SetupServer) {
|
||||
server.use(http.get(`api/plugins/${response.id}/settings`, () => HttpResponse.json(response)));
|
||||
},
|
||||
},
|
||||
getContactPointsList: (response: GrafanaManagedContactPoint[]) => {
|
||||
server.use(http.get(`/api/v1/notifications/receivers`, () => HttpResponse.json(response)));
|
||||
},
|
||||
|
||||
oncall: {
|
||||
getOnCallIntegrations: (response: OnCallIntegrationDTO[]) => {
|
||||
|
||||
@@ -139,7 +139,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
body: css({
|
||||
label: 'body',
|
||||
flex: 1,
|
||||
marginBottom: theme.spacing(3),
|
||||
overflowY: 'auto',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import { SceneGridLayout } from '@grafana/scenes';
|
||||
import { SceneGridLayout, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils';
|
||||
|
||||
import { DashboardGridItem } from './DashboardGridItem';
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: (id: string) => undefined,
|
||||
@@ -116,4 +118,18 @@ describe('PanelRepeaterGridItem', () => {
|
||||
'_____default_sys_repeat_var_____'
|
||||
);
|
||||
});
|
||||
|
||||
it('Should return className when repeat variable is set', () => {
|
||||
const { repeater } = buildPanelRepeaterScene({ variableQueryTime: 0 });
|
||||
|
||||
expect(repeater.getClassName()).toBe('panel-repeater-grid-item');
|
||||
});
|
||||
|
||||
it('Should not className variable is not set', () => {
|
||||
const gridItem = new DashboardGridItem({
|
||||
body: new VizPanel({ pluginId: 'text' }),
|
||||
});
|
||||
|
||||
expect(gridItem.getClassName()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -170,7 +170,7 @@ export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> i
|
||||
}
|
||||
|
||||
public getClassName() {
|
||||
return 'panel-repeater-grid-item';
|
||||
return this.state.variableName ? 'panel-repeater-grid-item' : '';
|
||||
}
|
||||
|
||||
public isRepeated() {
|
||||
|
||||
@@ -30,10 +30,7 @@ describe('NavToolbarActions', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.queryByText('Save dashboard')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Add visualization')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Add row')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Paste panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Add library panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Add')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('Edit')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Share')).toBeInTheDocument();
|
||||
});
|
||||
@@ -83,10 +80,7 @@ describe('NavToolbarActions', () => {
|
||||
|
||||
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Exit edit')).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('Add visualization')).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('Add row')).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('Paste panel')).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('Add library panel')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Add')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Share')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument();
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
hasCopiedPanel: copiedPanel,
|
||||
} = dashboard.useState();
|
||||
const { isPlaying } = playlistSrv.useState();
|
||||
const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false);
|
||||
|
||||
const canSaveAs = contextSrv.hasEditPermissionInFolders;
|
||||
const toolbarActions: ToolbarAction[] = [];
|
||||
@@ -66,73 +67,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
render: () => (
|
||||
<ToolbarButton
|
||||
key="add-visualization"
|
||||
tooltip={'Add visualization'}
|
||||
icon="graph-bar"
|
||||
onClick={() => {
|
||||
const id = dashboard.onCreateNewPanel();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
|
||||
locationService.partial({ editPanel: id });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
render: () => (
|
||||
<ToolbarButton
|
||||
key="add-library-panel"
|
||||
tooltip={'Add library panel'}
|
||||
icon="library-panel"
|
||||
onClick={() => {
|
||||
dashboard.onCreateLibPanelWidget();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
render: () => (
|
||||
<ToolbarButton
|
||||
key="add-row"
|
||||
tooltip={'Add row'}
|
||||
icon="wrap-text"
|
||||
onClick={() => {
|
||||
dashboard.onCreateNewRow();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
render: () => (
|
||||
<ToolbarButton
|
||||
key="paste-panel"
|
||||
disabled={!hasCopiedPanel}
|
||||
tooltip={'Paste panel'}
|
||||
icon="copy"
|
||||
onClick={() => {
|
||||
dashboard.pastePanel();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: uid && Boolean(meta.canStar) && isShowingDashboard,
|
||||
condition: uid && Boolean(meta.canStar) && isShowingDashboard && !isEditing,
|
||||
render: () => {
|
||||
let desc = meta.isStarred
|
||||
? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite')
|
||||
@@ -157,7 +92,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: isDevEnv && uid && isShowingDashboard,
|
||||
condition: isDevEnv && uid && isShowingDashboard && !isEditing,
|
||||
render: () => (
|
||||
<ToolbarButton
|
||||
key="view-in-old-dashboard-button"
|
||||
@@ -174,7 +109,84 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
group: 'icon-actions',
|
||||
condition: meta.isSnapshot && !isEditing,
|
||||
render: () => (
|
||||
<GoToSnapshotOriginButton originalURL={dashboard.getInitialSaveModel()?.snapshot?.originalUrl ?? ''} />
|
||||
<GoToSnapshotOriginButton
|
||||
key="go-to-snapshot-origin"
|
||||
originalURL={dashboard.getInitialSaveModel()?.snapshot?.originalUrl ?? ''}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (!isEditingPanel && !isEditing) {
|
||||
// This adds the alert rules button and the dashboard insights button
|
||||
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
|
||||
}
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'add-panel',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
render: () => (
|
||||
<Dropdown
|
||||
key="add-panel-dropdown"
|
||||
onVisibleChange={(isOpen) => {
|
||||
setIsAddPanelMenuOpen(isOpen);
|
||||
DashboardInteractions.toolbarAddClick();
|
||||
}}
|
||||
overlay={() => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="add-visualization"
|
||||
testId={selectors.pages.AddDashboard.itemButton('Add new visualization menu item')}
|
||||
label={t('dashboard.add-menu.visualization', 'Visualization')}
|
||||
onClick={() => {
|
||||
const id = dashboard.onCreateNewPanel();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
|
||||
locationService.partial({ editPanel: id });
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
key="add-panel-lib"
|
||||
testId={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
|
||||
label={t('dashboard.add-menu.import', 'Import from library')}
|
||||
onClick={() => {
|
||||
dashboard.onCreateLibPanelWidget();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
key="add-row"
|
||||
testId={selectors.pages.AddDashboard.itemButton('Add new row menu item')}
|
||||
label={t('dashboard.add-menu.row', 'Row')}
|
||||
onClick={() => {
|
||||
dashboard.onCreateNewRow();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
key="paste-panel"
|
||||
disabled={!hasCopiedPanel}
|
||||
testId={selectors.pages.AddDashboard.itemButton('Add new panel from clipboard menu item')}
|
||||
label={t('dashboard.add-menu.paste-panel', 'Paste panel')}
|
||||
onClick={() => {
|
||||
dashboard.pastePanel();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' });
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
placement="bottom"
|
||||
offset={[0, 6]}
|
||||
>
|
||||
<Button
|
||||
key="add-panel-button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fill="outline"
|
||||
data-testid={selectors.components.PageToolbar.itemButton('Add button')}
|
||||
>
|
||||
<Trans i18nKey="dashboard.toolbar.add">Add</Trans>
|
||||
<Icon name={isAddPanelMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -221,11 +233,6 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
),
|
||||
});
|
||||
|
||||
if (!isEditingPanel) {
|
||||
// This adds the alert rules button and the dashboard insights button
|
||||
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
|
||||
}
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'back-button',
|
||||
condition: (isViewingPanel || isEditingPanel) && !isEditingLibraryPanel,
|
||||
|
||||
@@ -136,6 +136,35 @@ describe('Plugins/Helpers', () => {
|
||||
config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall;
|
||||
config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled;
|
||||
});
|
||||
|
||||
test('plugins should have update when instance version is different from remote version', () => {
|
||||
const oldFeatureTogglesManagedPluginsInstall = config.featureToggles.managedPluginsInstall;
|
||||
const oldPluginAdminExternalManageEnabled = config.pluginAdminExternalManageEnabled;
|
||||
|
||||
config.featureToggles.managedPluginsInstall = true;
|
||||
config.pluginAdminExternalManageEnabled = true;
|
||||
|
||||
const pluginId = 'plugin-1';
|
||||
const remotePlugin = getRemotePluginMock({ slug: pluginId, version: '1.0.0' });
|
||||
const instancePlugin = {
|
||||
pluginSlug: pluginId,
|
||||
version: '0.0.9',
|
||||
};
|
||||
|
||||
const merged = mergeLocalsAndRemotes({
|
||||
local: [],
|
||||
remote: [remotePlugin],
|
||||
instance: [instancePlugin],
|
||||
});
|
||||
const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(findMerged(pluginId)).not.toBeUndefined();
|
||||
expect(findMerged(pluginId)?.hasUpdate).toBe(true);
|
||||
|
||||
config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall;
|
||||
config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeLocalAndRemote()', () => {
|
||||
|
||||
@@ -76,6 +76,10 @@ export function mergeLocalsAndRemotes({
|
||||
catalogPlugin.hasUpdate &&
|
||||
catalogPlugin.installedVersion !== instancePlugin?.version;
|
||||
|
||||
if (instancePlugin?.version && instancePlugin?.version !== remotePlugin.version) {
|
||||
catalogPlugin.hasUpdate = true;
|
||||
}
|
||||
|
||||
catalogPlugin.isUninstallingFromInstance = Boolean(localCounterpart) && !instancesMap.has(remotePlugin.slug);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
||||
}
|
||||
|
||||
public onActivate() {
|
||||
const { autoQuery } = getMetricSceneFor(this).state;
|
||||
this.setState({
|
||||
panel: this.getVizPanelFor(autoQuery.main),
|
||||
});
|
||||
if (!this.state.panel) {
|
||||
const { autoQuery } = getMetricSceneFor(this).state;
|
||||
|
||||
this.setState({ panel: this.getVizPanelFor(autoQuery.main) });
|
||||
}
|
||||
}
|
||||
|
||||
private getQuerySelector(def: AutoQueryDef) {
|
||||
@@ -43,9 +44,7 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
||||
|
||||
const def = metricScene.state.autoQuery.variants.find((q) => q.variant === variant)!;
|
||||
|
||||
this.setState({
|
||||
panel: this.getVizPanelFor(def),
|
||||
});
|
||||
this.setState({ panel: this.getVizPanelFor(def) });
|
||||
metricScene.setState({ queryDef: def });
|
||||
};
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
}
|
||||
|
||||
function buildGraphTopView() {
|
||||
const bodyAutoVizPanel = new AutoVizPanel({});
|
||||
|
||||
return new SceneFlexLayout({
|
||||
direction: 'column',
|
||||
$behaviors: [new behaviors.CursorSync({ key: 'metricCrosshairSync', sync: DashboardCursorSync.Crosshair })],
|
||||
@@ -81,7 +79,7 @@ function buildGraphTopView() {
|
||||
new SceneFlexItem({
|
||||
minHeight: MAIN_PANEL_MIN_HEIGHT,
|
||||
maxHeight: MAIN_PANEL_MAX_HEIGHT,
|
||||
body: bodyAutoVizPanel,
|
||||
body: new AutoVizPanel({}),
|
||||
}),
|
||||
new SceneFlexItem({
|
||||
ySizing: 'content',
|
||||
|
||||
Reference in new issue
Block a user