diff --git a/.betterer.results b/.betterer.results index a6e30ae9f67..29072afc566 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 650b9666263..6df07b690e0 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -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 diff --git a/contribute/style-guides/e2e-core.md b/contribute/style-guides/e2e-core.md index fb2c24e0bfc..321f02522c9 100644 --- a/contribute/style-guides/e2e-core.md +++ b/contribute/style-guides/e2e-core.md @@ -22,4 +22,4 @@ The above commands use some utils scripts under [_\/e2e_](../../e2e) ## Test suites -All the integration tests are located at _\/e2e/suite\/specs_. The page objects and reusable flows are in the [_\/packages/grafana-e2e_](../../packages/grafana-e2e) package. +All the integration tests are located at _\/e2e/suite\/specs_. diff --git a/contribute/style-guides/e2e-plugins.md b/contribute/style-guides/e2e-plugins.md index a40b9516fa7..f675db96784 100644 --- a/contribute/style-guides/e2e-plugins.md +++ b/contribute/style-guides/e2e-plugins.md @@ -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 _\/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` diff --git a/contribute/style-guides/e2e.md b/contribute/style-guides/e2e.md index 76958b6d53b..285936f2c5b 100644 --- a/contribute/style-guides/e2e.md +++ b/contribute/style-guides/e2e.md @@ -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 diff --git a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md index bb1ab192d74..7a45e25805a 100644 --- a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md +++ b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md @@ -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. diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index 3d2751e4009..a7e2a83c91e 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -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:*`
`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | -| `alert.instances.external:write` | `datasources:*`
`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:*`
`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications.external:write` | `datasources:*`
`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:*`
`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | -| `alert.rules.external:write` | `datasources:*`
`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | -| `alert.rules:create` | `folders:*`
`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:*`
`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:*`
`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:*`
`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:*`
`annotations:type:*` | Create annotations. | -| `annotations:delete` | `annotations:*`
`annotations:type:*` | Delete annotations. | -| `annotations:read` | `annotations:*`
`annotations:type:*` | Read annotations and annotation tags. | -| `annotations:write` | `annotations:*`
`annotations:type:*` | Update annotations. | -| `apikeys:create` | n/a | Create API keys. | -| `apikeys:read` | `apikeys:*`
`apikeys:id:*` | Read API keys. | -| `apikeys:delete` | `apikeys:*`
`apikeys:id:*` | Delete API keys. | -| `dashboards:create` | `folders:*`
`folders:uid:*` | Create dashboards in one or more folders and their subfolders. | -| `dashboards:delete` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Delete one or more dashboards. | -| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. | -| `dashboards.permissions:read` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Read permissions for one or more dashboards. | -| `dashboards.permissions:write` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Update permissions for one or more dashboards. | -| `dashboards:read` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Read one or more dashboards. | -| `dashboards:write` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Update one or more dashboards. | -| `dashboards.public:write` | `dashboards:*`
`dashboards:uid:*` | Write public dashboard configuration. | -| `datasources.caching:read` | `datasources:*`
`datasources:uid:*` | Read data source query caching settings. | -| `datasources.caching:write` | `datasources:*`
`datasources:uid:*` | Update data source query caching settings. | -| `datasources:create` | n/a | Create data sources. | -| `datasources:delete` | `datasources:*`
`datasources:uid:*` | Delete data sources. | -| `datasources:explore` | n/a | Enable access to the **Explore** tab. | -| `datasources.id:read` | `datasources:*`
`datasources:uid:*` | Read data source IDs. | -| `datasources.insights:read` | n/a | Read data sources insights data. | -| `datasources.permissions:read` | `datasources:*`
`datasources:uid:*` | List data source permissions. | -| `datasources.permissions:write` | `datasources:*`
`datasources:uid:*` | Update data source permissions. | -| `datasources:query` | `datasources:*`
`datasources:uid:*` | Query data sources. | -| `datasources:read` | `datasources:*`
`datasources:uid:*` | List data sources. | -| `datasources:write` | `datasources:*`
`datasources:uid:*` | Update data sources. | -| `featuremgmt.read` | n/a | Read feature toggles. | -| `featuremgmt.write` | n/a | Write feature toggles. | -| `folders.permissions:read` | `folders:*`
`folders:uid:*` | Read permissions for one or more folders and their subfolders. | -| `folders.permissions:write` | `folders:*`
`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:*`
`folders:uid:*` | Delete one or more folders and their subfolders. | -| `folders:read` | `folders:*`
`folders:uid:*` | Read one or more folders and their subfolders. | -| `folders:write` | `folders:*`
`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:*`
`folders:uid:*` | Create a library panel in one or more folders and their subfolders. | -| `library.panels:read` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Read one or more library panels. | -| `library.panels:write` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Update one or more library panels. | -| `library.panels:delete` | `folders:*`
`folders:uid:*`
`library.panels:*`
`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:*`
`users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. | -| `org.users:add` | `users:*`
`users:id:*` | Add a user to an organization or invite a new user to an organization. | -| `org.users:read` | `users:*`
`users:id:*` | Get user profiles within an organization. | -| `org.users:remove` | `users:*`
`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:*`
`plugins:id:*` | Access one or more application plugins (still enforcing the organization role) | -| `plugins:install` | n/a | Install and uninstall plugins. | -| `plugins:write` | `plugins:*`
`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:*`
`reports:id:*` | Update reports. | -| `reports.settings:read` | n/a | Read report settings. | -| `reports.settings:write` | n/a | Update report settings. | -| `reports:delete` | `reports:*`
`reports:id:*` | Delete reports. | -| `reports:read` | `reports:*`
`reports:id:*` | List all available reports or get a specific report. | -| `reports:send` | `reports:*`
`reports:id:*` | Send a report email. | -| `roles:delete` | `permissions:type:delegate` | Delete a custom role. | -| `roles:read` | `roles:*`
`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:*`
`serviceaccounts:id:*` | Delete Grafana service accounts. | -| `serviceaccounts:read` | `serviceaccounts:*`
`serviceaccounts:id:*` | Read Grafana service accounts. | -| `serviceaccounts.permissions:write` | `serviceaccounts:*`
`serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. | -| `serviceaccounts.permissions:read` | `serviceaccounts:*`
`serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. | -| `settings:read` | `settings:*`
`settings:auth.saml:*`
`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) | -| `settings:write` | `settings:*`
`settings:auth.saml:*`
`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:*`
`teams:id:*` | Read members and Team Sync setup for teams. | -| `teams.permissions:write` | `teams:*`
`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:*`
`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:*`
`teams:id:*` | Delete one or more teams. | -| `teams:read` | `teams:*`
`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:*`
`teams:id:*` | Update one or more teams and team preferences. | -| `users.authtoken:read` | `global.users:*`
`global.users:id:*` | List authentication tokens that are assigned to a user. | -| `users.authtoken:write` | `global.users:*`
`global.users:id:*` | Update authentication tokens that are assigned to a user. | -| `users.password:write` | `global.users:*`
`global.users:id:*` | Update a user’s password. | -| `users.permissions:read` | `users:*` | List permissions of a user. | -| `users.permissions:write` | `global.users:*`
`global.users:id:*` | Update a user’s organization-level permissions. | -| `users.quotas:read` | `global.users:*`
`global.users:id:*` | List a user’s quotas. | -| `users.quotas:write` | `global.users:*`
`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:*`
`global.users:id:*` | Delete a user. | -| `users:disable` | `global.users:*`
`global.users:id:*` | Disable a user. | -| `users:enable` | `global.users:*`
`global.users:id:*` | Enable a user. | -| `users:logout` | `global.users:*`
`global.users:id:*` | Sign out a user. | -| `users:read` | `global.users:*` | Read or search user profiles. | -| `users:write` | `global.users:*`
`global.users:id:*` | Update a user’s profile. | +| Action | Applicable scope | Description | +| ------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alert.instances.external:read` | `datasources:*`
`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | +| `alert.instances.external:write` | `datasources:*`
`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:*`
`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | +| `alert.notifications.external:write` | `datasources:*`
`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:*`
`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | +| `alert.rules.external:write` | `datasources:*`
`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | +| `alert.rules:create` | `folders:*`
`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:*`
`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:*`
`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:*`
`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:*`
`annotations:type:*` | Create annotations. | +| `annotations:delete` | `annotations:*`
`annotations:type:*` | Delete annotations. | +| `annotations:read` | `annotations:*`
`annotations:type:*` | Read annotations and annotation tags. | +| `annotations:write` | `annotations:*`
`annotations:type:*` | Update annotations. | +| `apikeys:create` | n/a | Create API keys. | +| `apikeys:read` | `apikeys:*`
`apikeys:id:*` | Read API keys. | +| `apikeys:delete` | `apikeys:*`
`apikeys:id:*` | Delete API keys. | +| `dashboards:create` | `folders:*`
`folders:uid:*` | Create dashboards in one or more folders and their subfolders. | +| `dashboards:delete` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Delete one or more dashboards. | +| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. | +| `dashboards.permissions:read` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Read permissions for one or more dashboards. | +| `dashboards.permissions:write` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Update permissions for one or more dashboards. | +| `dashboards:read` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Read one or more dashboards. | +| `dashboards:write` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Update one or more dashboards. | +| `dashboards.public:write` | `dashboards:*`
`dashboards:uid:*` | Write public dashboard configuration. | +| `datasources.caching:read` | `datasources:*`
`datasources:uid:*` | Read data source query caching settings. | +| `datasources.caching:write` | `datasources:*`
`datasources:uid:*` | Update data source query caching settings. | +| `datasources:create` | n/a | Create data sources. | +| `datasources:delete` | `datasources:*`
`datasources:uid:*` | Delete data sources. | +| `datasources:explore` | n/a | Enable access to the **Explore** tab. | +| `datasources.id:read` | `datasources:*`
`datasources:uid:*` | Read data source IDs. | +| `datasources.insights:read` | n/a | Read data sources insights data. | +| `datasources.permissions:read` | `datasources:*`
`datasources:uid:*` | List data source permissions. | +| `datasources.permissions:write` | `datasources:*`
`datasources:uid:*` | Update data source permissions. | +| `datasources:query` | `datasources:*`
`datasources:uid:*` | Query data sources. | +| `datasources:read` | `datasources:*`
`datasources:uid:*` | List data sources. | +| `datasources:write` | `datasources:*`
`datasources:uid:*` | Update data sources. | +| `featuremgmt.read` | n/a | Read feature toggles. | +| `featuremgmt.write` | n/a | Write feature toggles. | +| `folders.permissions:read` | `folders:*`
`folders:uid:*` | Read permissions for one or more folders and their subfolders. | +| `folders.permissions:write` | `folders:*`
`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:*`
`folders:uid:*` | Delete one or more folders and their subfolders. | +| `folders:read` | `folders:*`
`folders:uid:*` | Read one or more folders and their subfolders. | +| `folders:write` | `folders:*`
`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:*`
`folders:uid:*` | Create a library panel in one or more folders and their subfolders. | +| `library.panels:read` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Read one or more library panels. | +| `library.panels:write` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Update one or more library panels. | +| `library.panels:delete` | `folders:*`
`folders:uid:*`
`library.panels:*`
`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:*`
`users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. | +| `org.users:add` | `users:*`
`users:id:*` | Add a user to an organization or invite a new user to an organization. | +| `org.users:read` | `users:*`
`users:id:*` | Get user profiles within an organization. | +| `org.users:remove` | `users:*`
`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:*`
`plugins:id:*` | Access one or more application plugins (still enforcing the organization role) | +| `plugins:install` | n/a | Install and uninstall plugins. | +| `plugins:write` | `plugins:*`
`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:*`
`reports:id:*` | Update reports. | +| `reports.settings:read` | n/a | Read report settings. | +| `reports.settings:write` | n/a | Update report settings. | +| `reports:delete` | `reports:*`
`reports:id:*` | Delete reports. | +| `reports:read` | `reports:*`
`reports:id:*` | List all available reports or get a specific report. | +| `reports:send` | `reports:*`
`reports:id:*` | Send a report email. | +| `roles:delete` | `permissions:type:delegate` | Delete a custom role. | +| `roles:read` | `roles:*`
`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:*`
`serviceaccounts:id:*` | Delete Grafana service accounts. | +| `serviceaccounts:read` | `serviceaccounts:*`
`serviceaccounts:id:*` | Read Grafana service accounts. | +| `serviceaccounts.permissions:write` | `serviceaccounts:*`
`serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. | +| `serviceaccounts.permissions:read` | `serviceaccounts:*`
`serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. | +| `settings:read` | `settings:*`
`settings:auth.saml:*`
`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) | +| `settings:write` | `settings:*`
`settings:auth.saml:*`
`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:*`
`teams:id:*` | Read members and Team Sync setup for teams. | +| `teams.permissions:write` | `teams:*`
`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:*`
`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:*`
`teams:id:*` | Delete one or more teams. | +| `teams:read` | `teams:*`
`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:*`
`teams:id:*` | Update one or more teams and team preferences. | +| `users.authtoken:read` | `global.users:*`
`global.users:id:*` | List authentication tokens that are assigned to a user. | +| `users.authtoken:write` | `global.users:*`
`global.users:id:*` | Update authentication tokens that are assigned to a user. | +| `users.password:write` | `global.users:*`
`global.users:id:*` | Update a user’s password. | +| `users.permissions:read` | `users:*` | List permissions of a user. | +| `users.permissions:write` | `global.users:*`
`global.users:id:*` | Update a user’s organization-level permissions. | +| `users.quotas:read` | `global.users:*`
`global.users:id:*` | List a user’s quotas. | +| `users.quotas:write` | `global.users:*`
`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:*`
`global.users:id:*` | Delete a user. | +| `users:disable` | `global.users:*`
`global.users:id:*` | Disable a user. | +| `users:enable` | `global.users:*`
`global.users:id:*` | Enable a user. | +| `users:logout` | `global.users:*`
`global.users:id:*` | Sign out a user. | +| `users:read` | `global.users:*` | Read or search user profiles. | +| `users:write` | `global.users:*`
`global.users:id:*` | Update a user’s profile. | ### Grafana OnCall action definitions (beta) diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 05d1235169e..bd68b510d84 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -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`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:plugins:maintainer`
`fixed:authentication.config:writer`
`fixed:library.panels:creator`
`fixed:library.panels:reader`
`fixed:library.panels:general.reader`
`fixed:library.panels:writer`
`fixed:library.panels:general.writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. | -| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:dashboards.public:writer`
`fixed:folders:reader`
`fixed:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting.provisioning.secrets:reader`
`fixed:alerting.provisioning:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:plugins:writer`
`fixed:library.panels:creator`
`fixed:library.panels:reader`
`fixed:library.panels:general.reader`
`fixed:library.panels:writer`
`fixed:library.panels:general.writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. | -| Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled
`fixed:alerting:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:library.panels:creator`
`fixed:library.panels:general.reader`
`fixed:library.panels:general.writer` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. | -| Viewer | `fixed:datasources.id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader`
`fixed:plugins.app:reader`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`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`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:plugins:maintainer`
`fixed:authentication.config:writer`
`fixed:library.panels:creator`
`fixed:library.panels:reader`
`fixed:library.panels:general.reader`
`fixed:library.panels:writer`
`fixed:library.panels:general.writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. | +| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:dashboards.public:writer`
`fixed:folders:reader`
`fixed:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting.provisioning.secrets:reader`
`fixed:alerting.provisioning:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:plugins:writer`
`fixed:library.panels:creator`
`fixed:library.panels:reader`
`fixed:library.panels:general.reader`
`fixed:library.panels:writer`
`fixed:library.panels:general.writer`
`fixed:alerting.provisioning.status:writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. | +| Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled
`fixed:alerting:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:library.panels:creator`
`fixed:library.panels:general.reader`
`fixed:library.panels:general.writer`
`fixed:alerting.provisioning.status:writer` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. | +| Viewer | `fixed:datasources.id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader`
`fixed:plugins.app:reader`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`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`
`fixed:alerting.instances:reader`
`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`
`annotations.create`
`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`
`annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. | diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index 245376f6efb..1c4d93a05b0 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -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//administration/provisioning" +[provisioning_env_vars]: "/docs/ -> /docs/grafana//administration/provisioning#using-environment-variables" [reload-provisioning-configurations]: "/docs/ -> /docs/grafana//developers/http_api/admin#reload-provisioning-configurations" diff --git a/docs/sources/breaking-changes/breaking-changes-v11-0.md b/docs/sources/breaking-changes/breaking-changes-v11-0.md index abee05069d6..8122ac01c32 100644 --- a/docs/sources/breaking-changes/breaking-changes-v11-0.md +++ b/docs/sources/breaking-changes/breaking-changes-v11-0.md @@ -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 diff --git a/docs/sources/explore/explore-metrics.md b/docs/sources/explore/explore-metrics.md new file mode 100644 index 00000000000..a72d0a242ef --- /dev/null +++ b/docs/sources/explore/explore-metrics.md @@ -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. + +![show metrics explore overview](/media/metrics-explore/metrics-explore-overview.png) + +### Metrics exploration + +To further explore a metric, click **Select** in the upper right corner of the metric visualization. + +![show select box](/media/metrics-explore/select-metric.png) + +- 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. diff --git a/docs/sources/shared/alerts/alerting_provisioning.md b/docs/sources/shared/alerts/alerting_provisioning.md index c377aacbc66..38b04e46a39 100644 --- a/docs/sources/shared/alerts/alerting_provisioning.md +++ b/docs/sources/shared/alerts/alerting_provisioning.md @@ -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 diff --git a/docs/sources/upgrade-guide/upgrade-v11.0/index.md b/docs/sources/upgrade-guide/upgrade-v11.0/index.md index 068171c3003..db6373cec19 100644 --- a/docs/sources/upgrade-guide/upgrade-v11.0/index.md +++ b/docs/sources/upgrade-guide/upgrade-v11.0/index.md @@ -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 --- diff --git a/docs/sources/whatsnew/whats-new-in-v11-0.md b/docs/sources/whatsnew/whats-new-in-v11-0.md index 5613e2d5ec3..19e8ae502a8 100644 --- a/docs/sources/whatsnew/whats-new-in-v11-0.md +++ b/docs/sources/whatsnew/whats-new-in-v11-0.md @@ -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//upgrade-guide/upgrade-v11.0/). @@ -46,20 +48,48 @@ Use full URLs for links. When linking to versioned docs, replace the version wit +## Explore Metrics and Logs + +### Explore Metrics + + + +_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//explore/explore-metrics/) as well as the following video demo: + +{{< youtube id="JbaPufQs5LY" >}} + +### Explore Logs + + + +_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 - - - -_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 @@ -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. + + +### Scenes for viewers + + + +_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 @@ -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 @@ -123,19 +169,7 @@ When enabled, look for the **✨ Auto generate** option next to the **Title** an ![Auto-generate a panel description using AI](/media/docs/grafana/dashboards/auto-generate-description-10-2.gif) -### Substring matcher added to the filter by value transformation - - - -_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//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//panels-visualizations/visualizations/table/#color-background-gradient-or-solid). +{{< youtube id="PLfADTtCnmg" >}} + ### Set threshold colors in the Config from query transformation @@ -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 + + + +_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//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 - - - -_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! - - - -### Explore Logs - - - -_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 diff --git a/e2e/dashboards-suite/dashboard-time-zone.spec.ts b/e2e/dashboards-suite/dashboard-time-zone.spec.ts index 00fcc14ad85..3c2844188e3 100644 --- a/e2e/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e/dashboards-suite/dashboard-time-zone.spec.ts @@ -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(() => { diff --git a/packages/grafana-data/src/datetime/moment_wrapper.ts b/packages/grafana-data/src/datetime/moment_wrapper.ts index ae0f8d66ec1..0b97de1a3bf 100644 --- a/packages/grafana-data/src/datetime/moment_wrapper.ts +++ b/packages/grafana-data/src/datetime/moment_wrapper.ts @@ -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; diff --git a/packages/grafana-e2e/README.md b/packages/grafana-e2e/README.md index 48e8a285956..a488db87725 100644 --- a/packages/grafana-e2e/README.md +++ b/packages/grafana-e2e/README.md @@ -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. diff --git a/packages/grafana-ui/src/components/FilterPill/FilterPill.story.tsx b/packages/grafana-ui/src/components/FilterPill/FilterPill.story.tsx index aba8951b91c..40590ae4011 100644 --- a/packages/grafana-ui/src/components/FilterPill/FilterPill.story.tsx +++ b/packages/grafana-ui/src/components/FilterPill/FilterPill.story.tsx @@ -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 ( - + {elements.map((item) => ( setSelected(item)} /> ))} - + ); }; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index cacd311585a..9cedc4b298a 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -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 (
- - + + {leftItems} - - + + {children} - - + +
); } return (
- + {children} - +
); } diff --git a/pkg/apiserver/rest/dualwriter.go b/pkg/apiserver/rest/dualwriter.go index a2d5d215b95..61e5e6d3348 100644 --- a/pkg/apiserver/rest/dualwriter.go +++ b/pkg/apiserver/rest/dualwriter.go @@ -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) } } diff --git a/pkg/apiserver/rest/dualwriter_mode1.go b/pkg/apiserver/rest/dualwriter_mode1.go index 2dd7c235dc0..e153cfb95b4 100644 --- a/pkg/apiserver/rest/dualwriter_mode1.go +++ b/pkg/apiserver/rest/dualwriter_mode1.go @@ -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) diff --git a/pkg/apiserver/rest/dualwriter_mode2.go b/pkg/apiserver/rest/dualwriter_mode2.go index d3dbd586cc5..151c22a7f3c 100644 --- a/pkg/apiserver/rest/dualwriter_mode2.go +++ b/pkg/apiserver/rest/dualwriter_mode2.go @@ -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 } diff --git a/pkg/apiserver/rest/dualwriter_mode3.go b/pkg/apiserver/rest/dualwriter_mode3.go index a4f02bc48e9..a6ea69b85f7 100644 --- a/pkg/apiserver/rest/dualwriter_mode3.go +++ b/pkg/apiserver/rest/dualwriter_mode3.go @@ -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 } diff --git a/pkg/modules/dependencies.go b/pkg/modules/dependencies.go index 066a3f595b8..01d54255440 100644 --- a/pkg/modules/dependencies.go +++ b/pkg/modules/dependencies.go @@ -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}, } diff --git a/pkg/server/instrumentation_service.go b/pkg/server/instrumentation_service.go new file mode 100644 index 00000000000..138658800cc --- /dev/null +++ b/pkg/server/instrumentation_service.go @@ -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 +} diff --git a/pkg/server/instrumentation_service_test.go b/pkg/server/instrumentation_service_test.go new file mode 100644 index 00000000000..1c4d9bc14cb --- /dev/null +++ b/pkg/server/instrumentation_service_test.go @@ -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) +} diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go index af3f4d9e8d6..d226d5faeda 100644 --- a/pkg/server/module_server.go +++ b/pkg/server/module_server.go @@ -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) diff --git a/pkg/server/module_server_test.go b/pkg/server/module_server_test.go new file mode 100644 index 00000000000..23d8a89a20d --- /dev/null +++ b/pkg/server/module_server_test.go @@ -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]) + } +} diff --git a/pkg/server/wire.go b/pkg/server/wire.go index c61ebbd413d..4f0c3323d43 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -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, diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 79e35c65db3..0b5caa99a9c 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -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" diff --git a/pkg/services/apiserver/storage/entity/storage.go b/pkg/services/apiserver/storage/entity/storage.go index f3e4a9fcc1a..d971f3a9b2a 100644 --- a/pkg/services/apiserver/storage/entity/storage.go +++ b/pkg/services/apiserver/storage/entity/storage.go @@ -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) diff --git a/pkg/services/apiserver/storage/entity/test/requestinfo.go b/pkg/services/apiserver/storage/entity/test/requestinfo.go new file mode 100644 index 00000000000..9b19aa7f906 --- /dev/null +++ b/pkg/services/apiserver/storage/entity/test/requestinfo.go @@ -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: + // //[]/[] + // //[] + // + // cluster scoped: + // //[] + // / + 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 +} diff --git a/pkg/services/apiserver/storage/entity/test/watch_test.go b/pkg/services/apiserver/storage/entity/test/watch_test.go new file mode 100644 index 00000000000..58e6a6a4097 --- /dev/null +++ b/pkg/services/apiserver/storage/entity/test/watch_test.go @@ -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{} +} diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index 7b153f067ce..cdef230d271 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -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) diff --git a/pkg/services/ngalert/accesscontrol/accesscontrol.go b/pkg/services/ngalert/accesscontrol/accesscontrol.go new file mode 100644 index 00000000000..d779acdccd1 --- /dev/null +++ b/pkg/services/ngalert/accesscontrol/accesscontrol.go @@ -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 +} diff --git a/pkg/services/ngalert/accesscontrol/rules.go b/pkg/services/ngalert/accesscontrol/rules.go index 9f45193558b..b135cb6ac7d 100644 --- a/pkg/services/ngalert/accesscontrol/rules.go +++ b/pkg/services/ngalert/accesscontrol/rules.go @@ -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( diff --git a/pkg/services/ngalert/accesscontrol/rules_test.go b/pkg/services/ngalert/accesscontrol/rules_test.go index a95b0e9b8eb..05301b1c2ae 100644 --- a/pkg/services/ngalert/accesscontrol/rules_test.go +++ b/pkg/services/ngalert/accesscontrol/rules_test.go @@ -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) diff --git a/pkg/services/ngalert/accesscontrol/silences.go b/pkg/services/ngalert/accesscontrol/silences.go new file mode 100644 index 00000000000..178e5ffa4d0 --- /dev/null +++ b/pkg/services/ngalert/accesscontrol/silences.go @@ -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 +} diff --git a/pkg/services/ngalert/accesscontrol/silences_test.go b/pkg/services/ngalert/accesscontrol/silences_test.go new file mode 100644 index 00000000000..1d85886032b --- /dev/null +++ b/pkg/services/ngalert/accesscontrol/silences_test.go @@ -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) +} diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index c58da4729b2..4cc23d2b855 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -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) diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index c2979ea1925..2c8c71ec131 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -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 } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index f46074fcefc..d1c66575964 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -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 } diff --git a/pkg/services/store/entity/dummy/fake_store.go b/pkg/services/store/entity/dummy/fake_store.go index d0410e4da80..a6d85ed6156 100644 --- a/pkg/services/store/entity/dummy/fake_store.go +++ b/pkg/services/store/entity/dummy/fake_store.go @@ -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") } diff --git a/pkg/services/store/entity/entity.pb.go b/pkg/services/store/entity/entity.pb.go index 333d7c998fe..76a04593bd1 100644 --- a/pkg/services/store/entity/entity.pb.go +++ b/pkg/services/store/entity/entity.pb.go @@ -24,11 +24,12 @@ const ( type Entity_Action int32 const ( - Entity_UNKNOWN Entity_Action = 0 - Entity_CREATED Entity_Action = 1 - Entity_UPDATED Entity_Action = 2 - Entity_DELETED Entity_Action = 3 - Entity_ERROR Entity_Action = 4 + Entity_UNKNOWN Entity_Action = 0 + Entity_CREATED Entity_Action = 1 + Entity_UPDATED Entity_Action = 2 + Entity_DELETED Entity_Action = 3 + Entity_ERROR Entity_Action = 4 + Entity_BOOKMARK Entity_Action = 5 ) // Enum value maps for Entity_Action. @@ -39,13 +40,15 @@ var ( 2: "UPDATED", 3: "DELETED", 4: "ERROR", + 5: "BOOKMARK", } Entity_Action_value = map[string]int32{ - "UNKNOWN": 0, - "CREATED": 1, - "UPDATED": 2, - "DELETED": 3, - "ERROR": 4, + "UNKNOWN": 0, + "CREATED": 1, + "UPDATED": 2, + "DELETED": 3, + "ERROR": 4, + "BOOKMARK": 5, } ) @@ -120,7 +123,7 @@ func (x CreateEntityResponse_Status) Number() protoreflect.EnumNumber { // Deprecated: Use CreateEntityResponse_Status.Descriptor instead. func (CreateEntityResponse_Status) EnumDescriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{7, 0} + return file_entity_proto_rawDescGZIP(), []int{5, 0} } // Status enumeration @@ -170,7 +173,7 @@ func (x UpdateEntityResponse_Status) Number() protoreflect.EnumNumber { // Deprecated: Use UpdateEntityResponse_Status.Descriptor instead. func (UpdateEntityResponse_Status) EnumDescriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{9, 0} + return file_entity_proto_rawDescGZIP(), []int{7, 0} } // Status enumeration @@ -220,7 +223,53 @@ func (x DeleteEntityResponse_Status) Number() protoreflect.EnumNumber { // Deprecated: Use DeleteEntityResponse_Status.Descriptor instead. func (DeleteEntityResponse_Status) EnumDescriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{11, 0} + return file_entity_proto_rawDescGZIP(), []int{9, 0} +} + +type EntityWatchRequest_WatchAction int32 + +const ( + EntityWatchRequest_START EntityWatchRequest_WatchAction = 0 + EntityWatchRequest_STOP EntityWatchRequest_WatchAction = 1 +) + +// Enum value maps for EntityWatchRequest_WatchAction. +var ( + EntityWatchRequest_WatchAction_name = map[int32]string{ + 0: "START", + 1: "STOP", + } + EntityWatchRequest_WatchAction_value = map[string]int32{ + "START": 0, + "STOP": 1, + } +) + +func (x EntityWatchRequest_WatchAction) Enum() *EntityWatchRequest_WatchAction { + p := new(EntityWatchRequest_WatchAction) + *p = x + return p +} + +func (x EntityWatchRequest_WatchAction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EntityWatchRequest_WatchAction) Descriptor() protoreflect.EnumDescriptor { + return file_entity_proto_enumTypes[4].Descriptor() +} + +func (EntityWatchRequest_WatchAction) Type() protoreflect.EnumType { + return &file_entity_proto_enumTypes[4] +} + +func (x EntityWatchRequest_WatchAction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EntityWatchRequest_WatchAction.Descriptor instead. +func (EntityWatchRequest_WatchAction) EnumDescriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{15, 0} } // The canonical entity/document data -- this represents the raw bytes and storage level metadata @@ -725,100 +774,6 @@ func (x *ReadEntityRequest) GetWithStatus() bool { return false } -type BatchReadEntityRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Batch []*ReadEntityRequest `protobuf:"bytes,1,rep,name=batch,proto3" json:"batch,omitempty"` -} - -func (x *BatchReadEntityRequest) Reset() { - *x = BatchReadEntityRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *BatchReadEntityRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BatchReadEntityRequest) ProtoMessage() {} - -func (x *BatchReadEntityRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BatchReadEntityRequest.ProtoReflect.Descriptor instead. -func (*BatchReadEntityRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{4} -} - -func (x *BatchReadEntityRequest) GetBatch() []*ReadEntityRequest { - if x != nil { - return x.Batch - } - return nil -} - -type BatchReadEntityResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Results []*Entity `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` -} - -func (x *BatchReadEntityResponse) Reset() { - *x = BatchReadEntityResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *BatchReadEntityResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BatchReadEntityResponse) ProtoMessage() {} - -func (x *BatchReadEntityResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BatchReadEntityResponse.ProtoReflect.Descriptor instead. -func (*BatchReadEntityResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{5} -} - -func (x *BatchReadEntityResponse) GetResults() []*Entity { - if x != nil { - return x.Results - } - return nil -} - type CreateEntityRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -831,7 +786,7 @@ type CreateEntityRequest struct { func (x *CreateEntityRequest) Reset() { *x = CreateEntityRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[6] + mi := &file_entity_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -844,7 +799,7 @@ func (x *CreateEntityRequest) String() string { func (*CreateEntityRequest) ProtoMessage() {} func (x *CreateEntityRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[6] + mi := &file_entity_proto_msgTypes[4] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -857,7 +812,7 @@ func (x *CreateEntityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateEntityRequest.ProtoReflect.Descriptor instead. func (*CreateEntityRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{6} + return file_entity_proto_rawDescGZIP(), []int{4} } func (x *CreateEntityRequest) GetEntity() *Entity { @@ -883,7 +838,7 @@ type CreateEntityResponse struct { func (x *CreateEntityResponse) Reset() { *x = CreateEntityResponse{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[7] + mi := &file_entity_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -896,7 +851,7 @@ func (x *CreateEntityResponse) String() string { func (*CreateEntityResponse) ProtoMessage() {} func (x *CreateEntityResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[7] + mi := &file_entity_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -909,7 +864,7 @@ func (x *CreateEntityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateEntityResponse.ProtoReflect.Descriptor instead. func (*CreateEntityResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{7} + return file_entity_proto_rawDescGZIP(), []int{5} } func (x *CreateEntityResponse) GetError() *EntityErrorInfo { @@ -947,7 +902,7 @@ type UpdateEntityRequest struct { func (x *UpdateEntityRequest) Reset() { *x = UpdateEntityRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[8] + mi := &file_entity_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -960,7 +915,7 @@ func (x *UpdateEntityRequest) String() string { func (*UpdateEntityRequest) ProtoMessage() {} func (x *UpdateEntityRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[8] + mi := &file_entity_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -973,7 +928,7 @@ func (x *UpdateEntityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateEntityRequest.ProtoReflect.Descriptor instead. func (*UpdateEntityRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{8} + return file_entity_proto_rawDescGZIP(), []int{6} } func (x *UpdateEntityRequest) GetEntity() *Entity { @@ -1006,7 +961,7 @@ type UpdateEntityResponse struct { func (x *UpdateEntityResponse) Reset() { *x = UpdateEntityResponse{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[9] + mi := &file_entity_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1019,7 +974,7 @@ func (x *UpdateEntityResponse) String() string { func (*UpdateEntityResponse) ProtoMessage() {} func (x *UpdateEntityResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[9] + mi := &file_entity_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1032,7 +987,7 @@ func (x *UpdateEntityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateEntityResponse.ProtoReflect.Descriptor instead. func (*UpdateEntityResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{9} + return file_entity_proto_rawDescGZIP(), []int{7} } func (x *UpdateEntityResponse) GetError() *EntityErrorInfo { @@ -1070,7 +1025,7 @@ type DeleteEntityRequest struct { func (x *DeleteEntityRequest) Reset() { *x = DeleteEntityRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[10] + mi := &file_entity_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1083,7 +1038,7 @@ func (x *DeleteEntityRequest) String() string { func (*DeleteEntityRequest) ProtoMessage() {} func (x *DeleteEntityRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[10] + mi := &file_entity_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1096,7 +1051,7 @@ func (x *DeleteEntityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteEntityRequest.ProtoReflect.Descriptor instead. func (*DeleteEntityRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{10} + return file_entity_proto_rawDescGZIP(), []int{8} } func (x *DeleteEntityRequest) GetKey() string { @@ -1129,7 +1084,7 @@ type DeleteEntityResponse struct { func (x *DeleteEntityResponse) Reset() { *x = DeleteEntityResponse{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[11] + mi := &file_entity_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1142,7 +1097,7 @@ func (x *DeleteEntityResponse) String() string { func (*DeleteEntityResponse) ProtoMessage() {} func (x *DeleteEntityResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[11] + mi := &file_entity_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1155,7 +1110,7 @@ func (x *DeleteEntityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteEntityResponse.ProtoReflect.Descriptor instead. func (*DeleteEntityResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{11} + return file_entity_proto_rawDescGZIP(), []int{9} } func (x *DeleteEntityResponse) GetError() *EntityErrorInfo { @@ -1186,22 +1141,26 @@ type EntityHistoryRequest struct { // Entity identifier Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // return the history from before this version + Before int64 `protobuf:"varint,2,opt,name=before,proto3" json:"before,omitempty"` // Maximum number of items to return Limit int64 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + // guid of the entity + Guid string `protobuf:"bytes,4,opt,name=guid,proto3" json:"guid,omitempty"` // Starting from the requested page NextPageToken string `protobuf:"bytes,5,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` // Sorting instructions `field ASC/DESC` Sort []string `protobuf:"bytes,7,rep,name=sort,proto3" json:"sort,omitempty"` // Return the full body in each payload WithBody bool `protobuf:"varint,8,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` - // Return the full body in each payload + // Return the status in each payload WithStatus bool `protobuf:"varint,10,opt,name=with_status,json=withStatus,proto3" json:"with_status,omitempty"` } func (x *EntityHistoryRequest) Reset() { *x = EntityHistoryRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[12] + mi := &file_entity_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1214,7 +1173,7 @@ func (x *EntityHistoryRequest) String() string { func (*EntityHistoryRequest) ProtoMessage() {} func (x *EntityHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[12] + mi := &file_entity_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1227,7 +1186,7 @@ func (x *EntityHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityHistoryRequest.ProtoReflect.Descriptor instead. func (*EntityHistoryRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{12} + return file_entity_proto_rawDescGZIP(), []int{10} } func (x *EntityHistoryRequest) GetKey() string { @@ -1237,6 +1196,13 @@ func (x *EntityHistoryRequest) GetKey() string { return "" } +func (x *EntityHistoryRequest) GetBefore() int64 { + if x != nil { + return x.Before + } + return 0 +} + func (x *EntityHistoryRequest) GetLimit() int64 { if x != nil { return x.Limit @@ -1244,6 +1210,13 @@ func (x *EntityHistoryRequest) GetLimit() int64 { return 0 } +func (x *EntityHistoryRequest) GetGuid() string { + if x != nil { + return x.Guid + } + return "" +} + func (x *EntityHistoryRequest) GetNextPageToken() string { if x != nil { return x.NextPageToken @@ -1283,14 +1256,14 @@ type EntityHistoryResponse struct { Versions []*Entity `protobuf:"bytes,2,rep,name=versions,proto3" json:"versions,omitempty"` // More results exist... pass this in the next request NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - // ResourceVersion of the response + // Resource version of the response ResourceVersion int64 `protobuf:"varint,4,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` } func (x *EntityHistoryResponse) Reset() { *x = EntityHistoryResponse{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[13] + mi := &file_entity_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1303,7 +1276,7 @@ func (x *EntityHistoryResponse) String() string { func (*EntityHistoryResponse) ProtoMessage() {} func (x *EntityHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[13] + mi := &file_entity_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1316,7 +1289,7 @@ func (x *EntityHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityHistoryResponse.ProtoReflect.Descriptor instead. func (*EntityHistoryResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{13} + return file_entity_proto_rawDescGZIP(), []int{11} } func (x *EntityHistoryResponse) GetKey() string { @@ -1381,7 +1354,7 @@ type EntityListRequest struct { func (x *EntityListRequest) Reset() { *x = EntityListRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[14] + mi := &file_entity_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1394,7 +1367,7 @@ func (x *EntityListRequest) String() string { func (*EntityListRequest) ProtoMessage() {} func (x *EntityListRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[14] + mi := &file_entity_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1407,7 +1380,7 @@ func (x *EntityListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityListRequest.ProtoReflect.Descriptor instead. func (*EntityListRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{14} + return file_entity_proto_rawDescGZIP(), []int{12} } func (x *EntityListRequest) GetNextPageToken() string { @@ -1514,7 +1487,7 @@ type ReferenceRequest struct { func (x *ReferenceRequest) Reset() { *x = ReferenceRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[15] + mi := &file_entity_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1527,7 +1500,7 @@ func (x *ReferenceRequest) String() string { func (*ReferenceRequest) ProtoMessage() {} func (x *ReferenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[15] + mi := &file_entity_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1540,7 +1513,7 @@ func (x *ReferenceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReferenceRequest.ProtoReflect.Descriptor instead. func (*ReferenceRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{15} + return file_entity_proto_rawDescGZIP(), []int{13} } func (x *ReferenceRequest) GetNextPageToken() string { @@ -1600,7 +1573,7 @@ type EntityListResponse struct { func (x *EntityListResponse) Reset() { *x = EntityListResponse{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[16] + mi := &file_entity_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1613,7 +1586,7 @@ func (x *EntityListResponse) String() string { func (*EntityListResponse) ProtoMessage() {} func (x *EntityListResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[16] + mi := &file_entity_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1626,7 +1599,7 @@ func (x *EntityListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityListResponse.ProtoReflect.Descriptor instead. func (*EntityListResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{16} + return file_entity_proto_rawDescGZIP(), []int{14} } func (x *EntityListResponse) GetResults() []*Entity { @@ -1655,6 +1628,8 @@ type EntityWatchRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields + // Start or stop the watch + Action EntityWatchRequest_WatchAction `protobuf:"varint,8,opt,name=action,proto3,enum=entity.EntityWatchRequest_WatchAction" json:"action,omitempty"` // ResourceVersion of last changes. Empty will default to full history Since int64 `protobuf:"varint,1,opt,name=since,proto3" json:"since,omitempty"` // Watch specific entities @@ -1669,12 +1644,15 @@ type EntityWatchRequest struct { WithBody bool `protobuf:"varint,6,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` // Return the full status in each payload WithStatus bool `protobuf:"varint,7,opt,name=with_status,json=withStatus,proto3" json:"with_status,omitempty"` + // Return initial events + SendInitialEvents bool `protobuf:"varint,9,opt,name=send_initial_events,json=sendInitialEvents,proto3" json:"send_initial_events,omitempty"` + AllowWatchBookmarks bool `protobuf:"varint,10,opt,name=allow_watch_bookmarks,json=allowWatchBookmarks,proto3" json:"allow_watch_bookmarks,omitempty"` } func (x *EntityWatchRequest) Reset() { *x = EntityWatchRequest{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[17] + mi := &file_entity_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1687,7 +1665,7 @@ func (x *EntityWatchRequest) String() string { func (*EntityWatchRequest) ProtoMessage() {} func (x *EntityWatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[17] + mi := &file_entity_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1700,7 +1678,14 @@ func (x *EntityWatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityWatchRequest.ProtoReflect.Descriptor instead. func (*EntityWatchRequest) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{17} + return file_entity_proto_rawDescGZIP(), []int{15} +} + +func (x *EntityWatchRequest) GetAction() EntityWatchRequest_WatchAction { + if x != nil { + return x.Action + } + return EntityWatchRequest_START } func (x *EntityWatchRequest) GetSince() int64 { @@ -1752,6 +1737,20 @@ func (x *EntityWatchRequest) GetWithStatus() bool { return false } +func (x *EntityWatchRequest) GetSendInitialEvents() bool { + if x != nil { + return x.SendInitialEvents + } + return false +} + +func (x *EntityWatchRequest) GetAllowWatchBookmarks() bool { + if x != nil { + return x.AllowWatchBookmarks + } + return false +} + type EntityWatchResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1761,12 +1760,14 @@ type EntityWatchResponse struct { Timestamp int64 `protobuf:"varint,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Entity that was created, updated, or deleted Entity *Entity `protobuf:"bytes,2,opt,name=entity,proto3" json:"entity,omitempty"` + // previous version of the entity + Previous *Entity `protobuf:"bytes,3,opt,name=previous,proto3" json:"previous,omitempty"` } func (x *EntityWatchResponse) Reset() { *x = EntityWatchResponse{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[18] + mi := &file_entity_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1779,7 +1780,7 @@ func (x *EntityWatchResponse) String() string { func (*EntityWatchResponse) ProtoMessage() {} func (x *EntityWatchResponse) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[18] + mi := &file_entity_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1792,7 +1793,7 @@ func (x *EntityWatchResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityWatchResponse.ProtoReflect.Descriptor instead. func (*EntityWatchResponse) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{18} + return file_entity_proto_rawDescGZIP(), []int{16} } func (x *EntityWatchResponse) GetTimestamp() int64 { @@ -1809,6 +1810,13 @@ func (x *EntityWatchResponse) GetEntity() *Entity { return nil } +func (x *EntityWatchResponse) GetPrevious() *Entity { + if x != nil { + return x.Previous + } + return nil +} + type EntitySummary struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1837,7 +1845,7 @@ type EntitySummary struct { func (x *EntitySummary) Reset() { *x = EntitySummary{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[19] + mi := &file_entity_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1850,7 +1858,7 @@ func (x *EntitySummary) String() string { func (*EntitySummary) ProtoMessage() {} func (x *EntitySummary) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[19] + mi := &file_entity_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1863,7 +1871,7 @@ func (x *EntitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use EntitySummary.ProtoReflect.Descriptor instead. func (*EntitySummary) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{19} + return file_entity_proto_rawDescGZIP(), []int{17} } func (x *EntitySummary) GetUID() string { @@ -1964,7 +1972,7 @@ type EntityExternalReference struct { func (x *EntityExternalReference) Reset() { *x = EntityExternalReference{} if protoimpl.UnsafeEnabled { - mi := &file_entity_proto_msgTypes[20] + mi := &file_entity_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1977,7 +1985,7 @@ func (x *EntityExternalReference) String() string { func (*EntityExternalReference) ProtoMessage() {} func (x *EntityExternalReference) ProtoReflect() protoreflect.Message { - mi := &file_entity_proto_msgTypes[20] + mi := &file_entity_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1990,7 +1998,7 @@ func (x *EntityExternalReference) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityExternalReference.ProtoReflect.Descriptor instead. func (*EntityExternalReference) Descriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{20} + return file_entity_proto_rawDescGZIP(), []int{18} } func (x *EntityExternalReference) GetFamily() string { @@ -2018,7 +2026,7 @@ var File_entity_proto protoreflect.FileDescriptor var file_entity_proto_rawDesc = []byte{ 0x0a, 0x0c, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x9f, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xad, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x75, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x75, 0x69, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, @@ -2080,64 +2088,75 @@ var file_entity_proto_rawDesc = []byte{ 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0x47, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, + 0x55, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x09, 0x0a, - 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x22, 0x50, 0x0a, 0x10, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, - 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, - 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, - 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x0b, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x8e, - 0x01, 0x0a, 0x11, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, - 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, - 0x49, 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, - 0x63, 0x68, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x43, 0x0a, 0x17, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, - 0x3d, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xcc, - 0x01, 0x0a, 0x14, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, - 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, - 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x20, 0x0a, 0x06, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, - 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x22, 0x68, 0x0a, - 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, + 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x12, 0x0c, 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, + 0x4d, 0x41, 0x52, 0x4b, 0x10, 0x05, 0x22, 0x50, 0x0a, 0x10, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x63, + 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, + 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x74, + 0x61, 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x0b, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x8e, 0x01, 0x0a, + 0x11, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, + 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x3d, 0x0a, + 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x10, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xcc, 0x01, 0x0a, + 0x14, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x20, 0x0a, 0x06, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, + 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x22, 0x68, 0x0a, 0x13, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, + 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xdb, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, 0x0a, + 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x22, 0x2f, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, + 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, + 0x45, 0x44, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, + 0x44, 0x10, 0x02, 0x22, 0x52, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xdb, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xda, 0x01, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, @@ -2146,205 +2165,199 @@ var file_entity_proto_rawDesc = []byte{ 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x22, 0x2f, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, - 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, - 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, - 0x47, 0x45, 0x44, 0x10, 0x02, 0x22, 0x52, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, - 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, - 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xda, 0x01, 0x0a, 0x14, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x2e, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, - 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4e, 0x4f, 0x54, 0x46, - 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x02, 0x22, 0xb8, 0x01, 0x0a, 0x14, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, - 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x61, 0x74, 0x75, 0x73, 0x22, 0x2e, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, + 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, + 0x45, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4e, 0x4f, 0x54, 0x46, 0x4f, 0x55, + 0x4e, 0x44, 0x10, 0x02, 0x22, 0xe4, 0x01, 0x0a, 0x14, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x16, 0x0a, 0x06, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x06, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x67, 0x75, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x75, 0x69, + 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, + 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, + 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, + 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, + 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xa8, 0x01, 0x0a, 0x15, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, + 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xa9, 0x03, 0x0a, 0x11, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, + 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x06, + 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, + 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, + 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, + 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x22, 0xb4, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, + 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, - 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, - 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, - 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, - 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, - 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x22, 0xa8, 0x01, 0x0a, 0x15, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, - 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, - 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, - 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xa9, 0x03, 0x0a, - 0x11, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, - 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, - 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, - 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, - 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, - 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, - 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, - 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, - 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, - 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, - 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x1a, 0x39, 0x0a, - 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xb4, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x66, - 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, - 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, - 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, - 0x75, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, - 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, - 0x91, 0x01, 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, - 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, - 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x22, 0xa9, 0x02, 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, - 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, - 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, - 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, - 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, - 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0x5b, 0x0a, 0x13, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xa2, 0x04, 0x0a, - 0x0d, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, - 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, - 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, - 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x06, 0x6c, 0x61, - 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x65, 0x6e, 0x74, + 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, + 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x91, 0x01, 0x0a, 0x12, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, + 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xf1, 0x03, + 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x3e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x26, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, + 0x12, 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x26, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, + 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, + 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e, + 0x0a, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x65, + 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x73, 0x65, 0x6e, + 0x64, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x32, + 0x0a, 0x15, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x77, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x62, 0x6f, + 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x61, + 0x6c, 0x6c, 0x6f, 0x77, 0x57, 0x61, 0x74, 0x63, 0x68, 0x42, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, + 0x6b, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x22, 0x0a, + 0x0b, 0x57, 0x61, 0x74, 0x63, 0x68, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, + 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, + 0x01, 0x22, 0x87, 0x01, 0x0a, 0x13, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, + 0x2a, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x22, 0xa2, 0x04, 0x0a, 0x0d, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x55, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, 0x12, + 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x06, 0x6c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, + 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, + 0x62, 0x65, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, + 0x73, 0x6c, 0x75, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, + 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, + 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, + 0x39, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x21, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x6e, 0x65, + 0x73, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, - 0x79, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, - 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, - 0x67, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x12, 0x39, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x21, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x6e, - 0x65, 0x73, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, - 0x72, 0x79, 0x52, 0x06, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x12, 0x3f, 0x0a, 0x0a, 0x72, 0x65, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, - 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, - 0x0a, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x65, 0x0a, 0x17, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, - 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x61, - 0x6d, 0x69, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, - 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x32, 0xa8, 0x04, 0x0a, 0x0b, 0x45, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x31, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, - 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x4c, 0x0a, 0x09, 0x42, - 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, - 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, 0x2e, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, - 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1d, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x3d, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x42, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x30, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, - 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x73, - 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x79, 0x52, 0x06, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x12, 0x3f, 0x0a, 0x0a, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, 0x74, + 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0a, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, + 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x65, 0x0a, 0x17, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, + 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x61, 0x6d, + 0x69, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x32, 0xdc, 0x03, 0x0a, 0x0b, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x31, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, + 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x43, 0x0a, 0x06, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x43, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, + 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, + 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x3d, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x44, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, + 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x73, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2359,88 +2372,85 @@ func file_entity_proto_rawDescGZIP() []byte { return file_entity_proto_rawDescData } -var file_entity_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_entity_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_entity_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_entity_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_entity_proto_goTypes = []interface{}{ - (Entity_Action)(0), // 0: entity.Entity.Action - (CreateEntityResponse_Status)(0), // 1: entity.CreateEntityResponse.Status - (UpdateEntityResponse_Status)(0), // 2: entity.UpdateEntityResponse.Status - (DeleteEntityResponse_Status)(0), // 3: entity.DeleteEntityResponse.Status - (*Entity)(nil), // 4: entity.Entity - (*EntityOriginInfo)(nil), // 5: entity.EntityOriginInfo - (*EntityErrorInfo)(nil), // 6: entity.EntityErrorInfo - (*ReadEntityRequest)(nil), // 7: entity.ReadEntityRequest - (*BatchReadEntityRequest)(nil), // 8: entity.BatchReadEntityRequest - (*BatchReadEntityResponse)(nil), // 9: entity.BatchReadEntityResponse - (*CreateEntityRequest)(nil), // 10: entity.CreateEntityRequest - (*CreateEntityResponse)(nil), // 11: entity.CreateEntityResponse - (*UpdateEntityRequest)(nil), // 12: entity.UpdateEntityRequest - (*UpdateEntityResponse)(nil), // 13: entity.UpdateEntityResponse - (*DeleteEntityRequest)(nil), // 14: entity.DeleteEntityRequest - (*DeleteEntityResponse)(nil), // 15: entity.DeleteEntityResponse - (*EntityHistoryRequest)(nil), // 16: entity.EntityHistoryRequest - (*EntityHistoryResponse)(nil), // 17: entity.EntityHistoryResponse - (*EntityListRequest)(nil), // 18: entity.EntityListRequest - (*ReferenceRequest)(nil), // 19: entity.ReferenceRequest - (*EntityListResponse)(nil), // 20: entity.EntityListResponse - (*EntityWatchRequest)(nil), // 21: entity.EntityWatchRequest - (*EntityWatchResponse)(nil), // 22: entity.EntityWatchResponse - (*EntitySummary)(nil), // 23: entity.EntitySummary - (*EntityExternalReference)(nil), // 24: entity.EntityExternalReference - nil, // 25: entity.Entity.LabelsEntry - nil, // 26: entity.Entity.FieldsEntry - nil, // 27: entity.EntityListRequest.LabelsEntry - nil, // 28: entity.EntityWatchRequest.LabelsEntry - nil, // 29: entity.EntitySummary.LabelsEntry - nil, // 30: entity.EntitySummary.FieldsEntry + (Entity_Action)(0), // 0: entity.Entity.Action + (CreateEntityResponse_Status)(0), // 1: entity.CreateEntityResponse.Status + (UpdateEntityResponse_Status)(0), // 2: entity.UpdateEntityResponse.Status + (DeleteEntityResponse_Status)(0), // 3: entity.DeleteEntityResponse.Status + (EntityWatchRequest_WatchAction)(0), // 4: entity.EntityWatchRequest.WatchAction + (*Entity)(nil), // 5: entity.Entity + (*EntityOriginInfo)(nil), // 6: entity.EntityOriginInfo + (*EntityErrorInfo)(nil), // 7: entity.EntityErrorInfo + (*ReadEntityRequest)(nil), // 8: entity.ReadEntityRequest + (*CreateEntityRequest)(nil), // 9: entity.CreateEntityRequest + (*CreateEntityResponse)(nil), // 10: entity.CreateEntityResponse + (*UpdateEntityRequest)(nil), // 11: entity.UpdateEntityRequest + (*UpdateEntityResponse)(nil), // 12: entity.UpdateEntityResponse + (*DeleteEntityRequest)(nil), // 13: entity.DeleteEntityRequest + (*DeleteEntityResponse)(nil), // 14: entity.DeleteEntityResponse + (*EntityHistoryRequest)(nil), // 15: entity.EntityHistoryRequest + (*EntityHistoryResponse)(nil), // 16: entity.EntityHistoryResponse + (*EntityListRequest)(nil), // 17: entity.EntityListRequest + (*ReferenceRequest)(nil), // 18: entity.ReferenceRequest + (*EntityListResponse)(nil), // 19: entity.EntityListResponse + (*EntityWatchRequest)(nil), // 20: entity.EntityWatchRequest + (*EntityWatchResponse)(nil), // 21: entity.EntityWatchResponse + (*EntitySummary)(nil), // 22: entity.EntitySummary + (*EntityExternalReference)(nil), // 23: entity.EntityExternalReference + nil, // 24: entity.Entity.LabelsEntry + nil, // 25: entity.Entity.FieldsEntry + nil, // 26: entity.EntityListRequest.LabelsEntry + nil, // 27: entity.EntityWatchRequest.LabelsEntry + nil, // 28: entity.EntitySummary.LabelsEntry + nil, // 29: entity.EntitySummary.FieldsEntry } var file_entity_proto_depIdxs = []int32{ - 5, // 0: entity.Entity.origin:type_name -> entity.EntityOriginInfo - 25, // 1: entity.Entity.labels:type_name -> entity.Entity.LabelsEntry - 26, // 2: entity.Entity.fields:type_name -> entity.Entity.FieldsEntry - 6, // 3: entity.Entity.errors:type_name -> entity.EntityErrorInfo + 6, // 0: entity.Entity.origin:type_name -> entity.EntityOriginInfo + 24, // 1: entity.Entity.labels:type_name -> entity.Entity.LabelsEntry + 25, // 2: entity.Entity.fields:type_name -> entity.Entity.FieldsEntry + 7, // 3: entity.Entity.errors:type_name -> entity.EntityErrorInfo 0, // 4: entity.Entity.action:type_name -> entity.Entity.Action - 7, // 5: entity.BatchReadEntityRequest.batch:type_name -> entity.ReadEntityRequest - 4, // 6: entity.BatchReadEntityResponse.results:type_name -> entity.Entity - 4, // 7: entity.CreateEntityRequest.entity:type_name -> entity.Entity - 6, // 8: entity.CreateEntityResponse.error:type_name -> entity.EntityErrorInfo - 4, // 9: entity.CreateEntityResponse.entity:type_name -> entity.Entity - 1, // 10: entity.CreateEntityResponse.status:type_name -> entity.CreateEntityResponse.Status - 4, // 11: entity.UpdateEntityRequest.entity:type_name -> entity.Entity - 6, // 12: entity.UpdateEntityResponse.error:type_name -> entity.EntityErrorInfo - 4, // 13: entity.UpdateEntityResponse.entity:type_name -> entity.Entity - 2, // 14: entity.UpdateEntityResponse.status:type_name -> entity.UpdateEntityResponse.Status - 6, // 15: entity.DeleteEntityResponse.error:type_name -> entity.EntityErrorInfo - 4, // 16: entity.DeleteEntityResponse.entity:type_name -> entity.Entity - 3, // 17: entity.DeleteEntityResponse.status:type_name -> entity.DeleteEntityResponse.Status - 4, // 18: entity.EntityHistoryResponse.versions:type_name -> entity.Entity - 27, // 19: entity.EntityListRequest.labels:type_name -> entity.EntityListRequest.LabelsEntry - 4, // 20: entity.EntityListResponse.results:type_name -> entity.Entity - 28, // 21: entity.EntityWatchRequest.labels:type_name -> entity.EntityWatchRequest.LabelsEntry - 4, // 22: entity.EntityWatchResponse.entity:type_name -> entity.Entity - 29, // 23: entity.EntitySummary.labels:type_name -> entity.EntitySummary.LabelsEntry - 6, // 24: entity.EntitySummary.error:type_name -> entity.EntityErrorInfo - 30, // 25: entity.EntitySummary.fields:type_name -> entity.EntitySummary.FieldsEntry - 23, // 26: entity.EntitySummary.nested:type_name -> entity.EntitySummary - 24, // 27: entity.EntitySummary.references:type_name -> entity.EntityExternalReference - 7, // 28: entity.EntityStore.Read:input_type -> entity.ReadEntityRequest - 8, // 29: entity.EntityStore.BatchRead:input_type -> entity.BatchReadEntityRequest - 10, // 30: entity.EntityStore.Create:input_type -> entity.CreateEntityRequest - 12, // 31: entity.EntityStore.Update:input_type -> entity.UpdateEntityRequest - 14, // 32: entity.EntityStore.Delete:input_type -> entity.DeleteEntityRequest - 16, // 33: entity.EntityStore.History:input_type -> entity.EntityHistoryRequest - 18, // 34: entity.EntityStore.List:input_type -> entity.EntityListRequest - 21, // 35: entity.EntityStore.Watch:input_type -> entity.EntityWatchRequest - 4, // 36: entity.EntityStore.Read:output_type -> entity.Entity - 9, // 37: entity.EntityStore.BatchRead:output_type -> entity.BatchReadEntityResponse - 11, // 38: entity.EntityStore.Create:output_type -> entity.CreateEntityResponse - 13, // 39: entity.EntityStore.Update:output_type -> entity.UpdateEntityResponse - 15, // 40: entity.EntityStore.Delete:output_type -> entity.DeleteEntityResponse - 17, // 41: entity.EntityStore.History:output_type -> entity.EntityHistoryResponse - 20, // 42: entity.EntityStore.List:output_type -> entity.EntityListResponse - 22, // 43: entity.EntityStore.Watch:output_type -> entity.EntityWatchResponse - 36, // [36:44] is the sub-list for method output_type - 28, // [28:36] is the sub-list for method input_type + 5, // 5: entity.CreateEntityRequest.entity:type_name -> entity.Entity + 7, // 6: entity.CreateEntityResponse.error:type_name -> entity.EntityErrorInfo + 5, // 7: entity.CreateEntityResponse.entity:type_name -> entity.Entity + 1, // 8: entity.CreateEntityResponse.status:type_name -> entity.CreateEntityResponse.Status + 5, // 9: entity.UpdateEntityRequest.entity:type_name -> entity.Entity + 7, // 10: entity.UpdateEntityResponse.error:type_name -> entity.EntityErrorInfo + 5, // 11: entity.UpdateEntityResponse.entity:type_name -> entity.Entity + 2, // 12: entity.UpdateEntityResponse.status:type_name -> entity.UpdateEntityResponse.Status + 7, // 13: entity.DeleteEntityResponse.error:type_name -> entity.EntityErrorInfo + 5, // 14: entity.DeleteEntityResponse.entity:type_name -> entity.Entity + 3, // 15: entity.DeleteEntityResponse.status:type_name -> entity.DeleteEntityResponse.Status + 5, // 16: entity.EntityHistoryResponse.versions:type_name -> entity.Entity + 26, // 17: entity.EntityListRequest.labels:type_name -> entity.EntityListRequest.LabelsEntry + 5, // 18: entity.EntityListResponse.results:type_name -> entity.Entity + 4, // 19: entity.EntityWatchRequest.action:type_name -> entity.EntityWatchRequest.WatchAction + 27, // 20: entity.EntityWatchRequest.labels:type_name -> entity.EntityWatchRequest.LabelsEntry + 5, // 21: entity.EntityWatchResponse.entity:type_name -> entity.Entity + 5, // 22: entity.EntityWatchResponse.previous:type_name -> entity.Entity + 28, // 23: entity.EntitySummary.labels:type_name -> entity.EntitySummary.LabelsEntry + 7, // 24: entity.EntitySummary.error:type_name -> entity.EntityErrorInfo + 29, // 25: entity.EntitySummary.fields:type_name -> entity.EntitySummary.FieldsEntry + 22, // 26: entity.EntitySummary.nested:type_name -> entity.EntitySummary + 23, // 27: entity.EntitySummary.references:type_name -> entity.EntityExternalReference + 8, // 28: entity.EntityStore.Read:input_type -> entity.ReadEntityRequest + 9, // 29: entity.EntityStore.Create:input_type -> entity.CreateEntityRequest + 11, // 30: entity.EntityStore.Update:input_type -> entity.UpdateEntityRequest + 13, // 31: entity.EntityStore.Delete:input_type -> entity.DeleteEntityRequest + 15, // 32: entity.EntityStore.History:input_type -> entity.EntityHistoryRequest + 17, // 33: entity.EntityStore.List:input_type -> entity.EntityListRequest + 20, // 34: entity.EntityStore.Watch:input_type -> entity.EntityWatchRequest + 5, // 35: entity.EntityStore.Read:output_type -> entity.Entity + 10, // 36: entity.EntityStore.Create:output_type -> entity.CreateEntityResponse + 12, // 37: entity.EntityStore.Update:output_type -> entity.UpdateEntityResponse + 14, // 38: entity.EntityStore.Delete:output_type -> entity.DeleteEntityResponse + 16, // 39: entity.EntityStore.History:output_type -> entity.EntityHistoryResponse + 19, // 40: entity.EntityStore.List:output_type -> entity.EntityListResponse + 21, // 41: entity.EntityStore.Watch:output_type -> entity.EntityWatchResponse + 35, // [35:42] is the sub-list for method output_type + 28, // [28:35] is the sub-list for method input_type 28, // [28:28] is the sub-list for extension type_name 28, // [28:28] is the sub-list for extension extendee 0, // [0:28] is the sub-list for field type_name @@ -2501,30 +2511,6 @@ func file_entity_proto_init() { } } file_entity_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BatchReadEntityRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_entity_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BatchReadEntityResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_entity_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CreateEntityRequest); i { case 0: return &v.state @@ -2536,7 +2522,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CreateEntityResponse); i { case 0: return &v.state @@ -2548,7 +2534,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*UpdateEntityRequest); i { case 0: return &v.state @@ -2560,7 +2546,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*UpdateEntityResponse); i { case 0: return &v.state @@ -2572,7 +2558,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DeleteEntityRequest); i { case 0: return &v.state @@ -2584,7 +2570,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DeleteEntityResponse); i { case 0: return &v.state @@ -2596,7 +2582,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityHistoryRequest); i { case 0: return &v.state @@ -2608,7 +2594,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityHistoryResponse); i { case 0: return &v.state @@ -2620,7 +2606,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityListRequest); i { case 0: return &v.state @@ -2632,7 +2618,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ReferenceRequest); i { case 0: return &v.state @@ -2644,7 +2630,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityListResponse); i { case 0: return &v.state @@ -2656,7 +2642,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityWatchRequest); i { case 0: return &v.state @@ -2668,7 +2654,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityWatchResponse); i { case 0: return &v.state @@ -2680,7 +2666,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntitySummary); i { case 0: return &v.state @@ -2692,7 +2678,7 @@ func file_entity_proto_init() { return nil } } - file_entity_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + file_entity_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EntityExternalReference); i { case 0: return &v.state @@ -2710,8 +2696,8 @@ func file_entity_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_entity_proto_rawDesc, - NumEnums: 4, - NumMessages: 27, + NumEnums: 5, + NumMessages: 25, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/store/entity/entity.proto b/pkg/services/store/entity/entity.proto index 769cc09d6aa..d1a335475da 100644 --- a/pkg/services/store/entity/entity.proto +++ b/pkg/services/store/entity/entity.proto @@ -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); } diff --git a/pkg/services/store/entity/entity_grpc.pb.go b/pkg/services/store/entity/entity_grpc.pb.go index c1a7f6108d3..e76302c91dc 100644 --- a/pkg/services/store/entity/entity_grpc.pb.go +++ b/pkg/services/store/entity/entity_grpc.pb.go @@ -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", diff --git a/pkg/services/store/entity/server/service.go b/pkg/services/store/entity/server/service.go index f4e4468e40a..7e315a5777b 100644 --- a/pkg/services/store/entity/server/service.go +++ b/pkg/services/store/entity/server/service.go @@ -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 diff --git a/pkg/services/store/entity/sqlstash/metrics.go b/pkg/services/store/entity/sqlstash/metrics.go new file mode 100644 index 00000000000..075f058c7f5 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/metrics.go @@ -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) +} diff --git a/pkg/services/store/entity/sqlstash/querybuilder.go b/pkg/services/store/entity/sqlstash/querybuilder.go index a5da12f6476..655bbee4f7f 100644 --- a/pkg/services/store/entity/sqlstash/querybuilder.go +++ b/pkg/services/store/entity/sqlstash/querybuilder.go @@ -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 } diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go index 7e2d87294dc..7c89961dd5a 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math/rand" "slices" "strings" @@ -14,6 +15,7 @@ import ( "github.com/bwmarrin/snowflake" "github.com/google/uuid" + "google.golang.org/protobuf/proto" folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/appcontext" @@ -23,29 +25,50 @@ import ( "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/entity/db" + "github.com/prometheus/client_golang/prometheus" ) -// Make sure we implement both store + admin +const entityTable = "entity" +const entityHistoryTable = "entity_history" + +// Make sure we implement correct interfaces var _ entity.EntityStoreServer = &sqlEntityServer{} +var _ SqlEntityServer = &sqlEntityServer{} + +func ProvideSQLEntityServer(db db.EntityDBInterface /*, cfg *setting.Cfg */) (SqlEntityServer, error) { + ctx, cancel := context.WithCancel(context.Background()) -func ProvideSQLEntityServer(db db.EntityDBInterface /*, cfg *setting.Cfg */) (entity.EntityStoreServer, error) { entityServer := &sqlEntityServer{ - db: db, - log: log.New("sql-entity-server"), - ctx: context.Background(), + db: db, + log: log.New("sql-entity-server"), + ctx: ctx, + cancel: cancel, + } + + if err := prometheus.Register(NewStorageMetrics()); err != nil { + entityServer.log.Warn("error registering storage server metrics", "error", err) } return entityServer, nil } +type SqlEntityServer interface { + entity.EntityStoreServer + + Init() error + Stop() +} + type sqlEntityServer struct { log log.Logger db db.EntityDBInterface // needed to keep xorm engine in scope sess *session.SessionDB dialect migrator.Dialect snowflake *snowflake.Node - broadcaster Broadcaster[*entity.Entity] + broadcaster Broadcaster[*entity.EntityWatchResponse] ctx context.Context + cancel context.CancelFunc + stream chan *entity.EntityWatchResponse } func (s *sqlEntityServer) Init() error { @@ -82,7 +105,9 @@ func (s *sqlEntityServer) Init() error { } // set up the broadcaster - s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan *entity.Entity) error { + s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan *entity.EntityWatchResponse) error { + s.stream = stream + // start the poller go s.poller(stream) @@ -95,6 +120,10 @@ func (s *sqlEntityServer) Init() error { return nil } +func (s *sqlEntityServer) Stop() { + s.cancel() +} + type FieldSelectRequest interface { GetWithBody() bool GetWithStatus() bool @@ -139,7 +168,7 @@ func (s *sqlEntityServer) getReadSelect(r FieldSelectRequest) (string, error) { return "SELECT " + strings.Join(quotedFields, ","), nil } -func rowToEntity(rows *sql.Rows, r FieldSelectRequest) (*entity.Entity, error) { +func readEntity(rows *sql.Rows, r FieldSelectRequest) (*entity.Entity, error) { raw := &entity.Entity{ Origin: &entity.EntityOriginInfo{}, } @@ -203,7 +232,7 @@ func (s *sqlEntityServer) Read(ctx context.Context, r *entity.ReadEntityRequest) } func (s *sqlEntityServer) read(ctx context.Context, tx session.SessionQuerier, r *entity.ReadEntityRequest) (*entity.Entity, error) { - table := "entity" + table := entityTable where := []string{} args := []any{} @@ -220,7 +249,7 @@ func (s *sqlEntityServer) read(ctx context.Context, tx session.SessionQuerier, r args = append(args, key.Namespace, key.Group, key.Resource, key.Name) if r.ResourceVersion != 0 { - table = "entity_history" + table = entityHistoryTable where = append(where, s.dialect.Quote("resource_version")+">=?") args = append(args, r.ResourceVersion) } @@ -254,59 +283,7 @@ func (s *sqlEntityServer) read(ctx context.Context, tx session.SessionQuerier, r return &entity.Entity{}, nil } - return rowToEntity(rows, r) -} - -func (s *sqlEntityServer) BatchRead(ctx context.Context, b *entity.BatchReadEntityRequest) (*entity.BatchReadEntityResponse, error) { - if len(b.Batch) < 1 { - return nil, fmt.Errorf("missing querires") - } - - first := b.Batch[0] - args := []any{} - constraints := []string{} - - for _, r := range b.Batch { - if r.WithBody != first.WithBody || r.WithStatus != first.WithStatus { - return nil, fmt.Errorf("requests must want the same things") - } - - if r.Key == "" { - return nil, fmt.Errorf("missing key") - } - - constraints = append(constraints, s.dialect.Quote("key")+"=?") - args = append(args, r.Key) - - if r.ResourceVersion != 0 { - return nil, fmt.Errorf("version not supported for batch read (yet?)") - } - } - - req := b.Batch[0] - query, err := s.getReadSelect(req) - if err != nil { - return nil, err - } - - query += " FROM entity" + - " WHERE (" + strings.Join(constraints, " OR ") + ")" - rows, err := s.sess.Query(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - // TODO? make sure the results are in order? - rsp := &entity.BatchReadEntityResponse{} - for rows.Next() { - r, err := rowToEntity(rows, req) - if err != nil { - return nil, err - } - rsp.Results = append(rsp.Results, r) - } - return rsp, nil + return readEntity(rows, r) } //nolint:gocyclo @@ -488,13 +465,13 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ } // 1. Add row to the `entity_history` values - if err := s.dialect.Insert(ctx, tx, "entity_history", values); err != nil { + if err := s.dialect.Insert(ctx, tx, entityHistoryTable, values); err != nil { s.log.Error("error inserting entity history", "msg", err.Error()) return err } // 2. Add row to the main `entity` table - if err := s.dialect.Insert(ctx, tx, "entity", values); err != nil { + if err := s.dialect.Insert(ctx, tx, entityTable, values); err != nil { s.log.Error("error inserting entity", "msg", err.Error()) return err } @@ -520,6 +497,12 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ rsp.Status = entity.CreateEntityResponse_ERROR } + evt := &entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: rsp.Entity, + } + s.stream <- evt + return rsp, err } @@ -551,8 +534,11 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ Status: entity.UpdateEntityResponse_UPDATED, // Will be changed if not true } - err := s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { - current, err := s.read(ctx, tx, &entity.ReadEntityRequest{ + var previous *entity.Entity + var err error + + err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { + previous, err = s.read(ctx, tx, &entity.ReadEntityRequest{ Key: r.Entity.Key, WithBody: true, WithStatus: true, @@ -562,57 +548,60 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ } // Optimistic locking - if r.PreviousVersion > 0 && r.PreviousVersion != current.ResourceVersion { + if r.PreviousVersion > 0 && r.PreviousVersion != previous.ResourceVersion { + StorageServerMetrics.OptimisticLockFailed.WithLabelValues("update").Inc() return fmt.Errorf("optimistic lock failed") } // if we didn't find an existing entity - if current.Guid == "" { + if previous.Guid == "" { return fmt.Errorf("entity not found") } - rsp.Entity.Guid = current.Guid + rsp.Entity.Guid = previous.Guid // Clear the refs if _, err := tx.Exec(ctx, "DELETE FROM entity_ref WHERE guid=?", rsp.Entity.Guid); err != nil { return err } + updated := proto.Clone(previous).(*entity.Entity) + if r.Entity.GroupVersion != "" { - current.GroupVersion = r.Entity.GroupVersion + updated.GroupVersion = r.Entity.GroupVersion } if r.Entity.Folder != "" { - current.Folder = r.Entity.Folder + updated.Folder = r.Entity.Folder } if r.Entity.Slug != "" { - current.Slug = r.Entity.Slug + updated.Slug = r.Entity.Slug } if r.Entity.Body != nil { - current.Body = r.Entity.Body - current.Size = int64(len(current.Body)) + updated.Body = r.Entity.Body + updated.Size = int64(len(updated.Body)) } if r.Entity.Meta != nil { - current.Meta = r.Entity.Meta + updated.Meta = r.Entity.Meta } if r.Entity.Status != nil { - current.Status = r.Entity.Status + updated.Status = r.Entity.Status } - etag := createContentsHash(current.Body, current.Meta, current.Status) - current.ETag = etag + etag := createContentsHash(updated.Body, updated.Meta, updated.Status) + updated.ETag = etag - current.UpdatedAt = updatedAt - current.UpdatedBy = updatedBy + updated.UpdatedAt = updatedAt + updated.UpdatedBy = updatedBy if r.Entity.Title != "" { - current.Title = r.Entity.Title + updated.Title = r.Entity.Title } if r.Entity.Description != "" { - current.Description = r.Entity.Description + updated.Description = r.Entity.Description } labels, err := json.Marshal(r.Entity.Labels) @@ -620,84 +609,84 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ s.log.Error("error marshalling labels", "msg", err.Error()) return err } - current.Labels = r.Entity.Labels + updated.Labels = r.Entity.Labels fields, err := json.Marshal(r.Entity.Fields) if err != nil { s.log.Error("error marshalling fields", "msg", err.Error()) return err } - current.Fields = r.Entity.Fields + updated.Fields = r.Entity.Fields errors, err := json.Marshal(r.Entity.Errors) if err != nil { s.log.Error("error marshalling errors", "msg", err.Error()) return err } - current.Errors = r.Entity.Errors + updated.Errors = r.Entity.Errors - if current.Origin == nil { - current.Origin = &entity.EntityOriginInfo{} + if updated.Origin == nil { + updated.Origin = &entity.EntityOriginInfo{} } if r.Entity.Origin != nil { if r.Entity.Origin.Source != "" { - current.Origin.Source = r.Entity.Origin.Source + updated.Origin.Source = r.Entity.Origin.Source } if r.Entity.Origin.Key != "" { - current.Origin.Key = r.Entity.Origin.Key + updated.Origin.Key = r.Entity.Origin.Key } if r.Entity.Origin.Time > 0 { - current.Origin.Time = r.Entity.Origin.Time + updated.Origin.Time = r.Entity.Origin.Time } } // Set the comment on this write if r.Entity.Message != "" { - current.Message = r.Entity.Message + updated.Message = r.Entity.Message } // Update resource version - current.ResourceVersion = s.snowflake.Generate().Int64() + updated.ResourceVersion = s.snowflake.Generate().Int64() - current.Action = entity.Entity_UPDATED + updated.Action = entity.Entity_UPDATED values := map[string]any{ // below are only set in history table - "guid": current.Guid, - "key": current.Key, - "namespace": current.Namespace, - "group": current.Group, - "resource": current.Resource, - "name": current.Name, - "created_at": current.CreatedAt, - "created_by": current.CreatedBy, + "guid": updated.Guid, + "key": updated.Key, + "namespace": updated.Namespace, + "group": updated.Group, + "resource": updated.Resource, + "name": updated.Name, + "created_at": updated.CreatedAt, + "created_by": updated.CreatedBy, // below are updated - "group_version": current.GroupVersion, - "folder": current.Folder, - "slug": current.Slug, - "updated_at": current.UpdatedAt, - "updated_by": current.UpdatedBy, - "body": current.Body, - "meta": current.Meta, - "status": current.Status, - "size": current.Size, - "etag": current.ETag, - "resource_version": current.ResourceVersion, - "title": current.Title, - "description": current.Description, + "group_version": updated.GroupVersion, + "folder": updated.Folder, + "slug": updated.Slug, + "updated_at": updated.UpdatedAt, + "updated_by": updated.UpdatedBy, + "body": updated.Body, + "meta": updated.Meta, + "status": updated.Status, + "size": updated.Size, + "etag": updated.ETag, + "resource_version": updated.ResourceVersion, + "title": updated.Title, + "description": updated.Description, "labels": labels, "fields": fields, "errors": errors, - "origin": current.Origin.Source, - "origin_key": current.Origin.Key, - "origin_ts": current.Origin.Time, - "message": current.Message, - "action": current.Action, + "origin": updated.Origin.Source, + "origin_key": updated.Origin.Key, + "origin_ts": updated.Origin.Time, + "message": updated.Message, + "action": updated.Action, } // 1. Add the `entity_history` values - if err := s.dialect.Insert(ctx, tx, "entity_history", values); err != nil { + if err := s.dialect.Insert(ctx, tx, entityHistoryTable, values); err != nil { s.log.Error("error inserting entity history", "msg", err.Error()) return err } @@ -717,10 +706,10 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ err = s.dialect.Update( ctx, tx, - "entity", + entityTable, values, map[string]any{ - "guid": current.Guid, + "guid": updated.Guid, }, ) if err != nil { @@ -728,11 +717,11 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ return err } - switch current.Group { + switch updated.Group { case folder.GROUP: - switch current.Resource { + switch updated.Resource { case folder.RESOURCE: - err = s.updateFolderTree(ctx, tx, current.Namespace) + err = s.updateFolderTree(ctx, tx, updated.Namespace) if err != nil { s.log.Error("error updating folder tree", "msg", err.Error()) return err @@ -740,15 +729,23 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ } } - rsp.Entity = current + rsp.Entity = updated - return s.setLabels(ctx, tx, current.Guid, current.Labels) + return s.setLabels(ctx, tx, updated.Guid, updated.Labels) }) if err != nil { s.log.Error("error updating entity", "msg", err.Error()) rsp.Status = entity.UpdateEntityResponse_ERROR } + evt := &entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: rsp.Entity, + Previous: previous, + } + + s.stream <- evt + return rsp, err } @@ -790,9 +787,12 @@ func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequ rsp := &entity.DeleteEntityResponse{} + var previous *entity.Entity + var updated *entity.Entity + err := s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { var err error - rsp.Entity, err = s.Read(ctx, &entity.ReadEntityRequest{ + previous, err = s.Read(ctx, &entity.ReadEntityRequest{ Key: r.Key, WithBody: true, WithStatus: true, @@ -806,12 +806,18 @@ func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequ return err } - if r.PreviousVersion > 0 && r.PreviousVersion != rsp.Entity.ResourceVersion { + if previous.Guid == "" { + rsp.Status = entity.DeleteEntityResponse_NOTFOUND + return nil + } + + if r.PreviousVersion > 0 && r.PreviousVersion != previous.ResourceVersion { rsp.Status = entity.DeleteEntityResponse_ERROR + StorageServerMetrics.OptimisticLockFailed.WithLabelValues("delete").Inc() return fmt.Errorf("optimistic lock failed") } - err = s.doDelete(ctx, tx, rsp.Entity) + updated, err = s.doDelete(ctx, tx, previous) if err != nil { rsp.Status = entity.DeleteEntityResponse_ERROR return err @@ -821,114 +827,132 @@ func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequ return nil }) + if rsp.Status == entity.DeleteEntityResponse_DELETED { + // k8s expects us to return the entity as it was before the deletion, but with the updated RV + rsp.Entity = proto.Clone(previous).(*entity.Entity) + rsp.Entity.ResourceVersion = updated.ResourceVersion + + evt := &entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: updated, + Previous: previous, + } + s.stream <- evt + } else { + rsp.Entity = previous + } + return rsp, err } -func (s *sqlEntityServer) doDelete(ctx context.Context, tx *session.SessionTx, ent *entity.Entity) error { +func (s *sqlEntityServer) doDelete(ctx context.Context, tx *session.SessionTx, ent *entity.Entity) (*entity.Entity, error) { + updated := proto.Clone(ent).(*entity.Entity) + // Update resource version - ent.ResourceVersion = s.snowflake.Generate().Int64() + updated.ResourceVersion = s.snowflake.Generate().Int64() - ent.Action = entity.Entity_DELETED + updated.Action = entity.Entity_DELETED - // Set updated at/by - ent.UpdatedAt = time.Now().UnixMilli() + // Get updated by modifier, err := appcontext.User(ctx) if err != nil { - return err + return nil, err } if modifier == nil { - return fmt.Errorf("can not find user in context") + return nil, fmt.Errorf("can not find user in context") } - ent.UpdatedBy = store.GetUserIDString(modifier) - labels, err := json.Marshal(ent.Labels) + labels, err := json.Marshal(updated.Labels) if err != nil { s.log.Error("error marshalling labels", "msg", err.Error()) - return err + return nil, err } - fields, err := json.Marshal(ent.Fields) + fields, err := json.Marshal(updated.Fields) if err != nil { s.log.Error("error marshalling fields", "msg", err.Error()) - return err + return nil, err } - errors, err := json.Marshal(ent.Errors) + errors, err := json.Marshal(updated.Errors) if err != nil { s.log.Error("error marshalling errors", "msg", err.Error()) - return err + return nil, err } - if ent.Origin == nil { - ent.Origin = &entity.EntityOriginInfo{} + if updated.Origin == nil { + updated.Origin = &entity.EntityOriginInfo{} } + updated.UpdatedAt = time.Now().UnixMilli() + updated.UpdatedBy = store.GetUserIDString(modifier) + values := map[string]any{ // below are only set in history table - "guid": ent.Guid, - "key": ent.Key, - "namespace": ent.Namespace, - "group": ent.Group, - "resource": ent.Resource, - "name": ent.Name, - "created_at": ent.CreatedAt, - "created_by": ent.CreatedBy, + "guid": updated.Guid, + "key": updated.Key, + "namespace": updated.Namespace, + "group": updated.Group, + "resource": updated.Resource, + "name": updated.Name, + "created_at": updated.CreatedAt, + "created_by": updated.CreatedBy, // below are updated - "group_version": ent.GroupVersion, - "folder": ent.Folder, - "slug": ent.Slug, - "updated_at": ent.UpdatedAt, - "updated_by": ent.UpdatedBy, - "body": ent.Body, - "meta": ent.Meta, - "status": ent.Status, - "size": ent.Size, - "etag": ent.ETag, - "resource_version": ent.ResourceVersion, - "title": ent.Title, - "description": ent.Description, + "group_version": updated.GroupVersion, + "folder": updated.Folder, + "slug": updated.Slug, + "updated_at": updated.UpdatedAt, + "updated_by": updated.UpdatedBy, + "body": updated.Body, + "meta": updated.Meta, + "status": updated.Status, + "size": updated.Size, + "etag": updated.ETag, + "resource_version": updated.ResourceVersion, + "title": updated.Title, + "description": updated.Description, "labels": labels, "fields": fields, "errors": errors, - "origin": ent.Origin.Source, - "origin_key": ent.Origin.Key, - "origin_ts": ent.Origin.Time, - "message": ent.Message, - "action": ent.Action, + "origin": updated.Origin.Source, + "origin_key": updated.Origin.Key, + "origin_ts": updated.Origin.Time, + "message": updated.Message, + "action": updated.Action, } // 1. Add the `entity_history` values - if err := s.dialect.Insert(ctx, tx, "entity_history", values); err != nil { + if err := s.dialect.Insert(ctx, tx, entityHistoryTable, values); err != nil { s.log.Error("error inserting entity history", "msg", err.Error()) - return err + return nil, err } - _, err = tx.Exec(ctx, "DELETE FROM entity WHERE guid=?", ent.Guid) + _, err = tx.Exec(ctx, "DELETE FROM entity WHERE guid=?", updated.Guid) if err != nil { - return err + return nil, err } - _, err = tx.Exec(ctx, "DELETE FROM entity_labels WHERE guid=?", ent.Guid) + _, err = tx.Exec(ctx, "DELETE FROM entity_labels WHERE guid=?", updated.Guid) if err != nil { - return err + return nil, err } - _, err = tx.Exec(ctx, "DELETE FROM entity_ref WHERE guid=?", ent.Guid) + _, err = tx.Exec(ctx, "DELETE FROM entity_ref WHERE guid=?", updated.Guid) if err != nil { - return err + return nil, err } - switch ent.Group { + switch updated.Group { case folder.GROUP: - switch ent.Resource { + switch updated.Resource { case folder.RESOURCE: - err = s.updateFolderTree(ctx, tx, ent.Namespace) + err = s.updateFolderTree(ctx, tx, updated.Namespace) if err != nil { s.log.Error("error updating folder tree", "msg", err.Error()) - return err + return nil, err } } } - return nil + return updated, nil } func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRequest) (*entity.EntityHistoryResponse, error) { @@ -944,46 +968,54 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe return nil, fmt.Errorf("missing user in context") } - if r.Key == "" { - return nil, fmt.Errorf("missing key") - } - - key, err := entity.ParseKey(r.Key) - if err != nil { - return nil, err - } - - if key.Name == "" { - return nil, fmt.Errorf("missing name") - } + return s.history(ctx, r) +} +func (s *sqlEntityServer) history(ctx context.Context, r *entity.EntityHistoryRequest) (*entity.EntityHistoryResponse, error) { var limit int64 = 100 if r.Limit > 0 && r.Limit < 100 { limit = r.Limit } - fields := s.getReadFields(r) - entityQuery := selectQuery{ dialect: s.dialect, - fields: fields, - from: "entity_history", // the table - args: []any{}, + from: entityHistoryTable, // the table limit: r.Limit, - offset: 0, oneExtra: true, // request one more than the limit (and show next token if it exists) } - args := []any{key.Group, key.Resource} - whereclause := "(" + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" - if key.Namespace != "" { - args = append(args, key.Namespace) - whereclause += " AND " + s.dialect.Quote("namespace") + "=?" - } - args = append(args, key.Name) - whereclause += " AND " + s.dialect.Quote("name") + "=?)" + fields := s.getReadFields(r) + entityQuery.AddFields(fields...) - entityQuery.addWhere(whereclause, args...) + if r.Key != "" { + key, err := entity.ParseKey(r.Key) + if err != nil { + return nil, err + } + + if key.Name == "" { + return nil, fmt.Errorf("missing name") + } + + args := []any{key.Group, key.Resource} + whereclause := "(" + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" + if key.Namespace != "" { + args = append(args, key.Namespace) + whereclause += " AND " + s.dialect.Quote("namespace") + "=?" + } + args = append(args, key.Name) + whereclause += " AND " + s.dialect.Quote("name") + "=?)" + + entityQuery.AddWhere(whereclause, args...) + } else if r.Guid != "" { + entityQuery.AddWhere(s.dialect.Quote("guid")+"=?", r.Guid) + } else { + return nil, fmt.Errorf("no key or guid specified") + } + + if r.Before > 0 { + entityQuery.AddWhere(s.dialect.Quote("resource_version")+" 0 { - entityQuery.addWhereIn("group", r.Group) + entityQuery.AddWhereIn("group", ToAnyList(r.Group)) + rvMaxQuery.AddWhereIn("group", ToAnyList(r.Group)) + rvSubQuery.AddWhereIn("group", ToAnyList(r.Group)) } if len(r.Resource) > 0 { - entityQuery.addWhereIn("resource", r.Resource) + entityQuery.AddWhereIn("resource", ToAnyList(r.Resource)) + rvMaxQuery.AddWhereIn("resource", ToAnyList(r.Resource)) + rvSubQuery.AddWhereIn("resource", ToAnyList(r.Resource)) } if len(r.Key) > 0 { @@ -1164,27 +1212,41 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) } args = append(args, key.Group, key.Resource) - whereclause := "(" + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" + whereclause := "(t." + s.dialect.Quote("group") + "=? AND t." + s.dialect.Quote("resource") + "=?" if key.Namespace != "" { args = append(args, key.Namespace) - whereclause += " AND " + s.dialect.Quote("namespace") + "=?" + whereclause += " AND t." + s.dialect.Quote("namespace") + "=?" } if key.Name != "" { args = append(args, key.Name) - whereclause += " AND " + s.dialect.Quote("name") + "=?" + whereclause += " AND t." + s.dialect.Quote("name") + "=?" } whereclause += ")" where = append(where, whereclause) } - entityQuery.addWhere("("+strings.Join(where, " OR ")+")", args...) + entityQuery.AddWhere("("+strings.Join(where, " OR ")+")", args...) + rvMaxQuery.AddWhere("("+strings.Join(where, " OR ")+")", args...) + rvSubQuery.AddWhere("("+strings.Join(where, " OR ")+")", args...) } - // Folder guid - if r.Folder != "" { - entityQuery.addWhere("folder", r.Folder) + // get the maximum resource version and count of entities + type RVMaxRow struct { + Rv int64 `db:"rv"` + Cnt int64 `db:"cnt"` } + rvMaxRow := &RVMaxRow{} + query, args := rvMaxQuery.ToQuery() + + err = s.sess.Get(ctx, rvMaxRow, query, args...) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + } + + s.log.Debug("getting max rv", "maxRv", rvMaxRow.Rv, "cnt", rvMaxRow.Cnt, "query", query, "args", args) // if we have a page token, use that to specify the first record continueToken, err := GetContinueToken(r) @@ -1193,13 +1255,53 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) } if continueToken != nil { entityQuery.offset = continueToken.StartOffset + if continueToken.ResourceVersion > 0 { + if r.Deleted { + // if we're continuing, we need to list only revisions that are older than the given resource version + entityQuery.AddWhere("resource_version <= ?", continueToken.ResourceVersion) + } else { + // cap versions considered by the per resource max version subquery + rvSubQuery.AddWhere("resource_version <= ?", continueToken.ResourceVersion) + } + } + + if (continueToken.ResourceVersion > 0 && continueToken.ResourceVersion != rvMaxRow.Rv) || (continueToken.RecordCnt > 0 && continueToken.RecordCnt != rvMaxRow.Cnt) { + entityQuery.From(entityHistoryTable) + entityQuery.AddWhere("t.action != ?", entity.Entity_DELETED) + + rvSubQuery.AddGroupBy("guid") + query, args = rvSubQuery.ToQuery() + entityQuery.AddJoin("INNER JOIN ("+query+") rv ON rv.guid = t.guid AND rv.max_rv = t.resource_version", args...) + } + } else { + continueToken = &ContinueToken{ + Sort: r.Sort, + StartOffset: 0, + ResourceVersion: rvMaxRow.Rv, + RecordCnt: rvMaxRow.Cnt, + } + + if continueToken.ResourceVersion == 0 { + // we use a snowflake as a fallback resource version + continueToken.ResourceVersion = s.snowflake.Generate().Int64() + } + } + + // initialize the result + rsp := &entity.EntityListResponse{ + ResourceVersion: continueToken.ResourceVersion, + } + + // Folder guid + if r.Folder != "" { + entityQuery.AddWhere("folder", r.Folder) } if len(r.Labels) > 0 { // if we are looking for deleted entities, we need to use the labels column - if r.Deleted { + if entityQuery.from == entityHistoryTable { for labelKey, labelValue := range r.Labels { - entityQuery.addWhereJsonContainsKV("labels", labelKey, labelValue) + entityQuery.AddWhereJsonContainsKV("labels", labelKey, labelValue) } // for active entities, we can use the entity_labels table } else { @@ -1216,7 +1318,7 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) " HAVING COUNT(label) = ?" args = append(args, len(r.Labels)) - entityQuery.addWhereInSubquery("guid", query, args) + entityQuery.AddWhereInSubquery("guid", query, args) } } @@ -1225,11 +1327,11 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) if err != nil { return nil, err } - entityQuery.addOrderBy(sortBy.Field, sortBy.Direction) + entityQuery.AddOrderBy(sortBy.Field, sortBy.Direction) } - entityQuery.addOrderBy("guid", Ascending) + entityQuery.AddOrderBy("guid", Ascending) - query, args := entityQuery.toQuery() + query, args = entityQuery.ToQuery() s.log.Debug("listing", "query", query, "args", args) @@ -1238,21 +1340,15 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return nil, err } defer func() { _ = rows.Close() }() - rsp := &entity.EntityListResponse{ - ResourceVersion: s.snowflake.Generate().Int64(), - } for rows.Next() { - result, err := rowToEntity(rows, r) + result, err := readEntity(rows, r) if err != nil { return rsp, err } // found more than requested - if int64(len(rsp.Results)) >= entityQuery.limit { - continueToken := &ContinueToken{ - Sort: r.Sort, - StartOffset: entityQuery.offset + entityQuery.limit, - } + if entityQuery.limit > 0 && int64(len(rsp.Results)) >= entityQuery.limit { + continueToken.StartOffset = entityQuery.offset + entityQuery.limit rsp.NextPageToken = continueToken.String() break } @@ -1263,7 +1359,7 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return rsp, err } -func (s *sqlEntityServer) Watch(r *entity.EntityWatchRequest, w entity.EntityStore_WatchServer) error { +func (s *sqlEntityServer) Watch(w entity.EntityStore_WatchServer) error { if err := s.Init(); err != nil { return err } @@ -1276,12 +1372,22 @@ func (s *sqlEntityServer) Watch(r *entity.EntityWatchRequest, w entity.EntitySto return fmt.Errorf("missing user in context") } - // collect and send any historical events - err = s.watchInit(r, w) + r, err := w.Recv() if err != nil { return err } + // collect and send any historical events + if r.SendInitialEvents { + r.Since, err = s.watchInit(r, w) + if err != nil { + s.log.Error("watch init error", "err", err) + return err + } + } else if r.Since == 0 { + r.Since = s.snowflake.Generate().Int64() + } + // subscribe to new events err = s.watch(r, w) if err != nil { @@ -1293,31 +1399,25 @@ func (s *sqlEntityServer) Watch(r *entity.EntityWatchRequest, w entity.EntitySto } // watchInit is a helper function to send the initial set of entities to the client -func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.EntityStore_WatchServer) error { +func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.EntityStore_WatchServer) (int64, error) { + lastRv := r.Since + fields := s.getReadFields(r) entityQuery := selectQuery{ dialect: s.dialect, - fields: fields, - from: "entity", // the table - args: []any{}, - limit: 100, // r.Limit, - oneExtra: true, // request one more than the limit (and show next token if it exists) + from: entityTable, // the table + limit: 1000, // r.Limit, + oneExtra: true, // request one more than the limit (and show next token if it exists) } - // if we got an initial resource version, start from that location in the history - fromZero := true - if r.Since > 0 { - entityQuery.from = "entity_history" - entityQuery.addWhere("resource_version > ?", r.Since) - fromZero = false - } + entityQuery.AddFields(fields...) // TODO fix this // entityQuery.addWhere("namespace", user.OrgID) if len(r.Resource) > 0 { - entityQuery.addWhereIn("resource", r.Resource) + entityQuery.AddWhereIn("resource", ToAnyList(r.Resource)) } if len(r.Key) > 0 { @@ -1326,7 +1426,7 @@ func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.Entit for _, k := range r.Key { key, err := entity.ParseKey(k) if err != nil { - return err + return lastRv, err } args = append(args, key.Group, key.Resource) @@ -1344,18 +1444,18 @@ func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.Entit where = append(where, whereclause) } - entityQuery.addWhere("("+strings.Join(where, " OR ")+")", args...) + entityQuery.AddWhere("("+strings.Join(where, " OR ")+")", args...) } // Folder guid if r.Folder != "" { - entityQuery.addWhere("folder", r.Folder) + entityQuery.AddWhere("folder", r.Folder) } if len(r.Labels) > 0 { - if r.Since > 0 { + if entityQuery.from != entityTable { for labelKey, labelValue := range r.Labels { - entityQuery.addWhereJsonContainsKV("labels", labelKey, labelValue) + entityQuery.AddWhereJsonContainsKV("labels", labelKey, labelValue) } } else { var args []any @@ -1371,17 +1471,17 @@ func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.Entit " HAVING COUNT(label) = ?" args = append(args, len(r.Labels)) - entityQuery.addWhereInSubquery("guid", query, args) + entityQuery.AddWhereInSubquery("guid", query, args) } } - entityQuery.addOrderBy("resource_version", Ascending) + entityQuery.AddOrderBy("resource_version", Ascending) var err error for hasmore := true; hasmore; { err = func() error { - query, args := entityQuery.toQuery() + query, args := entityQuery.ToQuery() s.log.Debug("watch init", "query", query, "args", args) @@ -1400,24 +1500,23 @@ func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.Entit return nil } - result, err := rowToEntity(rows, r) + result, err := readEntity(rows, r) if err != nil { return err } - if result.ResourceVersion > r.Since { - r.Since = result.ResourceVersion + if result.ResourceVersion > lastRv { + lastRv = result.ResourceVersion } - if fromZero { - result.Action = entity.Entity_CREATED + resp := &entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: result, } s.log.Debug("sending init event", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) - err = w.Send(&entity.EntityWatchResponse{ - Timestamp: time.Now().UnixMilli(), - Entity: result, - }) + + err = w.Send(resp) if err != nil { return err } @@ -1427,29 +1526,52 @@ func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.Entit return nil }() if err != nil { - return err + return lastRv, err } } - return nil + // send a bookmark event + if r.AllowWatchBookmarks { + resp := &entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: &entity.Entity{ + Action: entity.Entity_BOOKMARK, + ResourceVersion: lastRv, + }, + } + err = w.Send(resp) + if err != nil { + return lastRv, err + } + } + + return lastRv, nil } -func (s *sqlEntityServer) poller(stream chan *entity.Entity) { +func (s *sqlEntityServer) poller(stream chan *entity.EntityWatchResponse) { var err error since := s.snowflake.Generate().Int64() - t := time.NewTicker(1 * time.Second) + interval := 1 * time.Second + + t := time.NewTicker(interval) defer t.Stop() - for range t.C { - since, err = s.poll(since, stream) - if err != nil { - s.log.Error("watch error", "err", err) + for { + select { + case <-s.ctx.Done(): + return + case <-t.C: + since, err = s.poll(since, stream) + if err != nil { + s.log.Error("watch error", "err", err) + } + t.Reset(interval) } } } -func (s *sqlEntityServer) poll(since int64, out chan *entity.Entity) (int64, error) { +func (s *sqlEntityServer) poll(since int64, out chan *entity.EntityWatchResponse) (int64, error) { rr := &entity.ReadEntityRequest{ WithBody: true, WithStatus: true, @@ -1461,18 +1583,17 @@ func (s *sqlEntityServer) poll(since int64, out chan *entity.Entity) (int64, err err := func() error { entityQuery := selectQuery{ dialect: s.dialect, - fields: fields, - from: "entity_history", // the table - args: []any{}, - limit: 100, // r.Limit, + from: entityHistoryTable, // the table + limit: 100, // r.Limit, // offset: 0, oneExtra: true, // request one more than the limit (and show next token if it exists) orderBy: []string{"resource_version"}, } - entityQuery.addWhere("resource_version > ?", since) + entityQuery.AddFields(fields...) + entityQuery.AddWhere("resource_version > ?", since) - query, args := entityQuery.toQuery() + query, args := entityQuery.ToQuery() rows, err := s.sess.Query(s.ctx, query, args...) if err != nil { @@ -1482,21 +1603,50 @@ func (s *sqlEntityServer) poll(since int64, out chan *entity.Entity) (int64, err found := int64(0) for rows.Next() { + // check if the context is done + if s.ctx.Err() != nil { + hasmore = false + return nil + } + found++ if found > entityQuery.limit { return nil } - result, err := rowToEntity(rows, rr) + updated, err := readEntity(rows, rr) if err != nil { return err } - if result.ResourceVersion > since { - since = result.ResourceVersion + if updated.ResourceVersion > since { + since = updated.ResourceVersion } - s.log.Debug("sending poll result", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) + result := &entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: updated, + } + + if updated.Action == entity.Entity_UPDATED || updated.Action == entity.Entity_DELETED { + rr := &entity.EntityHistoryRequest{ + Guid: updated.Guid, + Before: updated.ResourceVersion, + Limit: 1, + Sort: []string{"resource_version_desc"}, + WithBody: rr.WithBody, + WithStatus: rr.WithStatus, + } + history, err := s.history(s.ctx, rr) + if err != nil { + s.log.Error("error reading previous entity", "guid", updated.Guid, "err", err) + return err + } + + result.Previous = history.Versions[0] + } + + s.log.Debug("sending poll result", "guid", updated.Guid, "action", updated.Action, "rv", updated.ResourceVersion) out <- result } @@ -1512,8 +1662,7 @@ func (s *sqlEntityServer) poll(since int64, out chan *entity.Entity) (int64, err } func watchMatches(r *entity.EntityWatchRequest, result *entity.Entity) bool { - // Resource version too old - if result.ResourceVersion <= r.Since { + if result == nil { return false } @@ -1576,42 +1725,107 @@ func (s *sqlEntityServer) watch(r *entity.EntityWatchRequest, w entity.EntitySto return err } + stop := make(chan struct{}) + since := r.Since + + go func() { + for { + r, err := w.Recv() + if errors.Is(err, io.EOF) { + s.log.Debug("watch client closed stream") + stop <- struct{}{} + return + } + if err != nil { + s.log.Error("error receiving message", "err", err) + stop <- struct{}{} + return + } + if r.Action == entity.EntityWatchRequest_STOP { + s.log.Debug("watch stop requested") + stop <- struct{}{} + return + } + // handle any other message types + s.log.Debug("watch received unexpected message", "action", r.Action) + } + }() + for { select { - // user closed the connection + // stop signal + case <-stop: + s.log.Debug("watch stopped") + return nil + // context canceled case <-w.Context().Done(): + s.log.Debug("watch context done") return nil // got a raw result from the broadcaster - case result := <-evts: - // result doesn't match our watch params, skip it - if !watchMatches(r, result) { - s.log.Debug("watch result not matched", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) + case result, ok := <-evts: + if !ok { + s.log.Debug("watch events closed") + return nil + } + + // Invalid result or resource version too old + if result == nil || result.Entity == nil || result.Entity.ResourceVersion <= since { break } - // remove the body and status if not requested - if !r.WithBody { - result.Body = nil - } - if !r.WithStatus { - result.Status = nil - } + since = result.Entity.ResourceVersion - // update r.Since value so we don't send earlier results again - r.Since = result.ResourceVersion - - s.log.Debug("sending watch result", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) - err = w.Send(&entity.EntityWatchResponse{ - Timestamp: time.Now().UnixMilli(), - Entity: result, - }) + resp, err := s.watchEvent(r, result) if err != nil { + break + } + if resp == nil { + break + } + + err = w.Send(resp) + if err != nil { + s.log.Error("error sending watch event", "err", err) return err } } } } +func (s *sqlEntityServer) watchEvent(r *entity.EntityWatchRequest, result *entity.EntityWatchResponse) (*entity.EntityWatchResponse, error) { + // if this is an update or a delete, check the current or previous version matches + if result.Previous != nil { + // if neither the previous nor the current result match our watch params, skip it + if !watchMatches(r, result.Entity) && !watchMatches(r, result.Previous) { + s.log.Debug("watch result not matched", "guid", result.Entity.Guid, "action", result.Entity.Action, "rv", result.Entity.ResourceVersion) + return nil, nil + } + } else { + // if result doesn't match our watch params, skip it + if !watchMatches(r, result.Entity) { + s.log.Debug("watch result not matched", "guid", result.Entity.Guid, "action", result.Entity.Action, "rv", result.Entity.ResourceVersion) + return nil, nil + } + } + + // remove the body and status if not requested + if !r.WithBody { + result.Entity.Body = nil + if result.Previous != nil { + result.Previous.Body = nil + } + } + if !r.WithStatus { + result.Entity.Status = nil + if result.Previous != nil { + result.Previous.Status = nil + } + } + + s.log.Debug("sending watch result", "guid", result.Entity.Guid, "action", result.Entity.Action, "rv", result.Entity.ResourceVersion) + return result, nil +} + func (s *sqlEntityServer) FindReferences(ctx context.Context, r *entity.ReferenceRequest) (*entity.EntityListResponse, error) { if err := s.Init(); err != nil { return nil, err diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 9ac5da995a2..0b280eed3d3 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -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"` } diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index 53e46308857..95b5ec96e1c 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -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) diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go index fef80c0111e..b3c5a04aae6 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go @@ -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"}, diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go index 2dec40dc837..f5ccc6787f4 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go @@ -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", diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat1.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat1.golden.jsonc new file mode 100644 index 00000000000..20012de0fee --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat1.golden.jsonc @@ -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" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat1.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat1.sql new file mode 100644 index 00000000000..46e4049ca83 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat1.sql @@ -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'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat2.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat2.golden.jsonc new file mode 100644 index 00000000000..82b7f92c359 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat2.golden.jsonc @@ -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 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat2.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat2.sql new file mode 100644 index 00000000000..da909ae1018 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/multi_stat2.sql @@ -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') \ No newline at end of file diff --git a/pkg/util/strings.go b/pkg/util/strings.go index 2b469833e5c..44c5dcd4eb0 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -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. diff --git a/pkg/util/strings_test.go b/pkg/util/strings_test.go index 876cb705974..d3524f8539e 100644 --- a/pkg/util/strings_test.go +++ b/pkg/util/strings_test.go @@ -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)) diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index 325867253b8..7ef6152ab2b 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -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) { diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json b/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json index 5bfacc2cdcc..bc99b0531b9 100644 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json @@ -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 + } + ] } ] } diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap index 028e88fcac9..8739185052a 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap @@ -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": [ { diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index ea4fc89ca90..2f94d12cef6 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -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], +} +`; diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index 50f4fdbd03d..a4188d51789 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -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 }) => ( @@ -32,10 +44,42 @@ describe('useContactPoints', () => { ), }); - 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 }) => ( + + + {children} + + + ), + } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current).toMatchSnapshot(); + }); + }); + }); }); + +const receivers = JSON.parse(JSON.stringify(alertmanagerMock)).alertmanager_config.receivers; diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index a07e3cc03dc..6af5766d473 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -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, }; }), }; diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts index ba3f923ea1a..dc803813b4d 100644 --- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts @@ -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; diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 98bef2dccec..7aabf1c3184 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -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[]) => { diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx index f6c44a0f036..c53917e0c60 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx @@ -139,7 +139,7 @@ function getStyles(theme: GrafanaTheme2) { body: css({ label: 'body', flex: 1, - marginBottom: theme.spacing(3), + overflowY: 'auto', }), }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx index ea1a3647908..4a0ae48f83c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx @@ -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(''); + }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index 60796e0c812..753a504de5c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -170,7 +170,7 @@ export class DashboardGridItem extends SceneObjectBase i } public getClassName() { - return 'panel-repeater-grid-item'; + return this.state.variableName ? 'panel-repeater-grid-item' : ''; } public isRepeated() { diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index ce001bb53bb..7037971a2e6 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -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(); diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 3e7d396372b..ebf62af5d59 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -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: () => ( - { - const id = dashboard.onCreateNewPanel(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' }); - locationService.partial({ editPanel: id }); - }} - /> - ), - }); - - toolbarActions.push({ - group: 'icon-actions', - condition: isEditingAndShowingDashboard, - render: () => ( - { - dashboard.onCreateLibPanelWidget(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); - }} - /> - ), - }); - - toolbarActions.push({ - group: 'icon-actions', - condition: isEditingAndShowingDashboard, - render: () => ( - { - dashboard.onCreateNewRow(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' }); - }} - /> - ), - }); - - toolbarActions.push({ - group: 'icon-actions', - condition: isEditingAndShowingDashboard, - render: () => ( - { - 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: () => ( ( - + + ), + }); + + 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: () => ( + { + setIsAddPanelMenuOpen(isOpen); + DashboardInteractions.toolbarAddClick(); + }} + overlay={() => ( + + { + const id = dashboard.onCreateNewPanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' }); + locationService.partial({ editPanel: id }); + }} + /> + { + dashboard.onCreateLibPanelWidget(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); + }} + /> + { + dashboard.onCreateNewRow(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' }); + }} + /> + { + dashboard.pastePanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' }); + }} + /> + + )} + placement="bottom" + offset={[0, 6]} + > + + ), }); @@ -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, diff --git a/public/app/features/plugins/admin/helpers.test.ts b/public/app/features/plugins/admin/helpers.test.ts index d09682882ab..28771ea77bc 100644 --- a/public/app/features/plugins/admin/helpers.test.ts +++ b/public/app/features/plugins/admin/helpers.test.ts @@ -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()', () => { diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index a6e7b5dff09..a3ad9c38a8d 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -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); } diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx index 75593018623..4572bf47f6a 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx @@ -20,10 +20,11 @@ export class AutoVizPanel extends SceneObjectBase { } 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 { 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 }); }; diff --git a/public/app/features/trails/MetricGraphScene.tsx b/public/app/features/trails/MetricGraphScene.tsx index 7cb03d369ac..78ec601f135 100644 --- a/public/app/features/trails/MetricGraphScene.tsx +++ b/public/app/features/trails/MetricGraphScene.tsx @@ -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',