Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
291 files changed
+8728
-3226
No files matched your search
+1
-272
@@ -794,50 +794,12 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not re-export imported variable (\`notifyApp\`)", "2"],
|
||||
[0, 0, 0, "Do not re-export imported variable (\`hideAppNotification\`)", "3"]
|
||||
],
|
||||
"public/app/core/components/AccessControl/PermissionListItem.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/AccessControl/index.ts:5381": [
|
||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
||||
],
|
||||
"public/app/core/components/AppChrome/AppChrome.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/AppChrome/TopBar/SignInLink.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/AppNotifications/AppNotificationItem.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/DynamicImports/SafeDynamicImport.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/core/components/EmptyListCTA/EmptyListCTA.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/FolderFilter/FolderFilter.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/ForgottenPassword/ChangePassword.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/core/components/ForgottenPassword/ForgottenPassword.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/core/components/FormPrompt/FormPrompt.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/core/components/GraphNG/GraphNG.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
@@ -849,79 +811,14 @@ exports[`better eslint`] = {
|
||||
"public/app/core/components/LocalStorageValueProvider/index.tsx:5381": [
|
||||
[0, 0, 0, "Do not re-export imported variable (\`./LocalStorageValueProvider\`)", "0"]
|
||||
],
|
||||
"public/app/core/components/Login/LoginLayout.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/Login/LoginServiceButtons.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/NestedFolderPicker/NestedFolderList.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/PageNotFound/EntityNotFound.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/PluginHelp/PluginHelp.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/core/components/RolePicker/BuiltinRoleSelector.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/RolePicker/RolePickerInput.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/RolePicker/RolePickerMenu.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/core/components/RolePicker/RolePickerSubMenu.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/Select/OldFolderPicker.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/core/components/Signup/SignupPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/core/components/Signup/VerifyEmail.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/core/components/TagFilter/TagFilter.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/core/components/TimeSeries/utils.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/core/components/Upgrade/UpgradeBox.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/core/components/help/HelpModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/core/config.ts:5381": [
|
||||
[0, 0, 0, "Do not re-export imported variable (\`config\`)", "0"],
|
||||
[0, 0, 0, "Do not re-export imported variable (\`Settings\`)", "1"]
|
||||
@@ -936,15 +833,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not re-export imported variable (\`TimeSeries\`)", "6"],
|
||||
[0, 0, 0, "Do not re-export imported variable (\`updateLegendValues\`)", "7"]
|
||||
],
|
||||
"public/app/core/navigation/GrafanaRouteError.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/core/navigation/RouterDebugger.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/core/navigation/types.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
@@ -1035,161 +923,18 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/actions/ActionEditorModalContent.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/actions/ActionsInlineEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/actions/ParamsEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/admin/AdminEditOrgPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/admin/AdminFeatureTogglesPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/admin/AdminFeatureTogglesTable.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/admin/AdminListOrgsPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/AdminOrgsTable.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/admin/AdminSettings.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/ServerStats.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/features/admin/UpgradePage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
|
||||
],
|
||||
"public/app/features/admin/UserCreatePage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/UserLdapSyncInfo.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
|
||||
],
|
||||
"public/app/features/admin/UserListAdminPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/UserOrgs.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
|
||||
],
|
||||
"public/app/features/admin/UserPermissions.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/admin/UserProfile.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/admin/UserSessions.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"]
|
||||
],
|
||||
"public/app/features/admin/Users/AnonUsersTable.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/Users/OrgUsersTable.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/admin/Users/UsersTable.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/admin/ldap/LdapConnectionStatus.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/admin/ldap/LdapPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/admin/ldap/LdapSyncInfo.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/ldap/LdapUserGroups.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/ldap/LdapUserInfo.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/admin/ldap/LdapUserPermissions.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/routes.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
@@ -6011,22 +5756,6 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/features/admin/partials/edit_org.html:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/features/admin/partials/styleguide.html:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/features/annotations/partials/event_editor.html:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
|
||||
+309
-80
@@ -184,12 +184,27 @@ steps:
|
||||
- yarn-install
|
||||
image: node:20.9.0-alpine
|
||||
name: betterer-frontend
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- is_fork=$(curl --retry 5 "https://$GITHUB_TOKEN@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- is_fork=$(curl --retry 5 "https://$${GITHUB_TOKEN}@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
| jq .head.repo.fork)
|
||||
- if [ "$is_fork" != false ]; then return 1; fi
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -199,12 +214,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- yarn run ci:test-frontend
|
||||
depends_on:
|
||||
@@ -230,6 +247,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -248,12 +267,27 @@ platform:
|
||||
os: linux
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- is_fork=$(curl --retry 5 "https://$GITHUB_TOKEN@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- is_fork=$(curl --retry 5 "https://$${GITHUB_TOKEN}@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
| jq .head.repo.fork)
|
||||
- if [ "$is_fork" != false ]; then return 1; fi
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -263,12 +297,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- echo $DRONE_RUNNER_NAME
|
||||
image: alpine:3.20.3
|
||||
@@ -318,6 +354,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -336,12 +374,27 @@ platform:
|
||||
os: linux
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- is_fork=$(curl --retry 5 "https://$GITHUB_TOKEN@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- is_fork=$(curl --retry 5 "https://$${GITHUB_TOKEN}@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
| jq .head.repo.fork)
|
||||
- if [ "$is_fork" != false ]; then return 1; fi
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -351,12 +404,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- echo $DRONE_RUNNER_NAME
|
||||
image: alpine:3.20.3
|
||||
@@ -426,6 +481,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -455,12 +512,27 @@ steps:
|
||||
CGO_ENABLED: 0
|
||||
image: golang:1.23.1-alpine
|
||||
name: compile-build-cmd
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- is_fork=$(curl --retry 5 "https://$GITHUB_TOKEN@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- is_fork=$(curl --retry 5 "https://$${GITHUB_TOKEN}@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
| jq .head.repo.fork)
|
||||
- if [ "$is_fork" != false ]; then return 1; fi
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -470,12 +542,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update make
|
||||
- make gen-go
|
||||
@@ -515,6 +589,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -533,6 +609,20 @@ platform:
|
||||
os: linux
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- echo $DRONE_RUNNER_NAME
|
||||
image: alpine:3.20.3
|
||||
@@ -736,10 +826,12 @@ steps:
|
||||
image: cypress/included:13.10.0
|
||||
name: end-to-end-tests-old-arch/various-suite
|
||||
- commands:
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /
|
||||
- ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH}
|
||||
depends_on:
|
||||
- grafana-server
|
||||
- github-app-generate-token
|
||||
environment:
|
||||
AZURE_SP_APP_ID:
|
||||
from_secret: azure_sp_app_id
|
||||
@@ -748,11 +840,12 @@ steps:
|
||||
AZURE_TENANT:
|
||||
from_secret: azure_tenant
|
||||
CYPRESS_CI: "true"
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
HOST: grafana-server
|
||||
image: us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e-13.10.0:1.0.0
|
||||
name: end-to-end-tests-cloud-plugins-suite-azure
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
when:
|
||||
paths:
|
||||
include:
|
||||
@@ -795,6 +888,7 @@ steps:
|
||||
- success
|
||||
- failure
|
||||
- commands:
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- if [ ! -d ./playwright-report/trace ]; then echo 'all tests passed'; exit 0; fi
|
||||
- export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html
|
||||
- 'curl -L -X POST https://api.github.com/repos/grafana/grafana/issues/${DRONE_PULL_REQUEST}/comments
|
||||
@@ -806,17 +900,19 @@ steps:
|
||||
\"}"'
|
||||
depends_on:
|
||||
- playwright-e2e-report-upload
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: playwright-e2e-report-post-link
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
- commands:
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- if [ -z `find ./e2e -type f -name *spec.ts.mp4` ]; then echo 'missing videos';
|
||||
false; fi
|
||||
- apt-get update
|
||||
@@ -836,15 +932,17 @@ steps:
|
||||
- end-to-end-tests-panels-suite
|
||||
- end-to-end-tests-smoke-tests-suite
|
||||
- end-to-end-tests-various-suite
|
||||
- github-app-generate-token
|
||||
environment:
|
||||
E2E_TEST_ARTIFACTS_BUCKET: releng-pipeline-artifacts-dev
|
||||
GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY:
|
||||
from_secret: gcp_upload_artifacts_key
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
failure: ignore
|
||||
image: google/cloud-sdk:431.0.0
|
||||
name: e2e-tests-artifacts-upload
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
@@ -889,6 +987,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -953,12 +1053,27 @@ services:
|
||||
image: memcached:1.6.9-alpine
|
||||
name: memcached
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- is_fork=$(curl --retry 5 "https://$GITHUB_TOKEN@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- is_fork=$(curl --retry 5 "https://$${GITHUB_TOKEN}@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
| jq .head.repo.fork)
|
||||
- if [ "$is_fork" != false ]; then return 1; fi
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -968,12 +1083,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.1.1/grabpl
|
||||
@@ -1158,6 +1275,8 @@ volumes:
|
||||
- name: mysql80
|
||||
temp:
|
||||
medium: memory
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -1287,12 +1406,27 @@ platform:
|
||||
os: linux
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- is_fork=$(curl --retry 5 "https://$GITHUB_TOKEN@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- is_fork=$(curl --retry 5 "https://$${GITHUB_TOKEN}@api.github.com/repos/grafana/grafana/pulls/$DRONE_PULL_REQUEST"
|
||||
| jq .head.repo.fork)
|
||||
- if [ "$is_fork" != false ]; then return 1; fi
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -1302,12 +1436,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update git make
|
||||
- make swagger-clean && make openapi3-gen
|
||||
@@ -1318,9 +1454,6 @@ steps:
|
||||
&& return 1; fi
|
||||
depends_on:
|
||||
- clone-enterprise
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
image: golang:1.23.1-alpine
|
||||
name: swagger-gen
|
||||
trigger:
|
||||
@@ -1331,6 +1464,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -1395,9 +1530,24 @@ services:
|
||||
image: memcached:1.6.9-alpine
|
||||
name: memcached
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- apk add --update curl jq bash
|
||||
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- git clone "https://x-access-token:$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
|
||||
../grafana-enterprise
|
||||
- cd ../grafana-enterprise
|
||||
- if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}";
|
||||
@@ -1407,12 +1557,14 @@ steps:
|
||||
- ln -s src grafana
|
||||
- cd ./grafana-enterprise
|
||||
- ./build.sh
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: alpine/git:2.40.1
|
||||
name: clone-enterprise
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd
|
||||
depends_on: []
|
||||
@@ -1509,6 +1661,8 @@ volumes:
|
||||
- name: mysql80
|
||||
temp:
|
||||
medium: memory
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -1933,6 +2087,20 @@ platform:
|
||||
os: linux
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- echo $DRONE_RUNNER_NAME
|
||||
image: alpine:3.20.3
|
||||
@@ -2139,10 +2307,12 @@ steps:
|
||||
image: cypress/included:13.10.0
|
||||
name: end-to-end-tests-old-arch/various-suite
|
||||
- commands:
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /
|
||||
- ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH}
|
||||
depends_on:
|
||||
- grafana-server
|
||||
- github-app-generate-token
|
||||
environment:
|
||||
AZURE_SP_APP_ID:
|
||||
from_secret: azure_sp_app_id
|
||||
@@ -2151,11 +2321,12 @@ steps:
|
||||
AZURE_TENANT:
|
||||
from_secret: azure_tenant
|
||||
CYPRESS_CI: "true"
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
HOST: grafana-server
|
||||
image: us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e-13.10.0:1.0.0
|
||||
name: end-to-end-tests-cloud-plugins-suite-azure
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
when:
|
||||
paths:
|
||||
include:
|
||||
@@ -2198,6 +2369,7 @@ steps:
|
||||
- success
|
||||
- failure
|
||||
- commands:
|
||||
- GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- if [ ! -d ./playwright-report/trace ]; then echo 'all tests passed'; exit 0; fi
|
||||
- export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html
|
||||
- 'curl -L -X POST https://api.github.com/repos/grafana/grafana/issues/${DRONE_PULL_REQUEST}/comments
|
||||
@@ -2209,17 +2381,19 @@ steps:
|
||||
\"}"'
|
||||
depends_on:
|
||||
- playwright-e2e-report-upload
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
- github-app-generate-token
|
||||
failure: ignore
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: playwright-e2e-report-post-link
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
- commands:
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- if [ -z `find ./e2e -type f -name *spec.ts.mp4` ]; then echo 'missing videos';
|
||||
false; fi
|
||||
- apt-get update
|
||||
@@ -2239,15 +2413,17 @@ steps:
|
||||
- end-to-end-tests-panels-suite
|
||||
- end-to-end-tests-smoke-tests-suite
|
||||
- end-to-end-tests-various-suite
|
||||
- github-app-generate-token
|
||||
environment:
|
||||
E2E_TEST_ARTIFACTS_BUCKET: releng-pipeline-artifacts-dev
|
||||
GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY:
|
||||
from_secret: gcp_upload_artifacts_key
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
failure: ignore
|
||||
image: google/cloud-sdk:431.0.0
|
||||
name: e2e-tests-artifacts-upload
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
@@ -2404,6 +2580,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -3534,6 +3712,7 @@ platform:
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- apk add perl
|
||||
- v_target=`echo $${TAG} | perl -pe 's/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/v\1.\2.x/'`
|
||||
- curl -L $${GH_CLI_URL} | tar -xz --strip-components=1 -C /usr
|
||||
@@ -3542,10 +3721,11 @@ steps:
|
||||
depends_on: []
|
||||
environment:
|
||||
GH_CLI_URL: https://github.com/cli/cli/releases/download/v2.50.0/gh_2.50.0_linux_amd64.tar.gz
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: create-release-pr
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
@@ -3555,6 +3735,8 @@ volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
- name: github-app
|
||||
temp: {}
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
@@ -3604,6 +3786,21 @@ steps:
|
||||
image: grafana/grafana-ci-deploy:1.3.3
|
||||
name: publish-storybook
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- apk add perl
|
||||
- v_target=`echo $${TAG} | perl -pe 's/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/v\1.\2.x/'`
|
||||
- curl -L $${GH_CLI_URL} | tar -xz --strip-components=1 -C /usr
|
||||
@@ -3611,12 +3808,14 @@ steps:
|
||||
-f latest=$${LATEST} --repo=grafana/grafana release-pr.yml
|
||||
depends_on:
|
||||
- publish-artifacts
|
||||
- github-app-generate-token
|
||||
environment:
|
||||
GH_CLI_URL: https://github.com/cli/cli/releases/download/v2.50.0/gh_2.50.0_linux_amd64.tar.gz
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: create-release-pr
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
@@ -4118,6 +4317,7 @@ services: []
|
||||
steps:
|
||||
- commands:
|
||||
- export GRAFANA_DIR=$$(pwd)
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /src && ./scripts/drone_build_main.sh
|
||||
environment:
|
||||
_EXPERIMENTAL_DAGGER_CLOUD_TOKEN:
|
||||
@@ -4137,8 +4337,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GO_VERSION: 1.23.1
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
@@ -4157,6 +4355,8 @@ steps:
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
trigger:
|
||||
branch: main
|
||||
event:
|
||||
@@ -4192,6 +4392,7 @@ services: []
|
||||
steps:
|
||||
- commands:
|
||||
- export GRAFANA_DIR=$$(pwd)
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /src && ./scripts/drone_build_tag_grafana.sh
|
||||
environment:
|
||||
_EXPERIMENTAL_DAGGER_CLOUD_TOKEN:
|
||||
@@ -4211,8 +4412,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GO_VERSION: 1.23.1
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
@@ -4231,6 +4430,8 @@ steps:
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
trigger:
|
||||
event:
|
||||
exclude:
|
||||
@@ -4353,6 +4554,7 @@ services: []
|
||||
steps:
|
||||
- commands:
|
||||
- export GRAFANA_DIR=$$(pwd)
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /src && ./scripts/drone_build_tag_grafana.sh
|
||||
environment:
|
||||
_EXPERIMENTAL_DAGGER_CLOUD_TOKEN:
|
||||
@@ -4372,8 +4574,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GO_VERSION: 1.23.1
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
@@ -4392,6 +4592,8 @@ steps:
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
trigger:
|
||||
ref:
|
||||
- refs/heads/v[0-9]*
|
||||
@@ -4589,6 +4791,7 @@ services: []
|
||||
steps:
|
||||
- commands:
|
||||
- export GRAFANA_DIR=$$(pwd)
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /src && ./scripts/drone_build_nightly_grafana.sh
|
||||
environment:
|
||||
_EXPERIMENTAL_DAGGER_CLOUD_TOKEN:
|
||||
@@ -4608,8 +4811,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GO_VERSION: 1.23.1
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
@@ -4628,6 +4829,8 @@ steps:
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- mkdir -p $${DESTINATION}/$${DRONE_BUILD_EVENT}
|
||||
- printenv GCP_KEY_BASE64 | base64 -d > /tmp/key.json
|
||||
@@ -4652,8 +4855,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
GPG_PRIVATE_KEY:
|
||||
@@ -4718,8 +4919,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
GPG_PRIVATE_KEY:
|
||||
@@ -4734,6 +4933,7 @@ steps:
|
||||
name: rgm-copy
|
||||
- commands:
|
||||
- export GRAFANA_DIR=$$(pwd)
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- cd /src && ./scripts/drone_publish_nightly_grafana.sh
|
||||
depends_on:
|
||||
- rgm-copy
|
||||
@@ -4755,8 +4955,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GO_VERSION: 1.23.1
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
@@ -4775,6 +4973,8 @@ steps:
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- depends_on:
|
||||
- rgm-publish
|
||||
image: us.gcr.io/kubernetes-dev/package-publish:latest
|
||||
@@ -4844,9 +5044,26 @@ platform:
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
environment:
|
||||
GITHUB_APP_ID:
|
||||
from_secret: github-app-app-id
|
||||
GITHUB_APP_INSTALLATION_ID:
|
||||
from_secret: github-app-installation-id
|
||||
GITHUB_APP_PRIVATE_KEY:
|
||||
from_secret: github-app-private-key
|
||||
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
name: github-app-generate-token
|
||||
volumes:
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- export GITHUB_TOKEN=$(cat /github-app/token)
|
||||
- 'dagger run --silent /src/grafana-build artifacts -a $${ARTIFACTS} --grafana-ref=$${GRAFANA_REF}
|
||||
--enterprise-ref=$${ENTERPRISE_REF} --grafana-repo=$${GRAFANA_REPO} --version=$${VERSION} '
|
||||
- --go-version=1.23.1
|
||||
depends_on:
|
||||
- github-app-generate-token
|
||||
environment:
|
||||
_EXPERIMENTAL_DAGGER_CLOUD_TOKEN:
|
||||
from_secret: dagger_token
|
||||
@@ -4865,8 +5082,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GO_VERSION: 1.23.1
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
@@ -4885,6 +5100,8 @@ steps:
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
- name: github-app
|
||||
path: /github-app
|
||||
- commands:
|
||||
- printenv GCP_KEY_BASE64 | base64 -d > /tmp/key.json
|
||||
- gcloud auth activate-service-account --key-file=/tmp/key.json
|
||||
@@ -4906,8 +5123,6 @@ steps:
|
||||
from_secret: grafana_api_key
|
||||
GCP_KEY_BASE64:
|
||||
from_secret: gcp_key_base64
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
GPG_PASSPHRASE:
|
||||
from_secret: packages_gpg_passphrase
|
||||
GPG_PRIVATE_KEY:
|
||||
@@ -5471,6 +5686,7 @@ steps:
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM koalaman/shellcheck:stable
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM rockylinux:9
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM scottyhardy/docker-wine:stable-9.0
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
depends_on:
|
||||
- authenticate-gcr
|
||||
image: aquasec/trivy:0.21.0
|
||||
@@ -5509,6 +5725,7 @@ steps:
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL koalaman/shellcheck:stable
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL rockylinux:9
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL scottyhardy/docker-wine:stable-9.0
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
|
||||
depends_on:
|
||||
- authenticate-gcr
|
||||
environment:
|
||||
@@ -5541,6 +5758,24 @@ volumes:
|
||||
- name: config
|
||||
temp: {}
|
||||
---
|
||||
get:
|
||||
name: app-id
|
||||
path: ci/data/repo/grafana/grafana/github-app
|
||||
kind: secret
|
||||
name: github-app-app-id
|
||||
---
|
||||
get:
|
||||
name: app-installation-id
|
||||
path: ci/data/repo/grafana/grafana/github-app
|
||||
kind: secret
|
||||
name: github-app-installation-id
|
||||
---
|
||||
get:
|
||||
name: private-key
|
||||
path: ci/data/repo/grafana/grafana/github-app
|
||||
kind: secret
|
||||
name: github-app-private-key
|
||||
---
|
||||
get:
|
||||
name: credentials.json
|
||||
path: infra/data/ci/grafana-release-eng/grafanauploads
|
||||
@@ -5571,12 +5806,6 @@ get:
|
||||
kind: secret
|
||||
name: gar
|
||||
---
|
||||
get:
|
||||
name: pat
|
||||
path: ci/data/repo/grafana/grafana/grafanabot
|
||||
kind: secret
|
||||
name: github_token
|
||||
---
|
||||
get:
|
||||
name: machine-user-token
|
||||
path: infra/data/ci/drone
|
||||
@@ -5740,6 +5969,6 @@ kind: secret
|
||||
name: gcr_credentials
|
||||
---
|
||||
kind: signature
|
||||
hmac: bb28bcd274c9d2ce724db12659ed6e3dcc461f8a07ae0eb9385a64ca5daad4de
|
||||
hmac: b164dd562488c482f2e670688fca02ae20dcd2e7841ec80c95838472842b4b5e
|
||||
|
||||
...
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
/docs/sources/alerting/ @brendamuir
|
||||
/docs/sources/dashboards/ @imatwawana
|
||||
/docs/sources/datasources/ @lwandz13
|
||||
/docs/sources/panels-visualizations/ @imatwawana
|
||||
/docs/sources/release-notes/ @irenerl24 @GrafanaWriter
|
||||
/docs/sources/upgrade-guide/ @imatwawana
|
||||
@@ -664,6 +665,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/pkg/services/caching/ @grafana/grafana-operator-experience-squad
|
||||
/pkg/services/cloudmigration/ @grafana/grafana-operator-experience-squad
|
||||
/pkg/services/gcom/ @grafana/grafana-operator-experience-squad
|
||||
/pkg/services/authapi/ @grafana/grafana-operator-experience-squad
|
||||
|
||||
# Feature toggles
|
||||
/pkg/services/featuremgmt/ @grafana/grafana-backend-services-squad
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG BASE_IMAGE=alpine:3.19.1
|
||||
ARG BASE_IMAGE=alpine:3.20
|
||||
ARG JS_IMAGE=node:20-alpine
|
||||
ARG JS_PLATFORM=linux/amd64
|
||||
ARG GO_IMAGE=golang:1.23.1-alpine
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -72,9 +72,13 @@ refs:
|
||||
data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
- destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/
|
||||
compatible-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/GRAFANA_VERSION>/alerting/fundamentals/alert-rules/#supported-data-sources
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/#supported-data-sources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/#supported-data-sources
|
||||
---
|
||||
|
||||
# Configure Grafana-managed alert rules
|
||||
@@ -100,7 +104,7 @@ To make a backup of your configuration and to be able to restore deleted alertin
|
||||
If you are using Grafana OSS:
|
||||
|
||||
1. Configure your [data sources](ref:data-sources).
|
||||
1. Check which [data sources](ref:compatible-data-sources) are compatible with and supported by Grafana Alerting.
|
||||
2. Check which [data sources](ref:compatible-data-sources) are compatible with and supported by Grafana Alerting.
|
||||
|
||||
If you are using Grafana OSS, Enterprise, or Cloud:
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ You can save a report as a draft at any point during the report creation or upda
|
||||
|
||||
### Choose template variables
|
||||
|
||||
> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) version 7.5 and later behind the `reportVariables` feature flag, Grafana Enterprise version 8.0 and later without a feature flag, and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
|
||||
You can configure report-specific template variables for the dashboard on the report page. The variables that you select will override the variables from the dashboard, and they are used when rendering a PDF file of the report. For detailed information about using template variables, refer to the [Templates and variables](ref:templates-and-variables) section.
|
||||
|
||||
@@ -234,7 +234,6 @@ This feature relies on the same plugin that supports the [image rendering](ref:i
|
||||
### Scheduling
|
||||
|
||||
> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
> The scheduler was significantly changed in Grafana Enterprise version 8.1.
|
||||
|
||||
Scheduled reports can be sent once, or repeated on an hourly, daily, weekly, or monthly basis, or sent at custom intervals. You can also disable scheduling by selecting **Never**, for example to send the report via the API.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ aliases:
|
||||
- ../data-sources/influxdb/provision-influxdb/
|
||||
- ../features/datasources/influxdb/
|
||||
- provision-influxdb/
|
||||
description: Guide for using InfluxDB in Grafana
|
||||
description: InfluxDB data source for Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- influxdb
|
||||
@@ -19,242 +19,42 @@ menuTitle: InfluxDB
|
||||
title: InfluxDB data source
|
||||
weight: 700
|
||||
refs:
|
||||
explore:
|
||||
annotations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
build-dashboards:
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
data-source-management:
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
transformations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data/
|
||||
---
|
||||
|
||||
# InfluxDB data source
|
||||
|
||||
{{< docs/shared lookup="influxdb/intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
Grafana includes built-in support for InfluxDB.
|
||||
This topic explains options, variables, querying, and other features specific to the InfluxDB data source, which include
|
||||
its [feature-rich code editor for queries and visual query builder]({{< relref "./query-editor" >}}).
|
||||
Grafana includes built-in support for InfluxDB. You do not have to install a plugin to add the InfluxDB data source.
|
||||
|
||||
For instructions on how to add a data source to Grafana, refer to
|
||||
the [administration documentation](ref:data-source-management).
|
||||
Only users with the organization administrator role can add data sources.
|
||||
Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning
|
||||
system.
|
||||
Grafana offers multiple configuration options for the InfluxDB data source, including a choice of three query languages and a robust query editor that includes both a code editor and a visual query builder.
|
||||
|
||||
Once you've added the InfluxDB data source, you can [configure it](#configure-the-data-source) so that your Grafana
|
||||
instance's users can create queries in its [query editor]({{< relref "./query-editor" >}}) when
|
||||
they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore).
|
||||
## Get started with the InfluxDB data source
|
||||
|
||||
## Configure the data source
|
||||
The following documents will help you get started with the InfluxDB data source in Grafana:
|
||||
|
||||
To configure basic settings for the data source, complete the following steps:
|
||||
- [Get started with Grafana and InfluxDB](/docs/grafana/<GRAFANA_VERSION>/getting-started/get-started-grafana-influxdb/)
|
||||
- [Configure the InfluxDB data source](./configure-influxdb-data-source/)
|
||||
- [InfluxDB query editor](./query-editor/)
|
||||
- [InfluxDB templates and variables](./template-variables/)
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Under Your connections, click **Data sources**.
|
||||
1. Enter `InfluxDB` in the search bar.
|
||||
1. Select **InfluxDB**.
|
||||
Once you have configured the data source you can:
|
||||
|
||||
The **Settings** tab of the data source is displayed.
|
||||
|
||||
1. Set the data source's basic configuration options carefully:
|
||||
|
||||
| Name | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | Sets the name you use to refer to the data source in panels and queries. We recommend something like `InfluxDB-InfluxQL`. |
|
||||
| **Default** | Sets whether the data source is pre-selected for new panels. |
|
||||
| **URL** | The HTTP protocol, IP address, and port of your InfluxDB API. InfluxDB's default API port is 8086. |
|
||||
| **Min time interval** | _(Optional)_ Refer to [Min time interval](#configure-min-time-interval). |
|
||||
| **Max series** | _(Optional)_ Limits the number of series and tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have many small time series and not all are shown. Defaults to 1,000. |
|
||||
|
||||
You can also configure settings specific to the InfluxDB data source. These options are described in the sections below.
|
||||
|
||||
### Min time interval
|
||||
|
||||
The **Min time interval** setting defines a lower limit for the auto group-by time interval.
|
||||
|
||||
This value _must_ be formatted as a number followed by a valid time identifier:
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| `y` | year |
|
||||
| `M` | month |
|
||||
| `w` | week |
|
||||
| `d` | day |
|
||||
| `h` | hour |
|
||||
| `m` | minute |
|
||||
| `s` | second |
|
||||
| `ms` | millisecond |
|
||||
|
||||
We recommend setting this value to match your InfluxDB write frequency.
|
||||
For example, use `1m` if InfluxDB writes data every minute.
|
||||
|
||||
You can also override this setting in a dashboard panel under its data source options.
|
||||
|
||||
### Select a query language
|
||||
|
||||
InfluxDB data source options differ depending on which query language you select:
|
||||
|
||||
- [InfluxQL](https://docs.influxdata.com/influxdb/v1.8/query_language/explore-data/), a SQL-like language for querying
|
||||
InfluxDB, with statements such as SELECT, FROM, WHERE, and GROUP BY that are familiar to SQL users.
|
||||
InfluxQL is available in InfluxDB 1.0 onwards.
|
||||
- [SQL](https://www.influxdata.com/products/sql/) native SQL language with
|
||||
support [FlightSQL](https://www.influxdata.com/glossary/apache-arrow-flight-sql/).
|
||||
- [Flux](https://docs.influxdata.com/influxdb/v2.0/query-data/get-started/), which provides significantly broader
|
||||
functionality than InfluxQL. It supports not only queries but also built-in functions for data shaping, string
|
||||
manipulation, and joining to non-InfluxDB data sources, but also processing time-series data.
|
||||
It's similar to JavaScript with a functional style.
|
||||
|
||||
To help choose the best language for your needs, refer to
|
||||
a [comparison of Flux vs InfluxQL](https://docs.influxdata.com/influxdb/v1.8/flux/flux-vs-influxql/)
|
||||
and [why InfluxData created Flux](https://www.influxdata.com/blog/why-were-building-flux-a-new-data-scripting-and-query-language/).
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Though not required, we recommend that you append your query language choice to the data source's **Name** setting:
|
||||
|
||||
- InfluxDB-InfluxQL
|
||||
- InfluxDB-SQL
|
||||
- InfluxDB-Flux
|
||||
|
||||
{{% /admonition %}}
|
||||
|
||||
### Configure InfluxQL
|
||||
|
||||
Configure these options if you select the InfluxQL (classic InfluxDB) query language:
|
||||
|
||||
| Name | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Allowed cookies** | Defines which cookies are forwarded to the data source. All other cookies are deleted. |
|
||||
| **Database** | Sets the ID of the bucket to query. Copy this from the [Buckets page](https://docs.influxdata.com/influxdb/v2.0/organizations/buckets/view-buckets/) of the InfluxDB UI. |
|
||||
| **User** | Sets the username to sign into InfluxDB. |
|
||||
| **Password** | Defines the token you use to query the bucket defined in **Database**. Copy this from the [Tokens page](https://docs.influxdata.com/influxdb/v2.0/security/tokens/view-tokens/) of the InfluxDB UI. |
|
||||
| **HTTP mode** | Sets the HTTP method used to query your data source. The POST verb allows for larger queries that would return an error using the GET verb. Defaults to GET. |
|
||||
|
||||
### Configure SQL
|
||||
|
||||
Configure these options if you select the SQL query language:
|
||||
|
||||
| Name | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Database** | Sets the ID of the bucket to query. Copy this from the Buckets page of the InfluxDB UI. |
|
||||
| **Token** | API token used for SQL queries. It can be generated on InfluxDB Cloud dashboard under [Load Data > API Tokens](https://docs.influxdata.com/influxdb/cloud-serverless/get-started/setup/#create-an-all-access-api-token) menu. |
|
||||
| **Insecure Connection** | Disable gRPC TLS security. |
|
||||
|
||||
### Configure Flux
|
||||
|
||||
Configure these options if you select the Flux query language:
|
||||
|
||||
| Name | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Organization** | The [Influx organization](https://v2.docs.influxdata.com/v2.0/organizations/) that will be used for Flux queries. This is also used to for the `v.organization` query macro. |
|
||||
| **Token** | The authentication token used for Flux queries. With Influx 2.0, use the [influx authentication token to function](https://v2.docs.influxdata.com/v2.0/security/tokens/create-token/). Token must be set as `Authorization` header with the value `Token <generated-token>`. For influx 1.8, the token is `username:password`. |
|
||||
| **Default bucket** | _(Optional)_ The [Influx bucket](https://v2.docs.influxdata.com/v2.0/organizations/buckets/) that will be used for the `v.defaultBucket` macro in Flux queries. |
|
||||
|
||||
### Provision the data source
|
||||
|
||||
You can define and configure the data source in YAML files as part of Grafana's provisioning system.
|
||||
For more information about provisioning, and for available configuration options, refer
|
||||
to [Provisioning Grafana][provisioning-data-sources].
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
`database` [field is deprecated](https://github.com/grafana/grafana/pull/58647).
|
||||
We suggest to use `dbName` field in `jsonData`. Please see the examples below.
|
||||
No need to change existing provisioning settings.
|
||||
{{% /admonition %}}
|
||||
|
||||
#### Provisioning examples
|
||||
|
||||
**InfluxDB 1.x example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v1
|
||||
type: influxdb
|
||||
access: proxy
|
||||
user: grafana
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
dbName: site
|
||||
httpMode: GET
|
||||
secureJsonData:
|
||||
password: grafana
|
||||
```
|
||||
|
||||
**InfluxDB 2.x for Flux example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v2_Flux
|
||||
type: influxdb
|
||||
access: proxy
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
version: Flux
|
||||
organization: organization
|
||||
defaultBucket: bucket
|
||||
tlsSkipVerify: true
|
||||
secureJsonData:
|
||||
token: token
|
||||
```
|
||||
|
||||
**InfluxDB 2.x for InfluxQL example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v2_InfluxQL
|
||||
type: influxdb
|
||||
access: proxy
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
dbName: site
|
||||
httpHeaderName1: 'Authorization'
|
||||
secureJsonData:
|
||||
httpHeaderValue1: 'Token <token>'
|
||||
```
|
||||
|
||||
**InfluxDB 3.x for SQL example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v3_InfluxQL
|
||||
type: influxdb
|
||||
access: proxy
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
version: SQL
|
||||
dbName: site
|
||||
httpMode: POST
|
||||
insecureGrpc: false
|
||||
secureJsonData:
|
||||
token: '<api-token>'
|
||||
```
|
||||
|
||||
## Query the data source
|
||||
|
||||
The InfluxDB data source's query editor has two modes, InfluxQL and Flux, depending on your choice of query language in
|
||||
the [data source configuration](#configure-the-data-source):
|
||||
|
||||
For details, refer to the [query editor documentation]({{< relref "./query-editor" >}}).
|
||||
|
||||
## Use template variables
|
||||
|
||||
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables.
|
||||
Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed
|
||||
in your dashboard.
|
||||
Grafana refers to such variables as template variables.
|
||||
|
||||
For details, see the [template variables documentation]({{< relref "./template-variables" >}}).
|
||||
- Add [annotations](ref:annotations)
|
||||
- Set up [alerting](ref:alerting)
|
||||
- Add [transformations](ref:transformations)
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
aliases:
|
||||
- ../data-sources/influxdb/
|
||||
- ../data-sources/influxdb/provision-influxdb/
|
||||
- ../features/datasources/influxdb/
|
||||
- provision-influxdb/
|
||||
description: Guide for using InfluxDB in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- influxdb
|
||||
- guide
|
||||
- flux
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Configure the InfluxDB data source
|
||||
title: Configure the InfluxDB data source
|
||||
weight: 300
|
||||
refs:
|
||||
provision-grafana:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#provision-grafana
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#provision-grafana
|
||||
---
|
||||
|
||||
# Configure the InfluxDB data source
|
||||
|
||||
This document provides instructions for configuring the InfluxDB data source and explains the available configuration options.
|
||||
|
||||
## Before you begin
|
||||
|
||||
To configure the InfluxDB data source you must have the `Administrator` role.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Select the query language you want to use with InfluxDB before adding the InfluxDB data source. Configuration options differ based on query language type.
|
||||
{{< /admonition >}}
|
||||
|
||||
InfluxData provides three query languages. Some key points to consider:
|
||||
|
||||
- SQL is only available for InfluxDB v3.x.
|
||||
- Flux is a functional data scripting language for InfluxDB 2.x. Refer to [Query InfluxDB with Flux](https://docs.influxdata.com/influxdb/cloud/query-data/get-started/query-influxdb/) for a basic guide on working with Flux.
|
||||
- InfluxQL is SQL-like query language developed by InfluxData. It doesn't support more advanced functions such as JOINs.
|
||||
|
||||
To help choose the best language for your needs, refer to
|
||||
a [comparison of Flux vs InfluxQL](https://docs.influxdata.com/influxdb/v1.8/flux/flux-vs-influxql/)
|
||||
and [Why InfluxData created Flux](https://www.influxdata.com/blog/why-were-building-flux-a-new-data-scripting-and-query-language/).
|
||||
|
||||
## Add the InfluxDB data source
|
||||
|
||||
Complete the following steps to set up a new InfluxDB data source:
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
2. Click **Add new connection**.
|
||||
3. Type `InfluxDB` in the search bar.
|
||||
4. Select the **InfluxDB** data source.
|
||||
5. Click **Add new data source** in the upper right.
|
||||
|
||||
You are taken to the **Settings** tab where you will configure the data source.
|
||||
|
||||
## InfluxDB common configuration options
|
||||
|
||||
The following configuration options apply to **all three query language options**.
|
||||
|
||||
- **Name** - Sets the name you use to refer to the data source in panels and queries. Examples: `InfluxDB-InfluxQL`, `InfluxDB_SQL`.
|
||||
- **Default** - Toggle to set as the default data source.
|
||||
- **Query language** - Select the query language for your InfluxDB instance. The three options are:
|
||||
- **InfluxQL** - SQL-like language for querying InfluxDB, with statements such as SELECT, FROM, WHERE, and GROUP BY that are familiar to SQL users.
|
||||
- **SQL** - Native SQL language starting with InfluxDB v.3.0. Refer to InfluxData's [SQL reference documentation](https://docs.influxdata.com/influxdb/cloud-serverless/reference/sql/) for a list of supported statements, operators, and functions.
|
||||
- **Flux** - Flux is a data scripting language developed by InfluxData that allows you to query, analyze, and act on data. Refer to [Get started with Flux](https://docs.influxdata.com/influxdb/cloud/query-data/get-started/) for guidance on using Flux.
|
||||
|
||||
**HTTP section:**
|
||||
|
||||
- **URL** - The HTTP protocol, IP address, and port of your InfluxDB API. InfluxDB’s default API port is `8086`.
|
||||
- **Allowed cookies** - Defines which cookies are forwarded to the data source. All other cookies are deleted by default.
|
||||
- **Timeout** - Set an HTTP request timeout in seconds.
|
||||
|
||||
**Auth section:**
|
||||
|
||||
- **Basic auth** - The most common authentication method. Use your InfluxData user name and password to authenticate. Toggling requires you to add the user and password under **Basic auth details**.
|
||||
- **With credentials** - Toggle to enable credentials such as cookies or auth headers to be sent with cross-site requests.
|
||||
- **TLS client auth** - Toggle to use client authentication. When enabled, add the `Server name`, `Client cert` and `Client key` under the **TLS/SSL auth details** section. The client provides a certificate that the server validates to establish the client’s trusted identity. The client key encrypts the data between client and server.
|
||||
- **With CA cert** - Authenticate with a CA certificate. Follow the instructions of your CA (Certificate Authority) to download the certificate file.
|
||||
- **Skip TLS verify** - Toggle to bypass TLS certificate validation.
|
||||
- **Forward OAuth identity** - Forward the OAuth access token (and also the OIDC ID token if available) of the user querying the data source.
|
||||
|
||||
**Basic auth details:**
|
||||
|
||||
If you enable **Basic auth** under the Auth section you need to configure the following:
|
||||
|
||||
- **User** - Add the username used to sign in to InfluxDB.
|
||||
- **Password** - Defines the token you use to query the bucket defined in **Database**. Retrieve this from the [Tokens page](https://docs.influxdata.com/influxdb/v2.0/security/tokens/view-tokens/) in the InfluxDB UI.
|
||||
|
||||
**TLS/SSL auth details:**
|
||||
|
||||
TLS/SSL certificates are encrypted and stored in the Grafana database.
|
||||
|
||||
- **CA cert** - If you toggle **With CA cert** add your self-signed cert here.
|
||||
- **Server name** - Name of the server. Example: server1.domain.com
|
||||
- **Client cert** - Add the client certificate.
|
||||
- **Client key** - Add the client key.
|
||||
|
||||
**Custom HTTP headers:**
|
||||
|
||||
- **Header** - Add a custom HTTP header. Select an option from the drop-down. Allows custom headers to be passed based on the needs of your InfluxDB instance.
|
||||
- **Value** - The value for the header.
|
||||
|
||||
**Private data source connect:**
|
||||
|
||||
- **Private data source connect** - _Only for Grafana Cloud users._ Private data source connect, or PDC, allows you to establish a private, secured connection between a Grafana Cloud instance, or stack, and data sources secured within a private network. Click the drop-down to locate the URL for PDC. For more information regarding Grafana PDC refer to [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/).
|
||||
|
||||
Click **Manage private data source connect** to be taken to your PDC connection page, where you'll find your PDC configuration details.
|
||||
|
||||
Once you have added your connection settings, click **Save & test** to test the data source connection.
|
||||
|
||||
### InfluxQL-specific configuration section
|
||||
|
||||
The following settings are specific to the InfluxQL query language option.
|
||||
|
||||
**InfluxQL InfluxDB details section:**
|
||||
|
||||
- **Database** - Sets the ID of the bucket to query. Refer to [View buckets](https://docs.influxdata.com/influxdb/v2.0/organizations/buckets/view-buckets/) in InfluxData's documentation on how to locate the list of available buckets and their corresponding IDs.
|
||||
- **User** - The user name used to sign in to InfluxDB.
|
||||
- **Password** - Defines the token used to query the bucket defined in **Database**. Retrieve the password from the [Tokens page](https://docs.influxdata.com/influxdb/v2.0/security/tokens/view-tokens/) of the InfluxDB UI.
|
||||
- **HTTP method** - Sets the HTTP method used to query your data source. The POST method allows for larger queries that would return an error using the GET method. The default method is `POST`.
|
||||
- **Min time interval** - _(Optional)_ Sets the minimum time interval for auto group-by. Grafana recommends setting this to match the data write frequency. For example, if your data is written every minute, it’s recommended to set this interval to 1 minute, so that each group contains data from each new write. The default is `10s`. Refer to [Min time interval](#min-time-interval) for format examples.
|
||||
- **Max series** - _(Optional)_ Sets a limit on the maximum number of series or tables that Grafana processes. Set a lower limit to prevent system overload, or increase it if you have many small time series and need to display more of them. The default is `1000`.
|
||||
|
||||
### SQL-specific configuration section
|
||||
|
||||
The following settings are specific to the SQL query language option.
|
||||
|
||||
**SQL InfluxDB details section:**
|
||||
|
||||
- **Database** - Specify the **bucket ID**. Refer to the **Buckets page** in the InfluxDB UI to locate the ID.
|
||||
- **Token** The API token used for SQL queries. Generated on InfluxDB Cloud dashboard under [Load Data > API Tokens](https://docs.influxdata.com/influxdb/cloud-serverless/get-started/setup/#create-an-all-access-api-token) menu.
|
||||
- **Insecure Connection** - Toggle to disable gRPC TLS security.
|
||||
- **Max series** - _(Optional)_ Sets a limit on the maximum number of series or tables that Grafana processes. Set a lower limit to prevent system overload, or increase it if you have many small time series and need to display more of them. The default is `1000`.
|
||||
|
||||
### Flux-specific configuration section
|
||||
|
||||
The following settings are specific to the Flux query language option.
|
||||
|
||||
**Flux InfluxDB details section:**
|
||||
|
||||
- **Organization** - The [Influx organization](https://v2.docs.influxdata.com/v2.0/organizations/) used for Flux queries. Also used for the `v.organization` query macro.
|
||||
- **Token** - The authentication token used for Flux queries. With Influx 2.0, use the [influx authentication token to function](https://v2.docs.influxdata.com/v2.0/security/tokens/create-token/). Token must be set as `Authorization` header with the value `Token <generated-token>`. For Influx 1.8, the token is `username:password`.
|
||||
- **Default bucket** - _(Optional)_ The [Influx bucket](https://v2.docs.influxdata.com/v2.0/organizations/buckets/) used for the `v.defaultBucket` macro in Flux queries.
|
||||
- **Min time interval** - Sets the minimum time interval for auto group-by. Grafana recommends aligning this setting with the data write frequency. For example, if data is written every minute, set the interval to 1 minute to ensure each group includes data from every new write. The default is `10s`.
|
||||
- **Max series** - Sets a limit on the maximum number of series or tables that Grafana processes. Set a lower limit to prevent system overload, or increase it if you have many small time series and need to display more of them. The default is `1000`.
|
||||
|
||||
### Min time interval
|
||||
|
||||
The **Min time interval** setting defines a lower limit for the auto group-by time interval.
|
||||
|
||||
This value **must be** formatted as a number followed by a valid time identifier:
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| `y` | year |
|
||||
| `M` | month |
|
||||
| `w` | week |
|
||||
| `d` | day |
|
||||
| `h` | hour |
|
||||
| `m` | minute |
|
||||
| `s` | second |
|
||||
| `ms` | millisecond |
|
||||
|
||||
You can also override this setting in a dashboard panel under its data source options.
|
||||
|
||||
## Provision the InfluxDB data source
|
||||
|
||||
You can define and configure the data source in YAML files as part of Grafana's provisioning system.
|
||||
For more information about provisioning, and for available configuration options, refer
|
||||
to [Provision Grafana](ref:provision-grafana).
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
The `database` [field is deprecated](https://github.com/grafana/grafana/pull/58647).
|
||||
Grafana recommends using the `dbName` field in `jsonData`. There is no need to change existing provisioning settings.
|
||||
{{% /admonition %}}
|
||||
|
||||
### Provisioning examples
|
||||
|
||||
Provisioning differs based on query language.
|
||||
|
||||
**InfluxDB 1.x example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v1
|
||||
type: influxdb
|
||||
access: proxy
|
||||
user: grafana
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
dbName: site
|
||||
httpMode: GET
|
||||
secureJsonData:
|
||||
password: grafana
|
||||
```
|
||||
|
||||
**InfluxDB 2.x for Flux example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v2_Flux
|
||||
type: influxdb
|
||||
access: proxy
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
version: Flux
|
||||
organization: organization
|
||||
defaultBucket: bucket
|
||||
tlsSkipVerify: true
|
||||
secureJsonData:
|
||||
token: token
|
||||
```
|
||||
|
||||
**InfluxDB 2.x for InfluxQL example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v2_InfluxQL
|
||||
type: influxdb
|
||||
access: proxy
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
dbName: site
|
||||
httpHeaderName1: 'Authorization'
|
||||
secureJsonData:
|
||||
httpHeaderValue1: 'Token <token>'
|
||||
```
|
||||
|
||||
**InfluxDB 3.x for SQL example:**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: InfluxDB_v3_InfluxQL
|
||||
type: influxdb
|
||||
access: proxy
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
version: SQL
|
||||
dbName: site
|
||||
httpMode: POST
|
||||
insecureGrpc: false
|
||||
secureJsonData:
|
||||
token: '<api-token>'
|
||||
```
|
||||
@@ -2,14 +2,15 @@
|
||||
aliases:
|
||||
- ../../data-sources/influxdb/query-editor/
|
||||
- influxdb-flux/
|
||||
description: Guide for Flux in Grafana
|
||||
description: This topic describes the InfluxDB query editor, modes and querying the InfluxDB data source.
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
title: Query Editor
|
||||
weight: 200
|
||||
title: InfluxDB query Editor
|
||||
menuTitle: Query editor
|
||||
weight: 400
|
||||
refs:
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
@@ -31,45 +32,80 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: grafana-cloud/visualizations/panels-visualizations/visualizations/logs/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#query-editors
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#query-editors
|
||||
build-dashboards:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/
|
||||
data-source-management:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
annotations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/
|
||||
configure-influxdb-data-source:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/influxdb/configure-influxdb-data-source/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/influxdb/configure-influxdb-data-source/
|
||||
---
|
||||
|
||||
# InfluxDB query editor
|
||||
|
||||
This topic explains querying specific to the InfluxDB data source.
|
||||
For general documentation on querying data sources in Grafana, see [Query and transform data](ref:query-transform-data).
|
||||
Grafana's query editors are unique to each data source. For general information on Grafana query editors, refer to [Query editors](ref:query-editor). For general information on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data).
|
||||
|
||||
The InfluxDB query editor is located on the [Explore page](ref:explore). You can also access the InfluxDB query editor from a dashboard panel. Click the ellipsis in the upper right of the panel and select **Edit**.
|
||||
|
||||
You can also use the query editor to retrieve [log data](#query-logs) and [annotate](#apply-annotations) visualizations.
|
||||
|
||||
## Choose a query editing mode
|
||||
|
||||
The InfluxDB data source's query editor has two modes depending on your choice of query language in
|
||||
the [data source configuration]({{< relref "../#configure-the-data-source" >}}):
|
||||
The InfluxDB data source has three different types of query editors, each corresponding to the query language selected in the [data source configuration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/influxdb/configure-influxdb-data-source/#influxdb-configuration-options):
|
||||
|
||||
- [InfluxQL](#influxql-query-editor)
|
||||
- [SQL](#sql-query-editor)
|
||||
- [Flux](#flux-query-editor)
|
||||
|
||||
You also use the query editor to retrieve [log data](#query-logs) and [annotate](#apply-annotations) visualizations.
|
||||
Editor options vary based on query language.
|
||||
|
||||
## InfluxQL query editor
|
||||
|
||||
The InfluxQL query editor helps you select metrics and tags to create InfluxQL queries.
|
||||
The InfluxQL query editor helps you select metrics and tags to create InfluxQL queries. There are two modes: `visual editor mode` and `raw query mode`. To switch between the two modes click the **pencil icon** in the upper right.
|
||||
|
||||
**To enter edit mode:**
|
||||
Visual query editor mode contains the following components:
|
||||
|
||||
1. Hover over any part of the panel to display the actions menu on the top right corner.
|
||||
1. Click the menu and select **Edit**.
|
||||
- **FROM** - Select a measurement to query.
|
||||
- **WHERE** - Select filters by clicking the **+ sign**.
|
||||
- **SELECT** - Select fields and functions from the drop-down. You can add multiple fields and functions by clicking the **+ sign**.
|
||||
- **GROUP BY** - Select a tag from the drop-down menu.
|
||||
- **TIMEZONE** - _Optional_ Group data by a specific timezone.
|
||||
- **ORDER BY TIME** - Sort data by time in either ascending or descending order.
|
||||
- **LIMIT** - _Optional_ Limits the number of rows returned by the query.
|
||||
- **SLIMIT** - _Optional_ Limits the number of series returned by the query. Refer to [SLIMIT clause](https://docs.influxdata.com/influxdb/cloud/query-data/influxql/explore-data/limit-and-slimit/#slimit-clause) for more information on this option.
|
||||
- **FORMAT AS** - Select a format option from the drop-down menu.
|
||||
- **ALIAS** - Add an alias. Refer to [Alias patterns](#alias-patterns) for more information.
|
||||
|
||||
### Raw query editor mode
|
||||
|
||||
You can write raw InfluxQL queries by switching to raw query mode. Click the pencil in the upper right of the query editor to switch modes. Note that when you switch to visual editor mode, you will lose any changes made in raw query mode.
|
||||
|
||||
If you use raw query mode, your query must include `WHERE $timeFilter`. You should also provide a group by time and an aggregation function. Otherwise, InfluxDB may return hundreds of thousands of data points, potentially causing your browser to hang.
|
||||
|
||||

|
||||
|
||||
### Filter data (WHERE)
|
||||
|
||||
To add a tag filter, click the plus icon to the right of the `WHERE` condition.
|
||||
|
||||
To remove tag filters, click the tag key, then select **--remove tag filter--**.
|
||||
|
||||
#### Match by regular expressions
|
||||
### Match by regular expressions
|
||||
|
||||
You can enter regular expressions for metric names or tag filter values.
|
||||
Wrap the regex pattern in forward slashes (`/`).
|
||||
Wrap the regex pattern in forward slashes (`/`), as shown in this example: `/measurement/`.
|
||||
|
||||
Grafana automatically adjusts the filter tag condition to use the InfluxDB regex match condition operator (`=~`).
|
||||
|
||||
@@ -77,56 +113,35 @@ Grafana automatically adjusts the filter tag condition to use the InfluxDB regex
|
||||
|
||||
In the `SELECT` row, you can specify which fields and functions to use.
|
||||
|
||||
If you have a group by time, you must have an aggregation function.
|
||||
Some functions like `derivative` also require an aggregation function.
|
||||
If you **group by time** you must use an aggregation function. Certain functions such as `derivative` also require an aggregation function.
|
||||
|
||||
The editor helps you build this part of the query.
|
||||
For example:
|
||||
If you have the following:
|
||||
|
||||

|
||||
|
||||
This query editor input generates an InfluxDB `SELECT` clause:
|
||||
The query editor input generates an InfluxDB `SELECT` clause:
|
||||
|
||||
```sql
|
||||
SELECT derivative(mean("value"), 10s) / 10 AS "REQ/s"
|
||||
FROM....
|
||||
```
|
||||
|
||||
**To select multiple fields:**
|
||||
You can also use a \* in a SELECT statement to select all fields.
|
||||
|
||||
1. Click the plus button.
|
||||
1. Select **Field > field** to add another `SELECT` clause.
|
||||
```sql
|
||||
SELECT * FROM <measurement_name>
|
||||
```
|
||||
|
||||
You can also `SELECT` an asterisk (`*`) to select all fields.
|
||||
### GROUP BY results
|
||||
|
||||
### Group query results
|
||||
To group results by a tag, specify the tag in the **GROUP BY** row:
|
||||
|
||||
To group results by a tag, define it in a "Group By".
|
||||
1. Click the **+ sign** in the GROUP BY row.
|
||||
1. Select a tag from the drop-down.
|
||||
|
||||
**To group by a tag:**
|
||||
You can GROUP BY multiple options.
|
||||
|
||||
1. Click the plus icon at the end of the GROUP BY row.
|
||||
1. Select a tag from the dropdown that appears.
|
||||
|
||||
**To remove the "Group By":**
|
||||
|
||||
1. Click the tag.
|
||||
1. Click the "x" icon.
|
||||
|
||||
### Text editor mode (RAW)
|
||||
|
||||
You can write raw InfluxQL queries by switching the editor mode.
|
||||
However, be careful when writing queries manually.
|
||||
|
||||
If you use raw query mode, your query _must_ include at least `WHERE $timeFilter`.
|
||||
|
||||
Also, _always_ provide a group by time and an aggregation function.
|
||||
Otherwise, InfluxDB can easily return hundreds of thousands of data points that can hang your browser.
|
||||
|
||||
**To switch to raw query mode:**
|
||||
|
||||
1. Click the hamburger icon.
|
||||
1. Toggle **Switch editor mode**.
|
||||
To remove a GROUP BY option click the **X icon** next to the option.
|
||||
|
||||
### Alias patterns
|
||||
|
||||
@@ -138,20 +153,21 @@ Otherwise, InfluxDB can easily return hundreds of thousands of data points that
|
||||
| `$col` | Column name. |
|
||||
| `$tag_exampletag` | The value of the `exampletag` tag. The syntax is `$tag*yourTagName` and must start with `$tag*`. To use your tag as an alias in the ALIAS BY field, you must use the tag to group by in the query. |
|
||||
|
||||
You can also use `[[tag_hostname]]` pattern replacement syntax.
|
||||
You can also use the `[[tag_hostname]]` pattern replacement syntax.
|
||||
|
||||
For example, entering the value `Host: [[tag_hostname]]` in the ALIAS BY field replaces it with the `hostname` tag value
|
||||
for each legend value.
|
||||
An example legend value would be `Host: server1`.
|
||||
For example, entering the value `Host: [[tag_hostname]]` in the ALIAS BY field replaces it with the `hostname` tag value for each legend value.
|
||||
|
||||
An example legend value is `Host: server1`.
|
||||
|
||||
## SQL query editor
|
||||
|
||||
Grafana support [SQL querying language](https://docs.influxdata.com/influxdb/cloud-serverless/query-data/sql/)
|
||||
with [InfluxDB v3.0](https://www.influxdata.com/blog/introducing-influxdb-3-0/) and higher.
|
||||
Grafana supports the [SQL query language](https://docs.influxdata.com/influxdb/cloud-serverless/query-data/sql/) in [InfluxDB v3.0](https://www.influxdata.com/blog/introducing-influxdb-3-0/) and higher.
|
||||
|
||||
You construct your SQL query directly in the query editor.
|
||||
|
||||
### Macros
|
||||
|
||||
You can use macros within the query to replace them with the values from Grafana's context.
|
||||
You can use macros in your query to automatically substitute them with values from Grafana's context.
|
||||
|
||||
| Macro example | Replaced with |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -179,12 +195,12 @@ Examples:
|
||||
## Flux query editor
|
||||
|
||||
Grafana supports Flux when running InfluxDB v1.8 and higher.
|
||||
If your data source is [configured for Flux]({{< relref "./#configure-the-data-source" >}}), you can use
|
||||
the [Flux query and scripting language](https://www.influxdata.com/products/flux/) in the query editor, which serves as
|
||||
If your data source is [configured for Flux](ref:configure-influxdb-data-source), you can use
|
||||
the [Flux](https://docs.influxdata.com/flux/v0/) in the query editor, which serves as
|
||||
a text editor for raw Flux queries with macro support.
|
||||
|
||||
For more information and connection details, refer
|
||||
to [1.8 compatibility](https://github.com/influxdata/influxdb-client-go/#influxdb-18-api-compatibility).
|
||||
to [InfluxDB 1.8 API compatibility](https://github.com/influxdata/influxdb-client-go/#influxdb-18-api-compatibility).
|
||||
|
||||
### Use macros
|
||||
|
||||
@@ -199,7 +215,7 @@ Macros support copying and pasting from [Chronograf](https://www.influxdata.com/
|
||||
| `v.defaultBucket` | The data source configuration's "Default Bucket" setting. |
|
||||
| `v.organization` | The data source configuration's "Organization" setting. |
|
||||
|
||||
For example, the query editor interpolates this query:
|
||||
For example, consider the following Flux query:
|
||||
|
||||
```flux
|
||||
from(bucket: v.defaultBucket)
|
||||
@@ -210,8 +226,7 @@ from(bucket: v.defaultBucket)
|
||||
|> yield(name: "mean")
|
||||
```
|
||||
|
||||
Into this query to send to InfluxDB, with interval and time period values changing according to the active time
|
||||
selection:
|
||||
This Flux query is interpolated into the following query and sent to InfluxDB, with the interval and time period values changing according to the active time selection:
|
||||
|
||||
```flux
|
||||
from(bucket: "grafana")
|
||||
@@ -222,33 +237,20 @@ from(bucket: "grafana")
|
||||
|> yield(name: "mean")
|
||||
```
|
||||
|
||||
To view the interpolated version of a query with the query inspector, refer to [Panel Inspector](ref:panel-inspector).
|
||||
To view the interpolated version of a query with the Query inspector, refer to [Panel Inspector](ref:panel-inspector).
|
||||
|
||||
## Query logs
|
||||
|
||||
You can query and display log data from InfluxDB in [Explore](ref:explore) and with the [Logs panel](ref:logs) for dashboards.
|
||||
You can query and display log data from InfluxDB in [Explore](ref:explore) and in the dashboard [Logs panel](ref:logs).
|
||||
|
||||
Select the InfluxDB data source, then enter a query to display your logs.
|
||||
Select an InfluxDB data source in the Query editor. Under the **Select measurement field** next to the **FROM** section, choose a measurement containing your log data, then choose the appropriate fields that will display the log message. Add any additional filters by clicking the **+ sign** next to the **WHERE** field. Add additional conditions in the GROUP BY, ORDER BY and the rest of the options.
|
||||
|
||||
### Create log queries
|
||||
|
||||
The Logs Explorer next to the query field, accessed by the **Measurements/Fields** button, lists measurements and
|
||||
fields.
|
||||
Choose the desired measurement that contains your log data, then choose which field to use to display the log message.
|
||||
|
||||
Once InfluxDB returns the result, the log panel lists log rows and displays a bar chart, where the x axis represents the
|
||||
time and the y axis represents the frequency/count.
|
||||
|
||||
### Filter search
|
||||
|
||||
To add a filter, click the plus icon to the right of the **Measurements/Fields** button, or next to a condition.
|
||||
|
||||
To remove tag filters, click the first select, then choose **--remove filter--**.
|
||||
After InfluxDB returns the results, the log panel displays log rows along with a bar chart. The x-axis represents time, while the y-axis shows the frequency or count.
|
||||
|
||||
## Apply annotations
|
||||
|
||||
[Annotations][annotate-visualizations] overlay rich event information on top of graphs.
|
||||
You can add annotation queries in the Dashboard menu's Annotations view.
|
||||
[Annotations](ref:annotations) overlay rich event information on top of graphs.
|
||||
You can add annotation queries in the dashboard menu's **Annotations view**.
|
||||
|
||||
For InfluxDB, your query **must** include `WHERE $timeFilter`.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ labels:
|
||||
- oss
|
||||
menuTitle: Template variables
|
||||
title: InfluxDB template variables
|
||||
weight: 300
|
||||
weight: 600
|
||||
refs:
|
||||
add-template-variables-chained-variables:
|
||||
- pattern: /docs/grafana/
|
||||
@@ -39,57 +39,62 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#add-ad-hoc-filters
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#add-ad-hoc-filters
|
||||
add-template-variables-adds-a-query-variable:
|
||||
add-template-variables-add-a-query-variable:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#add-a-query-variable
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#add-a-query-variable
|
||||
variable-best-practices:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/#variable-best-practices
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/variables/#variable-best-practices
|
||||
---
|
||||
|
||||
# InfluxDB template variables
|
||||
|
||||
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables.
|
||||
Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard.
|
||||
Grafana refers to such variables as template variables.
|
||||
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables. Grafana displays these variables in drop-down select boxes at the top of the dashboard to help you change the data displayed in your dashboard. Grafana refers to such variables as template variables.
|
||||
|
||||
For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables) documentation.
|
||||
For additional information using variables and templates, refer to the following documentation:
|
||||
|
||||
- [Variables](ref:variables)
|
||||
- [Templates](ref:variables)
|
||||
- [Add and manage variables](ref:add-template-variables)
|
||||
- [Variable best practices](ref:variable-best-practices)
|
||||
|
||||
## Use query variables
|
||||
|
||||
If you add a query template variable, you can write an InfluxDB exploration (metadata) query.
|
||||
These queries can return results like measurement names, key names, or key values.
|
||||
By adding a query template variable, you can write an InfluxDB metadata exploration query. These queries return results such as measurement names, key names, and key values.
|
||||
|
||||
For more information, refer to [Add query variable](ref:add-template-variables-adds-a-query-variable).
|
||||
For more information, refer to [Add a query variable](ref:add-template-variables-add-a-query-variable).
|
||||
|
||||
For example, to create a variable that contains all values for tag `hostname`, specify a query like this in the query variable **Query**:
|
||||
To create a variable containing all values for the `hostname` tag, use the following query format in the **Query** variable:
|
||||
|
||||
```sql
|
||||
SHOW TAG VALUES WITH KEY = "hostname"
|
||||
```
|
||||
|
||||
### Chain or nest variables
|
||||
## Chain or nest variables
|
||||
|
||||
You can also create nested variables, sometimes called [chained variables](ref:add-template-variables-chained-variables).
|
||||
|
||||
For example, if you had a variable called `region`, you could have the `hosts` variable show only hosts from the selected region with a query like:
|
||||
For example, if you have a variable named `region`, you can configure the `hosts` variable to display only hosts from the selected region using the following query:
|
||||
|
||||
```sql
|
||||
SHOW TAG VALUES WITH KEY = "hostname" WHERE region = '$region'
|
||||
```
|
||||
|
||||
You can fetch key names for a given measurement:
|
||||
You can also fetch key names for a given measurement:
|
||||
|
||||
```sql
|
||||
SHOW TAG KEYS [FROM <measurement_name>]
|
||||
```
|
||||
|
||||
If you have a variable with key names, you can use this variable in a group-by clause.
|
||||
This helps you change group-by using the variable list at the top of the dashboard.
|
||||
If you have a variable containing key names, you can use it in a **GROUP BY** clause. This allows you to adjust the grouping by selecting from the variable list at the top of the dashboard
|
||||
|
||||
### Use ad hoc filters
|
||||
## Use ad hoc filters
|
||||
|
||||
InfluxDB supports the special **Ad hoc filters** variable type.
|
||||
You can use this variable type to specify any number of key/value filters, and Grafana applies them automatically to all of your InfluxDB queries.
|
||||
InfluxDB supports the **Ad hoc filters** variable type. This variable type allows you to define multiple key/value filters, which Grafana then automatically applies to all your InfluxDB queries.
|
||||
|
||||
For more information, refer to [Add ad hoc filters](ref:add-template-variables-add-ad-hoc-filters).
|
||||
|
||||
@@ -97,13 +102,13 @@ For more information, refer to [Add ad hoc filters](ref:add-template-variables-a
|
||||
|
||||
The InfluxDB data source supports two variable syntaxes for use in the **Query** field:
|
||||
|
||||
- `$<varname>`, which is easier to read and write but does not allow you to use a variable in the middle of a word.
|
||||
- `$<varname>` - This syntax is easy to read and write but does not allow you to use a variable in the middle of a word or expression.
|
||||
|
||||
```sql
|
||||
SELECT mean("value") FROM "logins" WHERE "hostname" =~ /^$host$/ AND $timeFilter GROUP BY time($__interval), "hostname"
|
||||
```
|
||||
|
||||
- `${varname}`
|
||||
- `${varname}` - Use this syntax when you want to interpolate a variable in the middle of an expression.
|
||||
|
||||
```sql
|
||||
SELECT mean("value") FROM "logins" WHERE "hostname" =~ /^[[host]]$/ AND $timeFilter GROUP BY time($__interval), "hostname"
|
||||
@@ -113,4 +118,4 @@ When you enable the **Multi-value** or **Include all value** options, Grafana co
|
||||
|
||||
### Templated dashboard example
|
||||
|
||||
To view an example templated dashboard, refer to this [InfluxDB templated dashboard](https://play.grafana.org/d/f62a0410-5abb-4dd8-9dfc-caddfc3e2ffd/eccb2445-b0a2-5e83-8e0f-6d5ea53ad575).
|
||||
To view an example of a templated dashboard, refer to this [InfluxDB example dashboard](https://play.grafana.org/d/f62a0410-5abb-4dd8-9dfc-caddfc3e2ffd/eccb2445-b0a2-5e83-8e0f-6d5ea53ad575).
|
||||
@@ -286,6 +286,8 @@ In the field mapping specify:
|
||||
|
||||
Grafana builds value mappings from your query result and applies them to the real data query results. You should see values being mapped and colored according to the config query results.
|
||||
|
||||
> **Note:** When you use this transformation for thresholds, the visualization continues to use the panel's base threshold.
|
||||
|
||||
### Convert field type
|
||||
|
||||
Use this transformation to modify the field type of a specified field.
|
||||
|
||||
@@ -110,6 +110,38 @@ Edges can also show statistics when you hover over the edge. Similar to nodes, y
|
||||
|
||||
The first data source supporting this visualization is X-Ray data source for its Service map feature. For more information, refer to the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource).
|
||||
|
||||
## Node graph navigation
|
||||
|
||||
You can use pan, zoom, and other functions to navigate a node graph.
|
||||
|
||||
### Pan
|
||||
|
||||
You can pan the view by clicking outside any node or edge and dragging your mouse.
|
||||
|
||||
### Zoom
|
||||
|
||||
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
|
||||
|
||||
### Hidden nodes
|
||||
|
||||
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
|
||||
|
||||

|
||||
|
||||
### Grid view
|
||||
|
||||
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
|
||||
|
||||

|
||||
|
||||
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
|
||||
|
||||

|
||||
|
||||
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
|
||||
|
||||

|
||||
|
||||
## Configuration options
|
||||
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
@@ -153,38 +185,6 @@ In node graphs, some data fields may have pre-configured data links. To add a di
|
||||
|
||||
{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Navigating the node graph
|
||||
|
||||
You can use pan, zoom, and other functions to navigate a node graph.
|
||||
|
||||
### Pan
|
||||
|
||||
You can pan the view by clicking outside any node or edge and dragging your mouse.
|
||||
|
||||
### Zoom
|
||||
|
||||
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
|
||||
|
||||
### Hidden nodes
|
||||
|
||||
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
|
||||
|
||||

|
||||
|
||||
### Grid view
|
||||
|
||||
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
|
||||
|
||||

|
||||
|
||||
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
|
||||
|
||||

|
||||
|
||||
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
|
||||
|
||||

|
||||
|
||||
## Data API
|
||||
|
||||
This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it.
|
||||
|
||||
@@ -23,6 +23,11 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/calculation-types/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/calculation-types/
|
||||
create-dashboard:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/create-dashboard/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/
|
||||
---
|
||||
|
||||
# Stat
|
||||
@@ -35,7 +40,7 @@ A stat visualization displays your data in single values of interest—such
|
||||
|
||||
For example, if you're monitoring the utilization of various services, you can use a stat visualization to show their latest usage:
|
||||
|
||||
{{< figure src="/static/img/docs/v66/stat_panel_dark3.png" max-width="1025px" alt="A stat panel showing latest usage of various services" >}}
|
||||

|
||||
|
||||
Use a stat visualization when you need to:
|
||||
|
||||
@@ -47,7 +52,7 @@ Use a stat visualization when you need to:
|
||||
|
||||
## Configure a stat visualization
|
||||
|
||||
Once you've [created a dashboard](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/create-dashboard/), the following video shows you how to configure a stat visualization:
|
||||
Once you've [created a dashboard](ref:create-dashboard), the following video shows you how to configure a stat visualization:
|
||||
|
||||
{{< youtube id="yNRnLyVntUw" start="1048" >}}
|
||||
|
||||
@@ -73,9 +78,9 @@ The following tables are examples of the type of data you need for a stat visual
|
||||
| 59 |
|
||||
| 40 |
|
||||
|
||||
The data is visualized as follows, with the last value displayed, along with a sparkline and [percentage change](#show-percent-change):
|
||||
The data is visualized as follows, with the last value displayed, along with a sparkline and [percentage change](#value-options):
|
||||
|
||||
{{< figure src="/static/img/docs/stat-panel/stat_panel_single.png" max-width="1025px" alt="A stat panel showing the latest number of high priority bugs" >}}
|
||||

|
||||
|
||||
#### Time-series data
|
||||
|
||||
@@ -89,7 +94,7 @@ The data is visualized as follows, with the last value displayed, along with a s
|
||||
|
||||
The data is visualized as follows, with the mean value displayed for each room, along with the room name, sparkline, and unit of measurement:
|
||||
|
||||
{{< figure src="/static/img/docs/stat-panel/stat_panel_multiple.png" max-width="1025px" alt="A stat panel showing some statistics for each room in square meters" >}}
|
||||

|
||||
|
||||
By default, a stat displays one of the following:
|
||||
|
||||
@@ -98,131 +103,87 @@ By default, a stat displays one of the following:
|
||||
|
||||
You can use the [**Text mode**](#text-mode) to control how the text is displayed.
|
||||
|
||||
## Automatic layout adjustment
|
||||
## Configuration options
|
||||
|
||||
The panel automatically adjusts the layout depending on available width and height in the dashboard. It automatically hides the graph (sparkline) if the panel becomes too small.
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Panel options
|
||||
### Panel options
|
||||
|
||||
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Value options
|
||||
### Value options
|
||||
|
||||
Use the following options to refine how your visualization displays its values:
|
||||
|
||||
### Show
|
||||
<!-- prettier-ignore-start -->
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| Show | Display a single value per column or series, or show values for each row. Choose from: <ul><li>**Calculate** - Display a calculated value based on all rows.</li><li>**All values** - Show a separate stat for every row. If you select this option, then you can also limit the number of rows to display.</li> |
|
||||
| Calculation | This option is displayed when you select **Calculate** as your **Show** option. Select a reducer function that Grafana will use to reduce many fields to a single value. For a list of available calculations, refer to [Calculation types](ref:calculation-types). |
|
||||
| Limit | This option is displayed when you select **All values** as your **Show** option. Set the maximum number of rows to display. Default is 5,000. |
|
||||
| Fields | Select the fields displayed in the visualization. |
|
||||
|
||||
Display a single value per column or series, or show values for each row.
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
#### Calculate
|
||||
### Stat styles
|
||||
|
||||
Display a calculated value based on all rows.
|
||||
The stat visualization automatically adjusts the layout depending on available width and height in the dashboard, but you can also use the following options to further style the visualization.
|
||||
|
||||
- **Calculation -** Select a reducer function that Grafana will use to reduce many fields to a single value. For a list of available calculations, refer to [Calculation types](ref:calculation-types).
|
||||
- **Fields -** Select the fields display in the visualization.
|
||||
<!-- prettier-ignore-start -->
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| Orientation | Select a stacking direction. Choose from: <ul><li>**Auto** - Grafana selects the ideal orientation.</li><li>**Horizontal** - Bars stretch horizontally, left to right.</li><li>**Vertical** - Bars stretch vertically, top to bottom.</li></ul> |
|
||||
| [Text mode](#text-mode) | You can use the **Text mode** option to control what text the visualization renders. If the value is not important, only the name and color is, then change the **Text mode** to **Name**. The value will still be used to determine color and is displayed in a tooltip. |
|
||||
| [Wide layout](#wide-layout) | Set whether wide layout is enabled or not. Wide layout is enabled by default. This option is only applicable when **Text mode** is set to **Value and name**. |
|
||||
| Color mode | Select a color mode. Choose from: <ul><li>**None** - No color applied to the value.</li><li>**Value** - Applies color to the value and graph area.</li><li>**Background Gradient** - Applies color to the value, graph area, and background, with a slight background gradient.</li><li>**Background Solid** - Applies color to the value, graph area, and background, with a solid background color.</li></ul> |
|
||||
| Graph mode | Select a graph sparkline mode. Choose from: <ul><li>**None** - Hides the graph sparkline and only shows the value.</li><li>**Area** - Shows the graph sparkline below the value. This requires that your query returns a time column.</li></ul> The graph sparkline is automatically hidden if the panel becomes too small.|
|
||||
| Text alignment | Select an alignment mode. Choose from: <ul><li>**Auto** - If only a single value is shown (no repeat), then the value is centered. If multiple series or rows are shown, then the value is left-aligned.</li><li>**Center** - Stat value is centered.</li></ul> |
|
||||
| Show percent change | Set whether percent change is displayed or not. Disabled by default. This option is applicable when the **Show** setting, under **Value options**, is set to **Calculate**. |
|
||||
| Percent change color mode | This option is only displayed when **Show percent change** is enabled. Choose from: <ul><li>**Standard** - Green if the percent change is positive, red if the percent change is negative.</li><li>**Inverted** - Red if the percent change is positive, green if the percent change is negative.</li><li>**Same as Value** - Use the same color as the value.</li></ul> |
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
#### All values
|
||||
|
||||
Show a separate stat for every row. If you select this option, then you can also limit the number of rows to display.
|
||||
|
||||
- **Limit -** The maximum number of rows to display. Default is 5,000.
|
||||
- **Fields -** Select the fields display in the visualization.
|
||||
|
||||
## Stat styles
|
||||
|
||||
Style your visualization.
|
||||
|
||||
### Orientation
|
||||
|
||||
Choose a stacking direction.
|
||||
|
||||
- **Auto -** Grafana selects what it thinks is the best orientation.
|
||||
- **Horizontal -** Bars stretch horizontally, left to right.
|
||||
- **Vertical -** Bars stretch vertically, top to bottom.
|
||||
|
||||
### Text mode
|
||||
#### Text mode
|
||||
|
||||
You can use the Text mode option to control what text the visualization renders. If the value is not important, only the name and color is, then change the **Text mode** to **Name**. The value will still be used to determine color and is displayed in a tooltip.
|
||||
|
||||
- **Auto -** If the data contains multiple series or fields, show both name and value.
|
||||
- **Value -** Show only value, never name. Name is displayed in the hover tooltip instead.
|
||||
- **Value and name -** Always show value and name.
|
||||
- **Name -** Show name instead of value. Value is displayed in the hover tooltip.
|
||||
- **None -** Show nothing (empty). Name and value are displayed in the hover tooltip.
|
||||
- **Auto** - If the data contains multiple series or fields, show both name and value.
|
||||
- **Value** - Show only value, never name. Name is displayed in the hover tooltip instead.
|
||||
- **Value and name** - Always show value and name.
|
||||
- **Name** - Show name instead of value. Value is displayed in the hover tooltip.
|
||||
- **None** - Show nothing (empty). Name and value are displayed in the hover tooltip.
|
||||
|
||||
### Wide layout
|
||||
#### Wide layout
|
||||
|
||||
Set whether wide layout is enabled or not. Wide layout is enabled by default.
|
||||
|
||||
- **On -** Wide layout is enabled.
|
||||
- **Off -** Wide layout is disabled.
|
||||
- **On** - Wide layout is enabled.
|
||||
- **Off** - Wide layout is disabled.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
This option is only applicable when **Text mode** is set to **Value and name**. When wide layout is enabled, the value and name are displayed side-by-side with the value on the right, if the panel is wide enough. When wide layout is disabled, the value is always rendered underneath the name.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Color mode
|
||||
|
||||
Select a color mode.
|
||||
|
||||
- **None -** No color applied to the value.
|
||||
- **Value -** Applies color to the value and graph area.
|
||||
- **Background Gradient -** Applies color to the value, graph area, and background, with a slight background gradient.
|
||||
- **Background Solid -** Applies color to the value, graph area, and background, with a solid background color.
|
||||
|
||||
### Graph mode
|
||||
|
||||
Select a graph and sparkline mode.
|
||||
|
||||
- **None -** Hides the graph and only shows the value.
|
||||
- **Area -** Shows the area graph below the value. This requires that your query returns a time column.
|
||||
|
||||
### Text alignment
|
||||
|
||||
Choose an alignment mode.
|
||||
|
||||
- **Auto -** If only a single value is shown (no repeat), then the value is centered. If multiple series or rows are shown, then the value is left-aligned.
|
||||
- **Center -** Stat value is centered.
|
||||
|
||||
### Show percent change
|
||||
|
||||
Set whether percent change is displayed or not. Disabled by default.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
This option is not applicable when the **Show** setting, under **Value options**, is set to **All values**.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Percent change color mode
|
||||
|
||||
This option is only displayed when **Show percent change** is enabled. Choose from:
|
||||
|
||||
- **Standard -** Green if the percent change is positive, red if the percent change is negative.
|
||||
- **Inverted -** Red if the percent change is positive, green if the percent change is negative.
|
||||
- **Same as Value -** Use the same color as the value.
|
||||
|
||||
## Text size
|
||||
### Text size
|
||||
|
||||
Adjust the sizes of the gauge text.
|
||||
|
||||
- **Title -** Enter a numeric value for the gauge title size.
|
||||
- **Value -** Enter a numeric value for the gauge value size.
|
||||
- **Title** - Enter a numeric value for the gauge title size.
|
||||
- **Value** - Enter a numeric value for the gauge value size.
|
||||
|
||||
## Standard options
|
||||
### Standard options
|
||||
|
||||
{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Data links
|
||||
### Data links
|
||||
|
||||
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Value mappings
|
||||
### Value mappings
|
||||
|
||||
{{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Thresholds
|
||||
### Thresholds
|
||||
|
||||
{{< docs/shared lookup="visualizations/thresholds-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Field overrides
|
||||
### Field overrides
|
||||
|
||||
{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
@@ -174,6 +174,10 @@ Name of the TrueType font file with italic style.
|
||||
|
||||
Maximum number of panel rendering request retries before returning an error. To disable the retry feature, enter `0`. This is available in public preview and requires the `reportingRetries` feature toggle.
|
||||
|
||||
### allowed_domains
|
||||
|
||||
Allowed domains to receive reports. Use an asterisk (`*`) to allow all domains. Use a comma-separated list to allow multiple domains. Example: allowed_domains = grafana.com, example.org
|
||||
|
||||
## [auditing]
|
||||
|
||||
[Auditing]({{< relref "../../configure-security/audit-grafana" >}}) allows you to track important changes to your Grafana instance. By default, audit logs are logged to file but the auditing feature also supports sending logs directly to Loki.
|
||||
|
||||
@@ -21,67 +21,67 @@ For more information about feature release stages, refer to [Release life cycle
|
||||
|
||||
Most [generally available](https://grafana.com/docs/release-life-cycle/#general-availability) features are enabled by default. You can disable these feature by setting the feature flag to "false" in the configuration.
|
||||
|
||||
| Feature toggle name | Description | Enabled by default |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
|
||||
| `publicDashboards` | [Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version. | Yes |
|
||||
| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes |
|
||||
| `featureHighlights` | Highlight Grafana Enterprise features | |
|
||||
| `correlations` | Correlations page | Yes |
|
||||
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
|
||||
| `accessControlOnCall` | Access control primitives for OnCall | Yes |
|
||||
| `nestedFolders` | Enable folder nesting | Yes |
|
||||
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
|
||||
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
|
||||
| `prometheusMetricEncyclopedia` | Adds the metrics explorer component to the Prometheus query builder as an option in metric select | Yes |
|
||||
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
|
||||
| `lokiMetricDataplane` | Changes metric responses from Loki to be compliant with the dataplane specification. | Yes |
|
||||
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
|
||||
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
|
||||
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
|
||||
| `transformationsRedesign` | Enables the transformations redesign | Yes |
|
||||
| `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source | |
|
||||
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
|
||||
| `prometheusConfigOverhaulAuth` | Update the Prometheus configuration page with the new auth component | Yes |
|
||||
| `alertingNoDataErrorExecution` | Changes how Alerting state manager handles execution of NoData/Error | Yes |
|
||||
| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes |
|
||||
| `dashgpt` | Enable AI powered features in dashboards | Yes |
|
||||
| `alertingInsights` | Show the new alerting insights landing page | Yes |
|
||||
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
|
||||
| `formatString` | Enable format string transformer | Yes |
|
||||
| `transformationsVariableSupport` | Allows using variables in transformations | Yes |
|
||||
| `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | Yes |
|
||||
| `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | Yes |
|
||||
| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes |
|
||||
| `managedPluginsInstall` | Install managed plugins directly from plugins catalog | Yes |
|
||||
| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes |
|
||||
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
|
||||
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
|
||||
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
|
||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
|
||||
| `exploreMetrics` | Enables the new Explore Metrics core app | Yes |
|
||||
| `alertingSimplifiedRouting` | Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule | Yes |
|
||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
|
||||
| `lokiQueryHints` | Enables query hints for Loki | Yes |
|
||||
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
|
||||
| `promQLScope` | In-development feature that will allow injection of labels into prometheus queries. | Yes |
|
||||
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
|
||||
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
|
||||
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
|
||||
| `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. | Yes |
|
||||
| `newDashboardSharingComponent` | Enables the new sharing drawer design | |
|
||||
| `notificationBanner` | Enables the notification banner UI and API | Yes |
|
||||
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
|
||||
| `pinNavItems` | Enables pinning of nav items | Yes |
|
||||
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes |
|
||||
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
|
||||
| `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes |
|
||||
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes |
|
||||
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
|
||||
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
|
||||
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
|
||||
| Feature toggle name | Description | Enabled by default |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
|
||||
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
|
||||
| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes |
|
||||
| `featureHighlights` | Highlight Grafana Enterprise features | |
|
||||
| `correlations` | Correlations page | Yes |
|
||||
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
|
||||
| `accessControlOnCall` | Access control primitives for OnCall | Yes |
|
||||
| `nestedFolders` | Enable folder nesting | Yes |
|
||||
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
|
||||
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
|
||||
| `prometheusMetricEncyclopedia` | Adds the metrics explorer component to the Prometheus query builder as an option in metric select | Yes |
|
||||
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
|
||||
| `lokiMetricDataplane` | Changes metric responses from Loki to be compliant with the dataplane specification. | Yes |
|
||||
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
|
||||
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
|
||||
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
|
||||
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
|
||||
| `transformationsRedesign` | Enables the transformations redesign | Yes |
|
||||
| `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source | |
|
||||
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
|
||||
| `prometheusConfigOverhaulAuth` | Update the Prometheus configuration page with the new auth component | Yes |
|
||||
| `alertingNoDataErrorExecution` | Changes how Alerting state manager handles execution of NoData/Error | Yes |
|
||||
| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes |
|
||||
| `dashgpt` | Enable AI powered features in dashboards | Yes |
|
||||
| `alertingInsights` | Show the new alerting insights landing page | Yes |
|
||||
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
|
||||
| `formatString` | Enable format string transformer | Yes |
|
||||
| `transformationsVariableSupport` | Allows using variables in transformations | Yes |
|
||||
| `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | Yes |
|
||||
| `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | Yes |
|
||||
| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes |
|
||||
| `managedPluginsInstall` | Install managed plugins directly from plugins catalog | Yes |
|
||||
| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes |
|
||||
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
|
||||
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
|
||||
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
|
||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
|
||||
| `exploreMetrics` | Enables the new Explore Metrics core app | Yes |
|
||||
| `alertingSimplifiedRouting` | Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule | Yes |
|
||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
|
||||
| `lokiQueryHints` | Enables query hints for Loki | Yes |
|
||||
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
|
||||
| `promQLScope` | In-development feature that will allow injection of labels into prometheus queries. | Yes |
|
||||
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
|
||||
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
|
||||
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
|
||||
| `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. | Yes |
|
||||
| `newDashboardSharingComponent` | Enables the new sharing drawer design | |
|
||||
| `notificationBanner` | Enables the notification banner UI and API | Yes |
|
||||
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
|
||||
| `pinNavItems` | Enables pinning of nav items | Yes |
|
||||
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes |
|
||||
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
|
||||
| `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes |
|
||||
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes |
|
||||
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
|
||||
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
|
||||
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
|
||||
|
||||
## Public preview feature toggles
|
||||
|
||||
@@ -148,7 +148,6 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `alertStateHistoryLokiSecondary` | Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations. |
|
||||
| `alertStateHistoryLokiPrimary` | Enable a remote Loki instance as the primary source for state history reads. |
|
||||
| `alertStateHistoryLokiOnly` | Disable Grafana alerts from emitting annotations when a remote Loki instance is available. |
|
||||
| `unifiedRequestLog` | Writes error logs to the request logger |
|
||||
| `extraThemes` | Enables extra themes |
|
||||
| `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor |
|
||||
| `pluginsFrontendSandbox` | Enables the plugins frontend sandbox |
|
||||
@@ -189,6 +188,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend |
|
||||
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
|
||||
| `onPremToCloudMigrationsAlerts` | Enables the migration of alerts and its child resources to your Grafana Cloud stack. Requires `onPremToCloudMigrations` to be enabled in conjunction. |
|
||||
| `onPremToCloudMigrationsAuthApiMig` | Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction. |
|
||||
| `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. |
|
||||
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
|
||||
| `kubernetesAggregator` | Enable grafana's embedded kube-aggregator |
|
||||
|
||||
@@ -217,16 +217,6 @@ Create a notification policy if you want to handle metrics returned by alert rul
|
||||
|
||||
The alert rule that you are about to create is meant to monitor web traffic page views. The objective is to explore what an alert instance is and how to leverage routing individual alert instances by using label matchers and notification policies.
|
||||
|
||||
### Add a data source
|
||||
|
||||
Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/datasources/testdata/) that creates simulated time series data.
|
||||
|
||||
1. In Grafana navigate to **Connections > Add new connection**.
|
||||
1. Search for **TestData**.
|
||||
1. Click **Add new data source**.
|
||||
1. Click **Save & test**.
|
||||
|
||||
You should see a message confirming that the data source is working.
|
||||
<!-- INTERACTIVE page step5.md END -->
|
||||
<!-- INTERACTIVE page step6.md START -->
|
||||
|
||||
@@ -243,6 +233,8 @@ Make it short and descriptive as this will appear in your alert notification. Fo
|
||||
|
||||
In this section, we use the default options for Grafana-managed alert rule creation. The default options let us define the query, a expression (used to manipulate the data -- the `WHEN` field in the UI), and the condition that must be met for the alert to be triggered (in default mode is the threshold).
|
||||
|
||||
Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/datasources/testdata/) that creates simulated time series data. This data source is included in the demo environment for this tutorial. If you're working in Grafana Cloud or your own local Grafana instance, you can add the data source through the **Connections** menu.
|
||||
|
||||
1. Select **TestData** data source from the drop-down menu.
|
||||
1. From **Scenario** select **CSV Content**.
|
||||
1. In the Query editor, switch to **Code** mode by clicking the button on the right.
|
||||
|
||||
@@ -189,15 +189,9 @@ Next, we establish an [alert rule](https://grafana.com/docs/grafana/latest/alert
|
||||
|
||||
In this section, we use the default options for Grafana-managed alert rule creation. The default options let us define the query, a expression (used to manipulate the data -- the `WHEN` field in the UI), and the condition that must be met for the alert to be triggered (in default mode is the threshold).
|
||||
|
||||
1. Select the **Prometheus** data source from the drop-down menu.
|
||||
1. In the Query editor, switch to **Code** mode by clicking the button on the right.
|
||||
1. Enter the following query:
|
||||
Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/datasources/testdata/) that creates simulated time series data. This data source is included in the demo environment for this tutorial. If you're working in Grafana Cloud or your own local Grafana instance, you can add the data source through the **Connections** menu.
|
||||
|
||||
```promql
|
||||
vector(1)
|
||||
```
|
||||
|
||||
In Prometheus, `vector(1)` is a special type of PromQL query that generates a constant vector. This is useful in testing and query manipulation, where you might need a constant value for calculations or comparisons. This query allows you to create an alert rule that is always firing.
|
||||
1. Select the **TestData** data source from the drop-down menu.
|
||||
|
||||
1. In the **Alert condition** section:
|
||||
|
||||
@@ -205,9 +199,9 @@ In this section, we use the default options for Grafana-managed alert rule creat
|
||||
|
||||
1. Click **Preview alert rule condition** to run the query.
|
||||
|
||||
It should return a single sample with the value 1 at the current timestamp. And, since `1` is above `0`, the alert condition has been met, and the alert rule state is `Firing`.
|
||||
It should return random time series data. The alert rule state should be `Firing`.
|
||||
|
||||
{{< figure src="/media/docs/alerting/firing-alert-preview.png" max-width="1200px" caption="A preview of a firing alert" >}}
|
||||
{{< figure src="/media/docs/alerting/random-walk-firing-alert-rule.png" max-width="1200px" caption="A preview of a firing alert" >}}
|
||||
|
||||
### Set evaluation behavior
|
||||
|
||||
|
||||
@@ -392,24 +392,23 @@ To begin, let's set up a webhook contact point. Once we have a usable endpoint,
|
||||
|
||||
### Create a contact point for Grafana-managed alert rules
|
||||
|
||||
In this step, we set up a new contact point. This contact point uses the _webhooks_ channel. In order to make this work, we also need an endpoint for our webhook channel to receive the alert notification. We can use [requestbin.com](https://requestbin.com) to quickly set up that test endpoint. This way we can make sure that our alert manager is actually sending a notification somewhere.
|
||||
In this step, we set up a new contact point. This contact point uses the _webhooks_ channel. In order to make this work, we also need an endpoint for our webhook channel to receive the alert notification. We can use [Webhook.site](https://webhook.site/) to quickly set up that test endpoint. This way we can make sure that our alert manager is actually sending a notification somewhere.
|
||||
|
||||
1. Browse to [requestbin.com](https://requestbin.com).
|
||||
1. Under the **Create Request Bin** button, click the link to create a **public bin** instead.
|
||||
1. From Request Bin, copy the endpoint URL.
|
||||
1. Browse to [Webhook.site](https://webhook.site/).
|
||||
1. Copy Your unique URL.
|
||||
|
||||
Your Request Bin is now waiting for the first request.
|
||||
Your webhook endpoint is now waiting for the first request.
|
||||
|
||||
Next, let's configure a Contact Point in Grafana's Alerting UI to send notifications to our Request Bin.
|
||||
Next, let's configure a Contact Point in Grafana's Alerting UI to send notifications to our webhook endpoint.
|
||||
|
||||
1. Return to Grafana. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Manage Contact points**.
|
||||
1. Click **+ Add contact point**.
|
||||
1. In **Name**, write **RequestBin**.
|
||||
1. In **Name**, write **Webhook**.
|
||||
1. In **Integration**, choose **Webhook**.
|
||||
1. In **URL**, paste the endpoint to your request bin.
|
||||
1. In **URL**, paste the endpoint to your webhook endpoint.
|
||||
|
||||
1. Click **Test**, and then click **Send test notification** to send a test alert notification to your request bin.
|
||||
1. Navigate back to the Request Bin you created earlier. On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent.
|
||||
1. Click **Test**, and then click **Send test notification** to send a test alert notification to your webhook endpoint.
|
||||
1. Navigate back to the webhook endpoint you created earlier. On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent.
|
||||
1. Return to Grafana and click **Save contact point**.
|
||||
|
||||
We have now created a dummy webhook endpoint and created a new Alerting Contact Point in Grafana. Now we can create an alert rule and link it to this new channel.
|
||||
@@ -424,24 +423,25 @@ Now that Grafana knows how to notify us, it's time to set up an alert rule:
|
||||
|
||||
1. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Alert rules**.
|
||||
|
||||
In this tutorial, we use the default options for Grafana-managed alert rule creation. The default options let us define the query, a expression (used to manipulate the data -- the `WHEN` field in the UI), and the condition that must be met for the alert to be triggered (in default mode is the threshold).
|
||||
In this tutorial, we use the advanced options for Grafana-managed alert rule creation. The advanced options let us define queries, expressions (used to manipulate the data), and the condition that must be met for the alert to be triggered (the default condition is the threshold).
|
||||
|
||||
1. Click **+ New alert rule**.
|
||||
1. For **Section 1**, name the rule `fundamentals-test`.
|
||||
1. For **Section 2**, Find the **query A** box, and choose your Prometheus data source.
|
||||
1. For **Section 2**, toggle the **Advanced options** button.
|
||||
1. Find the **query A** box, and choose your Prometheus data source.
|
||||
1. Enter the same Prometheus query that we used in our earlier panel:
|
||||
|
||||
```
|
||||
sum(rate(tns_request_duration_seconds_count[5m])) by(route)
|
||||
```
|
||||
|
||||
1. Scroll down to bottom of section #2 and click the **Preview alert rule condition** button. You should see some data returned.
|
||||
1. Keep `Last` as the value for the reducer function (`WHEN`), and `0.2` as the threshold value. This is the value above which the alert rule should trigger. [You can read more about queries and conditions here](/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/#expression-queries).
|
||||
1. Keep expressions **B** and **C** as they are. These expressions (Reduce and Threshold, respectively) are included by default when creating a new rule. Enter `0.2` as threshold value. You can read more about queries and conditions [here](/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/#expression-queries).
|
||||
1. Scroll down to bottom of section #2 and click the **Preview** button. You should see some data returned.
|
||||
1. In **Section 3**, in Folder, create a new folder, by clicking `New folder` and typing a name for the folder. This folder contains our alert rules. For example: `fundamentals`. Then, click `create`.
|
||||
1. In the Evaluation group, repeat the above step to create a new one. Name it `fundamentals` too.
|
||||
1. Choose an Evaluation interval (how often the alert rule are evaluated). For example, every `10s` (10 seconds).
|
||||
1. Set the pending period. This is the time that a condition has to be met until the alert instance enters in Firing state and a notification is sent. Enter `0s`. For the purposes of this tutorial, the evaluation interval is intentionally short. This makes it easier to test. This setting makes Grafana wait until an alert instance has fired for a given time before Grafana sends the notification.
|
||||
1. In **Section 4**, choose **RequestBin** as the **Contact point**.
|
||||
1. In **Section 4**, choose **Webhook** as the **Contact point**.
|
||||
1. Click **Save rule and exit** at the top of the page.
|
||||
|
||||
### Trigger a Grafana-managed alert rule
|
||||
@@ -451,7 +451,7 @@ We have now configured an alert rule and a contact point. Now let's see if we ca
|
||||
1. Browse to [localhost:8081](http://localhost:8081).
|
||||
1. Add a new title and URL, repeatedly click the vote button, or refresh the page to generate a traffic spike.
|
||||
|
||||
Once the query `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` returns a value greater than `0.2` Grafana triggers our alert rule. Browse to the Request Bin we created earlier and find the sent Grafana alert notification with details and metadata.
|
||||
Once the query `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` returns a value greater than `0.2` Grafana triggers our alert rule. Browse to the webhook endpoint we created earlier and find the sent Grafana alert notification with details and metadata.
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
|
||||
@@ -200,6 +200,8 @@ lineage: schemas: [{
|
||||
current?: #VariableOption
|
||||
// Whether multiple values can be selected or not from variable value list
|
||||
multi?: bool | *false
|
||||
// Allow custom values to be entered in the variable
|
||||
allowCustomValue?: bool | *true
|
||||
// Options that can be selected for a variable.
|
||||
options?: [...#VariableOption]
|
||||
// Options to config when to refresh a variable
|
||||
|
||||
+6
-6
@@ -224,7 +224,7 @@
|
||||
"react-test-renderer": "18.2.0",
|
||||
"redux-mock-store": "1.5.5",
|
||||
"rimraf": "6.0.1",
|
||||
"rudder-sdk-js": "2.48.26",
|
||||
"rudder-sdk-js": "2.48.29",
|
||||
"sass": "1.80.7",
|
||||
"sass-loader": "16.0.3",
|
||||
"smtp-tester": "^2.1.0",
|
||||
@@ -250,8 +250,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bsull/augurs": "^0.6.0",
|
||||
"@emotion/css": "11.13.4",
|
||||
"@emotion/react": "11.13.3",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@emotion/react": "11.13.5",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.2",
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@formatjs/intl-durationformat": "^0.6.0",
|
||||
@@ -260,7 +260,7 @@
|
||||
"@grafana/azure-sdk": "0.0.5",
|
||||
"@grafana/data": "workspace:*",
|
||||
"@grafana/e2e-selectors": "workspace:*",
|
||||
"@grafana/experimental": "2.1.2",
|
||||
"@grafana/experimental": "2.1.4",
|
||||
"@grafana/faro-core": "^1.3.6",
|
||||
"@grafana/faro-web-sdk": "^1.3.6",
|
||||
"@grafana/faro-web-tracing": "^1.8.2",
|
||||
@@ -272,8 +272,8 @@
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/saga-icons": "workspace:*",
|
||||
"@grafana/scenes": "5.25.0",
|
||||
"@grafana/scenes-react": "5.25.0",
|
||||
"@grafana/scenes": "5.25.1",
|
||||
"@grafana/scenes-react": "5.25.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`setFieldConfigDefaults applies the base threshold when one does not exist in the config 1`] = `
|
||||
{
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 50,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -730,6 +730,33 @@ describe('setFieldConfigDefaults', () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('applies the base threshold when one does not exist in the config', () => {
|
||||
const defaultConfig: FieldConfig = {
|
||||
thresholds: {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [{ value: -Infinity, color: 'red' }],
|
||||
},
|
||||
};
|
||||
|
||||
const config: FieldConfig = {
|
||||
thresholds: {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [{ value: 50, color: 'green' }],
|
||||
},
|
||||
};
|
||||
|
||||
const context: FieldOverrideEnv = {
|
||||
data: [],
|
||||
field: { type: FieldType.number } as Field,
|
||||
dataFrameIndex: 0,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
};
|
||||
|
||||
setFieldConfigDefaults(config, defaultConfig, context);
|
||||
|
||||
expect(config.thresholds).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDynamicConfigValue', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isNumber, set, unset, get, cloneDeep } from 'lodash';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
|
||||
import { VariableFormatID } from '@grafana/schema';
|
||||
import { ThresholdsMode, VariableFormatID } from '@grafana/schema';
|
||||
|
||||
import { compareArrayValues, compareDataFrameStructures } from '../dataframe/frameComparisons';
|
||||
import { guessFieldTypeForField } from '../dataframe/processDataFrame';
|
||||
@@ -347,6 +347,18 @@ export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfi
|
||||
// Combine the data source links and the panel default config links
|
||||
config.links = [...config.links, ...defaults.links];
|
||||
}
|
||||
|
||||
// if we have a base threshold set by default but not on the config, we need to merge it in
|
||||
const defaultBaseStep =
|
||||
defaults?.thresholds?.mode === ThresholdsMode.Absolute &&
|
||||
defaults.thresholds?.steps.find((step) => step.value === -Infinity);
|
||||
if (
|
||||
config.thresholds?.mode === ThresholdsMode.Absolute &&
|
||||
!config.thresholds.steps.some((step) => step.value === -Infinity) &&
|
||||
defaultBaseStep
|
||||
) {
|
||||
config.thresholds.steps = [defaultBaseStep, ...config.thresholds.steps];
|
||||
}
|
||||
for (const fieldConfigProperty of context.fieldConfigRegistry.list()) {
|
||||
if (fieldConfigProperty.isCustom && !config.custom) {
|
||||
config.custom = {};
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface FeatureToggles {
|
||||
['live-service-web-worker']?: boolean;
|
||||
queryOverLive?: boolean;
|
||||
panelTitleSearch?: boolean;
|
||||
publicDashboards?: boolean;
|
||||
publicDashboardsEmailSharing?: boolean;
|
||||
publicDashboardsScene?: boolean;
|
||||
lokiExperimentalStreaming?: boolean;
|
||||
@@ -159,6 +158,7 @@ export interface FeatureToggles {
|
||||
jitterAlertRulesWithinGroups?: boolean;
|
||||
onPremToCloudMigrations?: boolean;
|
||||
onPremToCloudMigrationsAlerts?: boolean;
|
||||
onPremToCloudMigrationsAuthApiMig?: boolean;
|
||||
alertingSaveStatePeriodic?: boolean;
|
||||
promQLScope?: boolean;
|
||||
logQLScope?: boolean;
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface AdHocVariableModel extends BaseVariableModel {
|
||||
* Static keys that override any dynamic keys from the datasource.
|
||||
*/
|
||||
defaultKeys?: MetricFindValue[];
|
||||
allowCustomValue?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupByVariableModel extends VariableWithOptions {
|
||||
@@ -127,6 +128,7 @@ export interface VariableWithMultiSupport extends VariableWithOptions {
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
allValue?: string | null;
|
||||
allowCustomValue?: boolean;
|
||||
}
|
||||
|
||||
export interface VariableWithOptions extends BaseVariableModel {
|
||||
|
||||
@@ -414,6 +414,9 @@ export const versionedPages = {
|
||||
generalHideSelectV2: {
|
||||
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Hide select',
|
||||
},
|
||||
selectionOptionsAllowCustomValueSwitch: {
|
||||
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Allow Custom Value switch',
|
||||
},
|
||||
selectionOptionsMultiSwitch: {
|
||||
'10.4.0': 'data-testid Variable editor Form Multi switch',
|
||||
[MIN_GRAFANA_VERSION]: 'Variable editor Form Multi switch',
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"not IE 11"
|
||||
],
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.4",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/ui": "11.4.0-pre",
|
||||
"@leeoniya/ufuzzy": "1.0.16",
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.4",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/e2e-selectors": "11.4.0-pre",
|
||||
"@grafana/experimental": "2.1.2",
|
||||
"@grafana/experimental": "2.1.4",
|
||||
"@grafana/runtime": "11.4.0-pre",
|
||||
"@grafana/schema": "11.4.0-pre",
|
||||
"@grafana/ui": "11.4.0-pre",
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.4",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/experimental": "2.1.2",
|
||||
"@grafana/faro-web-sdk": "1.12.1",
|
||||
"@grafana/experimental": "2.1.4",
|
||||
"@grafana/faro-web-sdk": "1.12.2",
|
||||
"@grafana/runtime": "11.4.0-pre",
|
||||
"@grafana/schema": "11.4.0-pre",
|
||||
"@grafana/ui": "11.4.0-pre",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as H from 'history';
|
||||
import React, { useContext } from 'react';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
import { deprecationWarning, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||
import { attachDebugger, createLogger } from '@grafana/ui';
|
||||
@@ -21,6 +22,7 @@ export interface LocationService {
|
||||
getHistory: () => H.History;
|
||||
getSearch: () => URLSearchParams;
|
||||
getSearchObject: () => UrlQueryMap;
|
||||
getLocationObservable: () => Observable<H.Location>;
|
||||
|
||||
/**
|
||||
* This is from the old LocationSrv interface
|
||||
@@ -31,6 +33,7 @@ export interface LocationService {
|
||||
/** @internal */
|
||||
export class HistoryWrapper implements LocationService {
|
||||
private readonly history: H.History;
|
||||
private locationObservable: BehaviorSubject<H.Location>;
|
||||
|
||||
constructor(history?: H.History) {
|
||||
// If no history passed create an in memory one if being called from test
|
||||
@@ -40,6 +43,12 @@ export class HistoryWrapper implements LocationService {
|
||||
? H.createMemoryHistory({ initialEntries: ['/'] })
|
||||
: H.createBrowserHistory({ basename: config.appSubUrl ?? '/' }));
|
||||
|
||||
this.locationObservable = new BehaviorSubject(this.history.location);
|
||||
|
||||
this.history.listen((location) => {
|
||||
this.locationObservable.next(location);
|
||||
});
|
||||
|
||||
this.partial = this.partial.bind(this);
|
||||
this.push = this.push.bind(this);
|
||||
this.replace = this.replace.bind(this);
|
||||
@@ -48,6 +57,10 @@ export class HistoryWrapper implements LocationService {
|
||||
this.getLocation = this.getLocation.bind(this);
|
||||
}
|
||||
|
||||
getLocationObservable() {
|
||||
return this.locationObservable.asObservable();
|
||||
}
|
||||
|
||||
getHistory() {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { locationService as mainLocationService } from './LocationService';
|
||||
import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
|
||||
|
||||
export const SidecarContext_EXPERIMENTAL = createContext<SidecarService_EXPERIMENTAL>(
|
||||
sidecarServiceSingleton_EXPERIMENTAL
|
||||
);
|
||||
|
||||
const HIDDEN_ROUTES = ['/login'];
|
||||
|
||||
/**
|
||||
* This is the main way to interact with the sidecar service inside a react context. It provides a wrapper around the
|
||||
* service props so that even though they are observables we just pass actual values to the components.
|
||||
@@ -27,32 +24,25 @@ export function useSidecar_EXPERIMENTAL() {
|
||||
const initialContext = useObservable(service.initialContextObservable, service.initialContext);
|
||||
const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId);
|
||||
const locationService = service.getLocationService();
|
||||
const forceHidden = HIDDEN_ROUTES.includes(mainLocationService.getLocation().pathname);
|
||||
|
||||
return {
|
||||
activePluginId: forceHidden ? undefined : activePluginId,
|
||||
initialContext: forceHidden ? undefined : initialContext,
|
||||
activePluginId,
|
||||
initialContext,
|
||||
locationService,
|
||||
// TODO: currently this allows anybody to open any app, in the future we should probably scope this to the
|
||||
// current app but that means we will need to incorporate this better into the plugin platform APIs which
|
||||
// we will do once the functionality is reasonably stable
|
||||
openApp: (pluginId: string, context?: unknown) => {
|
||||
if (forceHidden) {
|
||||
return;
|
||||
}
|
||||
return service.openApp(pluginId, context);
|
||||
},
|
||||
openAppV2: (pluginId: string, path?: string) => {
|
||||
if (forceHidden) {
|
||||
return;
|
||||
}
|
||||
return service.openAppV2(pluginId, path);
|
||||
},
|
||||
openAppV3: (options: { pluginId: string; path?: string; follow?: boolean }) => {
|
||||
return service.openAppV3(options);
|
||||
},
|
||||
closeApp: () => service.closeApp(),
|
||||
isAppOpened: (pluginId: string) => {
|
||||
if (forceHidden) {
|
||||
return false;
|
||||
}
|
||||
return service.isAppOpened(pluginId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import { config } from '../config';
|
||||
|
||||
import { HistoryWrapper } from './LocationService';
|
||||
import { SidecarService_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
|
||||
|
||||
describe('SidecarService_EXPERIMENTAL', () => {
|
||||
beforeEach(() => {
|
||||
let mainLocationService: HistoryWrapper;
|
||||
let sidecarService: SidecarService_EXPERIMENTAL;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.appSidecar = true;
|
||||
});
|
||||
afterEach(() => {
|
||||
config.featureToggles.appSidecar = undefined;
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.appSidecar = false;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/explore'] }));
|
||||
sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
|
||||
});
|
||||
|
||||
it('has the correct state after opening and closing an app', () => {
|
||||
const sidecarService = new SidecarService_EXPERIMENTAL();
|
||||
sidecarService.openApp('pluginId', { filter: 'test' });
|
||||
|
||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
||||
@@ -25,7 +36,6 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
});
|
||||
|
||||
it('has the correct state after opening and closing an app v2', () => {
|
||||
const sidecarService = new SidecarService_EXPERIMENTAL();
|
||||
sidecarService.openAppV2('pluginId', '/test');
|
||||
|
||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
||||
@@ -37,8 +47,19 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('has the correct state after opening and closing an app v3', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId', path: '/test' });
|
||||
|
||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId/test');
|
||||
|
||||
sidecarService.closeApp();
|
||||
expect(sidecarService.activePluginId).toBe(undefined);
|
||||
expect(sidecarService.initialContext).toBe(undefined);
|
||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('reports correct opened state', () => {
|
||||
const sidecarService = new SidecarService_EXPERIMENTAL();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
|
||||
sidecarService.openApp('pluginId');
|
||||
@@ -49,7 +70,6 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
});
|
||||
|
||||
it('reports correct opened state v2', () => {
|
||||
const sidecarService = new SidecarService_EXPERIMENTAL();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
|
||||
sidecarService.openAppV2('pluginId');
|
||||
@@ -58,4 +78,46 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
sidecarService.closeApp();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports correct opened state v3', () => {
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
|
||||
sidecarService.closeApp();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('autocloses on not allowed routes', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/config');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('autocloses on when changing route', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/a/other-app');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not autocloses when set to follow', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/a/other-app');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
});
|
||||
|
||||
it('autocloses on not allowed routes when set to follow', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/config');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,17 @@ import { BehaviorSubject, map, Observable } from 'rxjs';
|
||||
import { reportInteraction } from '../analytics/utils';
|
||||
import { config } from '../config';
|
||||
|
||||
import { HistoryWrapper, LocationService, locationService } from './LocationService';
|
||||
import { HistoryWrapper, locationService as mainLocationService, LocationService } from './LocationService';
|
||||
|
||||
// Only allow sidecar to be opened on these routes. It does not seem to make sense to keep the sidecar opened on
|
||||
// config/admin pages for example.
|
||||
// At this moment let's be restrictive about where the sidecar can show and add more routes if there is a need.
|
||||
const ALLOW_ROUTES = [
|
||||
/(^\/d\/)/, // dashboards
|
||||
/^\/explore/, // explore + explore metrics
|
||||
/^\/a\/[^\/]+/, // app plugins
|
||||
/^\/alerting/,
|
||||
];
|
||||
|
||||
/**
|
||||
* This is a service that handles state and operation of a sidecar feature (sideview to render a second app in grafana).
|
||||
@@ -19,15 +29,26 @@ import { HistoryWrapper, LocationService, locationService } from './LocationServ
|
||||
*/
|
||||
export class SidecarService_EXPERIMENTAL {
|
||||
private _initialContext: BehaviorSubject<unknown | undefined>;
|
||||
private memoryLocationService: LocationService;
|
||||
private history: LocationStorageHistory;
|
||||
|
||||
constructor() {
|
||||
private sidecarLocationService: LocationService;
|
||||
private mainLocationService: LocationService;
|
||||
|
||||
// If true we don't close the sidecar when user navigates to another app or part of Grafana from where the sidecar
|
||||
// was opened.
|
||||
private follow = false;
|
||||
|
||||
// Keep track of where the sidecar was originally opened for autoclose behaviour.
|
||||
private mainLocationWhenOpened: string | undefined;
|
||||
|
||||
private mainOnAllowedRoute = false;
|
||||
|
||||
constructor(mainLocationService: LocationService) {
|
||||
this._initialContext = new BehaviorSubject<unknown | undefined>(undefined);
|
||||
// We need a local ref for this so we can tap into the location changes and drive rerendering of components based
|
||||
// on it without having a parent Router.
|
||||
this.history = createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' });
|
||||
this.memoryLocationService = new HistoryWrapper(this.history);
|
||||
this.mainLocationService = mainLocationService;
|
||||
this.sidecarLocationService = new HistoryWrapper(
|
||||
createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' })
|
||||
);
|
||||
this.handleMainLocationChanges();
|
||||
}
|
||||
|
||||
private assertFeatureEnabled() {
|
||||
@@ -39,6 +60,48 @@ export class SidecarService_EXPERIMENTAL {
|
||||
return true;
|
||||
}
|
||||
|
||||
private updateMainLocationWhenOpened() {
|
||||
const pathname = this.mainLocationService.getLocation().pathname;
|
||||
for (const route of ALLOW_ROUTES) {
|
||||
const match = pathname.match(route)?.[0];
|
||||
if (match) {
|
||||
this.mainLocationWhenOpened = match;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every time the main location changes we check if we should keep the sidecar open or close it based on list
|
||||
* of allowed routes and also based on the follow flag when opening the app.
|
||||
*/
|
||||
private handleMainLocationChanges() {
|
||||
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) =>
|
||||
this.mainLocationService.getLocation().pathname.match(prefix)
|
||||
);
|
||||
|
||||
this.mainLocationService.getLocationObservable().subscribe((location) => {
|
||||
if (!this.activePluginId) {
|
||||
return;
|
||||
}
|
||||
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) => location.pathname.match(prefix));
|
||||
|
||||
if (!this.mainOnAllowedRoute) {
|
||||
this.closeApp();
|
||||
return;
|
||||
}
|
||||
|
||||
// We check if we moved to some other app or part of grafana from where we opened the sidecar.
|
||||
const isTheSameLocation = Boolean(
|
||||
this.mainLocationWhenOpened && location.pathname.startsWith(this.mainLocationWhenOpened)
|
||||
);
|
||||
|
||||
if (!(isTheSameLocation || this.follow)) {
|
||||
this.closeApp();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current app id of the app in sidecar. This is most probably provisional. In the future
|
||||
* this should be driven by URL addressing so that routing for the apps don't change. Useful just internally
|
||||
@@ -47,7 +110,7 @@ export class SidecarService_EXPERIMENTAL {
|
||||
* @experimental
|
||||
*/
|
||||
get activePluginIdObservable() {
|
||||
return this.history.getLocationObservable().pipe(
|
||||
return this.sidecarLocationService.getLocationObservable().pipe(
|
||||
map((val) => {
|
||||
return getPluginIdFromUrl(val?.pathname || '');
|
||||
})
|
||||
@@ -75,11 +138,11 @@ export class SidecarService_EXPERIMENTAL {
|
||||
* @experimental
|
||||
*/
|
||||
get activePluginId() {
|
||||
return getPluginIdFromUrl(this.memoryLocationService.getLocation().pathname);
|
||||
return getPluginIdFromUrl(this.sidecarLocationService.getLocation().pathname);
|
||||
}
|
||||
|
||||
getLocationService() {
|
||||
return this.memoryLocationService;
|
||||
return this.sidecarLocationService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,26 +151,41 @@ export class SidecarService_EXPERIMENTAL {
|
||||
* @experimental
|
||||
*/
|
||||
openApp(pluginId: string, context?: unknown) {
|
||||
if (!this.assertFeatureEnabled()) {
|
||||
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
|
||||
return;
|
||||
}
|
||||
this._initialContext.next(context);
|
||||
this.memoryLocationService.push({ pathname: `/a/${pluginId}` });
|
||||
|
||||
reportInteraction('sidecar_service_open_app', { pluginId, version: 1 });
|
||||
this.openAppV3({ pluginId, follow: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an app in a sidecar. You can also relative path inside the app to open.
|
||||
* @deprecated
|
||||
* @experimental
|
||||
*/
|
||||
openAppV2(pluginId: string, path?: string) {
|
||||
if (!this.assertFeatureEnabled()) {
|
||||
this.openAppV3({ pluginId, path, follow: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an app in a sidecar. You can also relative path inside the app to open.
|
||||
* @param options.pluginId Plugin ID of the app to open
|
||||
* @param options.path Relative path inside the app to open
|
||||
* @param options.follow If true, the sidecar will stay open even if the main location change to another app or
|
||||
* Grafana section
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
openAppV3(options: { pluginId: string; path?: string; follow?: boolean }) {
|
||||
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.memoryLocationService.push({ pathname: `/a/${pluginId}${path || ''}` });
|
||||
reportInteraction('sidecar_service_open_app', { pluginId, version: 2 });
|
||||
this.follow = options.follow || false;
|
||||
|
||||
this.updateMainLocationWhenOpened();
|
||||
this.sidecarLocationService.push({ pathname: `/a/${options.pluginId}${options.path || ''}` });
|
||||
reportInteraction('sidecar_service_open_app', { pluginId: options.pluginId, follow: options.follow });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +196,10 @@ export class SidecarService_EXPERIMENTAL {
|
||||
return;
|
||||
}
|
||||
|
||||
this.follow = false;
|
||||
this.mainLocationWhenOpened = undefined;
|
||||
this._initialContext.next(undefined);
|
||||
this.memoryLocationService.replace({ pathname: '/' });
|
||||
this.sidecarLocationService.replace({ pathname: '/' });
|
||||
|
||||
reportInteraction('sidecar_service_close_app');
|
||||
}
|
||||
@@ -160,7 +240,7 @@ function getPluginIdFromUrl(url: string) {
|
||||
function getMainAppPluginId() {
|
||||
// TODO: not great but we have to get a handle on the other locationService used for the main view and easiest way
|
||||
// right now is through this global singleton
|
||||
const { pathname } = locationService.getLocation();
|
||||
const { pathname } = mainLocationService.getLocation();
|
||||
|
||||
// A naive way to sort of simulate core features being an app and having an appID
|
||||
let mainApp = getPluginIdFromUrl(pathname);
|
||||
@@ -258,4 +338,4 @@ function createLocationStorageHistory(options: LocalStorageHistoryOptions): Loca
|
||||
};
|
||||
}
|
||||
|
||||
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL();
|
||||
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(mainLocationService);
|
||||
@@ -130,6 +130,10 @@ export interface VariableModel {
|
||||
* Custom all value
|
||||
*/
|
||||
allValue?: string;
|
||||
/**
|
||||
* Allow custom values to be entered in the variable
|
||||
*/
|
||||
allowCustomValue?: boolean;
|
||||
/**
|
||||
* Shows current selected variable text/value on the dashboard
|
||||
*/
|
||||
@@ -194,6 +198,7 @@ export interface VariableModel {
|
||||
}
|
||||
|
||||
export const defaultVariableModel: Partial<VariableModel> = {
|
||||
allowCustomValue: true,
|
||||
includeAll: false,
|
||||
multi: false,
|
||||
options: [],
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.4",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/e2e-selectors": "11.4.0-pre",
|
||||
"@grafana/experimental": "2.1.2",
|
||||
"@grafana/experimental": "2.1.4",
|
||||
"@grafana/runtime": "11.4.0-pre",
|
||||
"@grafana/ui": "11.4.0-pre",
|
||||
"@react-awesome-query-builder/ui": "6.6.3",
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
"not IE 11"
|
||||
],
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.4",
|
||||
"@emotion/react": "11.13.3",
|
||||
"@emotion/serialize": "1.3.2",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@emotion/react": "11.13.5",
|
||||
"@emotion/serialize": "1.3.3",
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/e2e-selectors": "11.4.0-pre",
|
||||
|
||||
@@ -36,6 +36,8 @@ export function ExpandedRow({ tableStyles, nestedData, rowIndex, width, cellHeig
|
||||
paddingLeft: EXPANDER_WIDTH,
|
||||
position: 'absolute',
|
||||
top,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
color: theme.colors.text.primary,
|
||||
};
|
||||
|
||||
top += height + theme.spacing.gridSize;
|
||||
|
||||
@@ -286,6 +286,7 @@ export const RowsList = (props: RowsListProps) => {
|
||||
const { bgColor, textColor } = rowBg(row.index);
|
||||
style.background = bgColor;
|
||||
style.color = textColor;
|
||||
style.borderLeft = `2px solid ${bgColor}`;
|
||||
}
|
||||
|
||||
// If there's a text wrapping field we set the height of it here
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AxisColorMode, AxisConfig, AxisPlacement, ScaleDistribution, ScaleDistr
|
||||
import { Field } from '../../components/Forms/Field';
|
||||
import { RadioButtonGroup } from '../../components/Forms/RadioButtonGroup/RadioButtonGroup';
|
||||
import { Input } from '../../components/Input/Input';
|
||||
import { Stack } from '../../components/Layout/Stack/Stack';
|
||||
import { Select } from '../../components/Select/Select';
|
||||
import { graphFieldOptions } from '../../components/uPlot/config';
|
||||
|
||||
@@ -163,21 +164,20 @@ const LOG_DISTRIBUTION_OPTIONS: Array<SelectableValue<number>> = [
|
||||
export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps<ScaleDistributionConfig>) => {
|
||||
const type = value?.type ?? ScaleDistribution.Linear;
|
||||
const log = value?.log ?? 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<RadioButtonGroup
|
||||
value={type}
|
||||
options={DISTRIBUTION_OPTIONS}
|
||||
onChange={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
type: v!,
|
||||
log: v === ScaleDistribution.Linear ? undefined : log,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Stack direction="column" gap={2}>
|
||||
<RadioButtonGroup
|
||||
value={type}
|
||||
options={DISTRIBUTION_OPTIONS}
|
||||
onChange={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
type: v!,
|
||||
log: v === ScaleDistribution.Linear ? undefined : log,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{(type === ScaleDistribution.Log || type === ScaleDistribution.Symlog) && (
|
||||
<Field label="Log base">
|
||||
<Select
|
||||
@@ -193,7 +193,7 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps
|
||||
</Field>
|
||||
)}
|
||||
{type === ScaleDistribution.Symlog && (
|
||||
<Field label="Linear threshold">
|
||||
<Field label="Linear threshold" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="1"
|
||||
value={value?.linearThreshold}
|
||||
@@ -206,6 +206,6 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -165,7 +165,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/dashboards/*", reqSignedIn, hs.Index)
|
||||
r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index)
|
||||
|
||||
if hs.Features.IsEnabledGlobally(featuremgmt.FlagPublicDashboards) && hs.Cfg.PublicDashboardsEnabled {
|
||||
if hs.Cfg.PublicDashboardsEnabled {
|
||||
// list public dashboards
|
||||
r.Get("/public-dashboards/list", reqSignedIn, hs.Index)
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
|
||||
)
|
||||
|
||||
// If public dashboards is enabled and we have a public dashboard, update meta values
|
||||
if hs.Features.IsEnabledGlobally(featuremgmt.FlagPublicDashboards) && hs.Cfg.PublicDashboardsEnabled {
|
||||
if hs.Cfg.PublicDashboardsEnabled {
|
||||
publicDashboard, err := hs.PublicDashboardsApi.PublicDashboardService.FindByDashboardUid(ctx, c.SignedInUser.GetOrgID(), dash.UID)
|
||||
if err != nil && !errors.Is(err, publicdashboardModels.ErrPublicDashboardNotFound) {
|
||||
return response.Error(http.StatusInternalServerError, "Error while retrieving public dashboards", err)
|
||||
|
||||
@@ -492,6 +492,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
|
||||
// 200: createOrUpdateDatasourceResponse
|
||||
// 401: unauthorisedError
|
||||
// 403: forbiddenError
|
||||
// 409: conflictError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
cmd := datasources.UpdateDataSourceCommand{}
|
||||
|
||||
@@ -924,6 +924,9 @@ type VariableModel struct {
|
||||
// Custom all value
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
|
||||
// Allow custom values to be entered in the variable
|
||||
AllowCustomValue *bool `json:"allowCustomValue,omitempty"`
|
||||
|
||||
// Option to be selected in a variable.
|
||||
Current *VariableOption `json:"current,omitempty"`
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ func (p *queryParser) getValidDataSourceRef(ctx context.Context, ds *data.DataSo
|
||||
if ds.UID == "" {
|
||||
return nil, fmt.Errorf("missing name/uid in data source reference")
|
||||
}
|
||||
if ds.UID == expr.DatasourceType {
|
||||
if expr.IsDataSource(ds.UID) {
|
||||
return ds, nil
|
||||
}
|
||||
if p.legacy == nil {
|
||||
|
||||
@@ -147,6 +147,8 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O
|
||||
},
|
||||
}
|
||||
|
||||
b.log.Error("Error parsing query", "refId", refID, "message", message)
|
||||
|
||||
responder.Object(statusCode, qdr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/user/userimpl"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified"
|
||||
unifiedsearch "github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
|
||||
cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring"
|
||||
"github.com/grafana/grafana/pkg/tsdb/cloudwatch"
|
||||
@@ -231,6 +232,7 @@ var wireBasicSet = wire.NewSet(
|
||||
authinfoimpl.ProvideStore,
|
||||
provisioningauth.ProvideProvisioningIdentityService,
|
||||
datasourceproxy.ProvideService,
|
||||
unifiedsearch.ProvideDocumentBuilders,
|
||||
search.ProvideService,
|
||||
searchV2.ProvideService,
|
||||
searchV2.ProvideSearchHTTPService,
|
||||
|
||||
@@ -98,9 +98,8 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
||||
r.reconcile(ctx)
|
||||
|
||||
// FIXME:
|
||||
// 1. We should be a bit graceful about reconciliations so we are not hammering dbs
|
||||
// 2. We should be able to configure reconciliation interval
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
// We should be a bit graceful about reconciliations so we are not hammering dbs
|
||||
ticker := time.NewTicker(r.cfg.RBAC.ZanzanaReconciliationInterval)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
|
||||
@@ -81,6 +81,7 @@ type RoleDTO struct {
|
||||
Group string `xorm:"group_name" json:"group"`
|
||||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
Delegatable *bool `json:"delegatable,omitempty"`
|
||||
Mapped bool `json:"mapped,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
|
||||
ID int64 `json:"-" xorm:"pk autoincr 'id'"`
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package authapi contains the connector for Grafana internal auth service. This can be used instead of the GCOM service
|
||||
// to create access policies and access tokens
|
||||
package authapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
)
|
||||
|
||||
const LogPrefix = "auth-api.service"
|
||||
|
||||
var ErrTokenNotFound = errors.New("auth-api: token not found")
|
||||
|
||||
type Service interface {
|
||||
CreateAccessPolicy(ctx context.Context, params CreateAccessPolicyParams, payload CreateAccessPolicyPayload) (AccessPolicy, error)
|
||||
ListAccessPolicies(ctx context.Context, params ListAccessPoliciesParams) ([]AccessPolicy, error)
|
||||
DeleteAccessPolicy(ctx context.Context, params DeleteAccessPolicyParams) (bool, error)
|
||||
ListTokens(ctx context.Context, params ListTokenParams) ([]TokenView, error)
|
||||
CreateToken(ctx context.Context, params CreateTokenParams, payload CreateTokenPayload) (Token, error)
|
||||
DeleteToken(ctx context.Context, params DeleteTokenParams) error
|
||||
}
|
||||
|
||||
type CreateAccessPolicyParams struct {
|
||||
RequestID string
|
||||
// this is needed until we fully migrate from gcom to authapi
|
||||
Region string
|
||||
}
|
||||
|
||||
type CreateAccessPolicyPayload struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Realms []Realm `json:"realms"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type Realm struct {
|
||||
Identifier string `json:"identifier"`
|
||||
LabelPolicies []LabelPolicy `json:"labelPolicies"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type LabelPolicy struct {
|
||||
Selector string `json:"selector"`
|
||||
}
|
||||
|
||||
type createAccessPolicyResponse struct {
|
||||
Data AccessPolicy `json:"data"`
|
||||
}
|
||||
|
||||
type AccessPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListAccessPoliciesParams struct {
|
||||
RequestID string
|
||||
Name string
|
||||
// this is needed until we fully migrate from gcom to authapi
|
||||
Region string
|
||||
}
|
||||
|
||||
type listAccessPoliciesResponse struct {
|
||||
Data []AccessPolicy `json:"data"`
|
||||
}
|
||||
|
||||
type DeleteAccessPolicyParams struct {
|
||||
RequestID string
|
||||
AccessPolicyID string
|
||||
// this is needed until we fully migrate from gcom to authapi
|
||||
Region string
|
||||
}
|
||||
|
||||
type ListTokenParams struct {
|
||||
RequestID string
|
||||
AccessPolicyName string
|
||||
TokenName string
|
||||
// this is needed until we fully migrate from gcom to authapi
|
||||
Region string
|
||||
}
|
||||
|
||||
type CreateTokenParams struct {
|
||||
RequestID string
|
||||
// this is needed until we fully migrate from gcom to authapi
|
||||
Region string
|
||||
}
|
||||
|
||||
type CreateTokenPayload struct {
|
||||
AccessPolicyID string `json:"accessPolicyId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type createTokenResponse struct {
|
||||
Data Token `json:"data"`
|
||||
}
|
||||
|
||||
// Token returned by authapi api when a token gets created.
|
||||
type Token struct {
|
||||
ID string `json:"id"`
|
||||
AccessPolicyID string `json:"accessPolicyId"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type DeleteTokenParams struct {
|
||||
RequestID string
|
||||
TokenID string
|
||||
// this is needed until we fully migrate from gcom to authapi
|
||||
Region string
|
||||
}
|
||||
|
||||
// TokenView returned by authapi api for a GET token request.
|
||||
type TokenView struct {
|
||||
ID string `json:"id"`
|
||||
AccessPolicyID string `json:"accessPolicyId"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
FirstUsedAt string `json:"firstUsedAt"`
|
||||
LastUsedAt string `json:"lastUsedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type listTokensResponse struct {
|
||||
Data []TokenView `json:"data"`
|
||||
}
|
||||
|
||||
var _ Service = (*AuthApiClient)(nil)
|
||||
|
||||
type AuthApiClient struct {
|
||||
log log.Logger
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ApiURL string
|
||||
Token string
|
||||
}
|
||||
|
||||
func New(cfg Config, httpClient *http.Client) Service {
|
||||
return &AuthApiClient{
|
||||
log: log.New(LogPrefix),
|
||||
cfg: cfg,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (client *AuthApiClient) CreateAccessPolicy(ctx context.Context, params CreateAccessPolicyParams, payload CreateAccessPolicyPayload) (AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("building authapi access policy url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return AccessPolicy{}, fmt.Errorf("unexpected response when creating access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var capResp createAccessPolicyResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&capResp); err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return capResp.Data, nil
|
||||
}
|
||||
|
||||
func (client *AuthApiClient) DeleteAccessPolicy(ctx context.Context, params DeleteAccessPolicyParams) (bool, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies/", params.AccessPolicyID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("building authapi access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return false, fmt.Errorf("unexpected response when deleting access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
func (client *AuthApiClient) ListAccessPolicies(ctx context.Context, params ListAccessPoliciesParams) ([]AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building authapi access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("name", params.Name)
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when listing access policies: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var lapResp listAccessPoliciesResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&lapResp); err != nil {
|
||||
return lapResp.Data, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
return lapResp.Data, nil
|
||||
}
|
||||
|
||||
func (client *AuthApiClient) ListTokens(ctx context.Context, params ListTokenParams) ([]TokenView, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building authapi tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("accessPolicyName", params.AccessPolicyName)
|
||||
query.Set("name", params.TokenName)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to list access tokens: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when fetching access tokens: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var body listTokensResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
return body.Data, nil
|
||||
}
|
||||
|
||||
func (client *AuthApiClient) CreateToken(ctx context.Context, params CreateTokenParams, payload CreateTokenPayload) (Token, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("building authapi tokens url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("sending http request to create access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return Token{}, fmt.Errorf("unexpected response when creating access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var ctResp createTokenResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&ctResp); err != nil {
|
||||
return Token{}, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return ctResp.Data, nil
|
||||
}
|
||||
|
||||
func (client *AuthApiClient) DeleteToken(ctx context.Context, params DeleteTokenParams) error {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens", params.TokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building authapi tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending http request to delete access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("token id: %s %w", params.TokenID, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("unexpected response when deleting access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var _ authapi.Service = (*AuthapiStub)(nil)
|
||||
|
||||
type AuthapiStub struct {
|
||||
// The cloud migration token created by this stub.
|
||||
Token *authapi.TokenView
|
||||
Policies map[string]authapi.AccessPolicy
|
||||
}
|
||||
|
||||
func (client *AuthapiStub) CreateAccessPolicy(_ context.Context, _ authapi.CreateAccessPolicyParams, _ authapi.CreateAccessPolicyPayload) (authapi.AccessPolicy, error) {
|
||||
randStr := fmt.Sprintf("random-policy-%s", util.GenerateShortUID())
|
||||
policy := authapi.AccessPolicy{
|
||||
ID: randStr,
|
||||
Name: randStr,
|
||||
}
|
||||
client.Policies[policy.ID] = policy
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (client *AuthapiStub) DeleteAccessPolicy(_ context.Context, params authapi.DeleteAccessPolicyParams) (bool, error) {
|
||||
delete(client.Policies, params.AccessPolicyID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (client *AuthapiStub) ListAccessPolicies(_ context.Context, _ authapi.ListAccessPoliciesParams) ([]authapi.AccessPolicy, error) {
|
||||
items := make([]authapi.AccessPolicy, 0)
|
||||
for _, v := range client.Policies {
|
||||
items = append(items, v)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (client *AuthapiStub) ListTokens(_ context.Context, _ authapi.ListTokenParams) ([]authapi.TokenView, error) {
|
||||
if client.Token == nil {
|
||||
return []authapi.TokenView{}, nil
|
||||
}
|
||||
|
||||
return []authapi.TokenView{*client.Token}, nil
|
||||
}
|
||||
|
||||
func (client *AuthapiStub) CreateToken(_ context.Context, _ authapi.CreateTokenParams, payload authapi.CreateTokenPayload) (authapi.Token, error) {
|
||||
token := authapi.Token{
|
||||
ID: fmt.Sprintf("random-token-%s", util.GenerateShortUID()),
|
||||
Name: payload.Name,
|
||||
AccessPolicyID: payload.AccessPolicyID,
|
||||
Token: fmt.Sprintf("completely_fake_token_%s", util.GenerateShortUID()),
|
||||
}
|
||||
client.Token = &authapi.TokenView{
|
||||
ID: token.ID,
|
||||
Name: token.Name,
|
||||
AccessPolicyID: token.AccessPolicyID,
|
||||
DisplayName: token.Name,
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *AuthapiStub) DeleteToken(_ context.Context, _ authapi.DeleteTokenParams) error {
|
||||
client.Token = nil
|
||||
return nil
|
||||
}
|
||||
@@ -266,23 +266,21 @@ func (c *OAuth) Logout(ctx context.Context, user identity.Requester) (*authn.Red
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if err := c.oauthService.InvalidateOAuthTokens(ctx, &login.UserAuth{
|
||||
UserId: userID,
|
||||
AuthId: user.GetAuthID(),
|
||||
AuthModule: user.GetAuthenticatedBy(),
|
||||
}); err != nil {
|
||||
c.log.FromContext(ctx).Error("Failed to invalidate tokens", "id", user.GetID(), "error", err)
|
||||
ctxLogger := c.log.FromContext(ctx).New("userID", userID)
|
||||
|
||||
if err := c.oauthService.InvalidateOAuthTokens(ctx, user); err != nil {
|
||||
ctxLogger.Error("Failed to invalidate tokens", "error", err)
|
||||
}
|
||||
|
||||
oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName)
|
||||
if !oauthCfg.Enabled {
|
||||
c.log.FromContext(ctx).Debug("OAuth client is disabled")
|
||||
ctxLogger.Debug("OAuth client is disabled")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
redirectURL := getOAuthSignoutRedirectURL(c.cfg, oauthCfg)
|
||||
if redirectURL == "" {
|
||||
c.log.FromContext(ctx).Debug("No signout redirect url configured")
|
||||
ctxLogger.Debug("No signout redirect url configured")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -71,10 +71,11 @@ func TestOAuth_Authenticate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return error when state from ipd does not match stored state",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-other-state"),
|
||||
},
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-other-state"),
|
||||
},
|
||||
},
|
||||
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true},
|
||||
addStateCookie: true,
|
||||
@@ -83,10 +84,11 @@ func TestOAuth_Authenticate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return error when pkce is configured but the cookie is not present",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
},
|
||||
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true},
|
||||
addStateCookie: true,
|
||||
@@ -95,10 +97,11 @@ func TestOAuth_Authenticate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return error when email is empty",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
},
|
||||
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true},
|
||||
addStateCookie: true,
|
||||
@@ -110,10 +113,11 @@ func TestOAuth_Authenticate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return error when email is not allowed",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
},
|
||||
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true},
|
||||
addStateCookie: true,
|
||||
@@ -144,10 +148,11 @@ func TestOAuth_Authenticate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return identity for valid request",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
},
|
||||
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true},
|
||||
addStateCookie: true,
|
||||
@@ -182,10 +187,11 @@ func TestOAuth_Authenticate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return identity for valid request - and lookup user by email",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
URL: mustParseURL("http://grafana.com/?state=some-state"),
|
||||
},
|
||||
},
|
||||
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true},
|
||||
allowInsecureTakeover: true,
|
||||
@@ -354,9 +360,7 @@ func TestOAuth_RedirectURL(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
var (
|
||||
authCodeUrlCalled = false
|
||||
)
|
||||
authCodeUrlCalled := false
|
||||
|
||||
fakeSocialSvc := &socialtest.FakeSocialService{
|
||||
ExpectedAuthInfoProvider: tt.oauthCfg,
|
||||
@@ -475,7 +479,7 @@ func TestOAuth_Logout(t *testing.T) {
|
||||
"id_token": "some.id.token",
|
||||
})
|
||||
},
|
||||
InvalidateOAuthTokensFunc: func(_ context.Context, _ *login.UserAuth) error {
|
||||
InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester) error {
|
||||
invalidateTokenCalled = true
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -49,14 +49,20 @@ func NewGrpcAuthenticator(cfg *setting.Cfg, tracer tracing.Tracer) (*authnlib.Gr
|
||||
keyRetriever := authnlib.NewKeyRetriever(grpcAuthCfg.KeyRetrieverConfig, authnlib.WithHTTPClientKeyRetrieverOpt(client))
|
||||
|
||||
grpcOpts := []authnlib.GrpcAuthenticatorOption{
|
||||
authnlib.WithIDTokenAuthOption(true),
|
||||
authnlib.WithKeyRetrieverOption(keyRetriever),
|
||||
authnlib.WithTracerAuthOption(tracer),
|
||||
}
|
||||
if authCfg.Mode == ModeOnPrem {
|
||||
switch authCfg.Mode {
|
||||
case ModeOnPrem:
|
||||
grpcOpts = append(grpcOpts,
|
||||
// Access token are not yet available on-prem
|
||||
authnlib.WithDisableAccessTokenAuthOption(),
|
||||
authnlib.WithIDTokenAuthOption(true),
|
||||
)
|
||||
case ModeCloud:
|
||||
grpcOpts = append(grpcOpts,
|
||||
// ID tokens are enabled but not required in cloud
|
||||
authnlib.WithIDTokenAuthOption(false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +72,8 @@ func NewGrpcAuthenticator(cfg *setting.Cfg, tracer tracing.Tracer) (*authnlib.Gr
|
||||
)
|
||||
}
|
||||
|
||||
type contextFallbackKey struct{}
|
||||
|
||||
type AuthenticatorWithFallback struct {
|
||||
authenticator *authnlib.GrpcAuthenticator
|
||||
fallback interceptors.Authenticator
|
||||
@@ -96,6 +104,10 @@ func NewGrpcAuthenticatorWithFallback(cfg *setting.Cfg, reg prometheus.Registere
|
||||
}, nil
|
||||
}
|
||||
|
||||
func FallbackUsed(ctx context.Context) bool {
|
||||
return ctx.Value(contextFallbackKey{}) != nil
|
||||
}
|
||||
|
||||
func (f *AuthenticatorWithFallback) Authenticate(ctx context.Context) (context.Context, error) {
|
||||
ctx, span := f.tracer.Start(ctx, "grpcutils.AuthenticatorWithFallback.Authenticate")
|
||||
defer span.End()
|
||||
@@ -107,6 +119,7 @@ func (f *AuthenticatorWithFallback) Authenticate(ctx context.Context) (context.C
|
||||
newCtx, err = f.fallback.Authenticate(ctx)
|
||||
f.metrics.fallbackCounter.WithLabelValues(fmt.Sprintf("%t", err == nil)).Inc()
|
||||
span.SetAttributes(attribute.Bool("fallback_used", true))
|
||||
newCtx = context.WithValue(newCtx, contextFallbackKey{}, true)
|
||||
}
|
||||
return newCtx, err
|
||||
}
|
||||
|
||||
@@ -402,7 +402,6 @@ type ReadRequest struct {
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// Use namespace instead of store id. It will be handled on the server side
|
||||
Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"`
|
||||
TupleKey *ReadRequestTupleKey `protobuf:"bytes,2,opt,name=tuple_key,json=tupleKey,proto3" json:"tuple_key,omitempty"`
|
||||
PageSize *wrapperspb.Int32Value `protobuf:"bytes,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
@@ -676,7 +675,6 @@ type WriteRequest struct {
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// Use namespace instead of store id. It will be handled on the server side
|
||||
Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"`
|
||||
Writes *WriteRequestWrites `protobuf:"bytes,2,opt,name=writes,proto3" json:"writes,omitempty"`
|
||||
Deletes *WriteRequestDeletes `protobuf:"bytes,3,opt,name=deletes,proto3" json:"deletes,omitempty"`
|
||||
@@ -1005,6 +1003,152 @@ func (x *BatchCheckGroupResource) GetItems() map[string]bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CapabilitiesRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Subject string `protobuf:"bytes,1,opt,name=subject,proto3" json:"subject,omitempty"`
|
||||
Group string `protobuf:"bytes,2,opt,name=group,proto3" json:"group,omitempty"`
|
||||
Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"`
|
||||
Resource string `protobuf:"bytes,4,opt,name=resource,proto3" json:"resource,omitempty"`
|
||||
Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Folder string `protobuf:"bytes,6,opt,name=folder,proto3" json:"folder,omitempty"`
|
||||
Path string `protobuf:"bytes,7,opt,name=path,proto3" json:"path,omitempty"`
|
||||
Subresource string `protobuf:"bytes,8,opt,name=subresource,proto3" json:"subresource,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) Reset() {
|
||||
*x = CapabilitiesRequest{}
|
||||
mi := &file_extention_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CapabilitiesRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CapabilitiesRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_extention_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CapabilitiesRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CapabilitiesRequest) Descriptor() ([]byte, []int) {
|
||||
return file_extention_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetSubject() string {
|
||||
if x != nil {
|
||||
return x.Subject
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetGroup() string {
|
||||
if x != nil {
|
||||
return x.Group
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetNamespace() string {
|
||||
if x != nil {
|
||||
return x.Namespace
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetResource() string {
|
||||
if x != nil {
|
||||
return x.Resource
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetFolder() string {
|
||||
if x != nil {
|
||||
return x.Folder
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetPath() string {
|
||||
if x != nil {
|
||||
return x.Path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CapabilitiesRequest) GetSubresource() string {
|
||||
if x != nil {
|
||||
return x.Subresource
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CapabilitiesResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Capabilities []string `protobuf:"bytes,1,rep,name=capabilities,proto3" json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CapabilitiesResponse) Reset() {
|
||||
*x = CapabilitiesResponse{}
|
||||
mi := &file_extention_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CapabilitiesResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CapabilitiesResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CapabilitiesResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_extention_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CapabilitiesResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CapabilitiesResponse) Descriptor() ([]byte, []int) {
|
||||
return file_extention_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *CapabilitiesResponse) GetCapabilities() []string {
|
||||
if x != nil {
|
||||
return x.Capabilities
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_extention_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_extention_proto_rawDesc = []byte{
|
||||
@@ -1151,8 +1295,26 @@ var file_extention_proto_rawDesc = []byte{
|
||||
0x69, 0x74, 0x65, 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, 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, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32,
|
||||
0xd8, 0x02, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69,
|
||||
0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
|
||||
0xe1, 0x01, 0x0a, 0x13, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65,
|
||||
0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63,
|
||||
0x74, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73,
|
||||
0x70, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65,
|
||||
0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
|
||||
0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
|
||||
0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x04, 0x6e, 0x61, 0x6d, 0x65, 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, 0x70, 0x61, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74,
|
||||
0x68, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
|
||||
0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75,
|
||||
0x72, 0x63, 0x65, 0x22, 0x3a, 0x0a, 0x14, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
|
||||
0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x63,
|
||||
0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
|
||||
0x09, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x32,
|
||||
0xbb, 0x03, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x4c, 0x69, 0x73,
|
||||
0x74, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
@@ -1164,20 +1326,26 @@ var file_extention_proto_rawDesc = []byte{
|
||||
0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68,
|
||||
0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42,
|
||||
0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68,
|
||||
0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52,
|
||||
0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74,
|
||||
0x65, 0x12, 0x61, 0x0a, 0x0c, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65,
|
||||
0x73, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
|
||||
0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74,
|
||||
0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e,
|
||||
0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05,
|
||||
0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78,
|
||||
0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e,
|
||||
0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 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, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61,
|
||||
0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e,
|
||||
0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a,
|
||||
0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72,
|
||||
0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74,
|
||||
0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e,
|
||||
0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a,
|
||||
0x36, 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, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -1192,7 +1360,7 @@ func file_extention_proto_rawDescGZIP() []byte {
|
||||
return file_extention_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
|
||||
var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
|
||||
var file_extention_proto_goTypes = []any{
|
||||
(*ListRequest)(nil), // 0: authz.extention.v1.ListRequest
|
||||
(*ListResponse)(nil), // 1: authz.extention.v1.ListResponse
|
||||
@@ -1211,38 +1379,42 @@ var file_extention_proto_goTypes = []any{
|
||||
(*BatchCheckItem)(nil), // 14: authz.extention.v1.BatchCheckItem
|
||||
(*BatchCheckResponse)(nil), // 15: authz.extention.v1.BatchCheckResponse
|
||||
(*BatchCheckGroupResource)(nil), // 16: authz.extention.v1.BatchCheckGroupResource
|
||||
nil, // 17: authz.extention.v1.BatchCheckResponse.GroupsEntry
|
||||
nil, // 18: authz.extention.v1.BatchCheckGroupResource.ItemsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp
|
||||
(*structpb.Struct)(nil), // 20: google.protobuf.Struct
|
||||
(*wrapperspb.Int32Value)(nil), // 21: google.protobuf.Int32Value
|
||||
(*CapabilitiesRequest)(nil), // 17: authz.extention.v1.CapabilitiesRequest
|
||||
(*CapabilitiesResponse)(nil), // 18: authz.extention.v1.CapabilitiesResponse
|
||||
nil, // 19: authz.extention.v1.BatchCheckResponse.GroupsEntry
|
||||
nil, // 20: authz.extention.v1.BatchCheckGroupResource.ItemsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 21: google.protobuf.Timestamp
|
||||
(*structpb.Struct)(nil), // 22: google.protobuf.Struct
|
||||
(*wrapperspb.Int32Value)(nil), // 23: google.protobuf.Int32Value
|
||||
}
|
||||
var file_extention_proto_depIdxs = []int32{
|
||||
5, // 0: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition
|
||||
2, // 1: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey
|
||||
19, // 2: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp
|
||||
20, // 3: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct
|
||||
21, // 2: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp
|
||||
22, // 3: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct
|
||||
7, // 4: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey
|
||||
21, // 5: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value
|
||||
23, // 5: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value
|
||||
3, // 6: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple
|
||||
2, // 7: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey
|
||||
4, // 8: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition
|
||||
9, // 9: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites
|
||||
10, // 10: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes
|
||||
14, // 11: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem
|
||||
17, // 12: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry
|
||||
18, // 13: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry
|
||||
19, // 12: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry
|
||||
20, // 13: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry
|
||||
16, // 14: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource
|
||||
0, // 15: authz.extention.v1.AuthzExtentionService.List:input_type -> authz.extention.v1.ListRequest
|
||||
13, // 16: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest
|
||||
6, // 17: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest
|
||||
11, // 18: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest
|
||||
1, // 19: authz.extention.v1.AuthzExtentionService.List:output_type -> authz.extention.v1.ListResponse
|
||||
15, // 20: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse
|
||||
8, // 21: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse
|
||||
12, // 22: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse
|
||||
19, // [19:23] is the sub-list for method output_type
|
||||
15, // [15:19] is the sub-list for method input_type
|
||||
17, // 17: authz.extention.v1.AuthzExtentionService.Capabilities:input_type -> authz.extention.v1.CapabilitiesRequest
|
||||
6, // 18: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest
|
||||
11, // 19: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest
|
||||
1, // 20: authz.extention.v1.AuthzExtentionService.List:output_type -> authz.extention.v1.ListResponse
|
||||
15, // 21: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse
|
||||
18, // 22: authz.extention.v1.AuthzExtentionService.Capabilities:output_type -> authz.extention.v1.CapabilitiesResponse
|
||||
8, // 23: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse
|
||||
12, // 24: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse
|
||||
20, // [20:25] is the sub-list for method output_type
|
||||
15, // [15:20] is the sub-list for method input_type
|
||||
15, // [15:15] is the sub-list for extension type_name
|
||||
15, // [15:15] is the sub-list for extension extendee
|
||||
0, // [0:15] is the sub-list for field type_name
|
||||
@@ -1259,7 +1431,7 @@ func file_extention_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_extention_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 19,
|
||||
NumMessages: 21,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import "google/protobuf/wrappers.proto";
|
||||
service AuthzExtentionService {
|
||||
rpc List(ListRequest) returns (ListResponse);
|
||||
rpc BatchCheck(BatchCheckRequest) returns (BatchCheckResponse);
|
||||
rpc Capabilities(CapabilitiesRequest) returns (CapabilitiesResponse);
|
||||
|
||||
rpc Read(ReadRequest) returns (ReadResponse);
|
||||
rpc Write(WriteRequest) returns (WriteResponse);
|
||||
@@ -54,7 +55,6 @@ message RelationshipCondition {
|
||||
}
|
||||
|
||||
message ReadRequest {
|
||||
// Use namespace instead of store id. It will be handled on the server side
|
||||
string namespace = 1;
|
||||
ReadRequestTupleKey tuple_key = 2;
|
||||
google.protobuf.Int32Value page_size = 3;
|
||||
@@ -81,7 +81,6 @@ message WriteRequestDeletes {
|
||||
}
|
||||
|
||||
message WriteRequest {
|
||||
// Use namespace instead of store id. It will be handled on the server side
|
||||
string namespace = 1;
|
||||
WriteRequestWrites writes = 2;
|
||||
WriteRequestDeletes deletes = 3;
|
||||
@@ -111,3 +110,18 @@ message BatchCheckResponse {
|
||||
message BatchCheckGroupResource {
|
||||
map<string, bool> items = 1;
|
||||
}
|
||||
|
||||
message CapabilitiesRequest {
|
||||
string subject = 1;
|
||||
string group = 2;
|
||||
string namespace = 3;
|
||||
string resource = 4;
|
||||
string name = 5;
|
||||
string folder = 6;
|
||||
string path = 7;
|
||||
string subresource = 8;
|
||||
}
|
||||
|
||||
message CapabilitiesResponse {
|
||||
repeated string capabilities = 1;
|
||||
}
|
||||
@@ -19,10 +19,11 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion8
|
||||
|
||||
const (
|
||||
AuthzExtentionService_List_FullMethodName = "/authz.extention.v1.AuthzExtentionService/List"
|
||||
AuthzExtentionService_BatchCheck_FullMethodName = "/authz.extention.v1.AuthzExtentionService/BatchCheck"
|
||||
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
|
||||
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
|
||||
AuthzExtentionService_List_FullMethodName = "/authz.extention.v1.AuthzExtentionService/List"
|
||||
AuthzExtentionService_BatchCheck_FullMethodName = "/authz.extention.v1.AuthzExtentionService/BatchCheck"
|
||||
AuthzExtentionService_Capabilities_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Capabilities"
|
||||
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
|
||||
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
|
||||
)
|
||||
|
||||
// AuthzExtentionServiceClient is the client API for AuthzExtentionService service.
|
||||
@@ -31,6 +32,7 @@ const (
|
||||
type AuthzExtentionServiceClient interface {
|
||||
List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error)
|
||||
BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error)
|
||||
Capabilities(ctx context.Context, in *CapabilitiesRequest, opts ...grpc.CallOption) (*CapabilitiesResponse, error)
|
||||
Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error)
|
||||
Write(ctx context.Context, in *WriteRequest, opts ...grpc.CallOption) (*WriteResponse, error)
|
||||
}
|
||||
@@ -63,6 +65,16 @@ func (c *authzExtentionServiceClient) BatchCheck(ctx context.Context, in *BatchC
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authzExtentionServiceClient) Capabilities(ctx context.Context, in *CapabilitiesRequest, opts ...grpc.CallOption) (*CapabilitiesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CapabilitiesResponse)
|
||||
err := c.cc.Invoke(ctx, AuthzExtentionService_Capabilities_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authzExtentionServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ReadResponse)
|
||||
@@ -89,6 +101,7 @@ func (c *authzExtentionServiceClient) Write(ctx context.Context, in *WriteReques
|
||||
type AuthzExtentionServiceServer interface {
|
||||
List(context.Context, *ListRequest) (*ListResponse, error)
|
||||
BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error)
|
||||
Capabilities(context.Context, *CapabilitiesRequest) (*CapabilitiesResponse, error)
|
||||
Read(context.Context, *ReadRequest) (*ReadResponse, error)
|
||||
Write(context.Context, *WriteRequest) (*WriteResponse, error)
|
||||
}
|
||||
@@ -103,6 +116,9 @@ func (UnimplementedAuthzExtentionServiceServer) List(context.Context, *ListReque
|
||||
func (UnimplementedAuthzExtentionServiceServer) BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchCheck not implemented")
|
||||
}
|
||||
func (UnimplementedAuthzExtentionServiceServer) Capabilities(context.Context, *CapabilitiesRequest) (*CapabilitiesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Capabilities not implemented")
|
||||
}
|
||||
func (UnimplementedAuthzExtentionServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Read not implemented")
|
||||
}
|
||||
@@ -157,6 +173,24 @@ func _AuthzExtentionService_BatchCheck_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthzExtentionService_Capabilities_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CapabilitiesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthzExtentionServiceServer).Capabilities(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AuthzExtentionService_Capabilities_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthzExtentionServiceServer).Capabilities(ctx, req.(*CapabilitiesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthzExtentionService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReadRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -208,6 +242,10 @@ var AuthzExtentionService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "BatchCheck",
|
||||
Handler: _AuthzExtentionService_BatchCheck_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Capabilities",
|
||||
Handler: _AuthzExtentionService_Capabilities_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Read",
|
||||
Handler: _AuthzExtentionService_Read_Handler,
|
||||
|
||||
@@ -47,6 +47,25 @@ const (
|
||||
RelationFolderResourcePermissionsWrite string = "resource_" + RelationPermissionsWrite
|
||||
)
|
||||
|
||||
var ResourceRelations = []string{
|
||||
RelationRead,
|
||||
RelationWrite,
|
||||
RelationCreate,
|
||||
RelationDelete,
|
||||
RelationPermissionsRead,
|
||||
RelationPermissionsWrite,
|
||||
}
|
||||
|
||||
var FolderRelations = append(
|
||||
ResourceRelations,
|
||||
RelationFolderResourceRead,
|
||||
RelationFolderResourceWrite,
|
||||
RelationFolderResourceCreate,
|
||||
RelationFolderResourceDelete,
|
||||
RelationFolderResourcePermissionsRead,
|
||||
RelationFolderResourcePermissionsWrite,
|
||||
)
|
||||
|
||||
func FolderResourceRelation(relation string) string {
|
||||
return fmt.Sprintf("%s_%s", TypeResource, relation)
|
||||
}
|
||||
|
||||
@@ -12,28 +12,28 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
)
|
||||
|
||||
func newBatch(subject, group, resource string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest {
|
||||
for i, item := range items {
|
||||
items[i] = &authzextv1.BatchCheckItem{
|
||||
Verb: utils.VerbGet,
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
Name: item.GetName(),
|
||||
Folder: item.GetFolder(),
|
||||
func testBatchCheck(t *testing.T, server *Server) {
|
||||
newReq := func(subject, group, resource string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest {
|
||||
for i, item := range items {
|
||||
items[i] = &authzextv1.BatchCheckItem{
|
||||
Verb: utils.VerbGet,
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
Name: item.GetName(),
|
||||
Folder: item.GetFolder(),
|
||||
}
|
||||
}
|
||||
|
||||
return &authzextv1.BatchCheckRequest{
|
||||
Namespace: namespace,
|
||||
Subject: subject,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
return &authzextv1.BatchCheckRequest{
|
||||
Namespace: "default",
|
||||
Subject: subject,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func testBatchCheck(t *testing.T, server *Server) {
|
||||
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
{Name: "2", Folder: "2"},
|
||||
}))
|
||||
@@ -46,7 +46,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
|
||||
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
{Name: "2", Folder: "2"},
|
||||
}))
|
||||
@@ -56,7 +56,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
|
||||
t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
{Name: "2", Folder: "2"},
|
||||
}))
|
||||
@@ -69,7 +69,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
|
||||
t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
{Name: "2", Folder: "3"},
|
||||
{Name: "3", Folder: "2"},
|
||||
@@ -84,7 +84,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
|
||||
t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
{Name: "2", Folder: "2"},
|
||||
}))
|
||||
@@ -97,7 +97,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
|
||||
t.Run("user:6 should be able to read folder 1", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(folderGroup, folderResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:6", folderGroup, folderResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:6", folderGroup, folderResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1"},
|
||||
{Name: "2"},
|
||||
}))
|
||||
@@ -110,7 +110,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
|
||||
t.Run("user:7 should be able to read folder {1,2} through namespace access", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(folderGroup, folderResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:7", folderGroup, folderResource, []*authzextv1.BatchCheckItem{
|
||||
res, err := server.BatchCheck(context.Background(), newReq("user:7", folderGroup, folderResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1"},
|
||||
{Name: "2"},
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) Capabilities(ctx context.Context, r *authzextv1.CapabilitiesRequest) (*authzextv1.CapabilitiesResponse, error) {
|
||||
store, err := s.getStoreInfo(ctx, r.GetNamespace())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info, ok := common.GetTypeInfo(r.Group, r.Resource); ok {
|
||||
return s.capabilitiesTyped(ctx, r, info, store)
|
||||
}
|
||||
return s.capabilitiesGeneric(ctx, r, store)
|
||||
}
|
||||
|
||||
func (s *Server) capabilitiesTyped(ctx context.Context, r *authzextv1.CapabilitiesRequest, info common.TypeInfo, store *storeInfo) (*authzextv1.CapabilitiesResponse, error) {
|
||||
out := make([]string, 0, len(common.ResourceRelations))
|
||||
for _, relation := range common.ResourceRelations {
|
||||
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.GetAllowed() {
|
||||
out = append(out, relation)
|
||||
continue
|
||||
}
|
||||
|
||||
res, err = s.checkTyped(ctx, r.GetSubject(), relation, r.GetName(), info, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.GetAllowed() {
|
||||
out = append(out, relation)
|
||||
}
|
||||
}
|
||||
|
||||
return &authzextv1.CapabilitiesResponse{Capabilities: out}, nil
|
||||
}
|
||||
|
||||
func (s *Server) capabilitiesGeneric(ctx context.Context, r *authzextv1.CapabilitiesRequest, store *storeInfo) (*authzextv1.CapabilitiesResponse, error) {
|
||||
out := make([]string, 0, len(common.ResourceRelations))
|
||||
for _, relation := range common.ResourceRelations {
|
||||
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.GetAllowed() {
|
||||
out = append(out, relation)
|
||||
continue
|
||||
}
|
||||
|
||||
res, err = s.checkGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), r.GetName(), r.GetFolder(), store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.GetAllowed() {
|
||||
out = append(out, relation)
|
||||
}
|
||||
}
|
||||
|
||||
return &authzextv1.CapabilitiesResponse{Capabilities: out}, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func testCapabilities(t *testing.T, server *Server) {
|
||||
newReq := func(subject, group, resource, folder, name string) *authzextv1.CapabilitiesRequest {
|
||||
return &authzextv1.CapabilitiesRequest{
|
||||
Namespace: namespace,
|
||||
Subject: subject,
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
Name: name,
|
||||
Folder: folder,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("user:1 should only be able to read and write resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:1", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead, common.RelationWrite}, res.GetCapabilities())
|
||||
})
|
||||
|
||||
t.Run("user:2 should be able to read and write resource:dashboards.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:2", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead, common.RelationWrite}, res.GetCapabilities())
|
||||
})
|
||||
|
||||
t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:3", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
|
||||
})
|
||||
|
||||
t.Run("user:4 should be able to read dashboards.grafana.app/dashboards in folder 1", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:4", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
|
||||
})
|
||||
|
||||
t.Run("user:5 should be able to read, write, create and delete resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:5", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead, common.RelationWrite, common.RelationCreate, common.RelationDelete}, res.GetCapabilities())
|
||||
})
|
||||
|
||||
t.Run("user:6 should be able to read folder 1 ", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:6", folderGroup, folderResource, "", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
|
||||
})
|
||||
|
||||
t.Run("user:7 should be able to read folder one through namespace access", func(t *testing.T) {
|
||||
res, err := server.Capabilities(context.Background(), newReq("user:7", folderGroup, folderResource, "", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func testCheck(t *testing.T, server *Server) {
|
||||
newRead := func(subject, group, resource, folder, name string) *authzv1.CheckRequest {
|
||||
return &authzv1.CheckRequest{
|
||||
Namespace: "default",
|
||||
Namespace: namespace,
|
||||
Subject: subject,
|
||||
Verb: utils.VerbGet,
|
||||
Group: group,
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func testList(t *testing.T, server *Server) {
|
||||
newList := func(subject, group, resource string) *authzextv1.ListRequest {
|
||||
return &authzextv1.ListRequest{
|
||||
Namespace: "default",
|
||||
Namespace: namespace,
|
||||
Verb: utils.VerbList,
|
||||
Subject: subject,
|
||||
Group: group,
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
namespace = "default"
|
||||
|
||||
dashboardGroup = "dashboard.grafana.app"
|
||||
dashboardResource = "dashboards"
|
||||
|
||||
@@ -53,6 +55,10 @@ func TestIntegrationServer(t *testing.T) {
|
||||
t.Run("test batch check", func(t *testing.T) {
|
||||
testBatchCheck(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test capabilities", func(t *testing.T) {
|
||||
testCapabilities(t, srv)
|
||||
})
|
||||
}
|
||||
|
||||
func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
@@ -65,7 +71,6 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
srv, err := NewAuthz(openfga)
|
||||
require.NoError(t, err)
|
||||
|
||||
namespace := "default"
|
||||
storeInf, err := srv.getStoreInfo(context.Background(), namespace)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -76,11 +81,13 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
Writes: &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: []*openfgav1.TupleKey{
|
||||
common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewResourceTuple("user:1", "write", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewNamespaceResourceTuple("user:2", "read", dashboardGroup, dashboardResource),
|
||||
common.NewNamespaceResourceTuple("user:2", "write", dashboardGroup, dashboardResource),
|
||||
common.NewResourceTuple("user:3", "view", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "3"),
|
||||
common.NewFolderResourceTuple("user:5", "view", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderResourceTuple("user:5", "edit", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderTuple("user:6", "read", "1"),
|
||||
common.NewNamespaceResourceTuple("user:7", "read", folderGroup, folderResource),
|
||||
common.NewFolderParentTuple("5", "4"),
|
||||
|
||||
@@ -49,23 +49,9 @@ const (
|
||||
RelationFolderResourcePermissionsWrite = common.RelationFolderResourcePermissionsWrite
|
||||
)
|
||||
|
||||
var ResourceRelations = []string{
|
||||
RelationRead,
|
||||
RelationWrite,
|
||||
RelationCreate,
|
||||
RelationDelete,
|
||||
RelationPermissionsRead,
|
||||
RelationPermissionsWrite,
|
||||
}
|
||||
|
||||
var FolderRelations = append(
|
||||
ResourceRelations,
|
||||
RelationFolderResourceRead,
|
||||
RelationFolderResourceWrite,
|
||||
RelationFolderResourceCreate,
|
||||
RelationFolderResourceDelete,
|
||||
RelationFolderResourcePermissionsRead,
|
||||
RelationFolderResourcePermissionsWrite,
|
||||
var (
|
||||
FolderRelations = common.FolderRelations
|
||||
ResourceRelations = common.ResourceRelations
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -3,13 +3,13 @@ package cloudmigration
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/gcom"
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
// GetToken Returns the cloud migration token if it exists.
|
||||
GetToken(ctx context.Context) (gcom.TokenView, error)
|
||||
GetToken(ctx context.Context) (authapi.TokenView, error)
|
||||
// CreateToken Creates a cloud migration token.
|
||||
CreateToken(ctx context.Context) (CreateAccessTokenResponse, error)
|
||||
// ValidateToken Sends a request to GMS to test the token.
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
"github.com/grafana/grafana/pkg/services/authapi/fake"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration/api"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration/gmsclient"
|
||||
@@ -62,6 +64,7 @@ type Service struct {
|
||||
|
||||
dsService datasources.DataSourceService
|
||||
gcomService gcom.Service
|
||||
authApiService authapi.Service
|
||||
dashboardService dashboards.DashboardService
|
||||
folderService folder.Service
|
||||
pluginStore pluginstore.Store
|
||||
@@ -151,9 +154,23 @@ func ProvideService(
|
||||
return nil, fmt.Errorf("creating http client for GCOM: %w", err)
|
||||
}
|
||||
s.gcomService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom)
|
||||
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrationsAuthApiMig) {
|
||||
s.log.Info("using authapi client because feature flag is enabled")
|
||||
httpClientAuthApi, err := httpClientProvider.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http client for AuthApi: %w", err)
|
||||
}
|
||||
// the api token is the same as for gcom
|
||||
s.authApiService = authapi.New(authapi.Config{ApiURL: cfg.CloudMigration.AuthAPIUrl, Token: cfg.CloudMigration.GcomAPIToken}, httpClientAuthApi)
|
||||
} else {
|
||||
s.log.Info("using gcom client for auth")
|
||||
s.authApiService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom).(*gcom.GcomClient)
|
||||
}
|
||||
} else {
|
||||
s.gmsClient = gmsclient.NewInMemoryClient()
|
||||
s.gcomService = &gcomStub{policies: map[string]gcom.AccessPolicy{}, token: nil}
|
||||
s.gcomService = &gcomStub{}
|
||||
s.authApiService = &fake.AuthapiStub{Policies: map[string]authapi.AccessPolicy{}, Token: nil}
|
||||
s.cfg.StackID = "12345"
|
||||
}
|
||||
|
||||
@@ -169,7 +186,7 @@ func ProvideService(
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetToken(ctx context.Context) (gcom.TokenView, error) {
|
||||
func (s *Service) GetToken(ctx context.Context) (authapi.TokenView, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetToken")
|
||||
defer span.End()
|
||||
logger := s.log.FromContext(ctx)
|
||||
@@ -179,7 +196,7 @@ func (s *Service) GetToken(ctx context.Context) (gcom.TokenView, error) {
|
||||
defer cancel()
|
||||
instance, err := s.gcomService.GetInstanceByID(timeoutCtx, requestID, s.cfg.StackID)
|
||||
if err != nil {
|
||||
return gcom.TokenView{}, fmt.Errorf("fetching instance by id: id=%s %w", s.cfg.StackID, err)
|
||||
return authapi.TokenView{}, fmt.Errorf("fetching instance by id: id=%s %w", s.cfg.StackID, err)
|
||||
}
|
||||
|
||||
logger.Info("instance found", "slug", instance.Slug)
|
||||
@@ -189,14 +206,14 @@ func (s *Service) GetToken(ctx context.Context) (gcom.TokenView, error) {
|
||||
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, s.cfg.CloudMigration.ListTokensTimeout)
|
||||
defer cancel()
|
||||
tokens, err := s.gcomService.ListTokens(timeoutCtx, gcom.ListTokenParams{
|
||||
tokens, err := s.authApiService.ListTokens(timeoutCtx, authapi.ListTokenParams{
|
||||
RequestID: requestID,
|
||||
Region: instance.RegionSlug,
|
||||
AccessPolicyName: accessPolicyName,
|
||||
TokenName: accessTokenName,
|
||||
Region: instance.RegionSlug,
|
||||
})
|
||||
if err != nil {
|
||||
return gcom.TokenView{}, fmt.Errorf("listing tokens: %w", err)
|
||||
return authapi.TokenView{}, fmt.Errorf("listing tokens: %w", err)
|
||||
}
|
||||
logger.Info("found access tokens", "num_tokens", len(tokens))
|
||||
|
||||
@@ -208,7 +225,7 @@ func (s *Service) GetToken(ctx context.Context) (gcom.TokenView, error) {
|
||||
}
|
||||
|
||||
logger.Info("cloud migration token not found")
|
||||
return gcom.TokenView{}, fmt.Errorf("fetching cloud migration token: instance=%+v accessPolicyName=%s accessTokenName=%s %w",
|
||||
return authapi.TokenView{}, fmt.Errorf("fetching cloud migration token: instance=%+v accessPolicyName=%s accessTokenName=%s %w",
|
||||
instance, accessPolicyName, accessTokenName, cloudmigration.ErrTokenNotFound)
|
||||
}
|
||||
|
||||
@@ -231,7 +248,7 @@ func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessT
|
||||
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, s.cfg.CloudMigration.FetchAccessPolicyTimeout)
|
||||
defer cancel()
|
||||
existingAccessPolicy, err := s.findAccessPolicyByName(timeoutCtx, instance.RegionSlug, accessPolicyName)
|
||||
existingAccessPolicy, err := s.findAccessPolicyByName(timeoutCtx, accessPolicyName, instance.RegionSlug)
|
||||
if err != nil {
|
||||
return cloudmigration.CreateAccessTokenResponse{}, fmt.Errorf("fetching access policy by name: name=%s %w", accessPolicyName, err)
|
||||
}
|
||||
@@ -239,7 +256,7 @@ func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessT
|
||||
if existingAccessPolicy != nil {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, s.cfg.CloudMigration.DeleteAccessPolicyTimeout)
|
||||
defer cancel()
|
||||
if _, err := s.gcomService.DeleteAccessPolicy(timeoutCtx, gcom.DeleteAccessPolicyParams{
|
||||
if _, err := s.authApiService.DeleteAccessPolicy(timeoutCtx, authapi.DeleteAccessPolicyParams{
|
||||
RequestID: requestID,
|
||||
AccessPolicyID: existingAccessPolicy.ID,
|
||||
Region: instance.RegionSlug,
|
||||
@@ -251,15 +268,15 @@ func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessT
|
||||
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, s.cfg.CloudMigration.CreateAccessPolicyTimeout)
|
||||
defer cancel()
|
||||
accessPolicy, err := s.gcomService.CreateAccessPolicy(timeoutCtx,
|
||||
gcom.CreateAccessPolicyParams{
|
||||
accessPolicy, err := s.authApiService.CreateAccessPolicy(timeoutCtx,
|
||||
authapi.CreateAccessPolicyParams{
|
||||
RequestID: requestID,
|
||||
Region: instance.RegionSlug,
|
||||
},
|
||||
gcom.CreateAccessPolicyPayload{
|
||||
authapi.CreateAccessPolicyPayload{
|
||||
Name: accessPolicyName,
|
||||
DisplayName: accessPolicyDisplayName,
|
||||
Realms: []gcom.Realm{{Type: "stack", Identifier: s.cfg.StackID, LabelPolicies: []gcom.LabelPolicy{}}},
|
||||
Realms: []authapi.Realm{{Type: "stack", Identifier: s.cfg.StackID, LabelPolicies: []authapi.LabelPolicy{}}},
|
||||
Scopes: []string{"cloud-migrations:read", "cloud-migrations:write"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -273,9 +290,12 @@ func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessT
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, s.cfg.CloudMigration.CreateTokenTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := s.gcomService.CreateToken(timeoutCtx,
|
||||
gcom.CreateTokenParams{RequestID: requestID, Region: instance.RegionSlug},
|
||||
gcom.CreateTokenPayload{
|
||||
token, err := s.authApiService.CreateToken(timeoutCtx,
|
||||
authapi.CreateTokenParams{
|
||||
RequestID: requestID,
|
||||
Region: instance.RegionSlug,
|
||||
},
|
||||
authapi.CreateTokenPayload{
|
||||
AccessPolicyID: accessPolicy.ID,
|
||||
Name: accessTokenName,
|
||||
DisplayName: accessTokenDisplayName,
|
||||
@@ -303,14 +323,14 @@ func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessT
|
||||
return cloudmigration.CreateAccessTokenResponse{Token: base64.StdEncoding.EncodeToString(bytes)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) findAccessPolicyByName(ctx context.Context, regionSlug, accessPolicyName string) (*gcom.AccessPolicy, error) {
|
||||
accessPolicies, err := s.gcomService.ListAccessPolicies(ctx, gcom.ListAccessPoliciesParams{
|
||||
func (s *Service) findAccessPolicyByName(ctx context.Context, accessPolicyName string, region string) (*authapi.AccessPolicy, error) {
|
||||
accessPolicies, err := s.authApiService.ListAccessPolicies(ctx, authapi.ListAccessPoliciesParams{
|
||||
RequestID: tracing.TraceIDFromContext(ctx, false),
|
||||
Region: regionSlug,
|
||||
Name: accessPolicyName,
|
||||
Region: region,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing access policies: name=%s region=%s :%w", accessPolicyName, regionSlug, err)
|
||||
return nil, fmt.Errorf("listing access policies: name=%s :%w", accessPolicyName, err)
|
||||
}
|
||||
|
||||
for _, accessPolicy := range accessPolicies {
|
||||
@@ -349,10 +369,10 @@ func (s *Service) DeleteToken(ctx context.Context, tokenID string) error {
|
||||
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, s.cfg.CloudMigration.DeleteTokenTimeout)
|
||||
defer cancel()
|
||||
if err := s.gcomService.DeleteToken(timeoutCtx, gcom.DeleteTokenParams{
|
||||
if err := s.authApiService.DeleteToken(timeoutCtx, authapi.DeleteTokenParams{
|
||||
RequestID: tracing.TraceIDFromContext(ctx, false),
|
||||
Region: instance.RegionSlug,
|
||||
TokenID: tokenID,
|
||||
Region: instance.RegionSlug,
|
||||
}); err != nil && !errors.Is(err, gcom.ErrTokenNotFound) {
|
||||
return fmt.Errorf("deleting cloud migration token: tokenID=%s %w", tokenID, err)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package cloudmigrationimpl
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration"
|
||||
"github.com/grafana/grafana/pkg/services/gcom"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ type NoopServiceImpl struct{}
|
||||
|
||||
var _ cloudmigration.Service = (*NoopServiceImpl)(nil)
|
||||
|
||||
func (s *NoopServiceImpl) GetToken(ctx context.Context) (gcom.TokenView, error) {
|
||||
return gcom.TokenView{}, cloudmigration.ErrFeatureDisabledError
|
||||
func (s *NoopServiceImpl) GetToken(ctx context.Context) (authapi.TokenView, error) {
|
||||
return authapi.TokenView{}, cloudmigration.ErrFeatureDisabledError
|
||||
}
|
||||
|
||||
func (s *NoopServiceImpl) CreateToken(ctx context.Context) (cloudmigration.CreateAccessTokenResponse, error) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration"
|
||||
"github.com/grafana/grafana/pkg/services/gcom"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
@@ -20,11 +20,11 @@ type FakeServiceImpl struct {
|
||||
|
||||
var _ cloudmigration.Service = (*FakeServiceImpl)(nil)
|
||||
|
||||
func (m FakeServiceImpl) GetToken(_ context.Context) (gcom.TokenView, error) {
|
||||
func (m FakeServiceImpl) GetToken(_ context.Context) (authapi.TokenView, error) {
|
||||
if m.ReturnError {
|
||||
return gcom.TokenView{}, fmt.Errorf("mock error")
|
||||
return authapi.TokenView{}, fmt.Errorf("mock error")
|
||||
}
|
||||
return gcom.TokenView{ID: "mock_id", DisplayName: "mock_name"}, nil
|
||||
return authapi.TokenView{ID: "mock_id", DisplayName: "mock_name"}, nil
|
||||
}
|
||||
|
||||
func (m FakeServiceImpl) CreateToken(_ context.Context) (cloudmigration.CreateAccessTokenResponse, error) {
|
||||
|
||||
@@ -6,16 +6,12 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/gcom"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type gcomStub struct {
|
||||
// The cloud migration token created by this stub.
|
||||
token *gcom.TokenView
|
||||
policies map[string]gcom.AccessPolicy
|
||||
}
|
||||
|
||||
func (client *gcomStub) GetInstanceByID(ctx context.Context, requestID string, instanceID string) (gcom.Instance, error) {
|
||||
func (client *gcomStub) GetInstanceByID(_ context.Context, _ string, instanceID string) (gcom.Instance, error) {
|
||||
id, err := strconv.Atoi(instanceID)
|
||||
if err != nil {
|
||||
return gcom.Instance{}, fmt.Errorf("parsing instanceID: %w", err)
|
||||
@@ -27,55 +23,3 @@ func (client *gcomStub) GetInstanceByID(ctx context.Context, requestID string, i
|
||||
ClusterSlug: "fake-cluser",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *gcomStub) CreateAccessPolicy(ctx context.Context, params gcom.CreateAccessPolicyParams, payload gcom.CreateAccessPolicyPayload) (gcom.AccessPolicy, error) {
|
||||
randStr := fmt.Sprintf("random-policy-%s", util.GenerateShortUID())
|
||||
policy := gcom.AccessPolicy{
|
||||
ID: randStr,
|
||||
Name: randStr,
|
||||
}
|
||||
client.policies[policy.ID] = policy
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (client *gcomStub) DeleteAccessPolicy(ctx context.Context, params gcom.DeleteAccessPolicyParams) (bool, error) {
|
||||
delete(client.policies, params.AccessPolicyID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (client *gcomStub) ListAccessPolicies(ctx context.Context, params gcom.ListAccessPoliciesParams) ([]gcom.AccessPolicy, error) {
|
||||
items := make([]gcom.AccessPolicy, 0)
|
||||
for _, v := range client.policies {
|
||||
items = append(items, v)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (client *gcomStub) ListTokens(ctx context.Context, params gcom.ListTokenParams) ([]gcom.TokenView, error) {
|
||||
if client.token == nil {
|
||||
return []gcom.TokenView{}, nil
|
||||
}
|
||||
|
||||
return []gcom.TokenView{*client.token}, nil
|
||||
}
|
||||
|
||||
func (client *gcomStub) CreateToken(ctx context.Context, params gcom.CreateTokenParams, payload gcom.CreateTokenPayload) (gcom.Token, error) {
|
||||
token := gcom.Token{
|
||||
ID: fmt.Sprintf("random-token-%s", util.GenerateShortUID()),
|
||||
Name: payload.Name,
|
||||
AccessPolicyID: payload.AccessPolicyID,
|
||||
Token: fmt.Sprintf("completely_fake_token_%s", util.GenerateShortUID()),
|
||||
}
|
||||
client.token = &gcom.TokenView{
|
||||
ID: token.ID,
|
||||
Name: token.Name,
|
||||
AccessPolicyID: token.AccessPolicyID,
|
||||
DisplayName: token.Name,
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *gcomStub) DeleteToken(ctx context.Context, params gcom.DeleteTokenParams) error {
|
||||
client.token = nil
|
||||
return nil
|
||||
}
|
||||
@@ -48,14 +48,6 @@ var (
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "publicDashboards",
|
||||
Description: "[Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version.",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaSharingSquad,
|
||||
Expression: "true", // enabled by default
|
||||
AllowSelfServe: true,
|
||||
},
|
||||
{
|
||||
Name: "publicDashboardsEmailSharing",
|
||||
Description: "Enables public dashboard sharing to be restricted to only allowed emails",
|
||||
@@ -372,10 +364,12 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
},
|
||||
{
|
||||
Name: "unifiedRequestLog",
|
||||
Description: "Writes error logs to the request logger",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaBackendGroup,
|
||||
Name: "unifiedRequestLog",
|
||||
Description: "Writes error logs to the request logger",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaBackendGroup,
|
||||
Expression: "true",
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "renderAuthJWT",
|
||||
@@ -1064,6 +1058,12 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
},
|
||||
{
|
||||
Name: "onPremToCloudMigrationsAuthApiMig",
|
||||
Description: "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
},
|
||||
{
|
||||
Name: "alertingSaveStatePeriodic",
|
||||
Description: "Writes the state periodically to the database, asynchronous to rule evaluation",
|
||||
|
||||
@@ -3,7 +3,6 @@ disableEnvelopeEncryption,GA,@grafana/grafana-as-code,false,false,false
|
||||
live-service-web-worker,experimental,@grafana/dashboards-squad,false,false,true
|
||||
queryOverLive,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelTitleSearch,preview,@grafana/search-and-storage,false,false,false
|
||||
publicDashboards,GA,@grafana/sharing-squad,false,false,false
|
||||
publicDashboardsEmailSharing,preview,@grafana/sharing-squad,false,false,false
|
||||
publicDashboardsScene,GA,@grafana/sharing-squad,false,false,true
|
||||
lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false
|
||||
@@ -48,7 +47,7 @@ disableSSEDataplane,experimental,@grafana/observability-metrics,false,false,fals
|
||||
alertStateHistoryLokiSecondary,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false
|
||||
unifiedRequestLog,experimental,@grafana/grafana-backend-group,false,false,false
|
||||
unifiedRequestLog,GA,@grafana/grafana-backend-group,false,false,false
|
||||
renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false
|
||||
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false
|
||||
faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true
|
||||
@@ -140,6 +139,7 @@ newFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
|
||||
onPremToCloudMigrations,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
onPremToCloudMigrationsAlerts,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
onPremToCloudMigrationsAuthApiMig,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
|
||||
promQLScope,GA,@grafana/observability-metrics,false,false,false
|
||||
logQLScope,privatePreview,@grafana/observability-logs,false,false,false
|
||||
|
||||
|
@@ -23,10 +23,6 @@ const (
|
||||
// Search for dashboards using panel title
|
||||
FlagPanelTitleSearch = "panelTitleSearch"
|
||||
|
||||
// FlagPublicDashboards
|
||||
// [Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version.
|
||||
FlagPublicDashboards = "publicDashboards"
|
||||
|
||||
// FlagPublicDashboardsEmailSharing
|
||||
// Enables public dashboard sharing to be restricted to only allowed emails
|
||||
FlagPublicDashboardsEmailSharing = "publicDashboardsEmailSharing"
|
||||
@@ -571,6 +567,10 @@ const (
|
||||
// Enables the migration of alerts and its child resources to your Grafana Cloud stack. Requires `onPremToCloudMigrations` to be enabled in conjunction.
|
||||
FlagOnPremToCloudMigrationsAlerts = "onPremToCloudMigrationsAlerts"
|
||||
|
||||
// FlagOnPremToCloudMigrationsAuthApiMig
|
||||
// Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.
|
||||
FlagOnPremToCloudMigrationsAuthApiMig = "onPremToCloudMigrationsAuthApiMig"
|
||||
|
||||
// FlagAlertingSaveStatePeriodic
|
||||
// Writes the state periodically to the database, asynchronous to rule evaluation
|
||||
FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic"
|
||||
|
||||
@@ -2404,6 +2404,18 @@
|
||||
"codeowner": "@grafana/grafana-operator-experience-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "onPremToCloudMigrationsAuthApiMig",
|
||||
"resourceVersion": "1732033809064",
|
||||
"creationTimestamp": "2024-11-19T16:30:09Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-operator-experience-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "openSearchBackendFlowEnabled",
|
||||
@@ -2833,6 +2845,7 @@
|
||||
"name": "publicDashboards",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2022-04-07T18:30:19Z",
|
||||
"deletionTimestamp": "2024-11-15T16:38:53Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -3373,13 +3386,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "unifiedRequestLog",
|
||||
"resourceVersion": "1718727528075",
|
||||
"creationTimestamp": "2023-03-31T13:38:09Z"
|
||||
"resourceVersion": "1731688509416",
|
||||
"creationTimestamp": "2023-03-31T13:38:09Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-15 16:35:09.416681 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Writes error logs to the request logger",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-backend-group"
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/grafana-backend-group",
|
||||
"hideFromAdminPage": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-364
@@ -1,7 +1,6 @@
|
||||
package gcom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
)
|
||||
@@ -20,12 +18,6 @@ var ErrTokenNotFound = errors.New("gcom: token not found")
|
||||
|
||||
type Service interface {
|
||||
GetInstanceByID(ctx context.Context, requestID string, instanceID string) (Instance, error)
|
||||
CreateAccessPolicy(ctx context.Context, params CreateAccessPolicyParams, payload CreateAccessPolicyPayload) (AccessPolicy, error)
|
||||
ListAccessPolicies(ctx context.Context, params ListAccessPoliciesParams) ([]AccessPolicy, error)
|
||||
DeleteAccessPolicy(ctx context.Context, params DeleteAccessPolicyParams) (bool, error)
|
||||
ListTokens(ctx context.Context, params ListTokenParams) ([]TokenView, error)
|
||||
CreateToken(ctx context.Context, params CreateTokenParams, payload CreateTokenPayload) (Token, error)
|
||||
DeleteToken(ctx context.Context, params DeleteTokenParams) error
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
@@ -33,98 +25,7 @@ type Instance struct {
|
||||
Slug string `json:"slug"`
|
||||
RegionSlug string `json:"regionSlug"`
|
||||
ClusterSlug string `json:"clusterSlug"`
|
||||
}
|
||||
|
||||
type CreateAccessPolicyParams struct {
|
||||
RequestID string
|
||||
Region string
|
||||
}
|
||||
|
||||
type CreateAccessPolicyPayload struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Realms []Realm `json:"realms"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type Realm struct {
|
||||
Identifier string `json:"identifier"`
|
||||
LabelPolicies []LabelPolicy `json:"labelPolicies"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type LabelPolicy struct {
|
||||
Selector string `json:"selector"`
|
||||
}
|
||||
|
||||
type AccessPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListAccessPoliciesParams struct {
|
||||
RequestID string
|
||||
Region string
|
||||
Name string
|
||||
}
|
||||
|
||||
type ListAccessPoliciesResponse struct {
|
||||
Items []AccessPolicy `json:"items"`
|
||||
}
|
||||
|
||||
type DeleteAccessPolicyParams struct {
|
||||
RequestID string
|
||||
AccessPolicyID string
|
||||
Region string
|
||||
}
|
||||
|
||||
type ListTokenParams struct {
|
||||
RequestID string
|
||||
Region string
|
||||
AccessPolicyName string
|
||||
TokenName string
|
||||
}
|
||||
|
||||
type CreateTokenParams struct {
|
||||
RequestID string
|
||||
Region string
|
||||
}
|
||||
|
||||
type CreateTokenPayload struct {
|
||||
AccessPolicyID string `json:"accessPolicyId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// The token returned by gcom api when a token gets created.
|
||||
type Token struct {
|
||||
ID string `json:"id"`
|
||||
AccessPolicyID string `json:"accessPolicyId"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type DeleteTokenParams struct {
|
||||
RequestID string
|
||||
Region string
|
||||
TokenID string
|
||||
}
|
||||
|
||||
// The token returned by gcom api for a GET token request.
|
||||
type TokenView struct {
|
||||
ID string `json:"id"`
|
||||
AccessPolicyID string `json:"accessPolicyId"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
FirstUsedAt string `json:"firstUsedAt"`
|
||||
LastUsedAt string `json:"lastUsedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type listTokensResponse struct {
|
||||
Items []TokenView `json:"items"`
|
||||
OrgId int `json:"orgId"`
|
||||
}
|
||||
|
||||
type GcomClient struct {
|
||||
@@ -184,267 +85,3 @@ func (client *GcomClient) GetInstanceByID(ctx context.Context, requestID string,
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) CreateAccessPolicy(ctx context.Context, params CreateAccessPolicyParams, payload CreateAccessPolicyPayload) (AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return AccessPolicy{}, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return AccessPolicy{}, fmt.Errorf("unexpected response when creating access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var accessPolicy AccessPolicy
|
||||
if err := json.NewDecoder(response.Body).Decode(&accessPolicy); err != nil {
|
||||
return accessPolicy, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return accessPolicy, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) DeleteAccessPolicy(ctx context.Context, params DeleteAccessPolicyParams) (bool, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies/", params.AccessPolicyID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return false, fmt.Errorf("unexpected response when deleting access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
func (client *GcomClient) ListAccessPolicies(ctx context.Context, params ListAccessPoliciesParams) ([]AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
query.Set("name", params.Name)
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when listing access policies: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var responseBody ListAccessPoliciesResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {
|
||||
return responseBody.Items, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return responseBody.Items, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) ListTokens(ctx context.Context, params ListTokenParams) ([]TokenView, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
query.Set("accessPolicyName", params.AccessPolicyName)
|
||||
query.Set("name", params.TokenName)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to list access tokens: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when fetching access tokens: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var body listTokensResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return body.Items, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) CreateToken(ctx context.Context, params CreateTokenParams, payload CreateTokenPayload) (Token, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("sending http request to create access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return Token{}, fmt.Errorf("unexpected response when creating access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var token Token
|
||||
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
|
||||
return token, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) DeleteToken(ctx context.Context, params DeleteTokenParams) error {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens", params.TokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending http request to delete access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("token id: %s %w", params.TokenID, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("unexpected response when deleting access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package gcom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
)
|
||||
|
||||
// this will be removed when service in authapi is fully enabled
|
||||
|
||||
type listTokensResponse struct {
|
||||
Items []authapi.TokenView `json:"items"`
|
||||
}
|
||||
|
||||
type listAccessPoliciesResponse struct {
|
||||
Items []authapi.AccessPolicy `json:"items"`
|
||||
}
|
||||
|
||||
var _ authapi.Service = (*GcomClient)(nil)
|
||||
|
||||
func (client *GcomClient) CreateAccessPolicy(ctx context.Context, params authapi.CreateAccessPolicyParams, payload authapi.CreateAccessPolicyPayload) (authapi.AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("unexpected response when creating access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var accessPolicy authapi.AccessPolicy
|
||||
if err := json.NewDecoder(response.Body).Decode(&accessPolicy); err != nil {
|
||||
return accessPolicy, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return accessPolicy, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) DeleteAccessPolicy(ctx context.Context, params authapi.DeleteAccessPolicyParams) (bool, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies/", params.AccessPolicyID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return false, fmt.Errorf("unexpected response when deleting access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
func (client *GcomClient) ListAccessPolicies(ctx context.Context, params authapi.ListAccessPoliciesParams) ([]authapi.AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
query.Set("name", params.Name)
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when listing access policies: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var responseBody listAccessPoliciesResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {
|
||||
return responseBody.Items, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return responseBody.Items, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) ListTokens(ctx context.Context, params authapi.ListTokenParams) ([]authapi.TokenView, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
query.Set("accessPolicyName", params.AccessPolicyName)
|
||||
query.Set("name", params.TokenName)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to list access tokens: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when fetching access tokens: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var body listTokensResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return body.Items, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) CreateToken(ctx context.Context, params authapi.CreateTokenParams, payload authapi.CreateTokenPayload) (authapi.Token, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("sending http request to create access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return authapi.Token{}, fmt.Errorf("unexpected response when creating access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var token authapi.Token
|
||||
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
|
||||
return token, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) DeleteToken(ctx context.Context, params authapi.DeleteTokenParams) error {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens", params.TokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending http request to delete access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("token id: %s %w", params.TokenID, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("unexpected response when deleting access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
|
||||
treeRoot.RemoveSectionByID(navtree.NavIDCfg)
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagPinNavItems) {
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagPinNavItems) && c.IsSignedIn {
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Bookmarks",
|
||||
Id: navtree.NavIDBookmarks,
|
||||
@@ -380,7 +380,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
||||
Icon: "library-panel",
|
||||
})
|
||||
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagPublicDashboards) && s.cfg.PublicDashboardsEnabled {
|
||||
if s.cfg.PublicDashboardsEnabled {
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Public dashboards",
|
||||
Id: "dashboards/public",
|
||||
|
||||
@@ -51,11 +51,12 @@ type OAuthTokenService interface {
|
||||
IsOAuthPassThruEnabled(*datasources.DataSource) bool
|
||||
HasOAuthEntry(context.Context, identity.Requester) (*login.UserAuth, bool, error)
|
||||
TryTokenRefresh(context.Context, identity.Requester) (*oauth2.Token, error)
|
||||
InvalidateOAuthTokens(context.Context, *login.UserAuth) error
|
||||
InvalidateOAuthTokens(context.Context, identity.Requester) error
|
||||
}
|
||||
|
||||
func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg, registerer prometheus.Registerer,
|
||||
serverLockService *serverlock.ServerLockService, tracer tracing.Tracer) *Service {
|
||||
serverLockService *serverlock.ServerLockService, tracer tracing.Tracer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
AuthInfoService: authInfoService,
|
||||
Cfg: cfg,
|
||||
@@ -71,6 +72,27 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Request
|
||||
ctx, span := o.tracer.Start(ctx, "oauthtoken.GetCurrentOAuthToken")
|
||||
defer span.End()
|
||||
|
||||
ctxLogger := logger.FromContext(ctx)
|
||||
|
||||
if usr == nil || usr.IsNil() {
|
||||
ctxLogger.Warn("Can only get OAuth tokens for existing users", "user", "nil")
|
||||
// Not user, no token.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !usr.IsIdentityType(claims.TypeUser) {
|
||||
ctxLogger.Warn("Can only get OAuth tokens for users", "id", usr.GetID())
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, err := usr.GetInternalID()
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctxLogger = ctxLogger.New("userID", userID)
|
||||
|
||||
authInfo, ok, _ := o.HasOAuthEntry(ctx, usr)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -84,7 +106,9 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Request
|
||||
return nil
|
||||
}
|
||||
|
||||
persistedToken, refreshNeeded := needTokenRefresh(authInfo)
|
||||
persistedToken := buildOAuthTokenFromAuthInfo(authInfo)
|
||||
|
||||
refreshNeeded := needTokenRefresh(ctx, persistedToken)
|
||||
if !refreshNeeded {
|
||||
return persistedToken
|
||||
}
|
||||
@@ -226,14 +250,16 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) (
|
||||
return
|
||||
}
|
||||
|
||||
storedToken, needRefresh := needTokenRefresh(authInfo)
|
||||
persistedToken := buildOAuthTokenFromAuthInfo(authInfo)
|
||||
|
||||
needRefresh := needTokenRefresh(ctx, persistedToken)
|
||||
if !needRefresh {
|
||||
// Set the token which is returned by the outer function in case there's no need to refresh the token
|
||||
newToken = storedToken
|
||||
newToken = persistedToken
|
||||
return
|
||||
}
|
||||
|
||||
newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, authInfo)
|
||||
newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr)
|
||||
}, retryOpt)
|
||||
if lockErr != nil {
|
||||
ctxLogger.Error("Failed to obtain token refresh lock", "error", lockErr)
|
||||
@@ -280,11 +306,17 @@ func checkOAuthRefreshToken(authInfo *login.UserAuth) error {
|
||||
}
|
||||
|
||||
// InvalidateOAuthTokens invalidates the OAuth tokens (access_token, refresh_token) and sets the Expiry to default/zero
|
||||
func (o *Service) InvalidateOAuthTokens(ctx context.Context, authInfo *login.UserAuth) error {
|
||||
func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester) error {
|
||||
userID, err := usr.GetInternalID()
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return o.AuthInfoService.UpdateAuthInfo(ctx, &login.UpdateAuthInfoCommand{
|
||||
UserId: authInfo.UserId,
|
||||
AuthModule: authInfo.AuthModule,
|
||||
AuthId: authInfo.AuthId,
|
||||
UserId: userID,
|
||||
AuthModule: usr.GetAuthenticatedBy(),
|
||||
AuthId: usr.GetAuthID(),
|
||||
OAuthToken: &oauth2.Token{
|
||||
AccessToken: "",
|
||||
RefreshToken: "",
|
||||
@@ -293,23 +325,31 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, authInfo *login.Use
|
||||
})
|
||||
}
|
||||
|
||||
func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, authInfo *login.UserAuth) (*oauth2.Token, error) {
|
||||
ctx, span := o.tracer.Start(ctx, "oauthtoken.tryGetOrRefreshOAuthToken",
|
||||
trace.WithAttributes(attribute.Int64("userID", authInfo.UserId)))
|
||||
func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester) (*oauth2.Token, error) {
|
||||
ctx, span := o.tracer.Start(ctx, "oauthtoken.tryGetOrRefreshOAuthToken")
|
||||
defer span.End()
|
||||
|
||||
ctxLogger := logger.FromContext(ctx).New("userID", authInfo.UserId)
|
||||
|
||||
if err := checkOAuthRefreshToken(authInfo); err != nil {
|
||||
userID, err := usr.GetInternalID()
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
persistedToken, refreshNeeded := needTokenRefresh(authInfo)
|
||||
span.SetAttributes(attribute.Int64("userID", userID))
|
||||
|
||||
ctxLogger := logger.FromContext(ctx).New("userID", userID)
|
||||
|
||||
if persistedToken.RefreshToken == "" {
|
||||
ctxLogger.Warn("No refresh token available", "authmodule", usr.GetAuthenticatedBy())
|
||||
return nil, ErrNoRefreshTokenFound
|
||||
}
|
||||
|
||||
refreshNeeded := needTokenRefresh(ctx, persistedToken)
|
||||
if !refreshNeeded {
|
||||
return persistedToken, nil
|
||||
}
|
||||
|
||||
authProvider := authInfo.AuthModule
|
||||
authProvider := usr.GetAuthenticatedBy()
|
||||
connect, err := o.SocialService.GetConnector(authProvider)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get oauth connector", "provider", authProvider, "error", err)
|
||||
@@ -331,11 +371,11 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, authInfo *login
|
||||
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to retrieve oauth access token",
|
||||
"provider", authInfo.AuthModule, "userId", authInfo.UserId, "error", err)
|
||||
"provider", usr.GetAuthenticatedBy(), "error", err)
|
||||
|
||||
// token refresh failed, invalidate the old token
|
||||
if err := o.InvalidateOAuthTokens(ctx, authInfo); err != nil {
|
||||
ctxLogger.Warn("Failed to invalidate OAuth tokens", "id", authInfo.Id, "error", err)
|
||||
if err := o.InvalidateOAuthTokens(ctx, usr); err != nil {
|
||||
ctxLogger.Warn("Failed to invalidate OAuth tokens", "authID", usr.GetAuthID(), "error", err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
@@ -344,15 +384,15 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, authInfo *login
|
||||
// If the tokens are not the same, update the entry in the DB
|
||||
if !tokensEq(persistedToken, token) {
|
||||
updateAuthCommand := &login.UpdateAuthInfoCommand{
|
||||
UserId: authInfo.UserId,
|
||||
AuthModule: authInfo.AuthModule,
|
||||
AuthId: authInfo.AuthId,
|
||||
UserId: userID,
|
||||
AuthModule: usr.GetAuthenticatedBy(),
|
||||
AuthId: usr.GetAuthID(),
|
||||
OAuthToken: token,
|
||||
}
|
||||
|
||||
if o.Cfg.Env == setting.Dev {
|
||||
ctxLogger.Debug("Oauth got token",
|
||||
"auth_module", authInfo.AuthModule,
|
||||
"auth_module", usr.GetAuthenticatedBy(),
|
||||
"expiry", fmt.Sprintf("%v", token.Expiry),
|
||||
"access_token", fmt.Sprintf("%v", token.AccessToken),
|
||||
"refresh_token", fmt.Sprintf("%v", token.RefreshToken),
|
||||
@@ -360,7 +400,7 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, authInfo *login
|
||||
}
|
||||
|
||||
if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil {
|
||||
ctxLogger.Error("Failed to update auth info during token refresh", "userId", authInfo.UserId, "error", err)
|
||||
ctxLogger.Error("Failed to update auth info during token refresh", "authID", usr.GetAuthID(), "error", err)
|
||||
return token, err
|
||||
}
|
||||
ctxLogger.Debug("Updated oauth info for user")
|
||||
@@ -401,14 +441,14 @@ func tokensEq(t1, t2 *oauth2.Token) bool {
|
||||
t1IdToken == t2IdToken
|
||||
}
|
||||
|
||||
func needTokenRefresh(authInfo *login.UserAuth) (*oauth2.Token, bool) {
|
||||
func needTokenRefresh(ctx context.Context, persistedToken *oauth2.Token) bool {
|
||||
var hasAccessTokenExpired, hasIdTokenExpired bool
|
||||
|
||||
persistedToken := buildOAuthTokenFromAuthInfo(authInfo)
|
||||
ctxLogger := logger.FromContext(ctx)
|
||||
|
||||
idTokenExp, err := GetIDTokenExpiry(persistedToken)
|
||||
if err != nil {
|
||||
logger.Warn("Could not get ID Token expiry", "error", err)
|
||||
ctxLogger.Warn("Could not get ID Token expiry", "error", err)
|
||||
}
|
||||
if !persistedToken.Expiry.IsZero() {
|
||||
_, hasAccessTokenExpired = getExpiryWithSkew(persistedToken.Expiry)
|
||||
@@ -417,14 +457,14 @@ func needTokenRefresh(authInfo *login.UserAuth) (*oauth2.Token, bool) {
|
||||
_, hasIdTokenExpired = getExpiryWithSkew(idTokenExp)
|
||||
}
|
||||
if !hasAccessTokenExpired && !hasIdTokenExpired {
|
||||
logger.Debug("Neither access nor id token have expired yet", "userID", authInfo.UserId)
|
||||
return persistedToken, false
|
||||
ctxLogger.Debug("Neither access nor id token have expired yet")
|
||||
return false
|
||||
}
|
||||
if hasIdTokenExpired {
|
||||
// Force refreshing token when id token is expired
|
||||
persistedToken.AccessToken = ""
|
||||
}
|
||||
return persistedToken, true
|
||||
return true
|
||||
}
|
||||
|
||||
// GetIDTokenExpiry extracts the expiry time from the ID token
|
||||
|
||||
@@ -31,7 +31,9 @@ import (
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
)
|
||||
|
||||
var EXPIRED_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
|
||||
const EXPIRED_ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2Nzg5MCIsImF1ZCI6InlvdXItY2xpZW50LWlkIiwiZXhwIjoxNjAwMDAwMDAwLCJpYXQiOjE2MDAwMDAwMDAsIm5hbWUiOiJKb2huIERvZSIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSJ9.c2lnbmF0dXJl" // #nosec G101 not a hardcoded credential
|
||||
|
||||
const UNEXPIRED_ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2Nzg5MCIsImF1ZCI6InlvdXItY2xpZW50LWlkIiwiZXhwIjo0ODg1NjA4MDAwLCJpYXQiOjE2ODU2MDgwMDAsIm5hbWUiOiJKb2huIERvZSIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSJ9.c2lnbmF0dXJl" // #nosec G101 not a hardcoded credential
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
@@ -162,19 +164,44 @@ func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.Delet
|
||||
}
|
||||
|
||||
func TestService_TryTokenRefresh(t *testing.T) {
|
||||
unexpiredToken := &oauth2.Token{
|
||||
AccessToken: "testaccess",
|
||||
RefreshToken: "testrefresh",
|
||||
Expiry: time.Now().Add(time.Hour),
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
unexpiredTokenWithIDToken := unexpiredToken.WithExtra(map[string]interface{}{
|
||||
"id_token": UNEXPIRED_ID_TOKEN,
|
||||
})
|
||||
|
||||
expiredToken := &oauth2.Token{
|
||||
AccessToken: "testaccess",
|
||||
RefreshToken: "testrefresh",
|
||||
Expiry: time.Now().Add(-time.Hour),
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
authInfoService *authinfotest.FakeService
|
||||
serverLock *serverlock.ServerLockService
|
||||
identity identity.Requester
|
||||
socialConnector *socialtest.MockSocialConnector
|
||||
socialService *socialtest.FakeSocialService
|
||||
|
||||
service *Service
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
desc string
|
||||
expectedErr error
|
||||
setup func(env *environment)
|
||||
desc string
|
||||
identity identity.Requester
|
||||
setup func(env *environment)
|
||||
expectedToken *oauth2.Token
|
||||
expectedErr error
|
||||
}
|
||||
|
||||
userIdentity := &authn.Identity{
|
||||
AuthenticatedBy: login.GenericOAuthModule,
|
||||
ID: "1234",
|
||||
Type: claims.TypeUser,
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
@@ -182,84 +209,44 @@ func TestService_TryTokenRefresh(t *testing.T) {
|
||||
desc: "should skip sync when identity is nil",
|
||||
},
|
||||
{
|
||||
desc: "should skip sync when identity is not a user",
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1", Type: claims.TypeServiceAccount}
|
||||
},
|
||||
desc: "should skip sync when identity is not a user",
|
||||
identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh and return nil if namespace and id cannot be converted to user ID",
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "invalid", Type: claims.TypeUser}
|
||||
},
|
||||
desc: "should skip token refresh and return nil if namespace and id cannot be converted to user ID",
|
||||
identity: &authn.Identity{ID: "invalid", Type: claims.TypeUser},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh since the token is still valid",
|
||||
desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "testaccess",
|
||||
RefreshToken: "testrefresh",
|
||||
Expiry: time.Now().Add(time.Hour),
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthAccessToken: token.AccessToken,
|
||||
OAuthRefreshToken: token.RefreshToken,
|
||||
OAuthExpiry: token.Expiry,
|
||||
OAuthTokenType: token.TokenType,
|
||||
}
|
||||
|
||||
env.identity = &authn.Identity{
|
||||
AuthenticatedBy: login.GenericOAuthModule,
|
||||
ID: "1234",
|
||||
Type: claims.TypeUser,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned",
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser}
|
||||
env.authInfoService.ExpectedError = errors.New("some error")
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh if the user doesn't have an oauth entry",
|
||||
desc: "should skip token refresh if the user doesn't have an oauth entry",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser}
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.SAMLAuthModule,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should do token refresh if access token or id token have not expired yet",
|
||||
desc: "should skip token refresh when no oauth provider was found",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser}
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh when no oauth provider was found",
|
||||
desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser}
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthIdToken: EXPIRED_JWT,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)",
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser}
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthIdToken: EXPIRED_JWT,
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
}
|
||||
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
|
||||
UseRefreshToken: false,
|
||||
@@ -267,29 +254,66 @@ func TestService_TryTokenRefresh(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh when there is no refresh token",
|
||||
desc: "should skip token refresh when the token is still valid and no id token is present",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser}
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthIdToken: EXPIRED_JWT,
|
||||
OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken,
|
||||
OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken,
|
||||
OAuthExpiry: unexpiredTokenWithIDToken.Expiry,
|
||||
OAuthTokenType: unexpiredTokenWithIDToken.TokenType,
|
||||
}
|
||||
|
||||
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
|
||||
UseRefreshToken: true,
|
||||
}
|
||||
},
|
||||
expectedToken: unexpiredToken,
|
||||
},
|
||||
{
|
||||
desc: "should not refresh the tokens if access token or id token have not expired yet",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthIdToken: UNEXPIRED_ID_TOKEN,
|
||||
OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken,
|
||||
OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken,
|
||||
OAuthExpiry: unexpiredTokenWithIDToken.Expiry,
|
||||
OAuthTokenType: unexpiredTokenWithIDToken.TokenType,
|
||||
}
|
||||
|
||||
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
|
||||
UseRefreshToken: true,
|
||||
}
|
||||
},
|
||||
expectedToken: unexpiredTokenWithIDToken,
|
||||
},
|
||||
{
|
||||
desc: "should skip token refresh when there is no refresh token",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
env.authInfoService.ExpectedUserAuth = &login.UserAuth{
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken,
|
||||
OAuthRefreshToken: "",
|
||||
OAuthExpiry: unexpiredTokenWithIDToken.Expiry,
|
||||
}
|
||||
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
|
||||
UseRefreshToken: true,
|
||||
}
|
||||
},
|
||||
expectedToken: &oauth2.Token{
|
||||
AccessToken: unexpiredTokenWithIDToken.AccessToken,
|
||||
RefreshToken: "",
|
||||
Expiry: unexpiredTokenWithIDToken.Expiry,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should do token refresh when the token is expired",
|
||||
desc: "should do token refresh when the token is expired",
|
||||
identity: userIdentity,
|
||||
setup: func(env *environment) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "testaccess",
|
||||
RefreshToken: "testrefresh",
|
||||
Expiry: time.Now().Add(-time.Hour),
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}
|
||||
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
|
||||
UseRefreshToken: true,
|
||||
}
|
||||
@@ -297,24 +321,20 @@ func TestService_TryTokenRefresh(t *testing.T) {
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
AuthId: "subject",
|
||||
UserId: 1,
|
||||
OAuthAccessToken: token.AccessToken,
|
||||
OAuthRefreshToken: token.RefreshToken,
|
||||
OAuthExpiry: token.Expiry,
|
||||
OAuthTokenType: token.TokenType,
|
||||
OAuthAccessToken: expiredToken.AccessToken,
|
||||
OAuthRefreshToken: expiredToken.RefreshToken,
|
||||
OAuthExpiry: expiredToken.Expiry,
|
||||
OAuthTokenType: expiredToken.TokenType,
|
||||
OAuthIdToken: EXPIRED_ID_TOKEN,
|
||||
}
|
||||
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)).Once()
|
||||
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once()
|
||||
},
|
||||
expectedToken: unexpiredTokenWithIDToken,
|
||||
},
|
||||
{
|
||||
desc: "should refresh token when the id token is expired",
|
||||
desc: "should refresh token when the id token is expired",
|
||||
identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule},
|
||||
setup: func(env *environment) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "testaccess",
|
||||
RefreshToken: "testrefresh",
|
||||
Expiry: time.Now().Add(time.Hour),
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
env.identity = &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}
|
||||
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
|
||||
UseRefreshToken: true,
|
||||
}
|
||||
@@ -322,19 +342,20 @@ func TestService_TryTokenRefresh(t *testing.T) {
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
AuthId: "subject",
|
||||
UserId: 1,
|
||||
OAuthAccessToken: token.AccessToken,
|
||||
OAuthRefreshToken: token.RefreshToken,
|
||||
OAuthExpiry: token.Expiry,
|
||||
OAuthTokenType: token.TokenType,
|
||||
OAuthIdToken: EXPIRED_JWT,
|
||||
OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken,
|
||||
OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken,
|
||||
OAuthExpiry: unexpiredTokenWithIDToken.Expiry,
|
||||
OAuthTokenType: unexpiredTokenWithIDToken.TokenType,
|
||||
OAuthIdToken: EXPIRED_ID_TOKEN,
|
||||
}
|
||||
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)).Once()
|
||||
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once()
|
||||
},
|
||||
expectedToken: unexpiredTokenWithIDToken,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
socialConnector := &socialtest.MockSocialConnector{}
|
||||
socialConnector := socialtest.NewMockSocialConnector(t)
|
||||
|
||||
store := db.InitTestDB(t)
|
||||
|
||||
@@ -361,11 +382,27 @@ func TestService_TryTokenRefresh(t *testing.T) {
|
||||
)
|
||||
|
||||
// token refresh
|
||||
_, err := env.service.TryTokenRefresh(context.Background(), env.identity)
|
||||
actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity)
|
||||
|
||||
// test and validations
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
socialConnector.AssertExpectations(t)
|
||||
if tt.expectedErr != nil {
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.expectedToken == nil {
|
||||
assert.Nil(t, actualToken)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedToken.AccessToken, actualToken.AccessToken)
|
||||
assert.Equal(t, tt.expectedToken.RefreshToken, actualToken.RefreshToken)
|
||||
assert.Equal(t, tt.expectedToken.Expiry, actualToken.Expiry)
|
||||
assert.Equal(t, tt.expectedToken.TokenType, actualToken.TokenType)
|
||||
if tt.expectedToken.Extra("id_token") != nil {
|
||||
assert.Equal(t, tt.expectedToken.Extra("id_token").(string), actualToken.Extra("id_token").(string))
|
||||
} else {
|
||||
assert.Nil(t, actualToken.Extra("id_token"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -392,7 +429,7 @@ func TestOAuthTokenSync_needTokenRefresh(t *testing.T) {
|
||||
{
|
||||
name: "should flag token refresh with id token is expired",
|
||||
usr: &login.UserAuth{
|
||||
OAuthIdToken: EXPIRED_JWT,
|
||||
OAuthIdToken: EXPIRED_ID_TOKEN,
|
||||
},
|
||||
expectedTokenRefreshFlag: true,
|
||||
expectedTokenDuration: time.Second,
|
||||
@@ -408,125 +445,10 @@ func TestOAuthTokenSync_needTokenRefresh(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token, needsTokenRefresh := needTokenRefresh(tt.usr)
|
||||
token := buildOAuthTokenFromAuthInfo(tt.usr)
|
||||
needsTokenRefresh := needTokenRefresh(context.Background(), token)
|
||||
|
||||
assert.NotNil(t, token)
|
||||
assert.Equal(t, tt.expectedTokenRefreshFlag, needsTokenRefresh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthTokenSync_tryGetOrRefreshOAuthToken(t *testing.T) {
|
||||
timeNow := time.Now()
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "oauth_access_token",
|
||||
RefreshToken: "refresh_token_found",
|
||||
Expiry: timeNow,
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
type environment struct {
|
||||
authInfoService *authinfotest.FakeService
|
||||
serverLock *serverlock.ServerLockService
|
||||
socialConnector *socialtest.MockSocialConnector
|
||||
socialService *socialtest.FakeSocialService
|
||||
|
||||
service *Service
|
||||
}
|
||||
tests := []struct {
|
||||
desc string
|
||||
expectedErr error
|
||||
expectedToken *oauth2.Token
|
||||
usr *login.UserAuth
|
||||
setup func(env *environment)
|
||||
}{
|
||||
{
|
||||
desc: "should return ErrNotAnOAuthProvider error when the user is not an oauth provider",
|
||||
usr: &login.UserAuth{
|
||||
UserId: int64(1234),
|
||||
AuthModule: login.SAMLAuthModule,
|
||||
},
|
||||
expectedErr: ErrNotAnOAuthProvider,
|
||||
},
|
||||
{
|
||||
desc: "should return ErrNoRefreshTokenFound error when the no refresh token was found",
|
||||
usr: &login.UserAuth{
|
||||
UserId: int64(1234),
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
},
|
||||
expectedErr: ErrNoRefreshTokenFound,
|
||||
},
|
||||
{
|
||||
desc: "should not refresh token if the token is not expired",
|
||||
usr: &login.UserAuth{
|
||||
UserId: int64(1234),
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthAccessToken: token.AccessToken,
|
||||
OAuthRefreshToken: token.RefreshToken,
|
||||
OAuthExpiry: timeNow.Add(time.Hour),
|
||||
OAuthTokenType: "Bearer",
|
||||
},
|
||||
expectedToken: &oauth2.Token{
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
Expiry: timeNow.Add(time.Hour),
|
||||
TokenType: "Bearer",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should update saved token if the user auth has new access/refresh tokens",
|
||||
usr: &login.UserAuth{
|
||||
UserId: int64(1234),
|
||||
AuthModule: login.GenericOAuthModule,
|
||||
OAuthAccessToken: "new_oauth_access_token",
|
||||
OAuthRefreshToken: "new_refresh_token_found",
|
||||
OAuthExpiry: timeNow,
|
||||
},
|
||||
expectedToken: &oauth2.Token{
|
||||
AccessToken: "oauth_access_token",
|
||||
RefreshToken: "refresh_token_found",
|
||||
Expiry: timeNow,
|
||||
TokenType: "Bearer",
|
||||
},
|
||||
setup: func(env *environment) {
|
||||
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)).Once()
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
socialConnector := &socialtest.MockSocialConnector{}
|
||||
|
||||
store := db.InitTestDB(t)
|
||||
|
||||
env := environment{
|
||||
authInfoService: &authinfotest.FakeService{},
|
||||
serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()),
|
||||
socialConnector: socialConnector,
|
||||
socialService: &socialtest.FakeSocialService{
|
||||
ExpectedConnector: socialConnector,
|
||||
},
|
||||
}
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(&env)
|
||||
}
|
||||
|
||||
env.service = ProvideService(
|
||||
env.socialService,
|
||||
env.authInfoService,
|
||||
setting.NewCfg(),
|
||||
prometheus.NewRegistry(),
|
||||
env.serverLock,
|
||||
tracing.InitializeTracerForTest(),
|
||||
)
|
||||
|
||||
token, err := env.service.tryGetOrRefreshOAuthToken(context.Background(), tt.usr)
|
||||
|
||||
if tt.expectedToken != nil {
|
||||
assert.Equal(t, tt.expectedToken, token)
|
||||
}
|
||||
assert.ErrorIs(t, tt.expectedErr, err)
|
||||
socialConnector.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type MockOauthTokenService struct {
|
||||
GetCurrentOauthTokenFunc func(ctx context.Context, usr identity.Requester) *oauth2.Token
|
||||
IsOAuthPassThruEnabledFunc func(ds *datasources.DataSource) bool
|
||||
HasOAuthEntryFunc func(ctx context.Context, usr identity.Requester) (*login.UserAuth, bool, error)
|
||||
InvalidateOAuthTokensFunc func(ctx context.Context, usr *login.UserAuth) error
|
||||
InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester) error
|
||||
TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester) (*oauth2.Token, error)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (m *MockOauthTokenService) HasOAuthEntry(ctx context.Context, usr identity.
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr *login.UserAuth) error {
|
||||
func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester) error {
|
||||
if m.InvalidateOAuthTokensFunc != nil {
|
||||
return m.InvalidateOAuthTokensFunc(ctx, usr)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,6 @@ func (s *Service) TryTokenRefresh(context.Context, identity.Requester) (*oauth2.
|
||||
return s.Token, nil
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateOAuthTokens(context.Context, *login.UserAuth) error {
|
||||
func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester) error {
|
||||
return nil
|
||||
}
|
||||
@@ -55,14 +55,10 @@ func ProvideApi(
|
||||
}
|
||||
|
||||
// register endpoints if the feature is enabled
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagPublicDashboards) && cfg.PublicDashboardsEnabled {
|
||||
if cfg.PublicDashboardsEnabled {
|
||||
api.RegisterAPIEndpoints()
|
||||
}
|
||||
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagPublicDashboards) {
|
||||
api.log.Warn("[Deprecated] The publicDashboards feature toggle will be removed in Grafana v11. To disable the public dashboards feature, use the public_dashboards.enabled setting.")
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
|
||||
@@ -74,14 +74,7 @@ func TestAPIFeatureDisabled(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.PublicDashboardsEnabled = false
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
testServer := setupTestServer(t, cfg, service, userAdmin, true)
|
||||
response := callAPI(testServer, test.Method, test.Path, nil, t)
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
})
|
||||
|
||||
t.Run(test.Name+" - feature flag disabled", func(t *testing.T) {
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
testServer := setupTestServer(t, nil, service, userAdmin, false)
|
||||
testServer := setupTestServer(t, cfg, service, userAdmin)
|
||||
response := callAPI(testServer, test.Method, test.Path, nil, t)
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
})
|
||||
@@ -136,7 +129,7 @@ func TestAPIListPublicDashboard(t *testing.T) {
|
||||
service.On("FindAllWithPagination", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(test.Response, test.ResponseErr).Maybe()
|
||||
|
||||
testServer := setupTestServer(t, nil, service, test.User, true)
|
||||
testServer := setupTestServer(t, nil, service, test.User)
|
||||
|
||||
response := callAPI(testServer, http.MethodGet, "/api/dashboards/public-dashboards", nil, t)
|
||||
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
||||
@@ -263,7 +256,7 @@ func TestAPIDeletePublicDashboard(t *testing.T) {
|
||||
Return(test.ResponseErr)
|
||||
}
|
||||
|
||||
testServer := setupTestServer(t, nil, service, test.User, true)
|
||||
testServer := setupTestServer(t, nil, service, test.User)
|
||||
|
||||
response := callAPI(testServer, http.MethodDelete, fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", test.DashboardUid, test.PublicDashboardUid), nil, t)
|
||||
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
||||
@@ -345,7 +338,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
Return(test.PublicDashboardResult, test.PublicDashboardErr)
|
||||
}
|
||||
|
||||
testServer := setupTestServer(t, nil, service, test.User, true)
|
||||
testServer := setupTestServer(t, nil, service, test.User)
|
||||
|
||||
response := callAPI(
|
||||
testServer,
|
||||
@@ -463,7 +456,7 @@ func TestApiCreatePublicDashboard(t *testing.T) {
|
||||
Return(&PublicDashboard{IsEnabled: true}, test.SaveDashboardErr)
|
||||
}
|
||||
|
||||
testServer := setupTestServer(t, nil, service, test.User, true)
|
||||
testServer := setupTestServer(t, nil, service, test.User)
|
||||
|
||||
response := callAPI(
|
||||
testServer,
|
||||
@@ -597,7 +590,7 @@ func TestAPIUpdatePublicDashboard(t *testing.T) {
|
||||
Return(test.ExpectedResponse, test.ExpectedError)
|
||||
}
|
||||
|
||||
testServer := setupTestServer(t, nil, service, test.User, true)
|
||||
testServer := setupTestServer(t, nil, service, test.User)
|
||||
url := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", test.DashboardUid, test.PublicDashboardUid)
|
||||
body := strings.NewReader(test.Body)
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ func setupTestServer(
|
||||
cfg *setting.Cfg,
|
||||
service publicdashboards.Service,
|
||||
user *user.SignedInUser,
|
||||
ffEnabled bool,
|
||||
) *web.Mux {
|
||||
t.Helper()
|
||||
|
||||
@@ -66,9 +65,6 @@ func setupTestServer(
|
||||
m.Use(contextProvider(&testContext{user}))
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
if ffEnabled {
|
||||
features = featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
cfg = setting.NewCfg()
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestAPIViewPublicDashboard(t *testing.T) {
|
||||
service.On("GetPublicDashboardForView", mock.Anything, mock.AnythingOfType("string")).
|
||||
Return(test.DashboardResult, test.Err).Maybe()
|
||||
|
||||
testServer := setupTestServer(t, nil, service, anonymousUser, true)
|
||||
testServer := setupTestServer(t, nil, service, anonymousUser)
|
||||
|
||||
response := callAPI(testServer, http.MethodGet,
|
||||
fmt.Sprintf("/api/public/dashboards/%s", test.AccessToken),
|
||||
@@ -193,7 +193,7 @@ func TestAPIQueryPublicDashboard(t *testing.T) {
|
||||
|
||||
setup := func(enabled bool) (*web.Mux, *publicdashboards.FakePublicDashboardService) {
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
testServer := setupTestServer(t, nil, service, anonymousUser, true)
|
||||
testServer := setupTestServer(t, nil, service, anonymousUser)
|
||||
|
||||
return testServer, service
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T)
|
||||
require.NoError(t, err)
|
||||
|
||||
// setup test server
|
||||
server := setupTestServer(t, cfg, pds, anonymousUser, true)
|
||||
server := setupTestServer(t, cfg, pds, anonymousUser)
|
||||
|
||||
resp := callAPI(server, http.MethodPost,
|
||||
fmt.Sprintf("/api/public/dashboards/%s/panels/1/query", pubdash.AccessToken),
|
||||
@@ -420,7 +420,7 @@ func TestAPIGetAnnotations(t *testing.T) {
|
||||
Return(test.Annotations, test.ServiceError).Once()
|
||||
}
|
||||
|
||||
testServer := setupTestServer(t, nil, service, anonymousUser, true)
|
||||
testServer := setupTestServer(t, nil, service, anonymousUser)
|
||||
|
||||
path := fmt.Sprintf("/api/public/dashboards/%s/annotations?from=%s&to=%s", test.AccessToken, test.From, test.To)
|
||||
response := callAPI(testServer, http.MethodGet, path, nil, t)
|
||||
|
||||
@@ -441,7 +441,7 @@ func (s *SecretsService) dataKeyById(ctx context.Context, id string) ([]byte, er
|
||||
return nil, fmt.Errorf("could not find encryption provider '%s'", dataKey.Provider)
|
||||
}
|
||||
|
||||
// 2.2. Encrypt the data key.
|
||||
// 2.2. Decrypt the data key.
|
||||
decrypted, err := provider.Decrypt(ctx, dataKey.EncryptedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -222,7 +222,7 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, q
|
||||
}
|
||||
|
||||
if query.UID != "" {
|
||||
whereConditions = append(whereConditions, "user.uid = ?")
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("%s.uid = ?", s.sqlStore.GetDialect().Quote("user")))
|
||||
whereParams = append(whereParams, query.UID)
|
||||
}
|
||||
|
||||
|
||||
@@ -265,15 +265,22 @@ func TestIntegrationStore_RetrieveServiceAccount(t *testing.T) {
|
||||
t.Skip("skipping test in short mode")
|
||||
}
|
||||
cases := []struct {
|
||||
desc string
|
||||
user tests.TestUser
|
||||
expectedErr error
|
||||
desc string
|
||||
user tests.TestUser
|
||||
retrieveByUID bool
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
desc: "service accounts should exist and get retrieved",
|
||||
user: tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
desc: "service accounts should be able to be retrieved with uid",
|
||||
user: tests.TestUser{Login: "test1@admin", IsServiceAccount: true},
|
||||
expectedErr: nil,
|
||||
retrieveByUID: true,
|
||||
},
|
||||
{
|
||||
desc: "service accounts is false should not retrieve user",
|
||||
user: tests.TestUser{Login: "test1@admin", IsServiceAccount: false},
|
||||
@@ -283,18 +290,30 @@ func TestIntegrationStore_RetrieveServiceAccount(t *testing.T) {
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
var dto *serviceaccounts.ServiceAccountProfileDTO
|
||||
var err error
|
||||
db, store := setupTestDatabase(t)
|
||||
user := tests.SetupUserServiceAccount(t, db, store.cfg, c.user)
|
||||
dto, err := store.RetrieveServiceAccount(context.Background(), &serviceaccounts.GetServiceAccountQuery{
|
||||
OrgID: user.OrgID,
|
||||
ID: user.ID,
|
||||
})
|
||||
if c.retrieveByUID {
|
||||
dto, err = store.RetrieveServiceAccount(context.Background(), &serviceaccounts.GetServiceAccountQuery{
|
||||
OrgID: user.OrgID,
|
||||
UID: user.UID,
|
||||
})
|
||||
} else {
|
||||
dto, err = store.RetrieveServiceAccount(context.Background(), &serviceaccounts.GetServiceAccountQuery{
|
||||
OrgID: user.OrgID,
|
||||
ID: user.ID,
|
||||
})
|
||||
}
|
||||
if c.expectedErr != nil {
|
||||
require.ErrorIs(t, err, c.expectedErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.user.Login, dto.Login)
|
||||
require.Len(t, dto.Teams, 0)
|
||||
if c.retrieveByUID {
|
||||
require.Equal(t, user.UID, dto.UID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type TestUser struct {
|
||||
Role string
|
||||
Login string
|
||||
IsServiceAccount bool
|
||||
UID string
|
||||
}
|
||||
|
||||
type TestApiKey struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package signingkeysimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
@@ -226,8 +227,8 @@ func (s *Service) encodePrivateKey(ctx context.Context, privateKey crypto.Signer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(encrypted)))
|
||||
base64.StdEncoding.Encode(encoded, encrypted)
|
||||
encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(encrypted)))
|
||||
base64.RawStdEncoding.Encode(encoded, encrypted)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
@@ -237,8 +238,12 @@ func (s *Service) decodePrivateKey(ctx context.Context, privateKey []byte) (cryp
|
||||
return nil, errors.New("private key is empty")
|
||||
}
|
||||
|
||||
payload := make([]byte, base64.StdEncoding.DecodedLen(len(privateKey)))
|
||||
_, err := base64.StdEncoding.Decode(payload, privateKey)
|
||||
// Backwards compatibility with old base64 encoding
|
||||
// Can be removed in the future
|
||||
privateKey = bytes.TrimRight(privateKey, "=")
|
||||
|
||||
payload := make([]byte, base64.RawStdEncoding.DecodedLen(len(privateKey)))
|
||||
_, err := base64.RawStdEncoding.Decode(payload, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
type CloudMigrationSettings struct {
|
||||
IsTarget bool
|
||||
GcomAPIToken string
|
||||
AuthAPIUrl string
|
||||
SnapshotFolder string
|
||||
GMSDomain string
|
||||
GMSStartSnapshotTimeout time.Duration
|
||||
@@ -33,6 +34,7 @@ func (cfg *Cfg) readCloudMigrationSettings() {
|
||||
cloudMigration := cfg.Raw.Section("cloud_migration")
|
||||
cfg.CloudMigration.IsTarget = cloudMigration.Key("is_target").MustBool(false)
|
||||
cfg.CloudMigration.GcomAPIToken = cloudMigration.Key("gcom_api_token").MustString("")
|
||||
cfg.CloudMigration.AuthAPIUrl = cloudMigration.Key("auth_api_url").MustString("")
|
||||
cfg.CloudMigration.SnapshotFolder = cloudMigration.Key("snapshot_folder").MustString("")
|
||||
cfg.CloudMigration.GMSDomain = cloudMigration.Key("domain").MustString("")
|
||||
cfg.CloudMigration.GMSValidateKeyTimeout = cloudMigration.Key("validate_key_timeout").MustDuration(5 * time.Second)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
@@ -13,6 +16,9 @@ type RBACSettings struct {
|
||||
ResetBasicRoles bool
|
||||
// RBAC single organization. This configuration option is subject to change.
|
||||
SingleOrganization bool
|
||||
// If zanzana feature toggle is enabled this controls how often we
|
||||
// run the zanzana reconciliation loop.
|
||||
ZanzanaReconciliationInterval time.Duration
|
||||
|
||||
OnlyStoreAccessActionSets bool
|
||||
|
||||
@@ -47,6 +53,12 @@ func (cfg *Cfg) readRBACSettings() {
|
||||
s.resourcesWithWildcardSeed[resource] = struct{}{}
|
||||
}
|
||||
|
||||
var err error
|
||||
s.ZanzanaReconciliationInterval, err = gtime.ParseDuration(rbac.Key("zanzana_reconciliation_interval").MustString("1h"))
|
||||
if err != nil {
|
||||
s.ZanzanaReconciliationInterval = 1 * time.Hour
|
||||
}
|
||||
|
||||
cfg.RBAC = s
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func ProvideUnifiedStorageClient(
|
||||
tracer tracing.Tracer,
|
||||
reg prometheus.Registerer,
|
||||
authzc authz.Client,
|
||||
docs resource.DocumentBuilderSupplier,
|
||||
) (resource.ResourceClient, error) {
|
||||
// See: apiserver.ApplyGrafanaConfig(cfg, features, o)
|
||||
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||
@@ -97,7 +98,7 @@ func ProvideUnifiedStorageClient(
|
||||
|
||||
// Use the local SQL
|
||||
default:
|
||||
server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg, authzc)
|
||||
server, err := sql.NewResourceServer(ctx, db, cfg, features, docs, tracer, reg, authzc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/authlib/claims"
|
||||
"github.com/grafana/grafana/pkg/services/authn/grpcutils"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
type staticAuthzClient struct {
|
||||
@@ -38,11 +42,19 @@ type authzLimitedClient struct {
|
||||
// whitelist is a map of group to resources that are compatible with RBAC.
|
||||
whitelist groupResource
|
||||
logger *slog.Logger
|
||||
tracer trace.Tracer
|
||||
}
|
||||
|
||||
type AuthzOptions struct {
|
||||
Tracer trace.Tracer
|
||||
}
|
||||
|
||||
// NewAuthzLimitedClient creates a new authzLimitedClient.
|
||||
func NewAuthzLimitedClient(client authz.AccessChecker) authz.AccessClient {
|
||||
func NewAuthzLimitedClient(client authz.AccessChecker, opts AuthzOptions) authz.AccessClient {
|
||||
logger := slog.Default().With("logger", "limited-authz-client")
|
||||
if opts.Tracer == nil {
|
||||
opts.Tracer = noop.NewTracerProvider().Tracer("limited-authz-client")
|
||||
}
|
||||
return &authzLimitedClient{
|
||||
client: client,
|
||||
whitelist: groupResource{
|
||||
@@ -50,31 +62,57 @@ func NewAuthzLimitedClient(client authz.AccessChecker) authz.AccessClient {
|
||||
"folder.grafana.app": map[string]interface{}{"folders": nil},
|
||||
},
|
||||
logger: logger,
|
||||
tracer: opts.Tracer,
|
||||
}
|
||||
}
|
||||
|
||||
// Check implements authz.AccessClient.
|
||||
func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) {
|
||||
ctx, span := c.tracer.Start(ctx, "authzLimitedClient.Check", trace.WithAttributes(
|
||||
attribute.String("group", req.Group),
|
||||
attribute.String("resource", req.Resource),
|
||||
attribute.Bool("fallback", grpcutils.FallbackUsed(ctx)),
|
||||
))
|
||||
defer span.End()
|
||||
if grpcutils.FallbackUsed(ctx) {
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "fallback", true, "rbac", false, "allowed", true)
|
||||
return authz.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", false, "allowed", true)
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "fallback", false, "rbac", false, "allowed", true)
|
||||
return authz.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
t := time.Now()
|
||||
resp, err := c.client.Check(ctx, id, req)
|
||||
if err != nil {
|
||||
c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "error", err, "duration", time.Since(t))
|
||||
c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "fallback", false, "rbac", true, "error", err, "duration", time.Since(t))
|
||||
return resp, err
|
||||
}
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "allowed", resp.Allowed, "duration", time.Since(t))
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "fallback", false, "rbac", true, "allowed", resp.Allowed, "duration", time.Since(t))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Compile implements authz.AccessClient.
|
||||
func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) {
|
||||
ctx, span := c.tracer.Start(ctx, "authzLimitedClient.Compile", trace.WithAttributes(
|
||||
attribute.String("group", req.Group),
|
||||
attribute.String("resource", req.Resource),
|
||||
))
|
||||
defer span.End()
|
||||
return func(namespace string, name, folder string) bool {
|
||||
ctx, span := c.tracer.Start(ctx, "authzLimitedClient.Compile.Check", trace.WithAttributes(
|
||||
attribute.String("group", req.Group),
|
||||
attribute.String("resource", req.Resource),
|
||||
attribute.Bool("fallback", grpcutils.FallbackUsed(ctx)),
|
||||
))
|
||||
defer span.End()
|
||||
if grpcutils.FallbackUsed(ctx) {
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "fallback", true, "rbac", false, "allowed", true)
|
||||
return true
|
||||
}
|
||||
// TODO: Implement For now we perform the check for each item.
|
||||
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
|
||||
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", false, "allowed", true)
|
||||
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "fallback", false, "rbac", false, "allowed", true)
|
||||
return true
|
||||
}
|
||||
t := time.Now()
|
||||
@@ -87,10 +125,10 @@ func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req
|
||||
Folder: folder,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "error", err, "duration", time.Since(t))
|
||||
c.logger.Error("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "fallback", false, "rbac", true, "error", err, "duration", time.Since(t))
|
||||
return false
|
||||
}
|
||||
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "allowed", r.Allowed, "duration", time.Since(t))
|
||||
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "fallback", false, "rbac", true, "allowed", r.Allowed, "duration", time.Since(t))
|
||||
return r.Allowed
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestAuthzLimitedClient_Check(t *testing.T) {
|
||||
mockClient := &staticAuthzClient{allowed: false}
|
||||
client := NewAuthzLimitedClient(mockClient)
|
||||
client := NewAuthzLimitedClient(mockClient, AuthzOptions{})
|
||||
|
||||
tests := []struct {
|
||||
group string
|
||||
@@ -35,7 +35,7 @@ func TestAuthzLimitedClient_Check(t *testing.T) {
|
||||
|
||||
func TestAuthzLimitedClient_Compile(t *testing.T) {
|
||||
mockClient := &staticAuthzClient{allowed: false}
|
||||
client := NewAuthzLimitedClient(mockClient)
|
||||
client := NewAuthzLimitedClient(mockClient, AuthzOptions{})
|
||||
|
||||
tests := []struct {
|
||||
group string
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
// Convert raw resource bytes into an IndexableDocument
|
||||
type DocumentBuilder interface {
|
||||
// Convert raw bytes into an document that can be written
|
||||
BuildDocument(ctx context.Context, key *ResourceKey, rv int64, value []byte) (*IndexableDocument, error)
|
||||
}
|
||||
|
||||
// Registry of the searchable document fields
|
||||
type SearchableDocumentFields interface {
|
||||
Fields() []string
|
||||
Field(name string) *ResourceTableColumnDefinition
|
||||
}
|
||||
|
||||
// Some kinds will require special processing for their namespace
|
||||
type NamespacedDocumentSupplier = func(ctx context.Context, namespace string, blob BlobSupport) (DocumentBuilder, error)
|
||||
|
||||
// Register how documents can be built for a resource
|
||||
type DocumentBuilderInfo struct {
|
||||
// The target resource (empty will be used to match anything)
|
||||
GroupResource schema.GroupResource
|
||||
|
||||
// Defines the searchable fields
|
||||
// NOTE: this does not include the root/common fields, only values specific to the the builder
|
||||
Fields SearchableDocumentFields
|
||||
|
||||
// simple/static builders that do not depend on the environment can be declared once
|
||||
Builder DocumentBuilder
|
||||
|
||||
// Complicated builders (eg dashboards!) will be declared dynamically and managed by the ResourceServer
|
||||
Namespaced NamespacedDocumentSupplier
|
||||
}
|
||||
|
||||
type DocumentBuilderSupplier interface {
|
||||
GetDocumentBuilders() ([]DocumentBuilderInfo, error)
|
||||
}
|
||||
|
||||
// IndexableDocument can be written to a ResourceIndex
|
||||
// Although public, this is *NOT* an end user interface
|
||||
type IndexableDocument struct {
|
||||
// The resource key
|
||||
Key *ResourceKey `json:"key"`
|
||||
|
||||
// Resource version for the resource (if known)
|
||||
RV int64 `json:"rv,omitempty"`
|
||||
|
||||
// The generic display name
|
||||
Title string `json:"title,omitempty"`
|
||||
|
||||
// A generic description -- helpful in global search
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Like dashboard tags
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
|
||||
// Generic metadata labels
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
|
||||
// The folder (K8s name)
|
||||
Folder string `json:"folder,omitempty"`
|
||||
|
||||
// The first time this resource was saved
|
||||
Created int64 `json:"created,omitempty"`
|
||||
|
||||
// Who created the resource (will be in the form `user:uid`)
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
|
||||
// The last time a user updated the spec
|
||||
Updated int64 `json:"updated,omitempty"`
|
||||
|
||||
// Who updated the resource (will be in the form `user:uid`)
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
|
||||
// Searchable nested keys
|
||||
// The key should exist from the fields defined in DocumentBuilderInfo
|
||||
// This should not contain duplicate information from the results above
|
||||
// The meaning of these fields changes depending on the field type
|
||||
// These values typically come from the Spec, but may also come from status
|
||||
// metadata, annotations, or external data linked at index time
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
|
||||
// Maintain a list of resource references.
|
||||
// Someday this will likely be part of https://github.com/grafana/gamma
|
||||
References ResourceReferences `json:"reference,omitempty"`
|
||||
|
||||
// When the resource is managed by an upstream repository
|
||||
RepoInfo *utils.ResourceRepositoryInfo `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
type ResourceReference struct {
|
||||
Relation string `json:"relation"` // eg: depends-on
|
||||
Group string `json:"group,omitempty"` // the api group
|
||||
Version string `json:"version,omitempty"` // the api version
|
||||
Kind string `json:"kind,omitempty"` // panel, data source (for now)
|
||||
Name string `json:"name"` // the UID / panel name
|
||||
}
|
||||
|
||||
func (m ResourceReference) String() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(m.Relation)
|
||||
sb.WriteString(">>")
|
||||
sb.WriteString(m.Group)
|
||||
if m.Version != "" {
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(m.Version)
|
||||
}
|
||||
if m.Kind != "" {
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(m.Kind)
|
||||
}
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(m.Name)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Sortable list of references
|
||||
type ResourceReferences []ResourceReference
|
||||
|
||||
func (m ResourceReferences) Len() int { return len(m) }
|
||||
func (m ResourceReferences) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
|
||||
func (m ResourceReferences) Less(i, j int) bool {
|
||||
a := m[i].String()
|
||||
b := m[j].String()
|
||||
return strings.Compare(a, b) > 0
|
||||
}
|
||||
|
||||
// Create a new indexable document based on a generic k8s resource
|
||||
func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAccessor) *IndexableDocument {
|
||||
doc := &IndexableDocument{
|
||||
Key: key,
|
||||
RV: rv,
|
||||
Title: obj.FindTitle(key.Name), // We always want *something* to display
|
||||
Labels: obj.GetLabels(),
|
||||
Folder: obj.GetFolder(),
|
||||
CreatedBy: obj.GetCreatedBy(),
|
||||
UpdatedBy: obj.GetUpdatedBy(),
|
||||
}
|
||||
doc.RepoInfo, _ = obj.GetRepositoryInfo()
|
||||
ts := obj.GetCreationTimestamp()
|
||||
if !ts.Time.IsZero() {
|
||||
doc.Created = ts.Time.UnixMilli()
|
||||
}
|
||||
tt, err := obj.GetUpdatedTimestamp()
|
||||
if err != nil && tt != nil {
|
||||
doc.Updated = tt.UnixMilli()
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func StandardDocumentBuilder() DocumentBuilderInfo {
|
||||
return DocumentBuilderInfo{
|
||||
Builder: &standardDocumentBuilder{},
|
||||
Fields: StandardSearchFields(),
|
||||
}
|
||||
}
|
||||
|
||||
type standardDocumentBuilder struct{}
|
||||
|
||||
func (s *standardDocumentBuilder) BuildDocument(ctx context.Context, key *ResourceKey, rv int64, value []byte) (*IndexableDocument, error) {
|
||||
tmp := &unstructured.Unstructured{}
|
||||
err := tmp.UnmarshalJSON(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := utils.MetaAccessor(tmp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc := NewIndexableDocument(key, rv, obj)
|
||||
doc.Title = obj.FindTitle(key.Name)
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
type searchableDocumentFields struct {
|
||||
names []string
|
||||
fields map[string]*resourceTableColumn
|
||||
}
|
||||
|
||||
// This requires unique names
|
||||
func NewSearchableDocumentFields(columns []*ResourceTableColumnDefinition) (SearchableDocumentFields, error) {
|
||||
f := &searchableDocumentFields{
|
||||
names: make([]string, len(columns)),
|
||||
fields: make(map[string]*resourceTableColumn),
|
||||
}
|
||||
for i, c := range columns {
|
||||
if f.fields[c.Name] != nil {
|
||||
return nil, fmt.Errorf("duplicate name")
|
||||
}
|
||||
col, err := newResourceTableColumn(c, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.names[i] = c.Name
|
||||
f.fields[c.Name] = col
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (x *searchableDocumentFields) Fields() []string {
|
||||
return x.names
|
||||
}
|
||||
|
||||
func (x *searchableDocumentFields) Field(name string) *ResourceTableColumnDefinition {
|
||||
f, ok := x.fields[name]
|
||||
if ok {
|
||||
return f.def
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const SEARCH_FIELD_ID = "_id" // {namespace}/{group}/{resource}/{name}
|
||||
const SEARCH_FIELD_GROUP_RESOURCE = "gr" // group/resource
|
||||
const SEARCH_FIELD_NAMESPACE = "namespace"
|
||||
const SEARCH_FIELD_NAME = "name"
|
||||
const SEARCH_FIELD_RV = "rv"
|
||||
const SEARCH_FIELD_TITLE = "title"
|
||||
const SEARCH_FIELD_DESCRIPTION = "description"
|
||||
const SEARCH_FIELD_TAGS = "tags"
|
||||
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
|
||||
|
||||
const SEARCH_FIELD_FOLDER = "folder"
|
||||
const SEARCH_FIELD_CREATED = "created"
|
||||
const SEARCH_FIELD_CREATED_BY = "createdBy"
|
||||
const SEARCH_FIELD_UPDATED = "updated"
|
||||
const SEARCH_FIELD_UPDATED_BY = "updatedBy"
|
||||
const SEARCH_FIELD_REPOSITORY = "repository"
|
||||
const SEARCH_FIELD_REPOSITORY_HASH = "repository_hash"
|
||||
|
||||
const SEARCH_FIELD_SCORE = "_score" // the match score
|
||||
const SEARCH_FIELD_EXPLAIN = "_explain" // score explanation as JSON object
|
||||
|
||||
var standardSearchFieldsInit sync.Once
|
||||
var standardSearchFields SearchableDocumentFields
|
||||
|
||||
func StandardSearchFields() SearchableDocumentFields {
|
||||
standardSearchFieldsInit.Do(func() {
|
||||
var err error
|
||||
standardSearchFields, err = NewSearchableDocumentFields([]*ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: SEARCH_FIELD_ID,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "Unique Identifier. {namespace}/{group}/{resource}/{name}",
|
||||
Properties: &ResourceTableColumnDefinition_Properties{
|
||||
NotNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_GROUP_RESOURCE,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "The resource kind: {group}/{resource}",
|
||||
Properties: &ResourceTableColumnDefinition_Properties{
|
||||
NotNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_NAMESPACE,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "Tenant isolation",
|
||||
Properties: &ResourceTableColumnDefinition_Properties{
|
||||
NotNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_NAME,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "Kubernetes name. Unique identifier within a namespace+group+resource",
|
||||
Properties: &ResourceTableColumnDefinition_Properties{
|
||||
NotNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_TITLE,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "Display name for the resource",
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_DESCRIPTION,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "An account of the resource.",
|
||||
Properties: &ResourceTableColumnDefinition_Properties{
|
||||
FreeText: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic("failed to initialize standard search fields")
|
||||
}
|
||||
})
|
||||
return standardSearchFields
|
||||
}
|
||||
|
||||
// // Helper function to convert everything except the "Fields" property to values
|
||||
// // NOTE: this is really to help testing things absent real backend index
|
||||
// func IndexableDocumentStandardFields(doc *IndexableDocument) map[string]any {
|
||||
// fields := make(map[string]any)
|
||||
|
||||
// // These should always exist
|
||||
// fields[SEARCH_FIELD_ID] = doc.Key.SearchID()
|
||||
// fields[SEARCH_FIELD_NAMESPACE] = doc.Key.Namespace
|
||||
// fields[SEARCH_FIELD_NAME] = doc.Key.Name
|
||||
// fields[SEARCH_FIELD_GROUP_RESOURCE] = fmt.Sprintf("%s/%s", doc.Key.Group, doc.Key.Resource)
|
||||
|
||||
// fields[SEARCH_FIELD_TITLE] = doc.Title
|
||||
|
||||
// return fields
|
||||
// }
|
||||
@@ -0,0 +1,47 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStandardDocumentBuilder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
builder := StandardDocumentBuilder().Builder
|
||||
|
||||
body, err := os.ReadFile("testdata/playlist-resource.json")
|
||||
require.NoError(t, err)
|
||||
doc, err := builder.BuildDocument(ctx, &ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "playlists.grafana.app",
|
||||
Resource: "playlists",
|
||||
Name: "test1",
|
||||
}, 10, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
jj, _ := json.MarshalIndent(doc, "", " ")
|
||||
fmt.Printf("%s\n", string(jj))
|
||||
require.JSONEq(t, `{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "playlists.grafana.app",
|
||||
"resource": "playlists",
|
||||
"name": "test1"
|
||||
},
|
||||
"rv": 10,
|
||||
"title": "test1",
|
||||
"created": 1717236672000,
|
||||
"createdBy": "user:ABC",
|
||||
"updatedBy": "user:XYZ",
|
||||
"repository": {
|
||||
"name": "SQL",
|
||||
"path": "15",
|
||||
"hash": "xyz"
|
||||
}
|
||||
}`, string(jj))
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func verifyRequestKey(key *ResourceKey) *ErrorResult {
|
||||
if key == nil {
|
||||
return NewBadRequestError("missing resource key")
|
||||
@@ -28,3 +33,39 @@ func matchesQueryKey(query *ResourceKey, key *ResourceKey) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const clusterNamespace = "**cluster**"
|
||||
|
||||
// Convert the key to a search ID string
|
||||
func (x *ResourceKey) SearchID() string {
|
||||
var sb strings.Builder
|
||||
if x.Namespace == "" {
|
||||
sb.WriteString(clusterNamespace)
|
||||
} else {
|
||||
sb.WriteString(x.Namespace)
|
||||
}
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(x.Group)
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(x.Resource)
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(x.Name)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (x *ResourceKey) ReadSearchID(v string) error {
|
||||
parts := strings.Split(v, "/")
|
||||
if len(parts) != 4 {
|
||||
return fmt.Errorf("invalid search id (expecting 3 slashes)")
|
||||
}
|
||||
|
||||
x.Namespace = parts[0]
|
||||
x.Group = parts[1]
|
||||
x.Resource = parts[2]
|
||||
x.Name = parts[3]
|
||||
|
||||
if x.Namespace == clusterNamespace {
|
||||
x.Namespace = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,3 +19,41 @@ func TestKeyMatching(t *testing.T) {
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchIDKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected *ResourceKey // nil error
|
||||
}{
|
||||
{input: "a"}, // error
|
||||
{input: "default/group/resource/name",
|
||||
expected: &ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "name",
|
||||
}},
|
||||
{input: "/group/resource/", // missing name
|
||||
expected: &ResourceKey{
|
||||
Namespace: "",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "",
|
||||
}},
|
||||
{input: "**cluster**/group/resource/aaa", // cluster namespace
|
||||
expected: &ResourceKey{
|
||||
Namespace: "",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "aaa",
|
||||
}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tmp := &ResourceKey{}
|
||||
err := tmp.ReadSearchID(test.input)
|
||||
if err == nil {
|
||||
require.Equal(t, test.expected, tmp, test.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
type NamespacedResource struct {
|
||||
Namespace string
|
||||
Group string
|
||||
Resource string
|
||||
}
|
||||
|
||||
type SearchBackend interface {
|
||||
// TODO
|
||||
}
|
||||
|
||||
const tracingPrexfixSearch = "unified_search."
|
||||
|
||||
// This supports indexing+search regardless of implementation
|
||||
type searchSupport struct {
|
||||
tracer trace.Tracer
|
||||
log *slog.Logger
|
||||
storage StorageBackend
|
||||
search SearchBackend
|
||||
builders *builderCache
|
||||
initWorkers int
|
||||
}
|
||||
|
||||
func newSearchSupport(opts SearchOptions, storage StorageBackend, blob BlobSupport, tracer trace.Tracer) (support *searchSupport, err error) {
|
||||
// No backend search support
|
||||
if opts.Backend == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if opts.WorkerThreads < 1 {
|
||||
opts.WorkerThreads = 1
|
||||
}
|
||||
|
||||
support = &searchSupport{
|
||||
tracer: tracer,
|
||||
storage: storage,
|
||||
search: opts.Backend,
|
||||
log: slog.Default().With("logger", "resource-search"),
|
||||
initWorkers: opts.WorkerThreads,
|
||||
}
|
||||
|
||||
info, err := opts.Resources.GetDocumentBuilders()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
support.builders, err = newBuilderCache(info, 100, time.Minute*2) // TODO? opts
|
||||
if support.builders != nil {
|
||||
support.builders.blob = blob
|
||||
}
|
||||
|
||||
return support, err
|
||||
}
|
||||
|
||||
// init is called during startup. any failure will block startup and continued execution
|
||||
func (s *searchSupport) init(ctx context.Context) error {
|
||||
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init")
|
||||
defer span.End()
|
||||
|
||||
// TODO, replace namespaces with a query that gets top values
|
||||
namespaces, err := s.storage.Namespaces(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hardcoded for now... should come from the query
|
||||
kinds := []schema.GroupResource{
|
||||
{Group: "dashboard.grafana.app", Resource: "dashboards"},
|
||||
{Group: "playlist.grafana.app", Resource: "playlists"},
|
||||
}
|
||||
|
||||
totalBatchesIndexed := 0
|
||||
group := errgroup.Group{}
|
||||
group.SetLimit(s.initWorkers)
|
||||
|
||||
// Prepare all the (large) indexes
|
||||
// TODO, threading and query real information:
|
||||
// SELECT namespace,"group",resource,COUNT(*),resource_version FROM resource
|
||||
// GROUP BY "group", "resource", "namespace"
|
||||
// ORDER BY resource_version desc;
|
||||
for _, ns := range namespaces {
|
||||
for _, gr := range kinds {
|
||||
group.Go(func() error {
|
||||
s.log.Debug("initializing search index", "namespace", ns, "gr", gr)
|
||||
totalBatchesIndexed++
|
||||
_, _, err = s.build(ctx, NamespacedResource{
|
||||
Group: gr.Group,
|
||||
Resource: gr.Resource,
|
||||
Namespace: ns,
|
||||
}, 10, 0) // TODO, approximate size
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = group.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
span.AddEvent("namespaces indexed", trace.WithAttributes(attribute.Int("namespaced_indexed", totalBatchesIndexed)))
|
||||
|
||||
s.log.Debug("TODO, listen to all events")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (any, int64, error) {
|
||||
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build")
|
||||
defer span.End()
|
||||
|
||||
builder, err := s.builders.get(ctx, nsr)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
s.log.Debug(fmt.Sprintf("TODO, build %+v (size:%d, rv:%d) // builder:%+v\n", nsr, size, rv, builder))
|
||||
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
type builderCache struct {
|
||||
// The default builder
|
||||
defaultBuilder DocumentBuilder
|
||||
|
||||
// Possible blob support
|
||||
blob BlobSupport
|
||||
|
||||
// lookup by group, then resource (namespace)
|
||||
// This is only modified at startup, so we do not need mutex for access
|
||||
lookup map[string]map[string]DocumentBuilderInfo
|
||||
|
||||
// For namespaced based resources that require a cache
|
||||
ns *expirable.LRU[NamespacedResource, DocumentBuilder]
|
||||
mu sync.Mutex // only locked for a cache miss
|
||||
}
|
||||
|
||||
func newBuilderCache(cfg []DocumentBuilderInfo, nsCacheSize int, ttl time.Duration) (*builderCache, error) {
|
||||
cache := &builderCache{
|
||||
lookup: make(map[string]map[string]DocumentBuilderInfo),
|
||||
ns: expirable.NewLRU[NamespacedResource, DocumentBuilder](nsCacheSize, nil, ttl),
|
||||
}
|
||||
if len(cfg) == 0 {
|
||||
return cache, fmt.Errorf("no builders configured")
|
||||
}
|
||||
|
||||
for _, b := range cfg {
|
||||
// the default
|
||||
if b.GroupResource.Group == "" && b.GroupResource.Resource == "" {
|
||||
if b.Builder == nil {
|
||||
return cache, fmt.Errorf("default document builder is missing")
|
||||
}
|
||||
cache.defaultBuilder = b.Builder
|
||||
continue
|
||||
}
|
||||
g, ok := cache.lookup[b.GroupResource.Group]
|
||||
if !ok {
|
||||
g = make(map[string]DocumentBuilderInfo)
|
||||
cache.lookup[b.GroupResource.Group] = g
|
||||
}
|
||||
g[b.GroupResource.Resource] = b
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// context is typically background. Holds an LRU cache for a
|
||||
func (s *builderCache) get(ctx context.Context, key NamespacedResource) (DocumentBuilder, error) {
|
||||
g, ok := s.lookup[key.Group]
|
||||
if ok {
|
||||
r, ok := g[key.Resource]
|
||||
if ok {
|
||||
if r.Builder != nil {
|
||||
return r.Builder, nil
|
||||
}
|
||||
|
||||
// The builder needs context
|
||||
builder, ok := s.ns.Get(key)
|
||||
if ok {
|
||||
return builder, nil
|
||||
}
|
||||
{
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
b, err := r.Namespaced(ctx, key.Namespace, s.blob)
|
||||
if err == nil {
|
||||
_ = s.ns.Add(key, b)
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.defaultBuilder, nil
|
||||
}
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user