diff --git a/.betterer.results b/.betterer.results
index fcaefeed57e..eb523e906fe 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -1050,9 +1050,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
- "public/app/core/components/SharedPreferences/SharedPreferences.tsx:5381": [
- [0, 0, 0, "Styles should be written using objects.", "0"]
- ],
"public/app/core/components/TagFilter/TagFilter.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@@ -2442,7 +2439,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
- [0, 0, 0, "Do not use any type assertions.", "5"]
+ [0, 0, 0, "Do not use any type assertions.", "5"],
+ [0, 0, 0, "Do not use any type assertions.", "6"]
],
"public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@@ -3477,8 +3475,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/explore/TraceView/components/model/link-patterns.test.ts:5381": [
- [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/explore/TraceView/components/model/link-patterns.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@@ -3492,8 +3489,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
- [0, 0, 0, "Do not use any type assertions.", "11"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "12"]
+ [0, 0, 0, "Do not use any type assertions.", "11"]
],
"public/app/features/explore/TraceView/components/model/transform-trace-data.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
@@ -4329,9 +4325,6 @@ exports[`better eslint`] = {
"public/app/features/variables/datasource/actions.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
- "public/app/features/variables/editor/LegacyVariableQueryEditor.tsx:5381": [
- [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
- ],
"public/app/features/variables/editor/VariableEditorContainer.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
diff --git a/.drone.yml b/.drone.yml
index 86a0013f759..a7112bfabae 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -123,6 +123,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -224,6 +225,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -535,6 +537,7 @@ steps:
image: golang:1.21.6-alpine
name: wire-install
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -1044,6 +1047,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -1403,6 +1407,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -1479,6 +1484,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -1537,6 +1543,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -1799,6 +1806,7 @@ steps:
image: golang:1.21.6-alpine
name: wire-install
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -2714,6 +2722,7 @@ steps:
image: golang:1.21.6-alpine
name: compile-build-cmd
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -2987,6 +2996,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -3410,6 +3420,7 @@ steps:
image: alpine:3.18.5
name: identify-runner
- commands:
+ - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python
- yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
@@ -4777,6 +4788,6 @@ kind: secret
name: gcr_credentials
---
kind: signature
-hmac: 0e34c95370617ee9f721421913cbe1fe103c117e2912ac589953298246fd2012
+hmac: c04928dc37abd22bc2d48c971f4f1fdfd722d38629016139fd0304e2aef60213
...
diff --git a/.eslintrc b/.eslintrc
index 0eacc7f00fd..0ba27fd484e 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -111,7 +111,9 @@
"public/app/plugins/datasource/parca/*.{ts,tsx}",
"public/app/plugins/datasource/parca/**/*.{ts,tsx}",
"public/app/plugins/datasource/tempo/*.{ts,tsx}",
- "public/app/plugins/datasource/tempo/**/*.{ts,tsx}"
+ "public/app/plugins/datasource/tempo/**/*.{ts,tsx}",
+ "public/app/plugins/datasource/loki/*.{ts,tsx}",
+ "public/app/plugins/datasource/loki/**/*.{ts,tsx}"
],
"settings": {
"import/resolver": {
@@ -127,8 +129,8 @@
"zones": [
{
"target": "./public/app/plugins",
- "from": "./public/app",
- "except": ["./plugins"],
+ "from": "./public",
+ "except": ["./app/plugins"],
"message": "Core plugins are not allowed to depend on Grafana core packages"
}
]
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 02a56c82a58..6bd4a84f4e4 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -108,7 +108,7 @@
/pkg/services/dashboardversion/ @grafana/backend-platform
/pkg/services/encryption/ @grafana/backend-platform
/pkg/services/folder/ @grafana/backend-platform
-/pkg/services/grafana-apiserver @grafana/grafana-app-platform-squad
+/pkg/services/apiserver @grafana/grafana-app-platform-squad
/pkg/services/hooks/ @grafana/backend-platform
/pkg/services/kmsproviders/ @grafana/backend-platform
/pkg/services/licensing/ @grafana/backend-platform
@@ -275,7 +275,6 @@
/pkg/modules/ @grafana/grafana-app-platform-squad
/pkg/kindsysreport/ @grafana/grafana-app-platform-squad
/pkg/services/grpcserver/ @grafana/grafana-app-platform-squad
-/pkg/aggregator @grafana/grafana-app-platform-squad
/pkg/generated @grafana/grafana-app-platform-squad
# Alerting
@@ -344,9 +343,9 @@
/packages/grafana-sql/ @grafana/partner-datasources @grafana/oss-big-tent
# root files, mostly frontend
-.browserslistrc @grafana/frontend-ops
-package.json @grafana/frontend-ops
-tsconfig.json @grafana/frontend-ops
+/.browserslistrc @grafana/frontend-ops
+/package.json @grafana/frontend-ops
+/tsconfig.json @grafana/frontend-ops
/.editorconfig @grafana/frontend-ops
/.eslintignore @grafana/frontend-ops
/.gitattributes @grafana/frontend-ops
@@ -356,7 +355,7 @@ tsconfig.json @grafana/frontend-ops
/.yarn @grafana/frontend-ops
/.yarnrc.yml @grafana/frontend-ops
/yarn.lock @grafana/frontend-ops
-lerna.json @grafana/frontend-ops
+/lerna.json @grafana/frontend-ops
/.prettierrc.js @grafana/frontend-ops
/.eslintrc @grafana/frontend-ops
/.vim @zoltanbedi
@@ -367,9 +366,9 @@ lerna.json @grafana/frontend-ops
/tools/ @grafana/frontend-ops
/lefthook.yml @grafana/frontend-ops
/lefthook.rc @grafana/frontend-ops
-.husky/pre-commit @grafana/frontend-ops
-cypress.config.js @grafana/grafana-frontend-platform
-.levignore.js @grafana/plugins-platform-frontend
+/.husky/pre-commit @grafana/frontend-ops
+/cypress.config.js @grafana/grafana-frontend-platform
+/.levignore.js @grafana/plugins-platform-frontend
# public folder
@@ -382,7 +381,7 @@ cypress.config.js @grafana/grafana-frontend-platform
/public/app/features/all.ts @grafana/grafana-frontend-platform
/public/app/features/admin/ @grafana/identity-access-team
/public/app/features/auth-config/ @grafana/identity-access-team
-/public/app/features/annotations/ @grafana/grafana-frontend-platform
+/public/app/features/annotations/ @grafana/dashboards-squad
/public/app/features/api-keys/ @grafana/identity-access-team
/public/app/features/canvas/ @grafana/dataviz-squad
/public/app/features/geo/ @grafana/dataviz-squad
@@ -678,7 +677,8 @@ embed.go @grafana/grafana-as-code
/.github/workflows/ephemeral-instances-pr-opened-closed.yml @grafana/grafana-operator-experience-squad
/.github/workflows/create-security-patch-from-security-mirror.yml @grafana/grafana-release-guild
/.github/workflows/core-plugins-build-and-release.yml @grafana/plugins-platform-frontend @grafana/plugins-platform-backend
-/.github/workflows/i18n-crowdin-fix-files.yml @grafana/grafana-frontend-platform
+/.github/workflows/i18n-crowdin-upload.yml @grafana/grafana-frontend-platform
+/.github/workflows/i18n-crowdin-download.yml @grafana/grafana-frontend-platform
/.github/workflows/feature-toggle-cleanup.yml @tolzhabayev
/.github/workflows/scripts/feature-toggle-cleanup/feature-toggle-cleanup.js @tolzhabayev
diff --git a/.github/workflows/i18n-crowdin-download.yml b/.github/workflows/i18n-crowdin-download.yml
new file mode 100644
index 00000000000..89d8a1c303e
--- /dev/null
+++ b/.github/workflows/i18n-crowdin-download.yml
@@ -0,0 +1,51 @@
+name: Crowdin Download Action
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 * * * *"
+
+jobs:
+ download-sources-from-crowdin:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write # needed to commit changes into the PR
+ pull-requests: write # needed to update PR description, labels, etc
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Download sources
+ uses: crowdin/github-action@v1
+ with:
+ upload_sources: false
+ upload_translations: false
+ download_sources: false
+ download_translations: true
+ localization_branch_name: i18n_crowdin_translations
+ create_pull_request: true
+ pull_request_title: 'I18n: Download translations from Crowdin'
+ pull_request_body: |
+ :robot: Automatic download of translations from Crowdin.
+
+ Steps for merging:
+ 1. A quick sanity check of the changes and approve. Things to look out for:
+ - No changes in the English file. The source of truth is in the main branch, NOT in Crowdin.
+ - Translations maybe be removed if the English phrase was removed, but there should not be many of these
+ - Anything else that looks 'funky'. Ask if you're not sure.
+ 2. Approve & (Auto-)merge. :tada:
+
+ If there's a conflict, close the pull request and **delete the branch**. A GH action will recreate the pull request.
+ Remember, the longer this pull request is open, the more likely it is that it'll get conflicts.
+ pull_request_labels: 'area/frontend, area/internationalization, no-changelog, no-backport'
+ pull_request_reviewers: 'grafana-frontend-platform'
+ pull_request_base_branch_name: 'main'
+ base_url: 'https://grafana.api.crowdin.com'
+ config: 'crowdin.yml'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.github/workflows/i18n-crowdin-fix-files.yml b/.github/workflows/i18n-crowdin-fix-files.yml
deleted file mode 100644
index 5117bd381d6..00000000000
--- a/.github/workflows/i18n-crowdin-fix-files.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-# When Crowdin creates a pull request from the crowdin-service-branch branch,
-# run `yarn i18n:extract` and commit the changed grafana.json files back into the PR
-# to reformat crowdin's changes to prevent conflicts with our CI checks.
-
-name: Fix Crowdin I18n files
-
-on:
- pull_request:
- paths:
- - 'public/locales/*/grafana.json'
- branches:
- - main # Only run on pull requests *target* main (will be merged into main)
-
-jobs:
- fix-files:
- # Only run on pull requests *from* the crowdin-service-branch branch
- if: github.head_ref == 'crowdin-service-branch'
-
- name: Fix files
- runs-on: ubuntu-latest
-
-
- permissions:
- contents: write # needed to commit changes back into the PR
- pull-requests: write # needed to update PR description
-
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ github.head_ref }}
-
- - uses: actions/setup-node@v4
- with:
- node-version: 20.9.0
- cache: 'yarn'
-
- - name: Install dependencies
- run: yarn install
-
- - name: Extract I18n files
- run: yarn i18n:extract
-
- - name: Commit changes
- uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0
- with:
- commit_message: "Github Action: Auto-fix i18n files"
- file_pattern: public/locales/*/grafana.json
-
- - name: Update PR description
- uses: devindford/Append_PR_Comment@32dd2619cd96ac8da9907c416c992fe265233ca8 # v1.1.3
- if: ${{ ! contains(github.event.pull_request.body, 'Steps for merging') }}
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- body-update-action: prefix
- body-template: |
- :robot: Automatic sync of translations from Crowdin.
-
- Steps for merging:
- 1. Wait for the "Github Action: Auto-fix i18n files" commit that may be required for CI to pass.
- 2. A quick sanity check of the changes and approve. Things to look out for:
- - No changes to the English strings. The source of truth is already in the main branch, NOT Crowdin.
- - Translations maybe be removed if the English phrase was removed, but there should not be many of these
- - Anything else that looks 'funky'. Ask if you're not sure.
- 3. Approve & (Auto-)merge. :tada:
-
- If there's a conflict, close the pull request and **delete the branch**. Crowdin will recreate the pull request eventually.
- Remember, the longer this pull request is open, the more likely it is that it'll get conflicts.
diff --git a/.github/workflows/i18n-crowdin-upload.yml b/.github/workflows/i18n-crowdin-upload.yml
new file mode 100644
index 00000000000..9dcdd5a6b10
--- /dev/null
+++ b/.github/workflows/i18n-crowdin-upload.yml
@@ -0,0 +1,29 @@
+name: Crowdin Upload Action
+
+on:
+ push:
+ paths:
+ - 'public/locales/en-US/grafana.json'
+ branches:
+ - main
+
+jobs:
+ upload-sources-to-crowdin:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Upload sources
+ uses: crowdin/github-action@v1
+ with:
+ upload_sources: true
+ upload_translations: false
+ download_translations: false
+ create_pull_request: false
+ base_url: 'https://grafana.api.crowdin.com'
+ config: 'crowdin.yml'
+ env:
+ CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.yarnrc.yml b/.yarnrc.yml
index f891eb4e5c5..4f0f49b98d0 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -33,6 +33,10 @@ packageExtensions:
react-resizable@3.0.4:
peerDependencies:
react-dom: 17.0.1
+ '@msagl/drawing@*':
+ dependencies:
+ queue-typescript: "^1.0.1"
+ "@esfx/collections-sortedmap": "^1.0.0"
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs
diff --git a/conf/defaults.ini b/conf/defaults.ini
index d26ea453e3f..3570b50ab47 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -1047,6 +1047,11 @@ instrumentations_console_enabled = false
# Should webvitals instrumentation be enabled, only affects Grafana Javascript Agent
instrumentations_webvitals_enabled = false
+# level of internal logging for debugging Grafana Javascript Agent.
+# possible values are: 0 = OFF, 1 = ERROR, 2 = WARN, 3 = INFO, 4 = VERBOSE
+# more details: https://github.com/grafana/faro-web-sdk/blob/v1.3.7/docs/sources/tutorials/quick-start-browser.md#how-to-activate-debugging
+internal_logger_level = 0
+
# Api Key, only applies to Grafana Javascript Agent provider
api_key =
diff --git a/contribute/internationalization.md b/contribute/internationalization.md
index 243db35efe2..8d32a65e3ed 100644
--- a/contribute/internationalization.md
+++ b/contribute/internationalization.md
@@ -9,7 +9,8 @@ Grafana uses the [i18next](https://www.i18next.com/) framework for managing tran
- Use `Go to {{ pageTitle }}` in code to add a translatable phrase
- Translations are stored in JSON files in `public/locales/{locale}/grafana.json`
- If a particular phrase is not available in the a language then it will fall back to English
-- To update phrases in English, edit the default phrase in both the component's source and the [English grafana.json message catalogue](../public/locales/en-US/grafana.json), then run `yarn i18n:extract`.
+- To update phrases in English, edit the default phrase in the component's source and then run `yarn i18n:extract`.
+- The single source of truth for en-US (fallback language) is in grafana/grafana, the single source of truth for any translated language is Crowdin
- To update phrases in any translated language, edit the phrase in Crowdin. Do not edit the `{locale}/grafana.json`
## How to add a new translation phrase
@@ -40,7 +41,8 @@ const ErrorMessage = ({ id, message }) => There
2. Upon reload, the default English phrase will appear on the page.
-3. Before submitting your PR, run the `yarn i18n:extract` command to extract the messages you added into the `grafana.json` file and make them available for translation.
+3. Before submitting your PR, run the `yarn i18n:extract` command to extract the messages you added into the `public/locales/en-US/grafana.json` file and make them available for translation.
+ **Note:** All other languages will receive their translations when they are ready to be downloaded from Crowdin.
### Plain JS usage
@@ -64,20 +66,20 @@ While the `t` function can technically be used outside of React functions (e.g,
## How to add a new language
-1. Add new locale in Crowdin and sync files to repo
+1. Add new locale in Crowdin and download files to repo
1. Grafana OSS Crowdin project -> "dot dot dot" menu in top right -> Target languages
- 2. Grafana OSS Crowdin project -> Integrations -> Github -> Sync Now
- 3. If Crowdin's locale code is different from our IETF language tag, add a custom mapping in Project Settings -> Language mapping
-2. Update `public/app/core/internationalization/constants.ts` (add new constant, and add to `LOCALES`)
-3. Update `public/locales/i18next-parser.config.js` to add the new locale to `locales`
-4. Run `yarn i18n:extract` and commit the result
+ 2. If Crowdin's locale code is different from our IETF language tag, add a custom mapping in Project Settings -> Language mapping
+ 3. GH repo grafana/grafana -> Actions -> Choose `Crowdin Download Action` -> Run workflow -> Creates a PR automatically
+2. Review the PR `I18n: Download translations from Crowdin`
+3. Update `public/app/core/internationalization/constants.ts` (add new constant, and add to `LOCALES`) and add changes to the open PR
+4. Approve and merge the PR
## How translations work in Grafana
Grafana uses the [i18next](https://www.i18next.com/) framework for managing translating phrases in the Grafana frontend. It:
- Marks up phrases within our code for extraction
-- Extracts phrases into messages catalogues for translating in external systems
+- Extracts phrases into the default messages catalogue for translating in external systems
- Manages the user's locale and putting the translated phrases in the UI
Grafana will load the message catalogue JSON before the initial render.
diff --git a/crowdin.yml b/crowdin.yml
index 1cdf69254df..ba8846585f1 100644
--- a/crowdin.yml
+++ b/crowdin.yml
@@ -2,7 +2,6 @@ files:
- source: /public/locales/en-US/grafana.json
translation: /public/locales/%locale%/%original_file_name%
type: i18next_json
-pull_request_title: 'I18n: Crowdin sync'
-pull_request_labels:
- - area/internationalization
- - no-changelog
+# The following are pulled from env variables
+project_id_env: CROWDIN_PROJECT_ID
+api_token_env: CROWDIN_PERSONAL_TOKEN
diff --git a/docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md b/docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md
index 78f2293b433..123124bc443 100644
--- a/docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md
+++ b/docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md
@@ -26,13 +26,13 @@ In the Contact points tab, you can see a list of your contact points.
**Note:** You can edit an existing contact by clicking the Edit icon.
-2. Execute a template from one or more fields such as Message and Subject:
+1. Execute a template from one or more fields such as Message and Subject:
{{< figure max-width="940px" src="/static/img/docs/alerting/unified/use-notification-template-9-4.png" caption="Use notification template" >}}
For more information on how to write and execute templates, refer to [Using Go's templating language][using-go-templating-language] and [Create notification templates][create-notification-templates].
-3. Click Save template.
+1. Click **Save contact point**.
{{% docs/reference %}}
[create-notification-templates]: "/docs/grafana/ -> /docs/grafana//alerting/manage-notifications/template-notifications/create-notification-templates"
diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md
index b64985632fd..63656ff6eb9 100644
--- a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md
+++ b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md
@@ -2,7 +2,7 @@
aliases:
- ../provision-alerting-resources/
canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/
-description: Provision alerting resources
+description: Import and export alerting resources
keywords:
- grafana
- alerting
@@ -14,23 +14,23 @@ labels:
- cloud
- enterprise
- oss
-title: Provision Grafana Alerting resources
+title: Import and export Grafana Alerting resources
weight: 300
---
-# Provision Grafana Alerting resources
+# Import and export Grafana Alerting resources
-Alerting infrastructure is often complex, with many pieces of the pipeline that often live in different places. Scaling this across multiple teams and organizations is an especially challenging task. Grafana Alerting provisioning makes this process easier by enabling you to create, manage, and maintain your alerting data in a way that best suits your organization.
+Alerting infrastructure is often complex, with many pieces of the pipeline that often live in different places. Scaling this across multiple teams and organizations is an especially challenging task. Importing and exporting (or provisioning) your alerting resources in Grafana Alerting makes this process easier by enabling you to create, manage, and maintain your alerting data in a way that best suits your organization.
-Provisioning for Grafana Alerting supports alert rules, contact points, notification policies, mute timings, and templates.
+You can import alert rules, contact points, notification policies, mute timings, and templates.
-You cannot edit provisioned alerting resources in the Grafana UI in the same way as unprovisioned alerting resources. You can only edit provisioned contact points, notification policies, templates, and mute timings in the source where they were created. For example, if you provision your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana.
+You cannot edit imported alerting resources in the Grafana UI in the same way as alerting resources that were not imported. You can only edit imported contact points, notification policies, templates, and mute timings in the source where they were created. For example, if you manage your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana.
-To modify provisioned alert rules, you can use the **Modify export** feature to edit and then export.
+To modify imported alert rules, you can use the **Modify export** feature to edit and then export.
-Choose from the options below to provision your Grafana Alerting resources.
+Choose from the options below to import your Grafana Alerting resources.
-1. Use file provisioning to provision your Grafana Alerting resources, such as alert rules and contact points, through files on disk.
+1. Use file provisioning to manage your Grafana Alerting resources, such as alert rules and contact points, through files on disk.
{{% admonition type="note" %}}
File provisioning is not available in Grafana Cloud instances.
diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md
index 890633ea8e1..b99e6a70f22 100644
--- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md
+++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md
@@ -14,13 +14,13 @@ labels:
- cloud
- enterprise
- oss
-title: Create and manage alerting resources using file provisioning
+title: Use file provisioning to manage alerting resources
weight: 100
---
-## Create and manage alerting resources using file provisioning
+## Use file provisioning to manage alerting resources
-Provision your alerting resources using files from disk. When you start Grafana, the data from these files is created in your Grafana system. Grafana adds any new resources you created, updates any that you changed, and deletes old ones.
+Manage your alerting resources using files from disk. When you start Grafana, the data from these files is created in your Grafana system. Grafana adds any new resources you created, updates any that you changed, and deletes old ones.
Arrange your files in a directory in a way that best suits your use case. For example, you can choose a team-based layout where every team has its own file, you can have one big file for all your teams; or you can have one file per resource type.
@@ -28,9 +28,9 @@ Details on how to set up the files and which fields are required for each object
**Note:**
-Provisioning takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API][reload-provisioning-configurations].
+Importing takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API][reload-provisioning-configurations].
-### Provision alert rules
+### Import alert rules
Create or delete alert rules in your Grafana instance(s).
@@ -41,11 +41,11 @@ Create or delete alert rules in your Grafana instance(s).
Example configuration files can be found below.
1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s).
-1. Delete the alert rules in Grafana that will be provisioned.
+1. Delete the alert rules in Grafana that are going to be imported.
**Note:**
- If you do not delete the alert rule, it will clash with the provisioned alert rule once uploaded.
+ If you do not delete the alert rule, it will clash with the imported alert rule once uploaded.
Here is an example of a configuration file for creating alert rules.
@@ -134,7 +134,7 @@ deleteRules:
uid: my_id_1
```
-### Provision contact points
+### Import contact points
Create or delete contact points in your Grafana instance(s).
@@ -495,7 +495,7 @@ settings:
{{ template "default.title" . }}
```
-### Provision notification policies
+### Import notification policies
Create or reset the notification policy tree in your Grafana instance(s).
@@ -587,7 +587,7 @@ In Grafana, the entire notification policy tree is considered a single, large re
Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means.
-### Provision templates
+### Import templates
Create or delete templates in your Grafana instance(s).
@@ -627,7 +627,7 @@ deleteTemplates:
name: my_first_template
```
-### Provision mute timings
+### Import mute timings
Create or delete mute timings in your Grafana instance(s).
diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md
index 00da7920444..0ad04166a99 100644
--- a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md
+++ b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md
@@ -13,11 +13,11 @@ labels:
products:
- enterprise
- oss
-title: Create and manage alerting resources using Terraform
+title: Use Terraform to manage alerting resources
weight: 200
---
-# Create and manage alerting resources using Terraform
+# Use Terraform to manage alerting resources
Use Terraform’s Grafana Provider to manage your alerting resources and provision them into your Grafana system. Terraform provider support for Grafana Alerting makes it easy to create, manage, and maintain your entire Grafana Alerting stack as code.
diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md
index 96e8c1d4245..5b473a7776d 100644
--- a/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md
+++ b/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md
@@ -13,12 +13,12 @@ labels:
- cloud
- enterprise
- oss
-menuTitle: Manage provisioned resources in Grafana
-title: Manage provisioned alerting resources in Grafana
+menuTitle: Manage provisioned alerting resources
+title: Manage provisioned alerting resources
weight: 300
---
-# Manage provisioned alerting resources in Grafana
+# Manage provisioned alerting resources
Verify that your alerting resources were created in Grafana, as well as edit or export your provisioned alerting resources.
diff --git a/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md
index 805542a6d3d..bc5da9a3aab 100644
--- a/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md
+++ b/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md
@@ -58,10 +58,11 @@ title: TestDataDataQuery kind
### NodesQuery
-| Property | Type | Required | Default | Description |
-|----------|---------|----------|---------|------------------------------------------------------------|
-| `count` | integer | No | | |
-| `type` | string | No | | Possible values are: `random`, `response`, `random edges`. |
+| Property | Type | Required | Default | Description |
+|----------|---------|----------|---------|-------------------------------------------------------------------------------------|
+| `count` | integer | No | | |
+| `seed` | integer | No | | |
+| `type` | string | No | | Possible values are: `random`, `response_small`, `response_medium`, `random edges`. |
### PulseWaveQuery
diff --git a/docs/sources/fundamentals/intro-to-prometheus/index.md b/docs/sources/fundamentals/intro-to-prometheus/index.md
index 3217abc965c..6fa0023c62f 100644
--- a/docs/sources/fundamentals/intro-to-prometheus/index.md
+++ b/docs/sources/fundamentals/intro-to-prometheus/index.md
@@ -1,6 +1,7 @@
---
aliases:
- ../basics/timeseries/
+ - /docs/grafana-cloud/introduction/prometheus/
description: Introduction to Prometheus
keywords:
- grafana
diff --git a/docs/sources/fundamentals/timeseries-dimensions/index.md b/docs/sources/fundamentals/timeseries-dimensions/index.md
index aeaa2182942..b42fb1482d3 100644
--- a/docs/sources/fundamentals/timeseries-dimensions/index.md
+++ b/docs/sources/fundamentals/timeseries-dimensions/index.md
@@ -3,6 +3,7 @@ aliases:
- ../basics/timeseries-dimensions/
- ../getting-started/timeseries-dimensions/
- ../guides/timeseries-dimensions/
+ - /docs/grafana-cloud/introduction/timeseries-dimensions/
description: time series dimensions
keywords:
- grafana
diff --git a/docs/sources/fundamentals/timeseries/index.md b/docs/sources/fundamentals/timeseries/index.md
index f95f6e6aa41..c08915ea295 100644
--- a/docs/sources/fundamentals/timeseries/index.md
+++ b/docs/sources/fundamentals/timeseries/index.md
@@ -1,6 +1,7 @@
---
aliases:
- ../basics/timeseries/
+ - /docs/grafana-cloud/introduction/timeseries/
description: Introduction to time series
keywords:
- grafana
diff --git a/docs/sources/panels-visualizations/configure-data-links/index.md b/docs/sources/panels-visualizations/configure-data-links/index.md
index d265641034c..b2fda39c1ac 100644
--- a/docs/sources/panels-visualizations/configure-data-links/index.md
+++ b/docs/sources/panels-visualizations/configure-data-links/index.md
@@ -38,6 +38,20 @@ For visualizations like stat, gauge, or bar gauge you can click anywhere on the
If there's only one data link in the visualization, clicking anywhere on the visualization opens the link rather than the context menu.
+## Supported visualizations
+
+You can configure data links for the following visualizations:
+
+| | | |
+| -------------------------- | ---------------------- | -------------------------------- |
+| [Bar chart][bar chart] | [Geomap][geomap] | [State timeline][state timeline] |
+| [Bar gauge][bar gauge] | [Heatmap][heatmap] | [Status history][status history] |
+| [Candlestick][candlestick] | [Histogram][histogram] | [Table][table] |
+| [Canvas][canvas] | [Pie chart][pie chart] | [Time series][time series] |
+| [Gauge][gauge] | [Stat][stat] | [Trend][trend] |
+
+
+
## Data link variables
Variables in data links let you send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a label, time range, series, or variable selection.
@@ -136,6 +150,51 @@ If you want to add all of the current dashboard's variables to the URL, then use
1. Click the **Save dashboard** icon to save your changes to the dashboard.
{{% docs/reference %}}
+[bar chart]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/bar-chart"
+[bar chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-chart"
+
+[bar gauge]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/bar-gauge"
+[bar gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-gauge"
+
+[candlestick]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/candlestick"
+[candlestick]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/candlestick"
+
+[canvas]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/canvas"
+[canvas]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/canvas"
+
+[gauge]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/gauge"
+[gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/gauge"
+
+[geomap]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/geomap"
+[geomap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/geomap"
+
+[heatmap]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/heatmap"
+[heatmap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/heatmap"
+
+[histogram]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/histogram"
+[histogram]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/histogram"
+
+[pie chart]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/pie-chart"
+[pie chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/pie-chart"
+
+[stat]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/stat"
+[stat]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/stat"
+
+[state timeline]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/state-timeline"
+[state timeline]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/state-timeline"
+
+[status history]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/status-history"
+[status history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/status-history"
+
+[table]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/table"
+[table]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/table"
+
+[time series]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/time-series"
+[time series]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series"
+
+[trend]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/trend"
+[trend]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/trend"
+
[Cloudwatch]: "/docs/grafana/ -> /docs/grafana//datasources/aws-cloudwatch/query-editor#deep-link-grafana-panels-to-the-cloudwatch-console-1"
[Cloudwatch]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/connect-externally-hosted/data-sources/aws-cloudwatch/query-editor#deep-link-grafana-panels-to-the-cloudwatch-console-1"
diff --git a/docs/sources/panels-visualizations/configure-value-mappings/index.md b/docs/sources/panels-visualizations/configure-value-mappings/index.md
index 4a62ace44ec..be516356ec5 100644
--- a/docs/sources/panels-visualizations/configure-value-mappings/index.md
+++ b/docs/sources/panels-visualizations/configure-value-mappings/index.md
@@ -30,6 +30,18 @@ For example, the mapping applied in the following image causes the visualization
Value mappings bypass unit formatting set in the **Standard options** section of panel editor, like color or number of decimal places displayed. When value mappings are present in a panel, Grafana displays a summary of them in the **Value mappings** section of the editor panel.
+## Supported visualizations
+
+You can configure value mappings for the following visualizations:
+
+| | | |
+| -------------------------- | -------------------------------- | -------------------------------- |
+| [Bar chart][bar chart] | [Geomap][geomap] | [Status history][status history] |
+| [Bar gauge][bar gauge] | [Histogram][histogram] | [Table][table] |
+| [Candlestick][candlestick] | [Pie chart][pie chart] | [Time series][time series] |
+| [Canvas][canvas] | [Stat][stat] | [Trend][trend] |
+| [Gauge][gauge] | [State timeline][state timeline] | |
+
## Types of value mappings
Grafana supports the following value mapping types:
@@ -98,6 +110,51 @@ The following image shows a table visualization with value mappings. If you want
1. (Optional) Enter display text.
1. (Optional) Set the color.
+1. (Optional) Set an icon (canvas visualizations only).
1. Click **Update** to save the value mapping.
After you've added a mapping, the **Edit value mappings** button replaces the **Add value mappings** button. Click the edit button to add or update mappings.
+
+{{% docs/reference %}}
+[bar chart]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/bar-chart"
+[bar chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-chart"
+
+[bar gauge]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/bar-gauge"
+[bar gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-gauge"
+
+[candlestick]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/candlestick"
+[candlestick]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/candlestick"
+
+[canvas]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/canvas"
+[canvas]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/canvas"
+
+[gauge]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/gauge"
+[gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/gauge"
+
+[geomap]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/geomap"
+[geomap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/geomap"
+
+[histogram]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/histogram"
+[histogram]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/histogram"
+
+[pie chart]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/pie-chart"
+[pie chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/pie-chart"
+
+[stat]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/stat"
+[stat]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/stat"
+
+[state timeline]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/state-timeline"
+[state timeline]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/state-timeline"
+
+[status history]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/status-history"
+[status history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/status-history"
+
+[table]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/table"
+[table]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/table"
+
+[time series]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/time-series"
+[time series]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series"
+
+[trend]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/trend"
+[trend]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/trend"
+{{% /docs/reference %}}
diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
index 9b785a3d3f0..22cccf9e608 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -57,7 +57,6 @@ Some features are enabled by default. You can disable these feature by setting t
| `lokiQueryHints` | Enables query hints for Loki | Yes |
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
| `jitterAlertRules` | Distributes alert rule evaluations more evenly over time, by rule group | |
-| `slateAutocomplete` | Adjusts the behaviour of the slate editor to properly handle autocomplete. Feature toggled for safety. | Yes |
## Preview feature toggles
@@ -174,6 +173,7 @@ Experimental features might be changed or removed without prior notice.
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
| `onPremToCloudMigrations` | In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud. |
| `promQLScope` | In-development feature that will allow injection of labels into prometheus queries. |
+| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
## Development feature toggles
@@ -185,4 +185,5 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref
| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services |
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
+| `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
| `panelTitleSearchInV1` | Enable searching for dashboards using panel title in search v1 |
diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md
index ffed7b045e8..19a15256d91 100644
--- a/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md
+++ b/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md
@@ -50,7 +50,7 @@ auto_login = true
## Skip organization role sync
-To prevent the sync of org roles from Grafana.com, set `skip_org_role_sync` to `true`. This is useful if you want to manage the organization roles for your users from within Grafana.
+If a user signs in with their Grafana.com credentials, their assigned org role overrides the role defined in the Grafana instance. To prevent Grafana.com roles from synchronizing, set `skip_org_role_sync` to `true`. This is useful if you want to manage the organization roles for your users from within Grafana.
```ini
[auth.grafana_com]
diff --git a/go.mod b/go.mod
index a99fef63c1d..6fd01e873d3 100644
--- a/go.mod
+++ b/go.mod
@@ -63,7 +63,7 @@ require (
github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code
github.com/grafana/grafana-aws-sdk v0.19.1 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go v1.11.0 // @grafana/partner-datasources
- github.com/grafana/grafana-plugin-sdk-go v0.204.0 // @grafana/plugins-platform-backend
+ github.com/grafana/grafana-plugin-sdk-go v0.206.0 // @grafana/plugins-platform-backend
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/backend-platform
github.com/hashicorp/go-hclog v1.6.2 // @grafana/plugins-platform-backend
github.com/hashicorp/go-plugin v1.6.0 // @grafana/plugins-platform-backend
diff --git a/go.sum b/go.sum
index 34dc3ce682b..5dc39ab09b4 100644
--- a/go.sum
+++ b/go.sum
@@ -1832,8 +1832,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M=
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
-github.com/grafana/grafana-plugin-sdk-go v0.204.0 h1:4+VtZEqmNwfN9OvDersv7rN861tKTvJdaZKCwfuIAmc=
-github.com/grafana/grafana-plugin-sdk-go v0.204.0/go.mod h1:MetVbF3bGwmUvxulWsl0SskJFUq3+ZGnyjDH0dQ4sSg=
+github.com/grafana/grafana-plugin-sdk-go v0.206.0 h1:DQK2M07/q0t7wqcJBSw6O/FQJZC1tm24uzGfHbA71+A=
+github.com/grafana/grafana-plugin-sdk-go v0.206.0/go.mod h1:MetVbF3bGwmUvxulWsl0SskJFUq3+ZGnyjDH0dQ4sSg=
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo=
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482/go.mod h1:GNcfpy5+SY6RVbNGQW264gC0r336Dm+0zgQ5vt6+M8Y=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20231027171310-70c52bf65758 h1:ATUhvJSJwzdzhnmzUI92fxVFqyqmcnzJ47wtHTK3LW4=
diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh
index 124dd19e24a..25f53d21474 100755
--- a/hack/update-codegen.sh
+++ b/hack/update-codegen.sh
@@ -29,6 +29,9 @@ if [[ "${UPDATE_API_KNOWN_VIOLATIONS:-}" == "true" ]]; then
fi
for api_pkg in $(ls ./pkg/apis); do
+ if [[ "${1-}" != "" && ${api_pkg} != $1 ]]; then
+ continue
+ fi
for pkg_version in $(ls ./pkg/apis/${api_pkg}); do
echo "Generating openapi package for ${api_pkg}, version=${pkg_version} ..."
grafana::codegen::gen_openapi \
diff --git a/package.json b/package.json
index 6d93ef2078b..598c446d0c2 100644
--- a/package.json
+++ b/package.json
@@ -82,8 +82,8 @@
"@swc/core": "1.3.107",
"@swc/helpers": "0.5.3",
"@testing-library/dom": "9.3.4",
- "@testing-library/jest-dom": "6.4.0",
- "@testing-library/react": "14.2.0",
+ "@testing-library/jest-dom": "6.4.1",
+ "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
"@types/angular": "1.8.9",
"@types/angular-route": "1.7.6",
@@ -101,7 +101,7 @@
"@types/gtag.js": "^0.0.18",
"@types/history": "4.7.11",
"@types/hoist-non-react-statics": "3.3.5",
- "@types/jest": "29.5.11",
+ "@types/jest": "29.5.12",
"@types/jquery": "3.5.29",
"@types/js-yaml": "^4.0.5",
"@types/jsurl": "^1.2.28",
@@ -236,7 +236,7 @@
"@grafana/aws-sdk": "0.3.1",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
- "@grafana/experimental": "1.7.8",
+ "@grafana/experimental": "1.7.9",
"@grafana/faro-core": "^1.3.6",
"@grafana/faro-web-sdk": "^1.3.6",
"@grafana/flamegraph": "workspace:*",
@@ -245,7 +245,7 @@
"@grafana/monaco-logql": "^0.0.7",
"@grafana/o11y-ds-frontend": "workspace:*",
"@grafana/runtime": "workspace:*",
- "@grafana/scenes": "^2.2.0",
+ "@grafana/scenes": "^2.4.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -257,6 +257,8 @@
"@locker/near-membrane-dom": "0.13.3",
"@locker/near-membrane-shared": "0.13.3",
"@locker/near-membrane-shared-dom": "0.13.3",
+ "@msagl/core": "^1.1.16",
+ "@msagl/parser": "^1.1.16",
"@opentelemetry/api": "1.7.0",
"@opentelemetry/exporter-collector": "0.25.0",
"@opentelemetry/semantic-conventions": "1.21.0",
@@ -297,8 +299,8 @@
"d3": "7.8.5",
"d3-force": "3.0.0",
"d3-scale-chromatic": "3.0.0",
- "dangerously-set-html-content": "1.0.9",
- "date-fns": "2.30.0",
+ "dangerously-set-html-content": "1.1.0",
+ "date-fns": "3.3.1",
"debounce-promise": "3.1.2",
"diff": "^5.1.0",
"emotion": "11.0.0",
diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json
index bb5f23dd78e..87eaec0e421 100644
--- a/packages/grafana-data/package.json
+++ b/packages/grafana-data/package.json
@@ -40,8 +40,8 @@
"@types/d3-interpolate": "^3.0.0",
"@types/string-hash": "1.1.3",
"d3-interpolate": "3.0.1",
- "date-fns": "2.30.0",
- "dompurify": "^2.4.3",
+ "date-fns": "3.3.1",
+ "dompurify": "^3.0.0",
"eventemitter3": "5.0.1",
"fast_array_intersect": "1.1.0",
"history": "4.10.1",
@@ -67,12 +67,12 @@
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-node-resolve": "15.2.3",
"@testing-library/dom": "9.3.4",
- "@testing-library/jest-dom": "6.4.0",
- "@testing-library/react": "14.2.0",
+ "@testing-library/jest-dom": "6.4.1",
+ "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
- "@types/dompurify": "^2",
+ "@types/dompurify": "^3.0.0",
"@types/history": "4.7.11",
- "@types/jest": "29.5.11",
+ "@types/jest": "29.5.12",
"@types/jquery": "3.5.29",
"@types/lodash": "4.14.202",
"@types/marked": "5.0.2",
diff --git a/packages/grafana-data/src/datetime/durationutil.ts b/packages/grafana-data/src/datetime/durationutil.ts
index c490554a2bc..c8049a641bb 100644
--- a/packages/grafana-data/src/datetime/durationutil.ts
+++ b/packages/grafana-data/src/datetime/durationutil.ts
@@ -1,6 +1,4 @@
-import { Duration, Interval, isAfter } from 'date-fns';
-import add from 'date-fns/add';
-import intervalToDuration from 'date-fns/intervalToDuration';
+import { add, Duration, intervalToDuration, Interval, isAfter } from 'date-fns';
const durationMap: { [key in Required]: string[] } = {
years: ['y', 'Y', 'years'],
diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts
index 9833f10e2a7..ff33188eff3 100644
--- a/packages/grafana-data/src/field/fieldDisplay.ts
+++ b/packages/grafana-data/src/field/fieldDisplay.ts
@@ -204,6 +204,25 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
}
}
+ // If there is only one row in the data frame, then set the
+ // valueRowIndex to that one row. This allows the data macros in
+ // things like links to access other fields from the data frame.
+ //
+ // If there were more rows, it still may be sane to set the row
+ // index, but it may be confusing; the calculation may have
+ // selected a value from a different row or it may have aggregated
+ // the values from multiple rows, so to make just the first row
+ // available would be arbitrary. For now, the users will have to
+ // ensure that the data frame has just one row if they want data
+ // link referencing other fields to work.
+ //
+ // TODO: A more complete solution here would be to allow the
+ // calculation to report a relevant row and use that value. For
+ // example, a common calculation is 'lastNotNull'. It'd be nifty to
+ // know which row the display value corresponds to in that case if
+ // there were potentially many
+ const valueRowIndex = dataFrame.length === 1 ? 0 : undefined;
+
values.push({
name: calc,
field: config,
@@ -215,6 +234,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
? () =>
fieldLinksSupplier({
calculatedValue: displayValue,
+ valueRowIndex,
})
: () => [],
hasLinks: hasLinks(field),
diff --git a/packages/grafana-data/src/transformations/transformDataFrame.ts b/packages/grafana-data/src/transformations/transformDataFrame.ts
index f9baf411d6a..6717b6565a5 100644
--- a/packages/grafana-data/src/transformations/transformDataFrame.ts
+++ b/packages/grafana-data/src/transformations/transformDataFrame.ts
@@ -55,19 +55,6 @@ const postProcessTransform =
return after;
}
- // Add a key to the metadata if the data changed
- for (const series of after) {
- if (!series.meta) {
- series.meta = {};
- }
-
- if (!series.meta.transformations) {
- series.meta.transformations = [info.id];
- } else {
- series.meta.transformations = [...series.meta.transformations, info.id];
- }
- }
-
// Add back the filtered out frames
if (matcher) {
// keep the frame order the same
diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts
index ae2b231b680..0f63477d51b 100644
--- a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts
+++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts
@@ -109,11 +109,6 @@ describe('ensureColumns transformer', () => {
},
],
"length": 2,
- "meta": {
- "transformations": [
- "ensureColumns",
- ],
- },
}
`);
});
diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts
index d6dfa26ab1f..012755695eb 100644
--- a/packages/grafana-data/src/types/config.ts
+++ b/packages/grafana-data/src/types/config.ts
@@ -105,6 +105,7 @@ export interface AnalyticsSettings {
export interface CurrentUserDTO {
isSignedIn: boolean;
id: number;
+ uid: string;
externalUserId: string;
login: string;
email: string;
diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts
index 8c88d3c99eb..13a0eb789dc 100644
--- a/packages/grafana-data/src/types/data.ts
+++ b/packages/grafana-data/src/types/data.ts
@@ -54,9 +54,6 @@ export interface QueryResultMeta {
/** Meta Notices */
notices?: QueryResultMetaNotice[];
- /** Used to track transformation ids that where part of the processing */
- transformations?: string[];
-
/** Currently used to show results in Explore only in preferred visualisation option */
preferredVisualisationType?: PreferredVisualisationType;
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index 8ffcae0e84e..b77af68741e 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -63,6 +63,7 @@ export interface FeatureToggles {
prometheusMetricEncyclopedia?: boolean;
influxdbBackendMigration?: boolean;
influxqlStreamingParser?: boolean;
+ influxdbRunQueriesInParallel?: boolean;
clientTokenRotation?: boolean;
prometheusDataplane?: boolean;
lokiMetricDataplane?: boolean;
@@ -130,6 +131,7 @@ export interface FeatureToggles {
transformationsVariableSupport?: boolean;
kubernetesPlaylists?: boolean;
kubernetesSnapshots?: boolean;
+ kubernetesQueryServiceRewrite?: boolean;
cloudWatchBatchQueries?: boolean;
recoveryThreshold?: boolean;
lokiStructuredMetadata?: boolean;
@@ -174,5 +176,5 @@ export interface FeatureToggles {
onPremToCloudMigrations?: boolean;
alertingSaveStatePeriodic?: boolean;
promQLScope?: boolean;
- slateAutocomplete?: boolean;
+ nodeGraphDotLayout?: boolean;
}
diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts
index 285b0447aa2..20bb47d9029 100644
--- a/packages/grafana-e2e-selectors/src/selectors/pages.ts
+++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts
@@ -145,14 +145,14 @@ export const Pages = {
applyButton: 'data-testid Variable editor Apply button',
},
QueryVariable: {
- queryOptionsDataSourceSelect: Components.DataSourcePicker.container,
+ queryOptionsDataSourceSelect: Components.DataSourcePicker.inputV2,
queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select',
queryOptionsRefreshSelectV2: 'data-testid Variable editor Form Query Refresh select',
queryOptionsRegExInput: 'Variable editor Form Query RegEx field',
queryOptionsRegExInputV2: 'data-testid Variable editor Form Query RegEx field',
queryOptionsSortSelect: 'Variable editor Form Query Sort select',
queryOptionsSortSelectV2: 'data-testid Variable editor Form Query Sort select',
- queryOptionsQueryInput: 'Variable editor Form Default Variable Query Editor textarea',
+ queryOptionsQueryInput: 'data-testid Variable editor Form Default Variable Query Editor textarea',
valueGroupsTagsEnabledSwitch: 'Variable editor Form Query UseTags switch',
valueGroupsTagsTagsQueryInput: 'Variable editor Form Query TagsQuery field',
valueGroupsTagsTagsValuesQueryInput: 'Variable editor Form Query TagsValuesQuery field',
diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json
index 6daf416e156..66f3ee1132c 100644
--- a/packages/grafana-flamegraph/package.json
+++ b/packages/grafana-flamegraph/package.json
@@ -62,7 +62,7 @@
"@grafana/tsconfig": "^1.2.0-rc1",
"@rollup/plugin-node-resolve": "15.2.3",
"@testing-library/jest-dom": "^6.1.2",
- "@testing-library/react": "14.2.0",
+ "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
"@types/d3": "^7",
"@types/jest": "^29.5.4",
diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json
index a5745a25ada..213dcb8c6a2 100644
--- a/packages/grafana-o11y-ds-frontend/package.json
+++ b/packages/grafana-o11y-ds-frontend/package.json
@@ -20,7 +20,7 @@
"@emotion/css": "11.11.2",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
- "@grafana/experimental": "1.7.8",
+ "@grafana/experimental": "1.7.9",
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -32,7 +32,7 @@
"devDependencies": {
"@grafana/tsconfig": "^1.2.0-rc1",
"@testing-library/jest-dom": "^6.1.2",
- "@testing-library/react": "14.2.0",
+ "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "^29.5.4",
"@types/react": "18.2.48",
diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json
index a724a669be9..e746d83be55 100644
--- a/packages/grafana-runtime/package.json
+++ b/packages/grafana-runtime/package.json
@@ -54,11 +54,11 @@
"@rollup/plugin-commonjs": "25.0.7",
"@rollup/plugin-node-resolve": "15.2.3",
"@testing-library/dom": "9.3.4",
- "@testing-library/react": "14.2.0",
+ "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
"@types/angular": "1.8.9",
"@types/history": "4.7.11",
- "@types/jest": "29.5.11",
+ "@types/jest": "29.5.12",
"@types/lodash": "4.14.202",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts
index 9092afd51ea..fe98d4ab619 100644
--- a/packages/grafana-runtime/src/index.ts
+++ b/packages/grafana-runtime/src/index.ts
@@ -15,7 +15,7 @@ export {
} from './utils/plugin';
export { reportMetaAnalytics, reportInteraction, reportPageview, reportExperimentView } from './analytics/utils';
export { featureEnabled } from './utils/licensing';
-export { logInfo, logDebug, logWarning, logError } from './utils/logging';
+export { logInfo, logDebug, logWarning, logError, createMonitoringLogger } from './utils/logging';
export {
DataSourceWithBackend,
HealthCheckError,
diff --git a/packages/grafana-runtime/src/utils/logging.ts b/packages/grafana-runtime/src/utils/logging.ts
index c37861c35dd..251719a9339 100644
--- a/packages/grafana-runtime/src/utils/logging.ts
+++ b/packages/grafana-runtime/src/utils/logging.ts
@@ -1,4 +1,4 @@
-import { faro, LogLevel, LogContext } from '@grafana/faro-web-sdk';
+import { faro, LogContext, LogLevel } from '@grafana/faro-web-sdk';
import { config } from '../config';
@@ -57,3 +57,55 @@ export function logError(err: Error, contexts?: LogContext) {
});
}
}
+
+/**
+ * Creates a monitoring logger with four levels of logging methods: `logDebug`, `logInfo`, `logWarning`, and `logError`.
+ * These methods use `faro.api.pushX` web SDK methods to report these logs or errors to the Faro collector.
+ *
+ * @param {string} source - Identifier for the source of the log messages.
+ * @param {LogContext} [defaultContext] - Context to be included in every log message.
+ *
+ * @returns {Object} Logger object with four methods:
+ * - `logDebug(message: string, contexts?: LogContext)`: Logs a debug message.
+ * - `logInfo(message: string, contexts?: LogContext)`: Logs an informational message.
+ * - `logWarning(message: string, contexts?: LogContext)`: Logs a warning message.
+ * - `logError(error: Error, contexts?: LogContext)`: Logs an error message.
+ * Each method combines the `defaultContext` (if provided), the `source`, and an optional `LogContext` parameter into a full context that is included with the log message.
+ */
+export function createMonitoringLogger(source: string, defaultContext?: LogContext) {
+ const createFullContext = (contexts?: LogContext) => ({
+ source: source,
+ ...defaultContext,
+ ...contexts,
+ });
+
+ return {
+ /**
+ * Logs a debug message with optional additional context.
+ * @param {string} message - The debug message to be logged.
+ * @param {LogContext} [contexts] - Optional additional context to be included.
+ */
+ logDebug: (message: string, contexts?: LogContext) => logDebug(message, createFullContext(contexts)),
+
+ /**
+ * Logs an informational message with optional additional context.
+ * @param {string} message - The informational message to be logged.
+ * @param {LogContext} [contexts] - Optional additional context to be included.
+ */
+ logInfo: (message: string, contexts?: LogContext) => logInfo(message, createFullContext(contexts)),
+
+ /**
+ * Logs a warning message with optional additional context.
+ * @param {string} message - The warning message to be logged.
+ * @param {LogContext} [contexts] - Optional additional context to be included.
+ */
+ logWarning: (message: string, contexts?: LogContext) => logWarning(message, createFullContext(contexts)),
+
+ /**
+ * Logs an error with optional additional context.
+ * @param {Error} error - The error object to be logged.
+ * @param {LogContext} [contexts] - Optional additional context to be included.
+ */
+ logError: (error: Error, contexts?: LogContext) => logError(error, createFullContext(contexts)),
+ };
+}
diff --git a/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts
index 01233d78f7c..91f7cbe3bdb 100644
--- a/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts
@@ -75,7 +75,8 @@ export interface SimulationQuery {
export interface NodesQuery {
count?: number;
- type?: ('random' | 'response' | 'random edges');
+ seed?: number;
+ type?: ('random' | 'response_small' | 'response_medium' | 'random edges');
}
export interface USAQuery {
diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json
index b818a471c9b..118057db472 100644
--- a/packages/grafana-sql/package.json
+++ b/packages/grafana-sql/package.json
@@ -16,7 +16,7 @@
"dependencies": {
"@emotion/css": "11.11.2",
"@grafana/data": "workspace:*",
- "@grafana/experimental": "1.7.8",
+ "@grafana/experimental": "1.7.9",
"@grafana/runtime": "workspace:*",
"@grafana/ui": "workspace:*",
"@react-awesome-query-builder/ui": "6.4.2",
@@ -35,7 +35,7 @@
"devDependencies": {
"@grafana/tsconfig": "^1.3.0-rc1",
"@testing-library/jest-dom": "^6.1.2",
- "@testing-library/react": "14.2.0",
+ "@testing-library/react": "14.2.1",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "^29.5.4",
diff --git a/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts
index cda44ce5924..a193b4a273e 100644
--- a/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts
+++ b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts
@@ -16,6 +16,9 @@ jest.mock('@grafana/runtime', () => {
},
},
logDebug: jest.fn(),
+ createMonitoringLogger: jest.fn().mockReturnValue({
+ logDebug: jest.fn(),
+ }),
};
});
diff --git a/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts
index 2a7ffbe8458..fec87709753 100644
--- a/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts
+++ b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
-import { logDebug, config } from '@grafana/runtime';
+import { config } from '@grafana/runtime';
import { SQLOptions } from '../../types';
+import { sqlPluginLogger } from '../../utils/logging';
/**
* 1. Moves the database field from the options object to jsonData.database and empties the database field.
@@ -20,7 +21,7 @@ export function useMigrateDatabaseFields({
// Migrate the database field from the column into the jsonData object
if (options.database) {
- logDebug(`Migrating from options.database with value ${options.database} for ${options.name}`);
+ sqlPluginLogger.logDebug(`Migrating from options.database with value ${options.database} for ${options.name}`);
newOptions.database = '';
newOptions.jsonData = { ...jsonData, database: options.database };
optionsUpdated = true;
@@ -35,7 +36,7 @@ export function useMigrateDatabaseFields({
) {
const { maxOpenConns, maxIdleConns } = config.sqlConnectionLimits;
- logDebug(
+ sqlPluginLogger.logDebug(
`Setting default max open connections to ${maxOpenConns} and setting max idle connection to ${maxIdleConns}`
);
diff --git a/packages/grafana-sql/src/utils/logging.ts b/packages/grafana-sql/src/utils/logging.ts
new file mode 100644
index 00000000000..95900184359
--- /dev/null
+++ b/packages/grafana-sql/src/utils/logging.ts
@@ -0,0 +1,3 @@
+import { createMonitoringLogger } from '@grafana/runtime';
+
+export const sqlPluginLogger = createMonitoringLogger('features.plugins.sql');
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index 256f4125c48..4092f042bf7 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -65,7 +65,7 @@
"calculate-size": "1.1.1",
"classnames": "2.5.1",
"d3": "7.8.5",
- "date-fns": "2.30.0",
+ "date-fns": "3.3.1",
"hoist-non-react-statics": "3.3.2",
"i18next": "^22.0.0",
"i18next-browser-languagedetector": "^7.0.2",
@@ -129,14 +129,14 @@
"@storybook/react-webpack5": "7.4.5",
"@storybook/theming": "7.4.5",
"@testing-library/dom": "9.3.4",
- "@testing-library/jest-dom": "6.4.0",
- "@testing-library/react": "14.2.0",
+ "@testing-library/jest-dom": "6.4.1",
+ "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
"@types/common-tags": "^1.8.0",
"@types/d3": "7.4.3",
"@types/hoist-non-react-statics": "3.3.5",
"@types/is-hotkey": "0.1.10",
- "@types/jest": "29.5.11",
+ "@types/jest": "29.5.12",
"@types/jquery": "3.5.29",
"@types/lodash": "4.14.202",
"@types/mock-raf": "1.0.6",
diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx
index 538badcee58..d11fbb258eb 100644
--- a/packages/grafana-ui/src/components/Cascader/Cascader.tsx
+++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx
@@ -93,7 +93,7 @@ export class Cascader extends PureComponent {
for (const option of options) {
const cpy = [...optionPath];
cpy.push(option);
- if (!option.items) {
+ if (!option.items || option.items.length === 0) {
selectOptions.push({
singleLabel: cpy[cpy.length - 1].label,
label: cpy.map((o) => o.label).join(this.props.separator || DEFAULT_SEPARATOR),
@@ -135,23 +135,27 @@ export class Cascader extends PureComponent {
: this.props.displayAllSelectedLevels
? selectedOptions.map((option) => option.label).join(this.props.separator || DEFAULT_SEPARATOR)
: selectedOptions[selectedOptions.length - 1].label;
- this.setState({
- rcValue: value,
+ const state: CascaderState = {
+ rcValue: { value, label: activeLabel },
focusCascade: true,
activeLabel,
- });
-
+ isSearching: false,
+ };
+ this.setState(state);
this.props.onSelect(selectedOptions[selectedOptions.length - 1].value);
};
//For select
onSelect = (obj: SelectableValue) => {
const valueArray = obj.value || [];
- this.setState({
- activeLabel: this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || '',
- rcValue: valueArray,
+ const activeLabel = this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || '';
+ const state: CascaderState = {
+ activeLabel: activeLabel,
+ rcValue: { value: valueArray, label: activeLabel },
isSearching: false,
- });
+ focusCascade: false,
+ };
+ this.setState(state);
this.props.onSelect(valueArray[valueArray.length - 1]);
};
diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx
index 1ea9e8e2163..7420a8034e0 100644
--- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx
+++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx
@@ -1,4 +1,4 @@
-import formatDuration from 'date-fns/formatDuration';
+import { formatDuration } from 'date-fns';
import React, { PureComponent } from 'react';
import { SelectableValue, parseDuration } from '@grafana/data';
diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx
index 8ce6c713d94..039e9bcac08 100644
--- a/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx
+++ b/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx
@@ -2,52 +2,28 @@ import { getNumCharsToDelete } from './suggestions';
describe('suggestions', () => {
describe('getNumCharsToDelete', () => {
- describe('when slateAutocomplete is enabled', () => {
- let originalBootData = window.grafanaBootData;
-
- // hacky way to enable the feature toggle for this test
- beforeEach(() => {
- if (originalBootData) {
- window.grafanaBootData = {
- ...originalBootData,
- settings: {
- ...originalBootData.settings,
- featureToggles: {
- ...originalBootData.settings.featureToggles,
- slateAutocomplete: true,
- },
- },
- };
- }
- });
-
- afterEach(() => {
- window.grafanaBootData = originalBootData;
- });
-
- const splunkCleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%:\\]/g, '').trim();
- it.each([
- // | represents the caret position
- ['$query0 ', '', '', false, 0, undefined, { forward: 0, backward: 0 }], // "|" --> "$query0 |"
- ['$query0 ', '$que', '$que', false, 0, undefined, { forward: 0, backward: 4 }], // "$que|" --> "$query0 |"
- ['$query0 ', '$q', '$que', false, 0, undefined, { forward: 2, backward: 2 }], // "$q|ue" --> "$query0 |"
- ['$query0 ', '$que', '($que)', false, 0, splunkCleanText, { forward: 0, backward: 4 }], // "($que|)" --> "($query0 |)"
- ['$query0 ', '$que', 'esarvotionUsagePercent=$que', false, 0, undefined, { forward: 0, backward: 4 }], // "esarvotionUsagePercent=$que|" --> "esarvotionUsagePercent=$query0 |"
- ])(
- 'should calculate the correct number of characters to delete forwards and backwards',
- (suggestionText, typeaheadPrefix, typeaheadText, preserveSuffix, deleteBackwards, cleanText, expected) => {
- expect(
- getNumCharsToDelete(
- suggestionText,
- typeaheadPrefix,
- typeaheadText,
- preserveSuffix,
- deleteBackwards,
- cleanText
- )
- ).toEqual(expected);
- }
- );
- });
+ const splunkCleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%:\\]/g, '').trim();
+ it.each([
+ // | represents the caret position
+ ['$query0 ', '', '', false, 0, undefined, { forward: 0, backward: 0 }], // "|" --> "$query0 |"
+ ['$query0 ', '$que', '$que', false, 0, undefined, { forward: 0, backward: 4 }], // "$que|" --> "$query0 |"
+ ['$query0 ', '$q', '$que', false, 0, undefined, { forward: 2, backward: 2 }], // "$q|ue" --> "$query0 |"
+ ['$query0 ', '$que', '($que)', false, 0, splunkCleanText, { forward: 0, backward: 4 }], // "($que|)" --> "($query0 |)"
+ ['$query0 ', '$que', 'esarvotionUsagePercent=$que', false, 0, undefined, { forward: 0, backward: 4 }], // "esarvotionUsagePercent=$que|" --> "esarvotionUsagePercent=$query0 |"
+ ])(
+ 'should calculate the correct number of characters to delete forwards and backwards',
+ (suggestionText, typeaheadPrefix, typeaheadText, preserveSuffix, deleteBackwards, cleanText, expected) => {
+ expect(
+ getNumCharsToDelete(
+ suggestionText,
+ typeaheadPrefix,
+ typeaheadText,
+ preserveSuffix,
+ deleteBackwards,
+ cleanText
+ )
+ ).toEqual(expected);
+ }
+ );
});
});
diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.tsx
index 9ef22285578..917895c437f 100644
--- a/packages/grafana-ui/src/slate-plugins/suggestions.tsx
+++ b/packages/grafana-ui/src/slate-plugins/suggestions.tsx
@@ -360,18 +360,10 @@ export function getNumCharsToDelete(
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
const offset = typeaheadText.indexOf(typeaheadPrefix);
- let forward: number;
-
- if (window.grafanaBootData?.settings.featureToggles['slateAutocomplete']) {
- const suffixLength =
- offset > -1 ? text.length - offset - typeaheadPrefix.length : text.length - typeaheadPrefix.length;
- const midWord = Boolean((typeaheadPrefix && suffixLength > 0) || suggestionText === typeaheadText);
- forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
- } else {
- const suffixLength = text.length - typeaheadPrefix.length;
- const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
- forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
- }
+ const suffixLength =
+ offset > -1 ? text.length - offset - typeaheadPrefix.length : text.length - typeaheadPrefix.length;
+ const midWord = Boolean((typeaheadPrefix && suffixLength > 0) || suggestionText === typeaheadText);
+ const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
return {
forward,
diff --git a/pkg/aggregator/aggregator.go b/pkg/aggregator/aggregator.go
deleted file mode 100644
index 74bc2118847..00000000000
--- a/pkg/aggregator/aggregator.go
+++ /dev/null
@@ -1,628 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/aggregator.go
-// Provenance-includes-license: Apache-2.0
-// Provenance-includes-copyright: The Kubernetes Authors.
-// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go
-// Provenance-includes-license: Apache-2.0
-// Provenance-includes-copyright: The Kubernetes Authors.
-// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/pkg/controlplane/apiserver/apiextensions.go
-// Provenance-includes-license: Apache-2.0
-// Provenance-includes-copyright: The Kubernetes Authors.
-
-package aggregator
-
-import (
- "crypto/tls"
- "fmt"
- "io"
- "net"
- "net/http"
- "path"
- "strings"
- "sync"
- "time"
-
- servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1"
- serviceclientset "github.com/grafana/grafana/pkg/generated/clientset/versioned"
- informersv0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions"
- "github.com/grafana/grafana/pkg/registry/apis/service"
- grafanaAPIServer "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- filestorage "github.com/grafana/grafana/pkg/services/grafana-apiserver/storage/file"
-
- apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
- apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver"
- apiextensionsinformers "k8s.io/apiextensions-apiserver/pkg/client/informers/externalversions"
- apiextensionsopenapi "k8s.io/apiextensions-apiserver/pkg/generated/openapi"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apimachinery/pkg/runtime/serializer"
- utilnet "k8s.io/apimachinery/pkg/util/net"
- "k8s.io/apimachinery/pkg/util/sets"
- "k8s.io/apiserver/pkg/endpoints/discovery/aggregated"
- openapinamer "k8s.io/apiserver/pkg/endpoints/openapi"
- genericfeatures "k8s.io/apiserver/pkg/features"
- genericapiserver "k8s.io/apiserver/pkg/server"
- "k8s.io/apiserver/pkg/server/healthz"
- "k8s.io/apiserver/pkg/server/options"
- "k8s.io/apiserver/pkg/server/resourceconfig"
- utilfeature "k8s.io/apiserver/pkg/util/feature"
- "k8s.io/apiserver/pkg/util/openapi"
- "k8s.io/client-go/informers"
- "k8s.io/client-go/kubernetes/fake"
- "k8s.io/client-go/tools/cache"
- "k8s.io/klog/v2"
- v1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
- v1helper "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1/helper"
- apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1"
- aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver"
- aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme"
- apiregistrationclientset "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
- apiregistrationclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1"
- apiregistrationInformers "k8s.io/kube-aggregator/pkg/client/informers/externalversions/apiregistration/v1"
- "k8s.io/kube-aggregator/pkg/controllers/autoregister"
- apiserver "k8s.io/kube-aggregator/pkg/controllers/status"
- aggregatoropenapi "k8s.io/kube-aggregator/pkg/generated/openapi"
- "k8s.io/kube-openapi/pkg/common"
-)
-
-// AggregatorServerOptions contains the state for the aggregator apiserver
-type AggregatorServerOptions struct {
- Builders []grafanaAPIServer.APIGroupBuilder
- AlternateDNS []string
- Config *Config
- serviceResolver ServiceResolver
-
- sharedInformerFactory informersv0alpha1.SharedInformerFactory
-
- StdOut io.Writer
- StdErr io.Writer
-}
-
-func NewAggregatorServerOptions(out, errOut io.Writer,
- options *options.RecommendedOptions,
- extraConfig *ExtraConfig,
-) (*AggregatorServerOptions, error) {
- sharedConfig, err := initSharedConfig(options, aggregatorscheme.Codecs, nil)
- if err != nil {
- klog.Errorf("Error creating shared config: %s", err)
- return nil, err
- }
-
- sharedInformerFactory, err := initSharedInformerFactory(sharedConfig)
- if err != nil {
- klog.Errorf("Error creating shared informer factory: %s", err)
- return nil, err
- }
-
- serviceResolver, err := initServiceResolver(sharedInformerFactory)
- if err != nil {
- klog.Errorf("Error creating service resolver: %s", err)
- return nil, err
- }
-
- fakeInformers := informers.NewSharedInformerFactory(fake.NewSimpleClientset(), 10*time.Minute)
- builders := []grafanaAPIServer.APIGroupBuilder{
- service.NewServiceAPIBuilder(),
- }
-
- extensionsConfig, err := initApiExtensionsConfig(options, sharedConfig, fakeInformers, serviceResolver, extraConfig.DataPath)
- if err != nil {
- klog.Errorf("Error creating extensions config: %s", err)
- return nil, err
- }
-
- aggregatorConfig, err := initAggregatorConfig(options, sharedConfig, extraConfig, fakeInformers, builders, serviceResolver, extraConfig.DataPath)
- if err != nil {
- klog.Errorf("Error creating aggregator config: %s", err)
- return nil, err
- }
-
- return &AggregatorServerOptions{
- StdOut: out,
- StdErr: errOut,
- Builders: builders,
- sharedInformerFactory: sharedInformerFactory,
- serviceResolver: serviceResolver,
- Config: &Config{
- Aggregator: aggregatorConfig,
- ApiExtensions: extensionsConfig,
-
- SharedConfig: sharedConfig,
- extraConfig: extraConfig,
- },
- }, nil
-}
-
-func (o *AggregatorServerOptions) LoadAPIGroupBuilders() error {
- // Install schemas
- for _, b := range o.Builders {
- if err := b.InstallSchema(aggregatorscheme.Scheme); err != nil {
- return err
- }
- }
- return nil
-}
-
-func initSharedConfig(options *options.RecommendedOptions, codecs serializer.CodecFactory, alternateDNS []string) (*genericapiserver.RecommendedConfig, error) {
- if err := options.SecureServing.MaybeDefaultWithSelfSignedCerts(
- "localhost", alternateDNS, []net.IP{net.IPv4(127, 0, 0, 1)},
- ); err != nil {
- return nil, fmt.Errorf("error creating self-signed certificates: %v", err)
- }
-
- options.Authentication.RemoteKubeConfigFileOptional = true
- options.Authorization.RemoteKubeConfigFileOptional = true
-
- options.Admission = nil
-
- if options.CoreAPI.CoreAPIKubeconfigPath == "" {
- options.CoreAPI = nil
- }
-
- serverConfig := genericapiserver.NewRecommendedConfig(codecs)
-
- // NOTE: AggregatedDiscoveryGroupManager in kube-apiserver is set up by controlplane APIServerConfig creation
- // Here, we adopt that one line in addition to what recommendedOptions gives us
- // Without it, CRDs work on API routes (and are registered in openapi) but not discoverable by kubectl
- serverConfig.AggregatedDiscoveryGroupManager = aggregated.NewResourceManager("apis")
-
- if options.CoreAPI == nil {
- if err := modifiedApplyTo(options, serverConfig); err != nil {
- return nil, err
- }
- } else {
- if err := options.ApplyTo(serverConfig); err != nil {
- return nil, err
- }
- }
-
- return serverConfig, nil
-}
-
-// A copy of ApplyTo in recommended.go, but for >= 0.28, server pkg in apiserver does a bit extra causing
-// a panic when CoreAPI is set to nil
-func modifiedApplyTo(options *options.RecommendedOptions, config *genericapiserver.RecommendedConfig) error {
- if err := options.Etcd.ApplyTo(&config.Config); err != nil {
- return err
- }
- if err := options.EgressSelector.ApplyTo(&config.Config); err != nil {
- return err
- }
- if err := options.Traces.ApplyTo(config.Config.EgressSelector, &config.Config); err != nil {
- return err
- }
- if err := options.SecureServing.ApplyTo(&config.Config.SecureServing, &config.Config.LoopbackClientConfig); err != nil {
- return err
- }
- if err := options.Authentication.ApplyTo(&config.Config.Authentication, config.SecureServing, config.OpenAPIConfig); err != nil {
- return err
- }
- if err := options.Authorization.ApplyTo(&config.Config.Authorization); err != nil {
- return err
- }
- if err := options.Audit.ApplyTo(&config.Config); err != nil {
- return err
- }
-
- // TODO: determine whether we need flow control (API priority and fairness)
- //if err := options.Features.ApplyTo(&config.Config); err != nil {
- // return err
- //}
-
- if err := options.CoreAPI.ApplyTo(config); err != nil {
- return err
- }
-
- _, err := options.ExtraAdmissionInitializers(config)
- if err != nil {
- return err
- }
- return nil
-}
-
-func getMergedOpenAPIDefinitions(builders []grafanaAPIServer.APIGroupBuilder, ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
- // Add OpenAPI specs for each group+version
- prerequisiteAPIs := grafanaAPIServer.GetOpenAPIDefinitions(builders)(ref)
- aggregatorAPIs := aggregatoropenapi.GetOpenAPIDefinitions(ref)
-
- for k, v := range prerequisiteAPIs {
- aggregatorAPIs[k] = v
- }
-
- return aggregatorAPIs
-}
-
-func initSharedInformerFactory(sharedConfig *genericapiserver.RecommendedConfig) (informersv0alpha1.SharedInformerFactory, error) {
- serviceClient, err := serviceclientset.NewForConfig(sharedConfig.LoopbackClientConfig)
- if err != nil {
- return nil, err
- }
- return informersv0alpha1.NewSharedInformerFactory(
- serviceClient,
- 5*time.Minute, // this is effectively used as a refresh interval right now. Might want to do something nicer later on.
- ), nil
-}
-
-func initServiceResolver(factory informersv0alpha1.SharedInformerFactory) (apiserver.ServiceResolver, error) {
- return NewExternalNameResolver(factory.Service().V0alpha1().ExternalNames().Lister()), nil
-}
-
-func initApiExtensionsConfig(options *options.RecommendedOptions,
- sharedConfig *genericapiserver.RecommendedConfig,
- fakeInfomers informers.SharedInformerFactory,
- serviceResolver apiserver.ServiceResolver,
- dataPath string,
-) (*apiextensionsapiserver.Config, error) {
- // make a shallow copy to let us twiddle a few things
- // most of the config actually remains the same. We only need to mess with a couple items related to the particulars of the api extensions
- genericConfig := sharedConfig.Config
-
- genericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{}
- genericConfig.RESTOptionsGetter = nil
-
- // copy the etcd options so we don't mutate originals.
- // we assume that the etcd options have been completed already. avoid messing with anything outside
- // of changes to StorageConfig as that may lead to unexpected behavior when the options are applied.
- etcdOptions := *options.Etcd
- // this is where the true decodable levels come from.
- etcdOptions.StorageConfig.Codec = apiextensionsapiserver.Codecs.LegacyCodec(apiextensionsv1beta1.SchemeGroupVersion, v1.SchemeGroupVersion)
- // prefer the more compact serialization (v1beta1) for storage until https://issue.k8s.io/82292 is resolved for objects whose v1 serialization is too big but whose v1beta1 serialization can be stored
- etcdOptions.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(apiextensionsv1beta1.SchemeGroupVersion, schema.GroupKind{Group: apiextensionsv1beta1.GroupName})
- etcdOptions.SkipHealthEndpoints = true // avoid double wiring of health checks
- if err := etcdOptions.ApplyTo(&genericConfig); err != nil {
- return nil, err
- }
-
- restOptionsGetter := filestorage.NewRESTOptionsGetter(path.Join(dataPath, "grafana-apiextensionsserver"), etcdOptions.StorageConfig)
- genericConfig.RESTOptionsGetter = restOptionsGetter
-
- // NOTE: ignoring genericConfig.ResourceTransformers in crdOptionsGetter creation for now
- // crdOptionsGetter := apiextensionsoptions.NewCRDRESTOptionsGetter(etcdOptions, genericConfig.ResourceTransformers, )
- // The following is equivalent code to apiextensionsoptions.NewCRDRESTOptionsGetter with lesser dependencies
- crdEtcdOptions := etcdOptions
- crdEtcdOptions.StorageConfig.Codec = unstructured.UnstructuredJSONScheme
- crdEtcdOptions.StorageConfig.StorageObjectCountTracker = genericConfig.StorageObjectCountTracker
- crdEtcdOptions.WatchCacheSizes = nil // this control is not provided for custom resources
-
- // override MergedResourceConfig with apiextensions defaults and registry
- mergedResourceConfig, err := resourceconfig.MergeAPIResourceConfigs(apiextensionsapiserver.DefaultAPIResourceConfigSource(), nil, apiextensionsapiserver.Scheme)
- if err != nil {
- return nil, err
- }
- genericConfig.MergedResourceConfig = mergedResourceConfig
-
- genericConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(apiextensionsopenapi.GetOpenAPIDefinitions), openapinamer.NewDefinitionNamer(apiextensionsapiserver.Scheme, apiextensionsapiserver.Scheme))
-
- apiextensionsConfig := &apiextensionsapiserver.Config{
- GenericConfig: &genericapiserver.RecommendedConfig{
- Config: genericConfig,
- SharedInformerFactory: fakeInfomers,
- },
- ExtraConfig: apiextensionsapiserver.ExtraConfig{
- CRDRESTOptionsGetter: filestorage.NewRESTOptionsGetter(path.Join(dataPath, "grafana-apiextensionsserver"), crdEtcdOptions.StorageConfig),
- // TODO: remove the hardcod when HA story is more developed
- MasterCount: 1,
- // TODO: leaving AuthResolverWrapper unset doesn't impact basic operation of CRDs
- // AuthResolverWrapper: authResolverWrapper,
- ServiceResolver: serviceResolver,
- },
- }
-
- // we need to clear the poststarthooks so we don't add them multiple times to all the servers (that fails)
- apiextensionsConfig.GenericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{}
-
- return apiextensionsConfig, nil
-}
-
-func initAggregatorConfig(options *options.RecommendedOptions,
- sharedConfig *genericapiserver.RecommendedConfig,
- extra *ExtraConfig,
- fakeInformers informers.SharedInformerFactory,
- builders []grafanaAPIServer.APIGroupBuilder,
- serviceResolver apiserver.ServiceResolver,
- dataPath string,
-) (*aggregatorapiserver.Config, error) {
- // make a shallow copy to let us twiddle a few things
- // most of the config actually remains the same. We only need to mess with a couple items related to the particulars of the aggregator
- genericConfig := sharedConfig.Config
-
- genericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{}
- genericConfig.RESTOptionsGetter = nil
- // prevent generic API server from installing the OpenAPI handler. Aggregator server
- // has its own customized OpenAPI handler.
- genericConfig.SkipOpenAPIInstallation = true
- mergedResourceConfig, err := resourceconfig.MergeAPIResourceConfigs(aggregatorapiserver.DefaultAPIResourceConfigSource(), nil, aggregatorscheme.Scheme)
- if err != nil {
- return nil, err
- }
- genericConfig.MergedResourceConfig = mergedResourceConfig
-
- getOpenAPIDefinitionsFunc := func() common.GetOpenAPIDefinitions {
- return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
- return getMergedOpenAPIDefinitions(builders, ref)
- }
- }
-
- namer := openapinamer.NewDefinitionNamer(aggregatorscheme.Scheme, apiextensionsapiserver.Scheme)
- genericConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(getOpenAPIDefinitionsFunc(), namer)
- genericConfig.OpenAPIV3Config.Info.Title = "Kubernetes"
- genericConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(getOpenAPIDefinitionsFunc(), namer)
- genericConfig.OpenAPIConfig.Info.Title = "Kubernetes"
-
- if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.StorageVersionAPI) &&
- utilfeature.DefaultFeatureGate.Enabled(genericfeatures.APIServerIdentity) {
- // Add StorageVersionPrecondition handler to aggregator-apiserver.
- // The handler will block write requests to built-in resources until the
- // target resources' storage versions are up-to-date.
- genericConfig.BuildHandlerChainFunc = genericapiserver.BuildHandlerChainWithStorageVersionPrecondition
- }
-
- // copy the etcd options so we don't mutate originals.
- // we assume that the etcd options have been completed already. avoid messing with anything outside
- // of changes to StorageConfig as that may lead to unexpected behavior when the options are applied.
- etcdOptions := *options.Etcd
- etcdOptions.StorageConfig.Codec = aggregatorscheme.Codecs.LegacyCodec(v1.SchemeGroupVersion,
- apiregistrationv1beta1.SchemeGroupVersion,
- servicev0alpha1.SchemeGroupVersion)
- etcdOptions.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(v1.SchemeGroupVersion,
- schema.GroupKind{Group: apiregistrationv1beta1.GroupName},
- schema.GroupKind{Group: servicev0alpha1.GROUP})
- etcdOptions.SkipHealthEndpoints = true // avoid double wiring of health checks
- if err := etcdOptions.ApplyTo(&genericConfig); err != nil {
- return nil, err
- }
- genericConfig.RESTOptionsGetter = filestorage.NewRESTOptionsGetter(path.Join(dataPath, "grafana-aggregator"), etcdOptions.StorageConfig)
-
- genericConfig.DisabledPostStartHooks = genericConfig.DisabledPostStartHooks.Insert("apiservice-status-available-controller")
- genericConfig.DisabledPostStartHooks = genericConfig.DisabledPostStartHooks.Insert("start-kube-aggregator-informers")
-
- aggregatorConfig := &aggregatorapiserver.Config{
- GenericConfig: &genericapiserver.RecommendedConfig{
- Config: genericConfig,
- SharedInformerFactory: fakeInformers,
- ClientConfig: genericConfig.LoopbackClientConfig,
- },
- ExtraConfig: aggregatorapiserver.ExtraConfig{
- ProxyClientCertFile: extra.ProxyClientCertFile,
- ProxyClientKeyFile: extra.ProxyClientKeyFile,
- // NOTE: while ProxyTransport can be skipped in the configuration, it allows honoring
- // DISABLE_HTTP2, HTTPS_PROXY and NO_PROXY env vars as needed
- ProxyTransport: createProxyTransport(),
- },
- }
-
- aggregatorConfig.ExtraConfig.ServiceResolver = serviceResolver
-
- // we need to clear the poststarthooks so we don't add them multiple times to all the servers (that fails)
- aggregatorConfig.GenericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{}
-
- return aggregatorConfig, nil
-}
-
-func (o *AggregatorServerOptions) CreateAggregatorServer(delegateAPIServer genericapiserver.DelegationTarget, apiExtensionsInformers apiextensionsinformers.SharedInformerFactory) (*aggregatorapiserver.APIAggregator, error) {
- completedConfig := o.Config.AggregatorComplete
- aggregatorServer, err := completedConfig.NewWithDelegate(delegateAPIServer)
- if err != nil {
- return nil, err
- }
-
- // create controllers for auto-registration
- apiRegistrationClient, err := apiregistrationclient.NewForConfig(completedConfig.GenericConfig.LoopbackClientConfig)
- if err != nil {
- return nil, err
- }
-
- autoRegistrationController := autoregister.NewAutoRegisterController(aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(), apiRegistrationClient)
- apiServices := apiServicesToRegister(delegateAPIServer, autoRegistrationController)
-
- crdRegistrationController := NewCRDRegistrationController(
- apiExtensionsInformers.Apiextensions().V1().CustomResourceDefinitions(),
- autoRegistrationController)
-
- // Imbue all builtin group-priorities onto the aggregated discovery
- if completedConfig.GenericConfig.AggregatedDiscoveryGroupManager != nil {
- for gv, entry := range apiVersionPriorities {
- completedConfig.GenericConfig.AggregatedDiscoveryGroupManager.SetGroupVersionPriority(metav1.GroupVersion(gv), int(entry.group), int(entry.version))
- }
- }
-
- err = aggregatorServer.GenericAPIServer.AddPostStartHook("kube-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error {
- go crdRegistrationController.Run(5, context.StopCh)
- go func() {
- crdRegistrationController.WaitForInitialSync()
- autoRegistrationController.Run(5, context.StopCh)
- }()
- return nil
- })
- if err != nil {
- return nil, err
- }
-
- err = aggregatorServer.GenericAPIServer.AddBootSequenceHealthChecks(
- makeAPIServiceAvailableHealthCheck(
- "autoregister-completion",
- apiServices,
- aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(),
- ),
- )
- if err != nil {
- return nil, err
- }
-
- apiregistrationClient, err := apiregistrationclientset.NewForConfig(completedConfig.GenericConfig.LoopbackClientConfig)
- if err != nil {
- return nil, err
- }
-
- availableController, err := NewAvailableConditionController(
- aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(),
- o.sharedInformerFactory.Service().V0alpha1().ExternalNames(),
- apiregistrationClient.ApiregistrationV1(),
- nil,
- (func() ([]byte, []byte))(nil),
- completedConfig.ExtraConfig.ServiceResolver,
- )
- if err != nil {
- return nil, err
- }
-
- aggregatorServer.GenericAPIServer.AddPostStartHookOrDie("apiservice-status-override-available-controller", func(context genericapiserver.PostStartHookContext) error {
- // if we end up blocking for long periods of time, we may need to increase workers.
- go availableController.Run(5, context.StopCh)
- return nil
- })
-
- aggregatorServer.GenericAPIServer.AddPostStartHookOrDie("start-grafana-aggregator-informers", func(context genericapiserver.PostStartHookContext) error {
- o.sharedInformerFactory.Start(context.StopCh)
- aggregatorServer.APIRegistrationInformers.Start(context.StopCh)
- return nil
- })
-
- // Install the API Group+version
- for _, b := range o.Builders {
- g, err := b.GetAPIGroupInfo(aggregatorscheme.Scheme, aggregatorscheme.Codecs, completedConfig.GenericConfig.RESTOptionsGetter)
- if err != nil {
- return nil, err
- }
- if g == nil || len(g.PrioritizedVersions) < 1 {
- continue
- }
- err = aggregatorServer.GenericAPIServer.InstallAPIGroup(g)
- if err != nil {
- return nil, err
- }
- }
-
- return aggregatorServer, nil
-}
-
-func makeAPIService(gv schema.GroupVersion) *v1.APIService {
- apiServicePriority, ok := apiVersionPriorities[gv]
- if !ok {
- // if we aren't found, then we shouldn't register ourselves because it could result in a CRD group version
- // being permanently stuck in the APIServices list.
- klog.Infof("Skipping APIService creation for %v", gv)
- return nil
- }
- return &v1.APIService{
- ObjectMeta: metav1.ObjectMeta{Name: gv.Version + "." + gv.Group},
- Spec: v1.APIServiceSpec{
- Group: gv.Group,
- Version: gv.Version,
- GroupPriorityMinimum: apiServicePriority.group,
- VersionPriority: apiServicePriority.version,
- },
- }
-}
-
-// makeAPIServiceAvailableHealthCheck returns a healthz check that returns healthy
-// once all of the specified services have been observed to be available at least once.
-func makeAPIServiceAvailableHealthCheck(name string, apiServices []*v1.APIService, apiServiceInformer apiregistrationInformers.APIServiceInformer) healthz.HealthChecker {
- // Track the auto-registered API services that have not been observed to be available yet
- pendingServiceNamesLock := &sync.RWMutex{}
- pendingServiceNames := sets.NewString()
- for _, service := range apiServices {
- pendingServiceNames.Insert(service.Name)
- }
-
- // When an APIService in the list is seen as available, remove it from the pending list
- handleAPIServiceChange := func(service *v1.APIService) {
- pendingServiceNamesLock.Lock()
- defer pendingServiceNamesLock.Unlock()
- if !pendingServiceNames.Has(service.Name) {
- return
- }
- if v1helper.IsAPIServiceConditionTrue(service, v1.Available) {
- pendingServiceNames.Delete(service.Name)
- }
- }
-
- // Watch add/update events for APIServices
- _, _ = apiServiceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
- AddFunc: func(obj interface{}) { handleAPIServiceChange(obj.(*v1.APIService)) },
- UpdateFunc: func(old, new interface{}) { handleAPIServiceChange(new.(*v1.APIService)) },
- })
-
- // Don't return healthy until the pending list is empty
- return healthz.NamedCheck(name, func(r *http.Request) error {
- pendingServiceNamesLock.RLock()
- defer pendingServiceNamesLock.RUnlock()
- if pendingServiceNames.Len() > 0 {
- return fmt.Errorf("missing APIService: %v", pendingServiceNames.List())
- }
- return nil
- })
-}
-
-// priority defines group priority that is used in discovery. This controls
-// group position in the kubectl output.
-type priority struct {
- // group indicates the order of the group relative to other groups.
- group int32
- // version indicates the relative order of the version inside of its group.
- version int32
-}
-
-// The proper way to resolve this letting the aggregator know the desired group and version-within-group order of the underlying servers
-// is to refactor the genericapiserver.DelegationTarget to include a list of priorities based on which APIs were installed.
-// This requires the APIGroupInfo struct to evolve and include the concept of priorities and to avoid mistakes, the core storage map there needs to be updated.
-// That ripples out every bit as far as you'd expect, so for 1.7 we'll include the list here instead of being built up during storage.
-var apiVersionPriorities = map[schema.GroupVersion]priority{
- {Group: "", Version: "v1"}: {group: 18000, version: 1},
- // to my knowledge, nothing below here collides
- {Group: "admissionregistration.k8s.io", Version: "v1"}: {group: 16700, version: 15},
- {Group: "admissionregistration.k8s.io", Version: "v1beta1"}: {group: 16700, version: 12},
- {Group: "admissionregistration.k8s.io", Version: "v1alpha1"}: {group: 16700, version: 9},
- {Group: "apiextensions.k8s.io", Version: "v1"}: {group: 16700, version: 15},
- // Append a new group to the end of the list if unsure.
- // You can use min(existing group)-100 as the initial value for a group.
- // Version can be set to 9 (to have space around) for a new group.
-}
-
-func apiServicesToRegister(delegateAPIServer genericapiserver.DelegationTarget, registration autoregister.AutoAPIServiceRegistration) []*v1.APIService {
- apiServices := []*v1.APIService{}
-
- for _, curr := range delegateAPIServer.ListedPaths() {
- if curr == "/api/v1" {
- apiService := makeAPIService(schema.GroupVersion{Group: "", Version: "v1"})
- registration.AddAPIServiceToSyncOnStart(apiService)
- apiServices = append(apiServices, apiService)
- continue
- }
-
- if !strings.HasPrefix(curr, "/apis/") {
- continue
- }
- // this comes back in a list that looks like /apis/rbac.authorization.k8s.io/v1alpha1
- tokens := strings.Split(curr, "/")
- if len(tokens) != 4 {
- continue
- }
-
- apiService := makeAPIService(schema.GroupVersion{Group: tokens[2], Version: tokens[3]})
- if apiService == nil {
- continue
- }
- registration.AddAPIServiceToSyncOnStart(apiService)
- apiServices = append(apiServices, apiService)
- }
-
- return apiServices
-}
-
-// NOTE: below function imported from https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go#L197
-// createProxyTransport creates the dialer infrastructure to connect to the api servers.
-func createProxyTransport() *http.Transport {
- // NOTE: We don't set proxyDialerFn but the below SetTransportDefaults will
- // See https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/net/http.go#L109
- var proxyDialerFn utilnet.DialFunc
- // Proxying to services is IP-based... don't expect to be able to verify the hostname
- proxyTLSClientConfig := &tls.Config{InsecureSkipVerify: true}
- proxyTransport := utilnet.SetTransportDefaults(&http.Transport{
- DialContext: proxyDialerFn,
- TLSClientConfig: proxyTLSClientConfig,
- })
- return proxyTransport
-}
diff --git a/pkg/aggregator/config.go b/pkg/aggregator/config.go
deleted file mode 100644
index e7bcd84e286..00000000000
--- a/pkg/aggregator/config.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package aggregator
-
-import (
- "github.com/spf13/pflag"
- apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver"
- genericapiserver "k8s.io/apiserver/pkg/server"
- "k8s.io/apiserver/pkg/server/options"
- aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver"
-)
-
-type ExtraConfig struct {
- ProxyClientCertFile string
- ProxyClientKeyFile string
-
- DataPath string
-}
-
-type Config struct {
- Aggregator *aggregatorapiserver.Config
- ApiExtensions *apiextensionsapiserver.Config
-
- AggregatorComplete aggregatorapiserver.CompletedConfig
- ApiExtensionsComplete apiextensionsapiserver.CompletedConfig
-
- recommendedOptions *options.RecommendedOptions
- SharedConfig *genericapiserver.RecommendedConfig
- extraConfig *ExtraConfig
-}
-
-func (c *Config) AddFlags(fs *pflag.FlagSet) {
- if c == nil {
- return
- }
-
- c.recommendedOptions.AddFlags(fs)
-}
-
-func (c *Config) Complete() {
- if c == nil {
- return
- }
-
- c.ApiExtensionsComplete = c.ApiExtensions.Complete()
- c.AggregatorComplete = c.Aggregator.Complete()
-}
-
-func (ec *ExtraConfig) AddFlags(fs *pflag.FlagSet) {
- if ec == nil {
- return
- }
-
- fs.StringVar(&ec.ProxyClientCertFile, "proxy-client-cert-file", ec.ProxyClientCertFile,
- "path to proxy client cert file")
-
- fs.StringVar(&ec.ProxyClientKeyFile, "proxy-client-key-file", ec.ProxyClientKeyFile,
- "path to proxy client cert file")
-}
diff --git a/pkg/aggregator/crdRegistrationController.go b/pkg/aggregator/crdRegistrationController.go
deleted file mode 100644
index f3bbdd3c85d..00000000000
--- a/pkg/aggregator/crdRegistrationController.go
+++ /dev/null
@@ -1,214 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/pkg/controlplane/controller/crdregistration/crdregistration_controller.go
-// Provenance-includes-license: Apache-2.0
-// Provenance-includes-copyright: The Kubernetes Authors.
-
-package aggregator
-
-import (
- "fmt"
- "time"
-
- "k8s.io/klog/v2"
-
- apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
- crdinformers "k8s.io/apiextensions-apiserver/pkg/client/informers/externalversions/apiextensions/v1"
- crdlisters "k8s.io/apiextensions-apiserver/pkg/client/listers/apiextensions/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/labels"
- "k8s.io/apimachinery/pkg/runtime/schema"
- utilruntime "k8s.io/apimachinery/pkg/util/runtime"
- "k8s.io/apimachinery/pkg/util/wait"
- "k8s.io/client-go/tools/cache"
- "k8s.io/client-go/util/workqueue"
- v1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
-)
-
-// AutoAPIServiceRegistration is an interface which callers can re-declare locally and properly cast to for
-// adding and removing APIServices
-type AutoAPIServiceRegistration interface {
- // AddAPIServiceToSync adds an API service to auto-register.
- AddAPIServiceToSync(in *v1.APIService)
- // RemoveAPIServiceToSync removes an API service to auto-register.
- RemoveAPIServiceToSync(name string)
-}
-
-type crdRegistrationController struct {
- crdLister crdlisters.CustomResourceDefinitionLister
- crdSynced cache.InformerSynced
-
- apiServiceRegistration AutoAPIServiceRegistration
-
- syncHandler func(groupVersion schema.GroupVersion) error
-
- syncedInitialSet chan struct{}
-
- // queue is where incoming work is placed to de-dup and to allow "easy" rate limited requeues on errors
- // this is actually keyed by a groupVersion
- queue workqueue.RateLimitingInterface
-}
-
-// NewCRDRegistrationController returns a controller which will register CRD GroupVersions with the auto APIService registration
-// controller so they automatically stay in sync.
-func NewCRDRegistrationController(crdinformer crdinformers.CustomResourceDefinitionInformer, apiServiceRegistration AutoAPIServiceRegistration) *crdRegistrationController {
- c := &crdRegistrationController{
- crdLister: crdinformer.Lister(),
- crdSynced: crdinformer.Informer().HasSynced,
- apiServiceRegistration: apiServiceRegistration,
- syncedInitialSet: make(chan struct{}),
- queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "crd_autoregistration_controller"),
- }
- c.syncHandler = c.handleVersionUpdate
-
- _, _ = crdinformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
- AddFunc: func(obj interface{}) {
- cast := obj.(*apiextensionsv1.CustomResourceDefinition)
- c.enqueueCRD(cast)
- },
- UpdateFunc: func(oldObj, newObj interface{}) {
- // Enqueue both old and new object to make sure we remove and add appropriate API services.
- // The working queue will resolve any duplicates and only changes will stay in the queue.
- c.enqueueCRD(oldObj.(*apiextensionsv1.CustomResourceDefinition))
- c.enqueueCRD(newObj.(*apiextensionsv1.CustomResourceDefinition))
- },
- DeleteFunc: func(obj interface{}) {
- cast, ok := obj.(*apiextensionsv1.CustomResourceDefinition)
- if !ok {
- tombstone, ok := obj.(cache.DeletedFinalStateUnknown)
- if !ok {
- klog.V(2).Infof("Couldn't get object from tombstone %#v", obj)
- return
- }
- cast, ok = tombstone.Obj.(*apiextensionsv1.CustomResourceDefinition)
- if !ok {
- klog.V(2).Infof("Tombstone contained unexpected object: %#v", obj)
- return
- }
- }
- c.enqueueCRD(cast)
- },
- })
-
- return c
-}
-
-func (c *crdRegistrationController) Run(workers int, stopCh <-chan struct{}) {
- defer utilruntime.HandleCrash()
- // make sure the work queue is shutdown which will trigger workers to end
- defer c.queue.ShutDown()
-
- klog.Infof("Starting crd-autoregister controller")
- defer klog.Infof("Shutting down crd-autoregister controller")
-
- // wait for your secondary caches to fill before starting your work
- if !cache.WaitForNamedCacheSync("crd-autoregister", stopCh, c.crdSynced) {
- return
- }
-
- // process each item in the list once
- if crds, err := c.crdLister.List(labels.Everything()); err != nil {
- utilruntime.HandleError(err)
- } else {
- for _, crd := range crds {
- for _, version := range crd.Spec.Versions {
- if err := c.syncHandler(schema.GroupVersion{Group: crd.Spec.Group, Version: version.Name}); err != nil {
- utilruntime.HandleError(err)
- }
- }
- }
- }
- close(c.syncedInitialSet)
-
- // start up your worker threads based on workers. Some controllers have multiple kinds of workers
- for i := 0; i < workers; i++ {
- // runWorker will loop until "something bad" happens. The .Until will then rekick the worker
- // after one second
- go wait.Until(c.runWorker, time.Second, stopCh)
- }
-
- // wait until we're told to stop
- <-stopCh
-}
-
-// WaitForInitialSync blocks until the initial set of CRD resources has been processed
-func (c *crdRegistrationController) WaitForInitialSync() {
- <-c.syncedInitialSet
-}
-
-func (c *crdRegistrationController) runWorker() {
- // hot loop until we're told to stop. processNextWorkItem will automatically wait until there's work
- // available, so we don't worry about secondary waits
- for c.processNextWorkItem() {
- }
-}
-
-// processNextWorkItem deals with one key off the queue. It returns false when it's time to quit.
-func (c *crdRegistrationController) processNextWorkItem() bool {
- // pull the next work item from queue. It should be a key we use to lookup something in a cache
- key, quit := c.queue.Get()
- if quit {
- return false
- }
- // you always have to indicate to the queue that you've completed a piece of work
- defer c.queue.Done(key)
-
- // do your work on the key. This method will contains your "do stuff" logic
- err := c.syncHandler(key.(schema.GroupVersion))
- if err == nil {
- // if you had no error, tell the queue to stop tracking history for your key. This will
- // reset things like failure counts for per-item rate limiting
- c.queue.Forget(key)
- return true
- }
-
- // there was a failure so be sure to report it. This method allows for pluggable error handling
- // which can be used for things like cluster-monitoring
- utilruntime.HandleError(fmt.Errorf("%v failed with : %v", key, err))
- // since we failed, we should requeue the item to work on later. This method will add a backoff
- // to avoid hotlooping on particular items (they're probably still not going to work right away)
- // and overall controller protection (everything I've done is broken, this controller needs to
- // calm down or it can starve other useful work) cases.
- c.queue.AddRateLimited(key)
-
- return true
-}
-
-func (c *crdRegistrationController) enqueueCRD(crd *apiextensionsv1.CustomResourceDefinition) {
- for _, version := range crd.Spec.Versions {
- c.queue.Add(schema.GroupVersion{Group: crd.Spec.Group, Version: version.Name})
- }
-}
-
-func (c *crdRegistrationController) handleVersionUpdate(groupVersion schema.GroupVersion) error {
- apiServiceName := groupVersion.Version + "." + groupVersion.Group
-
- // check all CRDs. There shouldn't that many, but if we have problems later we can index them
- crds, err := c.crdLister.List(labels.Everything())
- if err != nil {
- return err
- }
- for _, crd := range crds {
- if crd.Spec.Group != groupVersion.Group {
- continue
- }
- for _, version := range crd.Spec.Versions {
- if version.Name != groupVersion.Version || !version.Served {
- continue
- }
-
- c.apiServiceRegistration.AddAPIServiceToSync(&v1.APIService{
- ObjectMeta: metav1.ObjectMeta{Name: apiServiceName},
- Spec: v1.APIServiceSpec{
- Group: groupVersion.Group,
- Version: groupVersion.Version,
- GroupPriorityMinimum: 1000, // CRDs should have relatively low priority
- VersionPriority: 100, // CRDs will be sorted by kube-like versions like any other APIService with the same VersionPriority
- },
- })
- return nil
- }
- }
-
- c.apiServiceRegistration.RemoveAPIServiceToSync(apiServiceName)
- return nil
-}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 5ecc27e8029..2a857fbf65c 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -31,7 +31,6 @@ package api
import (
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/middleware/requestmeta"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -49,8 +48,6 @@ import (
"github.com/grafana/grafana/pkg/services/user"
)
-var plog = log.New("api")
-
// registerRoutes registers all API HTTP routes.
func (hs *HTTPServer) registerRoutes() {
reqNoAuth := middleware.NoAuth()
@@ -418,14 +415,6 @@ func (hs *HTTPServer) registerRoutes() {
pluginRoute.Get("/:pluginId/metrics", reqOrgAdmin, routing.Wrap(hs.CollectPluginMetrics))
})
- if hs.Features.IsEnabledGlobally(featuremgmt.FlagFeatureToggleAdminPage) {
- apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) {
- featuremgmtRoute.Get("/state", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureMgmtState)
- featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles)
- featuremgmtRoute.Post("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementWrite)), hs.UpdateFeatureToggle)
- })
- }
-
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Get("/frontend/assets", hs.GetFrontendAssets)
@@ -515,7 +504,7 @@ func (hs *HTTPServer) registerRoutes() {
// metrics
// DataSource w/ expressions
- apiRoute.Post("/ds/query", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.QueryMetricsV2))
+ apiRoute.Post("/ds/query", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.getDSQueryEndpoint())
apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) {
alertsRoute.Post("/test", routing.Wrap(hs.AlertTest))
diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go
index 2b0c5e5471b..67f4df9c7a7 100644
--- a/pkg/api/dashboard_snapshot.go
+++ b/pkg/api/dashboard_snapshot.go
@@ -1,32 +1,22 @@
package api
import (
- "bytes"
- "encoding/json"
"errors"
- "fmt"
"net/http"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
- "github.com/grafana/grafana/pkg/components/simplejson"
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/infra/metrics"
- "github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/guardian"
- "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
-var client = &http.Client{
- Timeout: time.Second * 5,
- Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
-}
-
// swagger:route GET /snapshot/shared-options snapshots getSharingOptions
//
// Get snapshot sharing settings.
@@ -43,58 +33,6 @@ func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) {
})
}
-type CreateExternalSnapshotResponse struct {
- Key string `json:"key"`
- DeleteKey string `json:"deleteKey"`
- Url string `json:"url"`
- DeleteUrl string `json:"deleteUrl"`
-}
-
-func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) {
- var createSnapshotResponse CreateExternalSnapshotResponse
- message := map[string]any{
- "name": cmd.Name,
- "expires": cmd.Expires,
- "dashboard": cmd.Dashboard,
- "key": cmd.Key,
- "deleteKey": cmd.DeleteKey,
- }
-
- messageBytes, err := simplejson.NewFromAny(message).Encode()
- if err != nil {
- return nil, err
- }
-
- resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
- if err != nil {
- return nil, err
- }
- defer func() {
- if err := resp.Body.Close(); err != nil {
- plog.Warn("Failed to close response body", "err", err)
- }
- }()
-
- if resp.StatusCode != 200 {
- return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode)
- }
-
- if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil {
- return nil, err
- }
-
- return &createSnapshotResponse, nil
-}
-
-func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) {
- dashUID := cmd.Dashboard.Get("uid").MustString("")
- if ok := util.IsValidShortUID(dashUID); !ok {
- return "", fmt.Errorf("invalid dashboard UID")
- }
-
- return fmt.Sprintf("/d/%v", dashUID), nil
-}
-
// swagger:route POST /snapshots snapshots createDashboardSnapshot
//
// When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.
@@ -106,95 +44,13 @@ func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotC
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
-func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) response.Response {
- if !hs.Cfg.SnapshotEnabled {
- c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
- return nil
- }
-
- cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{}
- if err := web.Bind(c.Req, &cmd); err != nil {
- return response.Error(http.StatusBadRequest, "bad request data", err)
- }
- if cmd.Name == "" {
- cmd.Name = "Unnamed snapshot"
- }
-
- userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
- if err != nil {
- return response.Error(http.StatusInternalServerError,
- "Failed to create external snapshot", err)
- }
-
- var snapshotUrl string
- cmd.ExternalURL = ""
- cmd.OrgID = c.SignedInUser.GetOrgID()
- cmd.UserID = userID
- originalDashboardURL, err := createOriginalDashboardURL(&cmd)
- if err != nil {
- return response.Error(http.StatusInternalServerError, "Invalid app URL", err)
- }
-
- if cmd.External {
- if !hs.Cfg.ExternalEnabled {
- c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
- return nil
- }
-
- resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl)
- if err != nil {
- c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
- return nil
- }
-
- snapshotUrl = resp.Url
- cmd.Key = resp.Key
- cmd.DeleteKey = resp.DeleteKey
- cmd.ExternalURL = resp.Url
- cmd.ExternalDeleteURL = resp.DeleteUrl
- cmd.Dashboard = simplejson.New()
-
- metrics.MApiDashboardSnapshotExternal.Inc()
- } else {
- cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL)
-
- if cmd.Key == "" {
- var err error
- cmd.Key, err = util.GetRandomString(32)
- if err != nil {
- c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
- return nil
- }
- }
-
- if cmd.DeleteKey == "" {
- var err error
- cmd.DeleteKey, err = util.GetRandomString(32)
- if err != nil {
- c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
- return nil
- }
- }
-
- snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
-
- metrics.MApiDashboardSnapshotCreate.Inc()
- }
-
- result, err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd)
- if err != nil {
- c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
- return nil
- }
-
- c.JSON(http.StatusOK, util.DynMap{
- "key": cmd.Key,
- "deleteKey": cmd.DeleteKey,
- "url": snapshotUrl,
- "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
- "id": result.ID,
- })
- return nil
+func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) {
+ dashboardsnapshots.CreateDashboardSnapshot(c, dashboardsnapshot.SnapshotSharingOptions{
+ SnapshotsEnabled: hs.Cfg.SnapshotEnabled,
+ ExternalEnabled: hs.Cfg.ExternalEnabled,
+ ExternalSnapshotName: hs.Cfg.ExternalSnapshotName,
+ ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl,
+ }, hs.dashboardsnapshotsService)
}
// GET /api/snapshots/:key
@@ -247,38 +103,6 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *contextmodel.ReqContext) response.
return response.JSON(http.StatusOK, dto).SetHeader("Cache-Control", "public, max-age=3600")
}
-func deleteExternalDashboardSnapshot(externalUrl string) error {
- resp, err := client.Get(externalUrl)
- if err != nil {
- return err
- }
-
- defer func() {
- if err := resp.Body.Close(); err != nil {
- plog.Warn("Failed to close response body", "err", err)
- }
- }()
-
- if resp.StatusCode == 200 {
- return nil
- }
-
- // Gracefully ignore "snapshot not found" errors as they could have already
- // been removed either via the cleanup script or by request.
- if resp.StatusCode == 500 {
- var respJson map[string]any
- if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil {
- return err
- }
-
- if respJson["message"] == "Failed to get dashboard snapshot" {
- return nil
- }
- }
-
- return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode)
-}
-
// swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey
//
// Delete Snapshot by deleteKey.
@@ -302,28 +126,16 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *contextmodel.ReqCont
return response.Error(404, "Snapshot not found", nil)
}
- query := &dashboardsnapshots.GetDashboardSnapshotQuery{DeleteKey: key}
- queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
+ err := dashboardsnapshots.DeleteWithKey(c.Req.Context(), key, hs.dashboardsnapshotsService)
if err != nil {
- return response.Err(err)
- }
-
- if queryResult.External {
- err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
- if err != nil {
- return response.Error(500, "Failed to delete external dashboard", err)
+ if errors.Is(err, dashboardsnapshots.ErrBaseNotFound) {
+ return response.Error(404, "Snapshot not found", err)
}
- }
-
- cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
-
- if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
return response.JSON(http.StatusOK, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
- "id": queryResult.ID,
})
}
@@ -357,8 +169,13 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon
return response.Error(http.StatusNotFound, "Failed to get dashboard snapshot", nil)
}
+ // TODO: enforce org ID same
+ // if queryResult.OrgID != c.OrgID {
+ // return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil)
+ // }
+
if queryResult.External {
- err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
+ err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err)
}
diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go
index d8023a9e83e..3fd0553610e 100644
--- a/pkg/api/dashboard_snapshot_test.go
+++ b/pkg/api/dashboard_snapshot_test.go
@@ -9,13 +9,14 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web/webtest"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
@@ -148,12 +149,11 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
- require.Equal(t, 200, sc.resp.Code)
+ require.Equal(t, 200, sc.resp.Code, "BODY: "+sc.resp.Body.String())
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
require.NoError(t, err)
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
- assert.Equal(t, 1, respJSON.Get("id").MustInt())
assert.Equal(t, http.MethodGet, externalRequest.Method)
assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host))
@@ -271,7 +271,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
- assert.Equal(t, http.StatusNotFound, sc.resp.Code)
+ assert.Equal(t, http.StatusNotFound, sc.resp.Code, "BODY: "+sc.resp.Body.String())
}, sqlmock)
loggedInUserScenarioWithRole(t,
@@ -282,7 +282,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
- assert.Equal(t, http.StatusNotFound, sc.resp.Code)
+ assert.Equal(t, http.StatusNotFound, sc.resp.Code, "BODY: "+sc.resp.Body.String())
}, sqlmock)
}
@@ -345,7 +345,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) {
sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
- assert.Equal(t, http.StatusForbidden, sc.resp.Code)
+ assert.Equal(t, http.StatusForbidden, sc.resp.Code, "BODY: "+sc.resp.Body.String())
}, sqlmock)
loggedInUserScenarioWithRole(t,
@@ -356,7 +356,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) {
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
- assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
+ assert.Equal(t, http.StatusInternalServerError, sc.resp.Code, "BODY: "+sc.resp.Body.String())
}, sqlmock)
loggedInUserScenarioWithRole(t,
@@ -367,7 +367,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) {
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
- assert.Equal(t, http.StatusForbidden, sc.resp.Code)
+ assert.Equal(t, http.StatusForbidden, sc.resp.Code, "BODY: "+sc.resp.Body.String())
}, sqlmock)
}
@@ -391,6 +391,7 @@ func setUpSnapshotTest(t *testing.T, userId int64, deleteUrl string) dashboardsn
res := &dashboardsnapshots.DashboardSnapshot{
ID: 1,
+ OrgID: 1,
Key: "12345",
DeleteKey: "54321",
Dashboard: jsonModel,
diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go
index c0291f65c30..8933f8b49bb 100644
--- a/pkg/api/dtos/models.go
+++ b/pkg/api/dtos/models.go
@@ -30,6 +30,7 @@ type LoginCommand struct {
type CurrentUser struct {
IsSignedIn bool `json:"isSignedIn"`
Id int64 `json:"id"`
+ UID string `json:"uid"`
Login string `json:"login"`
Email string `json:"email"`
Name string `json:"name"`
diff --git a/pkg/api/featuremgmt.go b/pkg/api/featuremgmt.go
deleted file mode 100644
index 81d60909b38..00000000000
--- a/pkg/api/featuremgmt.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "sort"
- "strconv"
-
- "github.com/grafana/grafana/pkg/api/response"
- "github.com/grafana/grafana/pkg/infra/log"
- contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/setting"
- "github.com/grafana/grafana/pkg/web"
-)
-
-func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.Response {
- // object being returned
- dtos := make([]featuremgmt.FeatureToggleDTO, 0)
-
- // loop through features an add features that should be visible to dtos
- for _, ft := range hs.featureManager.GetFlags() {
- flag := ft.Name
- if hs.featureManager.IsHiddenFromAdminPage(flag, false) {
- continue
- }
- dto := featuremgmt.FeatureToggleDTO{
- Name: flag,
- Description: ft.Description,
- Enabled: hs.featureManager.IsEnabled(ctx.Req.Context(), flag),
- ReadOnly: !hs.featureManager.IsEditableFromAdminPage(flag),
- }
-
- dtos = append(dtos, dto)
- sort.Slice(dtos, func(i, j int) bool {
- return dtos[i].Name < dtos[j].Name
- })
- }
-
- return response.JSON(http.StatusOK, dtos)
-}
-
-func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response.Response {
- featureMgmtCfg := hs.featureManager.Settings
- if !featureMgmtCfg.AllowEditing {
- return response.Error(http.StatusForbidden, "feature toggles are read-only", fmt.Errorf("feature toggles are configured to be read-only"))
- }
-
- if featureMgmtCfg.UpdateWebhook == "" {
- return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_webhook is not set"))
- }
-
- cmd := featuremgmt.UpdateFeatureTogglesCommand{}
- if err := web.Bind(ctx.Req, &cmd); err != nil {
- return response.Error(http.StatusBadRequest, "bad request data", err)
- }
-
- payload := UpdatePayload{
- FeatureToggles: make(map[string]string, len(cmd.FeatureToggles)),
- User: ctx.SignedInUser.Email,
- }
-
- for _, t := range cmd.FeatureToggles {
- // make sure flag exists, and only continue if flag is writeable
- if hs.featureManager.IsEditableFromAdminPage(t.Name) {
- hs.log.Info("UpdateFeatureToggle: updating toggle", "toggle_name", t.Name, "enabled", t.Enabled, "username", ctx.SignedInUser.Login)
- payload.FeatureToggles[t.Name] = strconv.FormatBool(t.Enabled)
- } else {
- hs.log.Warn("UpdateFeatureToggle: invalid toggle passed in", "toggle_name", t.Name)
- return response.Error(http.StatusBadRequest, "invalid toggle passed in", fmt.Errorf("invalid toggle passed in: %s", t.Name))
- }
- }
-
- err := sendWebhookUpdate(featureMgmtCfg, payload, hs.log)
- if err != nil {
- hs.log.Error("UpdateFeatureToggle: Failed to perform webhook request", "error", err)
- return response.Respond(http.StatusBadRequest, "Failed to perform webhook request")
- }
-
- hs.featureManager.SetRestartRequired()
-
- return response.Respond(http.StatusOK, "feature toggles updated successfully")
-}
-
-func (hs *HTTPServer) GetFeatureMgmtState(ctx *contextmodel.ReqContext) response.Response {
- fmState := hs.featureManager.GetState()
- return response.Respond(http.StatusOK, fmState)
-}
-
-type UpdatePayload struct {
- FeatureToggles map[string]string `json:"feature_toggles"`
- User string `json:"user"`
-}
-
-func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload UpdatePayload, logger log.Logger) error {
- data, err := json.Marshal(payload)
- if err != nil {
- return err
- }
-
- req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data))
- if err != nil {
- return err
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken)
-
- client := &http.Client{}
- resp, err := client.Do(req)
- if err != nil {
- return err
- }
- defer func() {
- if err := resp.Body.Close(); err != nil {
- logger.Warn("Failed to close response body", "err", err)
- }
- }()
-
- if resp.StatusCode >= http.StatusBadRequest {
- if body, err := io.ReadAll(resp.Body); err != nil {
- return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body))
- } else {
- return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err)
- }
- }
-
- return nil
-}
diff --git a/pkg/api/featuremgmt_test.go b/pkg/api/featuremgmt_test.go
deleted file mode 100644
index bea1b4c089c..00000000000
--- a/pkg/api/featuremgmt_test.go
+++ /dev/null
@@ -1,491 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/org/orgtest"
- "github.com/grafana/grafana/pkg/services/user"
- "github.com/grafana/grafana/pkg/services/user/usertest"
- "github.com/grafana/grafana/pkg/setting"
- "github.com/grafana/grafana/pkg/web/webtest"
-)
-
-func TestGetFeatureToggles(t *testing.T) {
- readPermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}}
-
- t.Run("should not be able to get feature toggles without permissions", func(t *testing.T) {
- result := runGetScenario(t, []*featuremgmt.FeatureFlag{}, []string{}, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
- assert.Len(t, result, 0)
- })
-
- t.Run("should be able to get feature toggles with correct permissions", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: "toggle1",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- },
- }
- disabled := []string{"toggle2"}
-
- result := runGetScenario(t, features, disabled, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
- assert.Len(t, result, 2)
- t1, _ := findResult(t, result, "toggle1")
- assert.True(t, t1.Enabled)
- t2, _ := findResult(t, result, "toggle2")
- assert.False(t, t2.Enabled)
- })
-
- t.Run("toggles hidden by config are not present in the response", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: "toggle1",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- },
- }
- settings := setting.FeatureMgmtSettings{
- HiddenToggles: map[string]struct{}{"toggle1": {}},
- }
-
- result := runGetScenario(t, features, []string{}, settings, readPermissions, http.StatusOK)
- assert.Len(t, result, 1)
- assert.Equal(t, "toggle2", result[0].Name)
- })
-
- t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: "toggle1",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- },
- }
- disabled := []string{"toggle2"}
- settings := setting.FeatureMgmtSettings{
- HiddenToggles: map[string]struct{}{"toggle1": {}},
- ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
- AllowEditing: true,
- UpdateWebhook: "bogus",
- }
-
- result := runGetScenario(t, features, disabled, settings, readPermissions, http.StatusOK)
- assert.Len(t, result, 1)
- assert.Equal(t, "toggle2", result[0].Name)
- assert.True(t, result[0].ReadOnly)
- })
-
- t.Run("feature toggle defailts", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: "toggle1",
- Stage: featuremgmt.FeatureStageUnknown,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStageExperimental,
- }, {
- Name: "toggle3",
- Stage: featuremgmt.FeatureStagePrivatePreview,
- }, {
- Name: "toggle4",
- Stage: featuremgmt.FeatureStagePublicPreview,
- AllowSelfServe: true,
- }, {
- Name: "toggle5",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- AllowSelfServe: true,
- }, {
- Name: "toggle6",
- Stage: featuremgmt.FeatureStageDeprecated,
- AllowSelfServe: true,
- }, {
- Name: "toggle7",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- AllowSelfServe: false,
- },
- }
-
- t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
- result := runGetScenario(t, features, []string{}, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
- assert.Len(t, result, 4)
-
- _, ok := findResult(t, result, "toggle1")
- assert.False(t, ok)
- _, ok = findResult(t, result, "toggle2")
- assert.False(t, ok)
- _, ok = findResult(t, result, "toggle3")
- assert.False(t, ok)
- })
-
- t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) {
- settings := setting.FeatureMgmtSettings{
- AllowEditing: true,
- UpdateWebhook: "bogus",
- }
- result := runGetScenario(t, features, []string{}, settings, readPermissions, http.StatusOK)
- assert.Len(t, result, 4)
-
- t4, ok := findResult(t, result, "toggle4")
- assert.True(t, ok)
- assert.True(t, t4.ReadOnly)
- t5, ok := findResult(t, result, "toggle5")
- assert.True(t, ok)
- assert.False(t, t5.ReadOnly)
- t6, ok := findResult(t, result, "toggle6")
- assert.True(t, ok)
- assert.False(t, t6.ReadOnly)
- })
-
- t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
- settings := setting.FeatureMgmtSettings{
- AllowEditing: false,
- UpdateWebhook: "",
- }
- result := runGetScenario(t, features, []string{}, settings, readPermissions, http.StatusOK)
- assert.Len(t, result, 4)
-
- t4, ok := findResult(t, result, "toggle4")
- assert.True(t, ok)
- assert.True(t, t4.ReadOnly)
- t5, ok := findResult(t, result, "toggle5")
- assert.True(t, ok)
- assert.True(t, t5.ReadOnly)
- t6, ok := findResult(t, result, "toggle6")
- assert.True(t, ok)
- assert.True(t, t6.ReadOnly)
- })
- })
-}
-
-func TestSetFeatureToggles(t *testing.T) {
- writePermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementWrite}}
-
- t.Run("fails without adequate permissions", func(t *testing.T) {
- res := runSetScenario(t, nil, []string{}, nil, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
- defer func() { require.NoError(t, res.Body.Close()) }()
- })
-
- t.Run("fails when toggle editing is not enabled", func(t *testing.T) {
- res := runSetScenario(t, nil, []string{}, nil, setting.FeatureMgmtSettings{}, writePermissions, http.StatusForbidden)
- defer func() { require.NoError(t, res.Body.Close()) }()
- p := readBody(t, res.Body)
- assert.Equal(t, "feature toggles are read-only", p["message"])
- })
-
- t.Run("fails when update toggle url is not set", func(t *testing.T) {
- s := setting.FeatureMgmtSettings{
- AllowEditing: true,
- }
- res := runSetScenario(t, nil, []string{}, nil, s, writePermissions, http.StatusInternalServerError)
- defer func() { require.NoError(t, res.Body.Close()) }()
- p := readBody(t, res.Body)
- assert.Equal(t, "feature toggles service is misconfigured", p["message"])
- })
-
- t.Run("fails with non-existent toggle", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: "toggle1",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- },
- }
- disabled := []string{"toggle2"}
- updates := []featuremgmt.FeatureToggleDTO{
- {
- Name: "toggle3",
- Enabled: true,
- },
- }
-
- s := setting.FeatureMgmtSettings{
- AllowEditing: true,
- UpdateWebhook: "random",
- }
- res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusBadRequest)
- defer func() { require.NoError(t, res.Body.Close()) }()
- p := readBody(t, res.Body)
- assert.Equal(t, "invalid toggle passed in", p["message"])
- })
-
- t.Run("fails with read-only toggles", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: featuremgmt.FlagFeatureToggleAdminPage,
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStagePublicPreview,
- }, {
- Name: "toggle3",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- },
- }
- disabled := []string{"toggle2", "toggle3"}
-
- s := setting.FeatureMgmtSettings{
- AllowEditing: true,
- UpdateWebhook: "random",
- ReadOnlyToggles: map[string]struct{}{
- "toggle3": {},
- },
- }
-
- t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) {
- updates := []featuremgmt.FeatureToggleDTO{
- {
- Name: featuremgmt.FlagFeatureToggleAdminPage,
- Enabled: true,
- },
- }
- res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusBadRequest)
- defer func() { require.NoError(t, res.Body.Close()) }()
- p := readBody(t, res.Body)
- assert.Equal(t, "invalid toggle passed in", p["message"])
- })
-
- t.Run("because it is not GA or Deprecated", func(t *testing.T) {
- updates := []featuremgmt.FeatureToggleDTO{
- {
- Name: "toggle2",
- Enabled: true,
- },
- }
- res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusBadRequest)
- defer func() { require.NoError(t, res.Body.Close()) }()
- p := readBody(t, res.Body)
- assert.Equal(t, "invalid toggle passed in", p["message"])
- })
-
- t.Run("because it is configured to be read-only", func(t *testing.T) {
- updates := []featuremgmt.FeatureToggleDTO{
- {
- Name: "toggle3",
- Enabled: true,
- },
- }
- res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusBadRequest)
- defer func() { require.NoError(t, res.Body.Close()) }()
- p := readBody(t, res.Body)
- assert.Equal(t, "invalid toggle passed in", p["message"])
- })
- })
-
- t.Run("when all conditions met", func(t *testing.T) {
- features := []*featuremgmt.FeatureFlag{
- {
- Name: featuremgmt.FlagFeatureToggleAdminPage,
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle2",
- Stage: featuremgmt.FeatureStagePublicPreview,
- }, {
- Name: "toggle3",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }, {
- Name: "toggle4",
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- AllowSelfServe: true,
- }, {
- Name: "toggle5",
- Stage: featuremgmt.FeatureStageDeprecated,
- AllowSelfServe: true,
- },
- }
- disabled := []string{"toggle2", "toggle3", "toggle4", "toggle5"}
-
- s := setting.FeatureMgmtSettings{
- AllowEditing: true,
- UpdateWebhook: "random",
- UpdateWebhookToken: "token",
- ReadOnlyToggles: map[string]struct{}{
- "toggle3": {},
- },
- }
-
- updates := []featuremgmt.FeatureToggleDTO{
- {
- Name: "toggle4",
- Enabled: true,
- }, {
- Name: "toggle5",
- Enabled: false,
- },
- }
- t.Run("fail when webhook request is not successful", func(t *testing.T) {
- webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusBadRequest)
- }))
- defer webhookServer.Close()
- s.UpdateWebhook = webhookServer.URL
- res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusBadRequest)
- defer func() { require.NoError(t, res.Body.Close()) }()
- assert.Equal(t, http.StatusBadRequest, res.StatusCode)
- })
-
- t.Run("succeed when webhook request is successul", func(t *testing.T) {
- webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization"))
-
- var req UpdatePayload
- require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
-
- assert.Equal(t, "true", req.FeatureToggles["toggle4"])
- assert.Equal(t, "false", req.FeatureToggles["toggle5"])
- w.WriteHeader(http.StatusOK)
- }))
- defer webhookServer.Close()
- s.UpdateWebhook = webhookServer.URL
- res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusOK)
- defer func() { require.NoError(t, res.Body.Close()) }()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- })
- })
-}
-
-func findResult(t *testing.T, result []featuremgmt.FeatureToggleDTO, name string) (featuremgmt.FeatureToggleDTO, bool) {
- t.Helper()
-
- for _, t := range result {
- if t.Name == name {
- return t, true
- }
- }
- return featuremgmt.FeatureToggleDTO{}, false
-}
-
-func readBody(t *testing.T, rc io.ReadCloser) map[string]any {
- t.Helper()
-
- b, err := io.ReadAll(rc)
- require.NoError(t, err)
- payload := map[string]any{}
- require.NoError(t, json.Unmarshal(b, &payload))
- return payload
-}
-
-func runGetScenario(
- t *testing.T,
- features []*featuremgmt.FeatureFlag,
- disabled []string, // the flags that are disabled
- settings setting.FeatureMgmtSettings,
- permissions []accesscontrol.Permission,
- expectedCode int,
-) []featuremgmt.FeatureToggleDTO {
- // Set up server and send request
- cfg := setting.NewCfg()
-
- fm := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
- Name: featuremgmt.FlagFeatureToggleAdminPage,
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }}, features...), disabled...)
-
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = cfg
- hs.Features = fm
- hs.featureManager = fm
- hs.orgService = orgtest.NewOrgServiceFake()
- hs.userService = &usertest.FakeUserService{
- ExpectedUser: &user.User{ID: 1},
- }
- hs.log = log.New("test")
- })
- req := webtest.RequestWithSignedInUser(server.NewGetRequest("/api/featuremgmt"), userWithPermissions(1, permissions))
- res, err := server.SendJSON(req)
- defer func() { require.NoError(t, res.Body.Close()) }()
-
- // Do some general checks for every request
- require.NoError(t, err)
- require.Equal(t, expectedCode, res.StatusCode)
- if res.StatusCode >= 400 {
- return nil
- }
-
- var result []featuremgmt.FeatureToggleDTO
- err = json.NewDecoder(res.Body).Decode(&result)
- require.NoError(t, err)
-
- for i := 0; i < len(result); {
- ft := result[i]
- // Always make sure admin page toggle is read-only, then remove it to make assertions easier
- if ft.Name == featuremgmt.FlagFeatureToggleAdminPage {
- assert.True(t, ft.ReadOnly)
- result = append(result[:i], result[i+1:]...)
- continue
- }
-
- // Make sure toggles explicitly marked "hidden" by config are hidden
- if _, ok := cfg.FeatureManagement.HiddenToggles[ft.Name]; ok {
- t.Fail()
- }
-
- // Make sure toggles explicitly marked "read only" by config are read only
- if _, ok := cfg.FeatureManagement.ReadOnlyToggles[ft.Name]; ok {
- assert.True(t, ft.ReadOnly)
- }
- i++
- }
-
- return result
-}
-
-func runSetScenario(
- t *testing.T,
- serverFeatures []*featuremgmt.FeatureFlag,
- disabled []string, // the flags that are disabled
- updateFeatures []featuremgmt.FeatureToggleDTO,
- settings setting.FeatureMgmtSettings,
- permissions []accesscontrol.Permission,
- expectedCode int,
-) *http.Response {
- // Set up server and send request
- cfg := setting.NewCfg()
- features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
- Name: featuremgmt.FlagFeatureToggleAdminPage,
- Stage: featuremgmt.FeatureStageGeneralAvailability,
- }}, serverFeatures...), disabled...)
-
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = cfg
- hs.Features = features
- hs.featureManager = features
- hs.orgService = orgtest.NewOrgServiceFake()
- hs.userService = &usertest.FakeUserService{
- ExpectedUser: &user.User{ID: 1},
- }
- hs.log = log.New("test")
- })
-
- cmd := featuremgmt.UpdateFeatureTogglesCommand{
- FeatureToggles: updateFeatures,
- }
- b, err := json.Marshal(cmd)
- require.NoError(t, err)
- req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/featuremgmt", bytes.NewReader(b)), userWithPermissions(1, permissions))
- res, err := server.SendJSON(req)
-
- require.NoError(t, err)
- require.NotNil(t, res)
- require.Equal(t, expectedCode, res.StatusCode)
-
- return res
-}
diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go
index 53a3a322377..faaf564c4da 100644
--- a/pkg/api/frontendsettings_test.go
+++ b/pkg/api/frontendsettings_test.go
@@ -19,8 +19,8 @@ import (
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index a6895f7a6ce..c7cce87d03f 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -25,8 +25,8 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/grafana/grafana/pkg/services/anonymous"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
@@ -127,7 +127,6 @@ type HTTPServer struct {
RenderService rendering.Service
Cfg *setting.Cfg
Features featuremgmt.FeatureToggles
- featureManager *featuremgmt.FeatureManager
SettingsProvider setting.Provider
HooksService *hooks.HooksService
navTreeService navtree.Service
@@ -235,7 +234,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
correlationsService correlations.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService,
accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService,
live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider,
- contextHandler *contexthandler.ContextHandler, loggerMiddleware loggermw.Logger, features *featuremgmt.FeatureManager,
+ contextHandler *contexthandler.ContextHandler, loggerMiddleware loggermw.Logger, features featuremgmt.FeatureToggles,
alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service,
quotaService quota.Service, socialService social.Service, tracer tracing.Tracer,
encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService,
@@ -292,7 +291,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
QueryHistoryService: queryHistoryService,
CorrelationsService: correlationsService,
Features: features, // a read only view of the managers state
- featureManager: features,
StorageService: storageService,
RemoteCacheService: remoteCache,
ProvisioningService: provisioningService,
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 75483655916..4c52249cbb4 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -92,6 +92,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
data := dtos.IndexViewData{
User: &dtos.CurrentUser{
Id: userID,
+ UID: c.UserUID, // << not set yet
IsSignedIn: c.IsSignedIn,
Login: c.Login,
Email: c.SignedInUser.GetEmail(),
diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go
index f6acadf1358..df798af8ca8 100644
--- a/pkg/api/metrics.go
+++ b/pkg/api/metrics.go
@@ -10,10 +10,14 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/api/routing"
+ "github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/middleware/requestmeta"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/util/errutil/errhttp"
"github.com/grafana/grafana/pkg/web"
)
@@ -33,6 +37,26 @@ func (hs *HTTPServer) handleQueryMetricsError(err error) *response.NormalRespons
return response.ErrOrFallback(http.StatusInternalServerError, "Query data error", err)
}
+// metrics.go
+func (hs *HTTPServer) getDSQueryEndpoint() web.Handler {
+ if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesQueryServiceRewrite) {
+ // DEV ONLY FEATURE FLAG!
+ // rewrite requests from /ds/query to the new query service
+ namespaceMapper := request.GetNamespaceMapper(hs.Cfg)
+ return func(w http.ResponseWriter, r *http.Request) {
+ user, err := appcontext.User(r.Context())
+ if err != nil || user == nil {
+ errhttp.Write(r.Context(), fmt.Errorf("no user"), w)
+ return
+ }
+ r.URL.Path = "/apis/query.grafana.app/v0alpha1/namespaces/" + namespaceMapper(user.OrgID) + "/query"
+ hs.clientConfigProvider.DirectlyServeHTTP(w, r)
+ }
+ }
+
+ return routing.Wrap(hs.QueryMetricsV2)
+}
+
// QueryMetricsV2 returns query metrics.
// swagger:route POST /ds/query ds queryMetricsWithExpressions
//
diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go
index 4a49bc59708..9f8b2acb85f 100644
--- a/pkg/api/playlist.go
+++ b/pkg/api/playlist.go
@@ -15,10 +15,10 @@ import (
"github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
"github.com/grafana/grafana/pkg/middleware"
internalplaylist "github.com/grafana/grafana/pkg/registry/apis/playlist"
+ grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/util/errutil/errhttp"
"github.com/grafana/grafana/pkg/web"
diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go
index 2b9bfa01951..964eb23da87 100644
--- a/pkg/api/user_test.go
+++ b/pkg/api/user_test.go
@@ -82,6 +82,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
}
usr, err := userSvc.Create(context.Background(), &createUserCmd)
require.NoError(t, err)
+ theUserUID := usr.UID
sc.handlerFunc = hs.GetUserByID
@@ -108,6 +109,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
expected := user.UserProfileDTO{
ID: 1,
+ UID: theUserUID, // from original request
Email: "user@test.com",
Name: "user",
Login: "loginuser",
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/doc.go b/pkg/apis/dashboardsnapshot/v0alpha1/doc.go
new file mode 100644
index 00000000000..a6b2fec52cb
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/doc.go
@@ -0,0 +1,6 @@
+// +k8s:deepcopy-gen=package
+// +k8s:openapi-gen=true
+// +k8s:defaulter-gen=TypeMeta
+// +groupName=dashboardsnapshot.grafana.app
+
+package v0alpha1
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/register.go b/pkg/apis/dashboardsnapshot/v0alpha1/register.go
new file mode 100644
index 00000000000..e8022d55bf7
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/register.go
@@ -0,0 +1,25 @@
+package v0alpha1
+
+import (
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+)
+
+const (
+ GROUP = "dashboardsnapshot.grafana.app"
+ VERSION = "v0alpha1"
+ APIVERSION = GROUP + "/" + VERSION
+)
+
+var DashboardSnapshotResourceInfo = common.NewResourceInfo(GROUP, VERSION,
+ "dashboardsnapshots", "dashboardsnapshot", "DashboardSnapshot",
+ func() runtime.Object { return &DashboardSnapshot{} },
+ func() runtime.Object { return &DashboardSnapshotList{} },
+)
+
+var (
+ // SchemeGroupVersion is group version used to register these objects
+ SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
+)
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/types.go b/pkg/apis/dashboardsnapshot/v0alpha1/types.go
new file mode 100644
index 00000000000..6a0785bdedb
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/types.go
@@ -0,0 +1,136 @@
+package v0alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+)
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DashboardSnapshot struct {
+ metav1.TypeMeta `json:",inline"`
+ // +optional
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Snapshot summary info
+ Spec SnapshotInfo `json:"spec"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DashboardSnapshotList struct {
+ metav1.TypeMeta `json:",inline"`
+ // +optional
+ metav1.ListMeta `json:"metadata,omitempty"`
+
+ Items []DashboardSnapshot `json:"items,omitempty"`
+}
+
+type SnapshotInfo struct {
+ Title string `json:"title,omitempty"`
+ // Optionally auto-remove the snapshot at a future date
+ Expires int64 `json:"expires,omitempty"`
+ // When set to true, the snapshot exists in a remote server
+ External bool `json:"external,omitempty"`
+ // The external URL where the snapshot can be seen
+ ExternalURL string `json:"externalUrl,omitempty"`
+ // The URL that created the dashboard originally
+ OriginalUrl string `json:"originalUrl,omitempty"`
+ // Snapshot creation timestamp
+ Timestamp string `json:"timestamp,omitempty"`
+}
+
+// This is returned from the POST command
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DashboardSnapshotWithDeleteKey struct {
+ DashboardSnapshot `json:",inline"`
+
+ // The delete key is only returned when the item is created. It is not returned from a get request
+ DeleteKey string `json:"deleteKey,omitempty"`
+}
+
+// This is the snapshot returned from the subresource
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type FullDashboardSnapshot struct {
+ metav1.TypeMeta `json:",inline"`
+ // +optional
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Snapshot summary info
+ Info SnapshotInfo `json:"info"`
+
+ // The raw dashboard (unstructured for now)
+ Dashboard common.Unstructured `json:"dashboard"`
+}
+
+// Each tenant, may have different sharing options
+// This is currently set using custom.ini, but multi-tenant support will need
+// to be managed differently
+type SnapshotSharingOptions struct {
+ SnapshotsEnabled bool `json:"snapshotEnabled"`
+ ExternalSnapshotURL string `json:"externalSnapshotURL,omitempty"`
+ ExternalSnapshotName string `json:"externalSnapshotName,omitempty"`
+ ExternalEnabled bool `json:"externalEnabled,omitempty"`
+}
+
+// These are the values expected to be sent from an end user
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DashboardCreateCommand struct {
+ metav1.TypeMeta `json:",inline"`
+
+ // Snapshot name
+ // required:false
+ Name string `json:"name"`
+
+ // The complete dashboard model.
+ // required:true
+ Dashboard *common.Unstructured `json:"dashboard" binding:"Required"`
+
+ // When the snapshot should expire in seconds in seconds. Default is never to expire.
+ // required:false
+ // default:0
+ Expires int64 `json:"expires"`
+
+ // these are passed when storing an external snapshot ref
+ // Save the snapshot on an external server rather than locally.
+ // required:false
+ // default: false
+ External bool `json:"external"`
+}
+
+// The create response
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DashboardCreateResponse struct {
+ metav1.TypeMeta `json:",inline"`
+
+ // The unique key
+ Key string `json:"key"`
+
+ // A unique key that will allow delete
+ DeleteKey string `json:"deleteKey"`
+
+ // Absolute URL to show the dashboard
+ URL string `json:"url"`
+
+ // URL that will delete the response
+ DeleteURL string `json:"deleteUrl"`
+}
+
+// Represents an options object that must be named for each namespace/team/user
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type SharingOptions struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Show the options inline
+ Spec SnapshotSharingOptions `json:"spec"`
+}
+
+// Represents a list of namespaced options
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type SharingOptionsList struct {
+ metav1.TypeMeta `json:",inline"`
+ // +optional
+ metav1.ListMeta `json:"metadata,omitempty"`
+
+ Items []SharingOptions `json:"items,omitempty"`
+}
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..51b5075f310
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,271 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by deepcopy-gen. DO NOT EDIT.
+
+package v0alpha1
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DashboardCreateCommand) DeepCopyInto(out *DashboardCreateCommand) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ if in.Dashboard != nil {
+ in, out := &in.Dashboard, &out.Dashboard
+ *out = (*in).DeepCopy()
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardCreateCommand.
+func (in *DashboardCreateCommand) DeepCopy() *DashboardCreateCommand {
+ if in == nil {
+ return nil
+ }
+ out := new(DashboardCreateCommand)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DashboardCreateCommand) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DashboardCreateResponse) DeepCopyInto(out *DashboardCreateResponse) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardCreateResponse.
+func (in *DashboardCreateResponse) DeepCopy() *DashboardCreateResponse {
+ if in == nil {
+ return nil
+ }
+ out := new(DashboardCreateResponse)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DashboardCreateResponse) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DashboardSnapshot) DeepCopyInto(out *DashboardSnapshot) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshot.
+func (in *DashboardSnapshot) DeepCopy() *DashboardSnapshot {
+ if in == nil {
+ return nil
+ }
+ out := new(DashboardSnapshot)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DashboardSnapshot) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DashboardSnapshotList) DeepCopyInto(out *DashboardSnapshotList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]DashboardSnapshot, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshotList.
+func (in *DashboardSnapshotList) DeepCopy() *DashboardSnapshotList {
+ if in == nil {
+ return nil
+ }
+ out := new(DashboardSnapshotList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DashboardSnapshotList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DashboardSnapshotWithDeleteKey) DeepCopyInto(out *DashboardSnapshotWithDeleteKey) {
+ *out = *in
+ in.DashboardSnapshot.DeepCopyInto(&out.DashboardSnapshot)
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshotWithDeleteKey.
+func (in *DashboardSnapshotWithDeleteKey) DeepCopy() *DashboardSnapshotWithDeleteKey {
+ if in == nil {
+ return nil
+ }
+ out := new(DashboardSnapshotWithDeleteKey)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DashboardSnapshotWithDeleteKey) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FullDashboardSnapshot) DeepCopyInto(out *FullDashboardSnapshot) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Info = in.Info
+ in.Dashboard.DeepCopyInto(&out.Dashboard)
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FullDashboardSnapshot.
+func (in *FullDashboardSnapshot) DeepCopy() *FullDashboardSnapshot {
+ if in == nil {
+ return nil
+ }
+ out := new(FullDashboardSnapshot)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FullDashboardSnapshot) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SharingOptions) DeepCopyInto(out *SharingOptions) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharingOptions.
+func (in *SharingOptions) DeepCopy() *SharingOptions {
+ if in == nil {
+ return nil
+ }
+ out := new(SharingOptions)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *SharingOptions) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SharingOptionsList) DeepCopyInto(out *SharingOptionsList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]SharingOptions, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharingOptionsList.
+func (in *SharingOptionsList) DeepCopy() *SharingOptionsList {
+ if in == nil {
+ return nil
+ }
+ out := new(SharingOptionsList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *SharingOptionsList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SnapshotInfo) DeepCopyInto(out *SnapshotInfo) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotInfo.
+func (in *SnapshotInfo) DeepCopy() *SnapshotInfo {
+ if in == nil {
+ return nil
+ }
+ out := new(SnapshotInfo)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SnapshotSharingOptions) DeepCopyInto(out *SnapshotSharingOptions) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotSharingOptions.
+func (in *SnapshotSharingOptions) DeepCopy() *SnapshotSharingOptions {
+ if in == nil {
+ return nil
+ }
+ out := new(SnapshotSharingOptions)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go
new file mode 100644
index 00000000000..238fc2f4edc
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go
@@ -0,0 +1,19 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by defaulter-gen. DO NOT EDIT.
+
+package v0alpha1
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// RegisterDefaults adds defaulters functions to the given scheme.
+// Public to allow building arbitrary schemes.
+// All generated defaulters are covering - they call all nested defaulters.
+func RegisterDefaults(scheme *runtime.Scheme) error {
+ return nil
+}
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go
new file mode 100644
index 00000000000..f5824a7d78a
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go
@@ -0,0 +1,521 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by openapi-gen. DO NOT EDIT.
+
+// This file was autogenerated by openapi-gen. Do not edit it manually!
+
+package v0alpha1
+
+import (
+ common "k8s.io/kube-openapi/pkg/common"
+ spec "k8s.io/kube-openapi/pkg/validation/spec"
+)
+
+func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
+ return map[string]common.OpenAPIDefinition{
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateCommand(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateResponse(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshot(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshotList": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotList(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshotWithDeleteKey": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotWithDeleteKey(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.FullDashboardSnapshot": schema_pkg_apis_dashboardsnapshot_v0alpha1_FullDashboardSnapshot(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions": schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptions(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptionsList": schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptionsList(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo": schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotInfo(ref),
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions": schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotSharingOptions(ref),
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateCommand(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "These are the values expected to be sent from an end user",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "name": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Snapshot name required:false",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "dashboard": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The complete dashboard model. required:true",
+ Ref: ref("github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured"),
+ },
+ },
+ "expires": {
+ SchemaProps: spec.SchemaProps{
+ Description: "When the snapshot should expire in seconds in seconds. Default is never to expire. required:false default:0",
+ Default: 0,
+ Type: []string{"integer"},
+ Format: "int64",
+ },
+ },
+ "external": {
+ SchemaProps: spec.SchemaProps{
+ Description: "these are passed when storing an external snapshot ref Save the snapshot on an external server rather than locally. required:false default: false",
+ Default: false,
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"name", "dashboard", "expires", "external"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateResponse(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "The create response",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "key": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The unique key",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "deleteKey": {
+ SchemaProps: spec.SchemaProps{
+ Description: "A unique key that will allow delete",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "url": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Absolute URL to show the dashboard",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "deleteUrl": {
+ SchemaProps: spec.SchemaProps{
+ Description: "URL that will delete the response",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"key", "deleteKey", "url", "deleteUrl"},
+ },
+ },
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshot(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "spec": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Snapshot summary info",
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"),
+ },
+ },
+ },
+ Required: []string{"spec"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+ },
+ },
+ "items": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotWithDeleteKey(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "This is returned from the POST command",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "spec": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Snapshot summary info",
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"),
+ },
+ },
+ "deleteKey": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The delete key is only returned when the item is created. It is not returned from a get request",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"spec"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_FullDashboardSnapshot(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "This is the snapshot returned from the subresource",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "info": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Snapshot summary info",
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"),
+ },
+ },
+ "dashboard": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The raw dashboard (unstructured for now)",
+ Ref: ref("github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured"),
+ },
+ },
+ },
+ Required: []string{"info", "dashboard"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured", "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "Represents an options object that must be named for each namespace/team/user",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "spec": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Show the options inline",
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions"),
+ },
+ },
+ },
+ Required: []string{"spec"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptionsList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "Represents a list of namespaced options",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+ },
+ },
+ "items": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "title": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "expires": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Optionally auto-remove the snapshot at a future date",
+ Type: []string{"integer"},
+ Format: "int64",
+ },
+ },
+ "external": {
+ SchemaProps: spec.SchemaProps{
+ Description: "When set to true, the snapshot exists in a remote server",
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ "externalUrl": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The external URL where the snapshot can be seen",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "originalUrl": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The URL that created the dashboard originally",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "timestamp": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Snapshot creation timestamp",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotSharingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "Each tenant, may have different sharing options This is currently set using custom.ini, but multi-tenant support will need to be managed differently",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "snapshotEnabled": {
+ SchemaProps: spec.SchemaProps{
+ Default: false,
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ "externalSnapshotURL": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "externalSnapshotName": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "externalEnabled": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"snapshotEnabled"},
+ },
+ },
+ }
+}
diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list
new file mode 100644
index 00000000000..72af08dd20f
--- /dev/null
+++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list
@@ -0,0 +1,3 @@
+API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,DashboardCreateResponse,DeleteURL
+API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,SnapshotInfo,ExternalURL
+API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,SnapshotSharingOptions,SnapshotsEnabled
\ No newline at end of file
diff --git a/pkg/apis/featuretoggle/v0alpha1/types.go b/pkg/apis/featuretoggle/v0alpha1/types.go
index 316145befd7..4fa25c5135d 100644
--- a/pkg/apis/featuretoggle/v0alpha1/types.go
+++ b/pkg/apis/featuretoggle/v0alpha1/types.go
@@ -78,8 +78,11 @@ type FeatureTogglesList struct {
type ResolvedToggleState struct {
metav1.TypeMeta `json:",inline"`
- // Can any flag be updated
- Writeable bool `json:"writeable,omitempty"`
+ // The user is allowed to edit feature toggles on this system
+ AllowEditing bool `json:"allowEditing,omitempty"`
+
+ // The system has changes that require still require a restart
+ RestartRequired bool `json:"restartRequired,omitempty"`
// The currently enabled flags
Enabled map[string]bool `json:"enabled,omitempty"`
@@ -99,7 +102,7 @@ type ToggleStatus struct {
Enabled bool `json:"enabled"`
// Can this flag be updated
- Writeable bool `json:"writeable,omitempty"`
+ Writeable bool `json:"writeable"`
// Where was the value configured
// eg: startup | tenant|org | user | browser
diff --git a/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go b/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go
index 3e9c9364b32..94ddee37f44 100644
--- a/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go
+++ b/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go
@@ -318,9 +318,16 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ResolvedToggleState(ref common.Refer
Format: "",
},
},
- "writeable": {
+ "allowEditing": {
SchemaProps: spec.SchemaProps{
- Description: "Can any flag be updated",
+ Description: "The user is allowed to edit feature toggles on this system",
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ "restartRequired": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The system has changes that require still require a restart",
Type: []string{"boolean"},
Format: "",
},
@@ -395,6 +402,7 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref common.ReferenceCal
"writeable": {
SchemaProps: spec.SchemaProps{
Description: "Can this flag be updated",
+ Default: false,
Type: []string{"boolean"},
Format: "",
},
@@ -413,7 +421,7 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref common.ReferenceCal
},
},
},
- Required: []string{"name", "enabled"},
+ Required: []string{"name", "enabled", "writeable"},
},
},
Dependencies: []string{
diff --git a/pkg/apis/query/v0alpha1/doc.go b/pkg/apis/query/v0alpha1/doc.go
new file mode 100644
index 00000000000..e6b9ec55032
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/doc.go
@@ -0,0 +1,6 @@
+// +k8s:deepcopy-gen=package
+// +k8s:openapi-gen=true
+// +k8s:defaulter-gen=TypeMeta
+// +groupName=query.grafana.app
+
+package v0alpha1
diff --git a/pkg/apis/query/v0alpha1/query.go b/pkg/apis/query/v0alpha1/query.go
new file mode 100644
index 00000000000..4ae9435f0d3
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/query.go
@@ -0,0 +1,211 @@
+package v0alpha1
+
+import (
+ "encoding/json"
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// Generic query request with shared time across all values
+// Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62
+type GenericQueryRequest struct {
+ metav1.TypeMeta `json:",inline"`
+
+ // From Start time in epoch timestamps in milliseconds or relative using Grafana time units.
+ // example: now-1h
+ From string `json:"from,omitempty"`
+
+ // To End time in epoch timestamps in milliseconds or relative using Grafana time units.
+ // example: now
+ To string `json:"to,omitempty"`
+
+ // queries.refId – Specifies an identifier of the query. Is optional and default to “A”.
+ // queries.datasourceId – Specifies the data source to be queried. Each query in the request must have an unique datasourceId.
+ // queries.maxDataPoints - Species maximum amount of data points that dashboard panel can render. Is optional and default to 100.
+ // queries.intervalMs - Specifies the time interval in milliseconds of time series. Is optional and defaults to 1000.
+ // required: true
+ // example: [ { "refId": "A", "intervalMs": 86400000, "maxDataPoints": 1092, "datasource":{ "uid":"PD8C576611E62080A" }, "rawSql": "SELECT 1 as valueOne, 2 as valueTwo", "format": "table" } ]
+ Queries []GenericDataQuery `json:"queries"`
+
+ // required: false
+ Debug bool `json:"debug,omitempty"`
+}
+
+type DataSourceRef struct {
+ // The datasource plugin type
+ Type string `json:"type"`
+
+ // Datasource UID
+ UID string `json:"uid"`
+}
+
+// GenericDataQuery is a replacement for `dtos.MetricRequest` that provides more explicit types
+type GenericDataQuery struct {
+ // RefID is the unique identifier of the query, set by the frontend call.
+ RefID string `json:"refId"`
+
+ // TimeRange represents the query range
+ // NOTE: unlike generic /ds/query, we can now send explicit time values in each query
+ TimeRange *TimeRange `json:"timeRange,omitempty"`
+
+ // The datasource
+ Datasource *DataSourceRef `json:"datasource,omitempty"`
+
+ // Deprecated -- use datasource ref instead
+ DatasourceId int64 `json:"datasourceId,omitempty"`
+
+ // QueryType is an optional identifier for the type of query.
+ // It can be used to distinguish different types of queries.
+ QueryType string `json:"queryType,omitempty"`
+
+ // MaxDataPoints is the maximum number of data points that should be returned from a time series query.
+ MaxDataPoints int64 `json:"maxDataPoints,omitempty"`
+
+ // Interval is the suggested duration between time points in a time series query.
+ IntervalMS float64 `json:"intervalMs,omitempty"`
+
+ // true if query is disabled (ie should not be returned to the dashboard)
+ // Note this does not always imply that the query should not be executed since
+ // the results from a hidden query may be used as the input to other queries (SSE etc)
+ Hide bool `json:"hide,omitempty"`
+
+ // Additional Properties (that live at the root)
+ props map[string]any `json:"-"`
+}
+
+// TimeRange represents a time range for a query and is a property of DataQuery.
+type TimeRange struct {
+ // From is the start time of the query.
+ From string `json:"from"`
+
+ // To is the end time of the query.
+ To string `json:"to"`
+}
+
+func (g *GenericDataQuery) AdditionalProperties() map[string]any {
+ if g.props == nil {
+ g.props = make(map[string]any)
+ }
+ return g.props
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (g *GenericDataQuery) DeepCopyInto(out *GenericDataQuery) {
+ *out = *g
+ if g.props != nil {
+ out.props = runtime.DeepCopyJSON(g.props)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GenericDataQuery.
+func (g *GenericDataQuery) DeepCopy() *GenericDataQuery {
+ if g == nil {
+ return nil
+ }
+ out := new(GenericDataQuery)
+ g.DeepCopyInto(out)
+ return out
+}
+
+// MarshalJSON ensures that the unstructured object produces proper
+// JSON when passed to Go's standard JSON library.
+func (g GenericDataQuery) MarshalJSON() ([]byte, error) {
+ vals := map[string]any{}
+ if g.props != nil {
+ for k, v := range g.props {
+ vals[k] = v
+ }
+ }
+
+ vals["refId"] = g.RefID
+ if g.Datasource.Type != "" || g.Datasource.UID != "" {
+ vals["datasource"] = g.Datasource
+ }
+ if g.DatasourceId > 0 {
+ vals["datasourceId"] = g.DatasourceId
+ }
+ if g.IntervalMS > 0 {
+ vals["intervalMs"] = g.IntervalMS
+ }
+ if g.MaxDataPoints > 0 {
+ vals["maxDataPoints"] = g.MaxDataPoints
+ }
+ return json.Marshal(vals)
+}
+
+// UnmarshalJSON ensures that the unstructured object properly decodes
+// JSON when passed to Go's standard JSON library.
+func (g *GenericDataQuery) UnmarshalJSON(b []byte) error {
+ vals := map[string]any{}
+ err := json.Unmarshal(b, &vals)
+ if err != nil {
+ return err
+ }
+ key := "refId"
+ v, ok := vals[key]
+ if ok {
+ g.RefID, ok = v.(string)
+ if !ok {
+ return fmt.Errorf("expected string refid (got: %t)", v)
+ }
+ delete(vals, key)
+ }
+
+ key = "datasource"
+ v, ok = vals[key]
+ if ok {
+ wrap, ok := v.(map[string]any)
+ if ok {
+ g.Datasource = &DataSourceRef{}
+ g.Datasource.Type, _ = wrap["type"].(string)
+ g.Datasource.UID, _ = wrap["uid"].(string)
+ delete(vals, key)
+ } else {
+ // Old old queries may arrive with just the name
+ name, ok := v.(string)
+ if !ok {
+ return fmt.Errorf("expected datasource as object (got: %t)", v)
+ }
+ g.Datasource = &DataSourceRef{}
+ g.Datasource.UID = name // Not great, but the lookup function will try its best to resolve
+ delete(vals, key)
+ }
+ }
+
+ key = "intervalMs"
+ v, ok = vals[key]
+ if ok {
+ g.IntervalMS, ok = v.(float64)
+ if !ok {
+ return fmt.Errorf("expected intervalMs as float (got: %t)", v)
+ }
+ delete(vals, key)
+ }
+
+ key = "maxDataPoints"
+ v, ok = vals[key]
+ if ok {
+ count, ok := v.(float64)
+ if !ok {
+ return fmt.Errorf("expected maxDataPoints as number (got: %t)", v)
+ }
+ g.MaxDataPoints = int64(count)
+ delete(vals, key)
+ }
+
+ key = "datasourceId"
+ v, ok = vals[key]
+ if ok {
+ count, ok := v.(float64)
+ if !ok {
+ return fmt.Errorf("expected datasourceId as number (got: %t)", v)
+ }
+ g.DatasourceId = int64(count)
+ delete(vals, key)
+ }
+
+ g.props = vals
+ return nil
+}
diff --git a/pkg/apis/query/v0alpha1/query_test.go b/pkg/apis/query/v0alpha1/query_test.go
new file mode 100644
index 00000000000..e2ea3ce53c4
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/query_test.go
@@ -0,0 +1,75 @@
+package v0alpha1_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+)
+
+func TestParseQueriesIntoQueryDataRequest(t *testing.T) {
+ request := []byte(`{
+ "queries": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "grafana-googlesheets-datasource",
+ "uid": "b1808c48-9fc9-4045-82d7-081781f8a553"
+ },
+ "cacheDurationSeconds": 300,
+ "spreadsheet": "spreadsheetID",
+ "datasourceId": 4,
+ "intervalMs": 30000,
+ "maxDataPoints": 794
+ },
+ {
+ "refId": "Z",
+ "datasource": "old",
+ "maxDataPoints": 10,
+ "timeRange": {
+ "from": "100",
+ "to": "200"
+ }
+ }
+ ],
+ "from": "1692624667389",
+ "to": "1692646267389"
+ }`)
+
+ req := &v0alpha1.GenericQueryRequest{}
+ err := json.Unmarshal(request, req)
+ require.NoError(t, err)
+
+ require.Len(t, req.Queries, 2)
+ require.Equal(t, "b1808c48-9fc9-4045-82d7-081781f8a553", req.Queries[0].Datasource.UID)
+ require.Equal(t, "spreadsheetID", req.Queries[0].AdditionalProperties()["spreadsheet"])
+
+ // Write the query (with additional spreadsheetID) to JSON
+ out, err := json.MarshalIndent(req.Queries[0], "", " ")
+ require.NoError(t, err)
+
+ // And read it back with standard JSON marshal functions
+ query := &v0alpha1.GenericDataQuery{}
+ err = json.Unmarshal(out, query)
+ require.NoError(t, err)
+ require.Equal(t, "spreadsheetID", query.AdditionalProperties()["spreadsheet"])
+
+ // The second query has an explicit time range, and legacy datasource name
+ out, err = json.MarshalIndent(req.Queries[1], "", " ")
+ require.NoError(t, err)
+ // fmt.Printf("%s\n", string(out))
+ require.JSONEq(t, `{
+ "datasource": {
+ "type": "", ` /* NOTE! this implies legacy naming */ +`
+ "uid": "old"
+ },
+ "maxDataPoints": 10,
+ "refId": "Z",
+ "timeRange": {
+ "from": "100",
+ "to": "200"
+ }
+ }`, string(out))
+}
diff --git a/pkg/apis/query/v0alpha1/register.go b/pkg/apis/query/v0alpha1/register.go
new file mode 100644
index 00000000000..b728ad7b43a
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/register.go
@@ -0,0 +1,25 @@
+package v0alpha1
+
+import (
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+)
+
+const (
+ GROUP = "query.grafana.app"
+ VERSION = "v0alpha1"
+ APIVERSION = GROUP + "/" + VERSION
+)
+
+var DataSourceApiServerResourceInfo = common.NewResourceInfo(GROUP, VERSION,
+ "datasourceapiservers", "datasourceapiserver", "DataSourceApiServer",
+ func() runtime.Object { return &DataSourceApiServer{} },
+ func() runtime.Object { return &DataSourceApiServerList{} },
+)
+
+var (
+ // SchemeGroupVersion is group version used to register these objects
+ SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
+)
diff --git a/pkg/apis/query/v0alpha1/results.go b/pkg/apis/query/v0alpha1/results.go
new file mode 100644
index 00000000000..2c3cab7b3f0
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/results.go
@@ -0,0 +1,64 @@
+package v0alpha1
+
+import (
+ "encoding/json"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ openapi "k8s.io/kube-openapi/pkg/common"
+ spec "k8s.io/kube-openapi/pkg/validation/spec"
+)
+
+// Wraps backend.QueryDataResponse, however it includes TypeMeta and implements runtime.Object
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type QueryDataResponse struct {
+ metav1.TypeMeta `json:",inline"`
+
+ // Backend wrapper (external dependency)
+ backend.QueryDataResponse
+}
+
+// Expose backend DataResponse in OpenAPI (yes this still requires some serious love!)
+func (r QueryDataResponse) OpenAPIDefinition() openapi.OpenAPIDefinition {
+ return openapi.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ AdditionalProperties: &spec.SchemaOrBool{Allows: true},
+ },
+ VendorExtensible: spec.VendorExtensible{
+ Extensions: map[string]interface{}{
+ "x-kubernetes-preserve-unknown-fields": true,
+ },
+ },
+ },
+ }
+}
+
+// MarshalJSON writes the results as json
+func (r QueryDataResponse) MarshalJSON() ([]byte, error) {
+ return r.QueryDataResponse.MarshalJSON()
+}
+
+// UnmarshalJSON will read JSON into a QueryDataResponse
+func (r *QueryDataResponse) UnmarshalJSON(b []byte) error {
+ return r.QueryDataResponse.UnmarshalJSON(b)
+}
+
+func (r *QueryDataResponse) DeepCopy() *QueryDataResponse {
+ if r == nil {
+ return nil
+ }
+
+ // /!\ The most dumb approach, but OK for now...
+ // likely best to move DeepCopy into SDK
+ out := &QueryDataResponse{}
+ body, _ := json.Marshal(r.QueryDataResponse)
+ _ = json.Unmarshal(body, &out.QueryDataResponse)
+ return out
+}
+
+func (r *QueryDataResponse) DeepCopyInto(out *QueryDataResponse) {
+ clone := r.DeepCopy()
+ *out = *clone
+}
diff --git a/pkg/apis/query/v0alpha1/types.go b/pkg/apis/query/v0alpha1/types.go
new file mode 100644
index 00000000000..3f288eee25d
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/types.go
@@ -0,0 +1,65 @@
+package v0alpha1
+
+import (
+ "context"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// The query runner interface
+type QueryRunner interface {
+ // Runs the query as the user in context
+ ExecuteQueryData(ctx context.Context,
+ // The k8s group for the datasource (pluginId)
+ datasource schema.GroupVersion,
+
+ // The datasource name/uid
+ name string,
+
+ // The raw backend query objects
+ query []GenericDataQuery,
+ ) (*backend.QueryDataResponse, error)
+}
+
+type DataSourceApiServerRegistry interface {
+ // Get the group and preferred version for a plugin
+ GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error)
+
+ // Get the list of available datasource api servers
+ // The values will be managed though API discovery/reconciliation
+ GetDatasourceApiServers(ctx context.Context) (*DataSourceApiServerList, error)
+}
+
+// The data source resource is a reflection of the individual datasource instances
+// that are exposed in the groups: {datasource}.datasource.grafana.app
+// The status is updated periodically.
+// The name is the plugin id
+//
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DataSourceApiServer struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // The display name
+ Title string `json:"title"`
+
+ // Describe the plugin
+ Description string `json:"description,omitempty"`
+
+ // The group + preferred version
+ GroupVersion string `json:"groupVersion"`
+
+ // Possible alternative plugin IDs
+ AliasIDs []string `json:"aliasIDs,omitempty"`
+}
+
+// List of datasource plugins
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type DataSourceApiServerList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+
+ Items []DataSourceApiServer `json:"items,omitempty"`
+}
diff --git a/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..927ed373ddc
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,140 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by deepcopy-gen. DO NOT EDIT.
+
+package v0alpha1
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DataSourceApiServer) DeepCopyInto(out *DataSourceApiServer) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ if in.AliasIDs != nil {
+ in, out := &in.AliasIDs, &out.AliasIDs
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceApiServer.
+func (in *DataSourceApiServer) DeepCopy() *DataSourceApiServer {
+ if in == nil {
+ return nil
+ }
+ out := new(DataSourceApiServer)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DataSourceApiServer) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DataSourceApiServerList) DeepCopyInto(out *DataSourceApiServerList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]DataSourceApiServer, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceApiServerList.
+func (in *DataSourceApiServerList) DeepCopy() *DataSourceApiServerList {
+ if in == nil {
+ return nil
+ }
+ out := new(DataSourceApiServerList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DataSourceApiServerList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DataSourceRef) DeepCopyInto(out *DataSourceRef) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceRef.
+func (in *DataSourceRef) DeepCopy() *DataSourceRef {
+ if in == nil {
+ return nil
+ }
+ out := new(DataSourceRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GenericQueryRequest) DeepCopyInto(out *GenericQueryRequest) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ if in.Queries != nil {
+ in, out := &in.Queries, &out.Queries
+ *out = make([]GenericDataQuery, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GenericQueryRequest.
+func (in *GenericQueryRequest) DeepCopy() *GenericQueryRequest {
+ if in == nil {
+ return nil
+ }
+ out := new(GenericQueryRequest)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *QueryDataResponse) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *TimeRange) DeepCopyInto(out *TimeRange) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeRange.
+func (in *TimeRange) DeepCopy() *TimeRange {
+ if in == nil {
+ return nil
+ }
+ out := new(TimeRange)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/pkg/apis/query/v0alpha1/zz_generated.defaults.go b/pkg/apis/query/v0alpha1/zz_generated.defaults.go
new file mode 100644
index 00000000000..238fc2f4edc
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/zz_generated.defaults.go
@@ -0,0 +1,19 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by defaulter-gen. DO NOT EDIT.
+
+package v0alpha1
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// RegisterDefaults adds defaulters functions to the given scheme.
+// Public to allow building arbitrary schemes.
+// All generated defaulters are covering - they call all nested defaulters.
+func RegisterDefaults(scheme *runtime.Scheme) error {
+ return nil
+}
diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi.go b/pkg/apis/query/v0alpha1/zz_generated.openapi.go
new file mode 100644
index 00000000000..c3a66de455c
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/zz_generated.openapi.go
@@ -0,0 +1,343 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by openapi-gen. DO NOT EDIT.
+
+// This file was autogenerated by openapi-gen. Do not edit it manually!
+
+package v0alpha1
+
+import (
+ common "k8s.io/kube-openapi/pkg/common"
+ spec "k8s.io/kube-openapi/pkg/validation/spec"
+)
+
+func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
+ return map[string]common.OpenAPIDefinition{
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer": schema_pkg_apis_query_v0alpha1_DataSourceApiServer(ref),
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServerList": schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref),
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceRef": schema_pkg_apis_query_v0alpha1_DataSourceRef(ref),
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.GenericDataQuery": schema_pkg_apis_query_v0alpha1_GenericDataQuery(ref),
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.GenericQueryRequest": schema_pkg_apis_query_v0alpha1_GenericQueryRequest(ref),
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": QueryDataResponse{}.OpenAPIDefinition(),
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.TimeRange": schema_pkg_apis_query_v0alpha1_TimeRange(ref),
+ }
+}
+
+func schema_pkg_apis_query_v0alpha1_DataSourceApiServer(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "The data source resource is a reflection of the individual datasource instances that are exposed in the groups: {datasource}.datasource.grafana.app The status is updated periodically. The name is the plugin id",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "title": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The display name",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "description": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Describe the plugin",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "groupVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The group + preferred version",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "aliasIDs": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Possible alternative plugin IDs",
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ },
+ },
+ },
+ Required: []string{"title", "groupVersion"},
+ },
+ },
+ Dependencies: []string{
+ "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "List of datasource plugins",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+ },
+ },
+ "items": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
+ }
+}
+
+func schema_pkg_apis_query_v0alpha1_DataSourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "type": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The datasource plugin type",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "uid": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Datasource UID",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"type", "uid"},
+ },
+ },
+ }
+}
+
+func schema_pkg_apis_query_v0alpha1_GenericDataQuery(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "GenericDataQuery is a replacement for `dtos.MetricRequest` that provides more explicit types",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "refId": {
+ SchemaProps: spec.SchemaProps{
+ Description: "RefID is the unique identifier of the query, set by the frontend call.",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "timeRange": {
+ SchemaProps: spec.SchemaProps{
+ Description: "TimeRange represents the query range NOTE: unlike generic /ds/query, we can now send explicit time values in each query",
+ Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.TimeRange"),
+ },
+ },
+ "datasource": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The datasource",
+ Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceRef"),
+ },
+ },
+ "datasourceId": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Deprecated -- use datasource ref instead",
+ Type: []string{"integer"},
+ Format: "int64",
+ },
+ },
+ "queryType": {
+ SchemaProps: spec.SchemaProps{
+ Description: "QueryType is an optional identifier for the type of query. It can be used to distinguish different types of queries.",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "maxDataPoints": {
+ SchemaProps: spec.SchemaProps{
+ Description: "MaxDataPoints is the maximum number of data points that should be returned from a time series query.",
+ Type: []string{"integer"},
+ Format: "int64",
+ },
+ },
+ "intervalMs": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Interval is the suggested duration between time points in a time series query.",
+ Type: []string{"number"},
+ Format: "double",
+ },
+ },
+ "hide": {
+ SchemaProps: spec.SchemaProps{
+ Description: "true if query is disabled (ie should not be returned to the dashboard) Note this does not always imply that the query should not be executed since the results from a hidden query may be used as the input to other queries (SSE etc)",
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"refId"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceRef", "github.com/grafana/grafana/pkg/apis/query/v0alpha1.TimeRange"},
+ }
+}
+
+func schema_pkg_apis_query_v0alpha1_GenericQueryRequest(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "Generic query request with shared time across all values Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "from": {
+ SchemaProps: spec.SchemaProps{
+ Description: "From Start time in epoch timestamps in milliseconds or relative using Grafana time units. example: now-1h",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "to": {
+ SchemaProps: spec.SchemaProps{
+ Description: "To End time in epoch timestamps in milliseconds or relative using Grafana time units. example: now",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "queries": {
+ SchemaProps: spec.SchemaProps{
+ Description: "queries.refId – Specifies an identifier of the query. Is optional and default to “A”. queries.datasourceId – Specifies the data source to be queried. Each query in the request must have an unique datasourceId. queries.maxDataPoints - Species maximum amount of data points that dashboard panel can render. Is optional and default to 100. queries.intervalMs - Specifies the time interval in milliseconds of time series. Is optional and defaults to 1000. required: true example: [ { \"refId\": \"A\", \"intervalMs\": 86400000, \"maxDataPoints\": 1092, \"datasource\":{ \"uid\":\"PD8C576611E62080A\" }, \"rawSql\": \"SELECT 1 as valueOne, 2 as valueTwo\", \"format\": \"table\" } ]",
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.GenericDataQuery"),
+ },
+ },
+ },
+ },
+ },
+ "debug": {
+ SchemaProps: spec.SchemaProps{
+ Description: "required: false",
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"queries"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1.GenericDataQuery"},
+ }
+}
+
+func schema_pkg_apis_query_v0alpha1_TimeRange(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "TimeRange represents a time range for a query and is a property of DataQuery.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "from": {
+ SchemaProps: spec.SchemaProps{
+ Description: "From is the start time of the query.",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "to": {
+ SchemaProps: spec.SchemaProps{
+ Description: "To is the end time of the query.",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"from", "to"},
+ },
+ },
+ }
+}
diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list
new file mode 100644
index 00000000000..b6d2a25a924
--- /dev/null
+++ b/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list
@@ -0,0 +1,5 @@
+API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/query/v0alpha1,DataSourceApiServer,AliasIDs
+API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/query/v0alpha1,GenericQueryRequest,Queries
+API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1,GenericDataQuery,IntervalMS
+API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1,GenericDataQuery,RefID
+API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1,QueryDataResponse,QueryDataResponse
diff --git a/pkg/cmd/grafana/apiserver/aggregator.md b/pkg/cmd/grafana/apiserver/aggregator.md
deleted file mode 100644
index 0c8ae3fae9a..00000000000
--- a/pkg/cmd/grafana/apiserver/aggregator.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# grafana aggregator
-
-The `aggregator` command in this binary is our equivalent of what kube-apiserver does for aggregation using
-the `kube-aggregator` pkg. Here, we enable only select controllers that are useful for aggregation in a Grafana
-cloud context. In future, Grafana microservices (and even plugins) will run as separate API servers
-hosting each their own APIs (with specific Group/Versions). The `aggregator` component here shall act similar to what
-`kube-apiserver` does: doing healthchecks for `APIService` objects registered against it and acting as a proxy for
-the specified `GroupVersion` therein.
-
-## How to get started
-
-1. Generate the PKI using `openssl` (for development purposes, we will use the CN of `system:masters`):
- ```shell
- ./hack/make-aggregator-pki.sh
- ```
-2. Start the aggregator:
- ```shell
- # This will generate the kubeconfig which you can use in the extension apiservers for
- # enforcing delegate authnz under $PWD/data/grafana-apiserver/aggregator.kubeconfig
- go run ./pkg/cmd/grafana aggregator --secure-port 8443 \
- --proxy-client-cert-file $PWD/data/grafana-aggregator/client.crt \
- --proxy-client-key-file $PWD/data/grafana-aggregator/client.key
- ```
-3. Apply the manifests:
- ```shell
- export KUBECONFIG=$PWD/data/grafana-apiserver/aggregator.kubeconfig
- kubectl apply -k ./pkg/cmd/grafana/apiserver/deploy/aggregator-test
- # SAMPLE OUTPUT
- # apiservice.apiregistration.k8s.io/v0alpha1.example.grafana.app created
- # externalname.service.grafana.app/example-apiserver created
-
- kubectl get apiservice
- # SAMPLE OUTPUT
- # NAME SERVICE AVAILABLE AGE
- # v0alpha1.example.grafana.app grafana/example-apiserver False (FailedDiscoveryCheck) 29m
- ```
-4. In another tab, start the example microservice that will be aggregated by the parent apiserver:
- ```shell
- go run ./pkg/cmd/grafana apiserver example.grafana.app \
- --kubeconfig $PWD/data/grafana-aggregator/aggregator.kubeconfig \
- --secure-port 7443 \
- --client-ca-file=$PWD/data/grafana-aggregator/ca.crt
- ```
-5. Check `APIService` again:
- ```shell
- export KUBECONFIG=$PWD/data/grafana-apiserver/aggregator.kubeconfig
- kubectl get apiservice
- # SAMPLE OUTPUT
- # NAME SERVICE AVAILABLE AGE
- # v0alpha1.example.grafana.app grafana/example-apiserver True 30m
- ```
diff --git a/pkg/cmd/grafana/apiserver/cmd.go b/pkg/cmd/grafana/apiserver/cmd.go
index 338c1ca9662..50f6ee979bf 100644
--- a/pkg/cmd/grafana/apiserver/cmd.go
+++ b/pkg/cmd/grafana/apiserver/cmd.go
@@ -1,31 +1,21 @@
package apiserver
import (
+ "fmt"
"os"
- "path"
+ "strings"
"github.com/spf13/cobra"
- genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/apiserver/pkg/server/options"
- "k8s.io/apiserver/pkg/util/notfoundhandler"
- "k8s.io/client-go/tools/clientcmd"
"k8s.io/component-base/cli"
- "k8s.io/klog/v2"
- aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme"
- "github.com/grafana/grafana/pkg/aggregator"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
-)
-
-const (
- aggregatorDataPath = "data"
- defaultAggregatorEtcdPathPrefix = "/registry/grafana.aggregator"
+ grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
)
func newCommandStartExampleAPIServer(o *APIServerOptions, stopCh <-chan struct{}) *cobra.Command {
devAcknowledgementNotice := "The apiserver command is in heavy development. The entire setup is subject to change without notice"
+ runtimeConfig := ""
cmd := &cobra.Command{
Use: "apiserver [api group(s)]",
@@ -34,8 +24,13 @@ func newCommandStartExampleAPIServer(o *APIServerOptions, stopCh <-chan struct{}
devAcknowledgementNotice,
Example: "grafana apiserver example.grafana.app",
RunE: func(c *cobra.Command, args []string) error {
+ apis, err := readRuntimeConfig(runtimeConfig)
+ if err != nil {
+ return err
+ }
+
// Load each group from the args
- if err := o.loadAPIGroupBuilders(args[1:]); err != nil {
+ if err := o.loadAPIGroupBuilders(apis); err != nil {
return err
}
@@ -56,6 +51,8 @@ func newCommandStartExampleAPIServer(o *APIServerOptions, stopCh <-chan struct{}
},
}
+ cmd.Flags().StringVar(&runtimeConfig, "runtime-config", "", "A set of key=value pairs that enable or disable built-in APIs.")
+
// Register standard k8s flags with the command line
o.RecommendedOptions = options.NewRecommendedOptions(
defaultEtcdPathPrefix,
@@ -75,96 +72,42 @@ func RunCLI() int {
return cli.Run(cmd)
}
-func newCommandStartAggregator() *cobra.Command {
- devAcknowledgementNotice := "The aggregator command is in heavy development. The entire setup is subject to change without notice"
-
- cwd, err := os.Getwd()
- if err != nil {
- panic("could not determine current directory")
- }
-
- extraConfig := &aggregator.ExtraConfig{
- DataPath: path.Join(cwd, aggregatorDataPath),
- }
-
- // Register standard k8s flags with the command line
- recommendedOptions := options.NewRecommendedOptions(
- defaultAggregatorEtcdPathPrefix,
- aggregatorscheme.Codecs.LegacyCodec(), // codec is passed to etcd and hence not used
- )
-
- cmd := &cobra.Command{
- Use: "aggregator",
- Short: "Run the grafana aggregator",
- Long: "Run a standalone kubernetes based aggregator server. " +
- devAcknowledgementNotice,
- Example: "grafana aggregator",
- RunE: func(c *cobra.Command, args []string) error {
- serverOptions, err := aggregator.NewAggregatorServerOptions(os.Stdout, os.Stderr, recommendedOptions, extraConfig)
- serverOptions.Config.Complete()
-
- if err != nil {
- klog.Errorf("Could not create aggregator server options: %s", err)
- os.Exit(1)
- }
-
- return run(serverOptions)
- },
- }
-
- recommendedOptions.AddFlags(cmd.Flags())
- extraConfig.AddFlags(cmd.Flags())
-
- return cmd
+type apiConfig struct {
+ group string
+ version string
+ enabled bool
}
-func run(serverOptions *aggregator.AggregatorServerOptions) error {
- if err := serverOptions.LoadAPIGroupBuilders(); err != nil {
- klog.Errorf("Error loading prerequisite APIs: %s", err)
- return err
- }
-
- notFoundHandler := notfoundhandler.New(serverOptions.Config.SharedConfig.Serializer, genericapifilters.NoMuxAndDiscoveryIncompleteKey)
- apiExtensionsServer, err := serverOptions.Config.ApiExtensionsComplete.New(genericapiserver.NewEmptyDelegateWithCustomHandler(notFoundHandler))
- if err != nil {
- return err
- }
-
- aggregator, err := serverOptions.CreateAggregatorServer(apiExtensionsServer.GenericAPIServer, apiExtensionsServer.Informers)
- if err != nil {
- klog.Errorf("Error creating aggregator server: %s", err)
- return err
- }
-
- // Install the API Group+version
- err = grafanaapiserver.InstallAPIs(aggregator.GenericAPIServer, serverOptions.Config.Aggregator.GenericConfig.RESTOptionsGetter, serverOptions.Builders)
- if err != nil {
- klog.Errorf("Error installing apis: %s", err)
- return err
- }
-
- if err := clientcmd.WriteToFile(
- utils.FormatKubeConfig(aggregator.GenericAPIServer.LoopbackClientConfig),
- path.Join(aggregatorDataPath, "grafana-aggregator", "aggregator.kubeconfig"),
- ); err != nil {
- klog.Errorf("Error persisting aggregator.kubeconfig: %s", err)
- return err
- }
-
- prepared, err := aggregator.PrepareRun()
- if err != nil {
- return err
- }
-
- stopCh := genericapiserver.SetupSignalHandler()
- if err := prepared.Run(stopCh); err != nil {
- return err
- }
- return nil
+func (a apiConfig) String() string {
+ return fmt.Sprintf("%s/%s=%v", a.group, a.version, a.enabled)
}
-func RunCobraWrapper() int {
- cmd := newCommandStartAggregator()
-
- return cli.Run(cmd)
+// Supported options are:
+//
+// /=true|false for a specific API group and version (e.g. dashboards.grafana.app/v0alpha1=true)
+// api/all=true|false controls all API versions
+// api/ga=true|false controls all API versions of the form v[0-9]+
+// api/beta=true|false controls all API versions of the form v[0-9]+beta[0-9]+
+// api/alpha=true|false controls all API versions of the form v[0-9]+alpha[0-9]+`)
+//
+// See: https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/
+func readRuntimeConfig(cfg string) ([]apiConfig, error) {
+ if cfg == "" {
+ return nil, fmt.Errorf("missing --runtime-config={apiservers}")
+ }
+ parts := strings.Split(cfg, ",")
+ apis := make([]apiConfig, len(parts))
+ for i, part := range parts {
+ idx0 := strings.Index(part, "/")
+ idx1 := strings.LastIndex(part, "=")
+ if idx1 < idx0 || idx0 < 0 {
+ return nil, fmt.Errorf("expected values in the form: group/version=true")
+ }
+ apis[i] = apiConfig{
+ group: part[:idx0],
+ version: part[idx0+1 : idx1],
+ enabled: part[idx1+1:] == "true",
+ }
+ }
+ return apis, nil
}
diff --git a/pkg/cmd/grafana/apiserver/cmd_test.go b/pkg/cmd/grafana/apiserver/cmd_test.go
new file mode 100644
index 00000000000..bf9b4b920c7
--- /dev/null
+++ b/pkg/cmd/grafana/apiserver/cmd_test.go
@@ -0,0 +1,22 @@
+package apiserver
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminAPIEndpoint(t *testing.T) {
+ out, err := readRuntimeConfig("all/all=true,dashboards.grafana.app/v0alpha1=false")
+ require.NoError(t, err)
+ require.Equal(t, []apiConfig{
+ {group: "all", version: "all", enabled: true},
+ {group: "dashboards.grafana.app", version: "v0alpha1", enabled: false},
+ }, out)
+ require.Equal(t, "all/all=true", fmt.Sprintf("%v", out[0]))
+
+ // Empty is an error
+ _, err = readRuntimeConfig("")
+ require.Error(t, err)
+}
diff --git a/pkg/cmd/grafana/apiserver/server.go b/pkg/cmd/grafana/apiserver/server.go
index 447567bb3de..ed9c4df6946 100644
--- a/pkg/cmd/grafana/apiserver/server.go
+++ b/pkg/cmd/grafana/apiserver/server.go
@@ -14,10 +14,14 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
+ "github.com/grafana/grafana/pkg/registry/apis/query"
+ "github.com/grafana/grafana/pkg/registry/apis/query/runner"
"github.com/grafana/grafana/pkg/server"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
+ grafanaAPIServer "github.com/grafana/grafana/pkg/services/apiserver"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaAPIServer "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/setting"
)
@@ -28,7 +32,7 @@ const (
// APIServerOptions contains the state for the apiserver
type APIServerOptions struct {
- builders []grafanaAPIServer.APIGroupBuilder
+ builders []builder.APIGroupBuilder
RecommendedOptions *options.RecommendedOptions
AlternateDNS []string
@@ -43,29 +47,44 @@ func newAPIServerOptions(out, errOut io.Writer) *APIServerOptions {
}
}
-func (o *APIServerOptions) loadAPIGroupBuilders(args []string) error {
- o.builders = []grafanaAPIServer.APIGroupBuilder{}
- for _, g := range args {
- switch g {
- // No dependencies for testing
+func (o *APIServerOptions) loadAPIGroupBuilders(runtime []apiConfig) error {
+ o.builders = []builder.APIGroupBuilder{}
+ for _, gv := range runtime {
+ if !gv.enabled {
+ return fmt.Errorf("disabling apis is not yet supported")
+ }
+ switch gv.group {
+ case "all":
+ return fmt.Errorf("managing all APIs is not yet supported")
case "example.grafana.app":
o.builders = append(o.builders, example.NewTestingAPIBuilder())
+ // Only works with testdata
+ case "query.grafana.app":
+ o.builders = append(o.builders, query.NewQueryAPIBuilder(
+ featuremgmt.WithFeatures(),
+ runner.NewDummyTestRunner(),
+ runner.NewDummyRegistry(),
+ ))
case "featuretoggle.grafana.app":
- features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil) // none... for now
- o.builders = append(o.builders, featuretoggle.NewFeatureFlagAPIBuilder(features))
+ o.builders = append(o.builders,
+ featuretoggle.NewFeatureFlagAPIBuilder(
+ featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil), // none... for now
+ &actest.FakeAccessControl{ExpectedEvaluate: false},
+ ),
+ )
case "testdata.datasource.grafana.app":
- ds, err := server.InitializeDataSourceAPIServer(g)
+ ds, err := server.InitializeDataSourceAPIServer(gv.group)
if err != nil {
return err
}
o.builders = append(o.builders, ds)
default:
- return fmt.Errorf("unknown group: %s", g)
+ return fmt.Errorf("unsupported runtime-config: %v", gv)
}
}
if len(o.builders) < 1 {
- return fmt.Errorf("expected group name(s) in the command line arguments")
+ return fmt.Errorf("no apis matched ")
}
// Install schemas
@@ -162,7 +181,7 @@ func (o *APIServerOptions) Config() (*genericapiserver.RecommendedConfig, error)
serverConfig.DisabledPostStartHooks = serverConfig.DisabledPostStartHooks.Insert("priority-and-fairness-config-consumer")
// Add OpenAPI specs for each group+version
- err := grafanaAPIServer.SetupConfig(serverConfig, o.builders)
+ err := builder.SetupConfig(grafanaAPIServer.Scheme, serverConfig, o.builders)
return serverConfig, err
}
@@ -190,7 +209,7 @@ func (o *APIServerOptions) RunAPIServer(config *genericapiserver.RecommendedConf
}
// Install the API Group+version
- err = grafanaAPIServer.InstallAPIs(server, config.RESTOptionsGetter, o.builders)
+ err = builder.InstallAPIs(grafanaAPIServer.Scheme, grafanaAPIServer.Codecs, server, config.RESTOptionsGetter, o.builders, true)
if err != nil {
return err
}
diff --git a/pkg/cmd/grafana/main.go b/pkg/cmd/grafana/main.go
index d86e66c3a21..696198e40b9 100644
--- a/pkg/cmd/grafana/main.go
+++ b/pkg/cmd/grafana/main.go
@@ -46,18 +46,6 @@ func main() {
},
},
gsrv.ServerCommand(version, commit, enterpriseCommit, buildBranch, buildstamp),
- {
- // The kube-aggregator inspired grafana aggregator
- Name: "aggregator",
- Usage: "run grafana aggregator (experimental)",
- // Skip parsing flags because the command line is actually managed by cobra
- SkipFlagParsing: true,
- Action: func(context *cli.Context) error {
- // exit here because apiserver handles its own error output
- os.Exit(apiserver.RunCobraWrapper())
- return nil
- },
- },
},
CommandNotFound: cmdNotFound,
EnableBashCompletion: true,
diff --git a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go
index 3f8c95ac956..012755732d6 100644
--- a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go
+++ b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go
@@ -2,6 +2,7 @@ package httpclientprovider
import (
"net/http"
+ "strconv"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/infra/httpclient"
@@ -18,7 +19,7 @@ var (
Name: "datasource_request_total",
Help: "A counter for outgoing requests for a data source",
},
- []string{"datasource", "datasource_type", "code", "method"},
+ []string{"datasource", "datasource_type", "code", "method", "secure_socks_ds_proxy_enabled"},
)
datasourceRequestHistogram = promauto.NewHistogramVec(
@@ -27,7 +28,7 @@ var (
Name: "datasource_request_duration_seconds",
Help: "histogram of durations of outgoing data source requests sent from Grafana",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100},
- }, []string{"datasource", "datasource_type", "code", "method"},
+ }, []string{"datasource", "datasource_type", "code", "method", "secure_socks_ds_proxy_enabled"},
)
datasourceResponseHistogram = promauto.NewHistogramVec(
@@ -36,7 +37,7 @@ var (
Name: "datasource_response_size_bytes",
Help: "histogram of data source response sizes returned to Grafana",
Buckets: []float64{128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576},
- }, []string{"datasource", "datasource_type"},
+ }, []string{"datasource", "datasource_type", "secure_socks_ds_proxy_enabled"},
)
datasourceRequestsInFlight = promauto.NewGaugeVec(
@@ -45,7 +46,7 @@ var (
Name: "datasource_request_in_flight",
Help: "A gauge of outgoing data source requests currently being sent by Grafana",
},
- []string{"datasource", "datasource_type"},
+ []string{"datasource", "datasource_type", "secure_socks_ds_proxy_enabled"},
)
)
@@ -82,7 +83,11 @@ func DataSourceMetricsMiddleware() sdkhttpclient.Middleware {
return next
}
- labels := prometheus.Labels{"datasource": datasourceLabelName, "datasource_type": datasourceLabelType}
+ labels := prometheus.Labels{
+ "datasource": datasourceLabelName,
+ "datasource_type": datasourceLabelType,
+ "secure_socks_ds_proxy_enabled": strconv.FormatBool(opts.ProxyOptions != nil && opts.ProxyOptions.Enabled),
+ }
return executeMiddlewareFunc(next, labels)
})
diff --git a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go
index 37afde4fd96..28874be941f 100644
--- a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go
+++ b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
+ "github.com/grafana/grafana-plugin-sdk-go/backend/proxy"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
@@ -103,29 +104,56 @@ func TestDataSourceMetricsMiddleware(t *testing.T) {
executeMiddlewareFunc = origExecuteMiddlewareFunc
})
- ctx := &testContext{}
- finalRoundTripper := ctx.createRoundTripper("finalrt")
- mw := DataSourceMetricsMiddleware()
- rt := mw.CreateMiddleware(httpclient.Options{Labels: map[string]string{"datasource_name": "My Data Source 123", "datasource_type": "prometheus"}}, finalRoundTripper)
- require.NotNil(t, rt)
- middlewareName, ok := mw.(httpclient.MiddlewareName)
- require.True(t, ok)
- require.Equal(t, DataSourceMetricsMiddlewareName, middlewareName.MiddlewareName())
+ testCases := []struct {
+ description string
+ httpClientOptions httpclient.Options
+ expectedSecureSocksDSProxyEnabled string
+ }{
+ {
+ description: "secure socks ds proxy is disabled",
+ httpClientOptions: httpclient.Options{
+ Labels: map[string]string{"datasource_name": "My Data Source 123", "datasource_type": "prometheus"},
+ },
+ expectedSecureSocksDSProxyEnabled: "false",
+ },
+ {
+ description: "secure socks ds proxy is enabled",
+ httpClientOptions: httpclient.Options{
+ Labels: map[string]string{"datasource_name": "My Data Source 123", "datasource_type": "prometheus"},
+ ProxyOptions: &proxy.Options{Enabled: true},
+ },
+ expectedSecureSocksDSProxyEnabled: "true",
+ },
+ }
- req, err := http.NewRequest(http.MethodGet, "http://", nil)
- require.NoError(t, err)
- res, err := rt.RoundTrip(req)
- require.NoError(t, err)
- require.NotNil(t, res)
- if res.Body != nil {
- require.NoError(t, res.Body.Close())
+ for _, tt := range testCases {
+ t.Run(tt.description, func(t *testing.T) {
+ ctx := &testContext{}
+ finalRoundTripper := ctx.createRoundTripper("finalrt")
+ mw := DataSourceMetricsMiddleware()
+ rt := mw.CreateMiddleware(tt.httpClientOptions, finalRoundTripper)
+ require.NotNil(t, rt)
+ middlewareName, ok := mw.(httpclient.MiddlewareName)
+ require.True(t, ok)
+ require.Equal(t, DataSourceMetricsMiddlewareName, middlewareName.MiddlewareName())
+
+ req, err := http.NewRequest(http.MethodGet, "http://", nil)
+ require.NoError(t, err)
+ res, err := rt.RoundTrip(req)
+ require.NoError(t, err)
+ require.NotNil(t, res)
+ if res.Body != nil {
+ require.NoError(t, res.Body.Close())
+ }
+ require.Len(t, ctx.callChain, 1)
+ require.ElementsMatch(t, []string{"finalrt"}, ctx.callChain)
+ require.True(t, executeMiddlewareCalled)
+ require.Len(t, labels, 3)
+ require.Equal(t, "My_Data_Source_123", labels["datasource"])
+ require.Equal(t, "prometheus", labels["datasource_type"])
+ require.Equal(t, tt.expectedSecureSocksDSProxyEnabled, labels["secure_socks_ds_proxy_enabled"])
+ require.True(t, middlewareCalled)
+ })
}
- require.Len(t, ctx.callChain, 1)
- require.ElementsMatch(t, []string{"finalrt"}, ctx.callChain)
- require.True(t, executeMiddlewareCalled)
- require.Len(t, labels, 2)
- require.Equal(t, "My_Data_Source_123", labels["datasource"])
- require.Equal(t, "prometheus", labels["datasource_type"])
- require.True(t, middlewareCalled)
})
}
diff --git a/pkg/registry/apis/datasource/utils.go b/pkg/plugins/apiserver.go
similarity index 72%
rename from pkg/registry/apis/datasource/utils.go
rename to pkg/plugins/apiserver.go
index 5beca00d70b..e72355beb9e 100644
--- a/pkg/registry/apis/datasource/utils.go
+++ b/pkg/plugins/apiserver.go
@@ -1,11 +1,13 @@
-package datasource
+package plugins
import (
"fmt"
"strings"
)
-func getDatasourceGroupNameFromPluginID(pluginId string) (string, error) {
+// Get the default API group name for from a plugin ID
+// NOTE: this is a work in progress, and may change without notice
+func GetDatasourceGroupNameFromPluginID(pluginId string) (string, error) {
if pluginId == "" {
return "", fmt.Errorf("bad pluginID (empty)")
}
diff --git a/pkg/registry/apis/datasource/utils_test.go b/pkg/plugins/apiserver_test.go
similarity index 89%
rename from pkg/registry/apis/datasource/utils_test.go
rename to pkg/plugins/apiserver_test.go
index 2bfded3d4e7..24d7102a40e 100644
--- a/pkg/registry/apis/datasource/utils_test.go
+++ b/pkg/plugins/apiserver_test.go
@@ -1,4 +1,4 @@
-package datasource
+package plugins
import (
"testing"
@@ -22,11 +22,11 @@ func TestUtils(t *testing.T) {
}
func getIDIgnoreError(id string) string {
- v, _ := getDatasourceGroupNameFromPluginID(id)
+ v, _ := GetDatasourceGroupNameFromPluginID(id)
return v
}
func getErrorIgnoreValue(id string) error {
- _, err := getDatasourceGroupNameFromPluginID(id)
+ _, err := GetDatasourceGroupNameFromPluginID(id)
return err
}
diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go
index bedd5cd45f4..e3611a86a13 100644
--- a/pkg/plugins/config/config.go
+++ b/pkg/plugins/config/config.go
@@ -48,12 +48,14 @@ type Cfg struct {
AngularSupportEnabled bool
HideAngularDeprecation []string
+
+ ConcurrentQueryCount int
}
func NewCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string,
awsAllowedAuthProviders []string, awsAssumeRoleEnabled bool, awsExternalId string, azure *azsettings.AzureSettings, secureSocksDSProxy setting.SecureSocksDSProxySettings,
grafanaVersion string, logDatasourceRequests bool, pluginsCDNURLTemplate string, appURL string, appSubURL string, tracing Tracing, features featuremgmt.FeatureToggles, angularSupportEnabled bool,
- grafanaComURL string, disablePlugins []string, hideAngularDeprecation []string, forwardHostEnvVars []string) *Cfg {
+ grafanaComURL string, disablePlugins []string, hideAngularDeprecation []string, forwardHostEnvVars []string, concurrentQueryCount int) *Cfg {
return &Cfg{
log: log.New("plugin.cfg"),
PluginsPath: pluginsPath,
@@ -77,5 +79,6 @@ func NewCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSetti
AngularSupportEnabled: angularSupportEnabled,
HideAngularDeprecation: hideAngularDeprecation,
ForwardHostEnvVars: forwardHostEnvVars,
+ ConcurrentQueryCount: concurrentQueryCount,
}
}
diff --git a/pkg/plugins/envvars/envvars.go b/pkg/plugins/envvars/envvars.go
index 9fc106113eb..624ed773fc4 100644
--- a/pkg/plugins/envvars/envvars.go
+++ b/pkg/plugins/envvars/envvars.go
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/proxy"
"github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles"
+
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/auth"
"github.com/grafana/grafana/pkg/plugins/config"
@@ -105,14 +106,17 @@ func (s *Service) GetConfigMap(ctx context.Context, pluginID string, _ *auth.Ext
if s.cfg.GrafanaAppURL != "" {
m[backend.AppURL] = s.cfg.GrafanaAppURL
}
+ if s.cfg.ConcurrentQueryCount != 0 {
+ m[backend.ConcurrentQueryCount] = strconv.Itoa(s.cfg.ConcurrentQueryCount)
+ }
// TODO add support via plugin SDK
- //if externalService != nil {
+ // if externalService != nil {
// m[oauthtokenretriever.AppURL] = s.cfg.GrafanaAppURL
// m[oauthtokenretriever.AppClientID] = externalService.ClientID
// m[oauthtokenretriever.AppClientSecret] = externalService.ClientSecret
// m[oauthtokenretriever.AppPrivateKey] = externalService.PrivateKey
- //}
+ // }
if s.cfg.Features != nil {
enabledFeatures := s.cfg.Features.GetEnabled(ctx)
@@ -126,15 +130,15 @@ func (s *Service) GetConfigMap(ctx context.Context, pluginID string, _ *auth.Ext
}
}
// TODO add support via plugin SDK
- //if s.cfg.AWSAssumeRoleEnabled {
+ // if s.cfg.AWSAssumeRoleEnabled {
// m[awsds.AssumeRoleEnabledEnvVarKeyName] = "true"
- //}
- //if len(s.cfg.AWSAllowedAuthProviders) > 0 {
+ // }
+ // if len(s.cfg.AWSAllowedAuthProviders) > 0 {
// m[awsds.AllowedAuthProvidersEnvVarKeyName] = strings.Join(s.cfg.AWSAllowedAuthProviders, ",")
- //}
- //if s.cfg.AWSExternalId != "" {
+ // }
+ // if s.cfg.AWSExternalId != "" {
// m[awsds.GrafanaAssumeRoleExternalIdKeyName] = s.cfg.AWSExternalId
- //}
+ // }
if s.cfg.ProxySettings.Enabled {
m[proxy.PluginSecureSocksProxyEnabled] = "true"
@@ -198,10 +202,10 @@ func (s *Service) GetConfigMap(ctx context.Context, pluginID string, _ *auth.Ext
}
// TODO add support via plugin SDK
- //ps := getPluginSettings(pluginID, s.cfg)
- //for k, v := range ps {
+ // ps := getPluginSettings(pluginID, s.cfg)
+ // for k, v := range ps {
// m[fmt.Sprintf("%s_%s", customConfigPrefix, strings.ToUpper(k))] = v
- //}
+ // }
return m
}
diff --git a/pkg/plugins/envvars/envvars_test.go b/pkg/plugins/envvars/envvars_test.go
index 7ed2c968f33..2e93f4cba1a 100644
--- a/pkg/plugins/envvars/envvars_test.go
+++ b/pkg/plugins/envvars/envvars_test.go
@@ -798,6 +798,33 @@ func TestService_GetConfigMap_appURL(t *testing.T) {
})
}
+func TestService_GetConfigMap_concurrentQueryCount(t *testing.T) {
+ t.Run("Uses the configured concurrent query count", func(t *testing.T) {
+ s := &Service{
+ cfg: &config.Cfg{
+ ConcurrentQueryCount: 42,
+ },
+ }
+ require.Equal(t, map[string]string{"GF_CONCURRENT_QUERY_COUNT": "42"}, s.GetConfigMap(context.Background(), "", nil))
+ })
+
+ t.Run("Doesn't set the concurrent query count if it is not in the config", func(t *testing.T) {
+ s := &Service{
+ cfg: &config.Cfg{},
+ }
+ require.Equal(t, map[string]string{}, s.GetConfigMap(context.Background(), "", nil))
+ })
+
+ t.Run("Doesn't set the concurrent query count if it is zero", func(t *testing.T) {
+ s := &Service{
+ cfg: &config.Cfg{
+ ConcurrentQueryCount: 0,
+ },
+ }
+ require.Equal(t, map[string]string{}, s.GetConfigMap(context.Background(), "", nil))
+ })
+}
+
func TestService_GetConfigMap_azure(t *testing.T) {
azSettings := &azsettings.AzureSettings{
Cloud: azsettings.AzurePublic,
diff --git a/pkg/plugins/pfs/corelist/corelist_load_gen.go b/pkg/plugins/pfs/corelist/corelist_load_gen.go
index 35be65e201f..3394d9fe03d 100644
--- a/pkg/plugins/pfs/corelist/corelist_load_gen.go
+++ b/pkg/plugins/pfs/corelist/corelist_load_gen.go
@@ -34,7 +34,7 @@ func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin {
return []pfs.ParsedPlugin{
parsePluginOrPanic("public/app/plugins/datasource/alertmanager", "alertmanager", rt),
parsePluginOrPanic("public/app/plugins/datasource/azuremonitor", "grafana_azure_monitor_datasource", rt),
- parsePluginOrPanic("public/app/plugins/datasource/cloud-monitoring", "grafana_cloud_monitoring_datasource", rt),
+ parsePluginOrPanic("public/app/plugins/datasource/cloud-monitoring", "stackdriver", rt),
parsePluginOrPanic("public/app/plugins/datasource/cloudwatch", "cloudwatch", rt),
parsePluginOrPanic("public/app/plugins/datasource/dashboard", "dashboard", rt),
parsePluginOrPanic("public/app/plugins/datasource/elasticsearch", "elasticsearch", rt),
diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go
index c2575481d70..2a918867edc 100644
--- a/pkg/registry/apis/apis.go
+++ b/pkg/registry/apis/apis.go
@@ -5,11 +5,14 @@ import (
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/playlist"
+ "github.com/grafana/grafana/pkg/registry/apis/query"
+ "github.com/grafana/grafana/pkg/registry/apis/service"
)
var (
@@ -24,9 +27,12 @@ func ProvideRegistryServiceSink(
_ *dashboard.DashboardsAPIBuilder,
_ *playlist.PlaylistAPIBuilder,
_ *example.TestingAPIBuilder,
+ _ *dashboardsnapshot.SnapshotsAPIBuilder,
_ *featuretoggle.FeatureFlagAPIBuilder,
_ *datasource.DataSourceAPIBuilder,
_ *folders.FolderAPIBuilder,
+ _ *service.ServiceAPIBuilder,
+ _ *query.QueryAPIBuilder,
) *Service {
return &Service{}
}
diff --git a/pkg/registry/apis/dashboard/access/sql_dashboards.go b/pkg/registry/apis/dashboard/access/sql_dashboards.go
index b0e8304fa6f..4064a1fa7a3 100644
--- a/pkg/registry/apis/dashboard/access/sql_dashboards.go
+++ b/pkg/registry/apis/dashboard/access/sql_dashboards.go
@@ -14,9 +14,9 @@ import (
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
)
diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go
index 9aa8d2b0f59..54a772591d5 100644
--- a/pkg/registry/apis/dashboard/authorizer.go
+++ b/pkg/registry/apis/dashboard/authorizer.go
@@ -7,8 +7,8 @@ import (
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/guardian"
)
diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go
index 73062163836..e5a3a5ede65 100644
--- a/pkg/registry/apis/dashboard/legacy_storage.go
+++ b/pkg/registry/apis/dashboard/legacy_storage.go
@@ -14,7 +14,7 @@ import (
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
)
var (
diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go
index 7acf642e0d1..e6434240b15 100644
--- a/pkg/registry/apis/dashboard/register.go
+++ b/pkg/registry/apis/dashboard/register.go
@@ -19,19 +19,19 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ grafanaregistry "github.com/grafana/grafana/pkg/services/apiserver/registry/generic"
+ grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
- grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/setting"
)
-var _ grafanaapiserver.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
@@ -47,7 +47,7 @@ type DashboardsAPIBuilder struct {
}
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
- apiregistration grafanaapiserver.APIRegistrar,
+ apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
dashboardVersionService dashver.Service,
accessControl accesscontrol.AccessControl,
@@ -113,6 +113,7 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
optsGetter generic.RESTOptionsGetter,
+ dualWrite bool,
) (*genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
@@ -171,7 +172,7 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
}
// Dual writes if a RESTOptionsGetter is provided
- if optsGetter != nil {
+ if dualWrite && optsGetter != nil {
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs}
if err := store.CompleteWithOptions(options); err != nil {
return nil, err
@@ -195,6 +196,6 @@ func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefiniti
return v0alpha1.GetOpenAPIDefinitions
}
-func (b *DashboardsAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
+func (b *DashboardsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
return nil // no custom API routes
}
diff --git a/pkg/registry/apis/dashboard/sub_access.go b/pkg/registry/apis/dashboard/sub_access.go
index c184cc8adce..ca2c783b5e2 100644
--- a/pkg/registry/apis/dashboard/sub_access.go
+++ b/pkg/registry/apis/dashboard/sub_access.go
@@ -11,9 +11,9 @@ import (
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/auth/identity"
dashboardssvc "github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/guardian"
)
diff --git a/pkg/registry/apis/dashboard/sub_versions.go b/pkg/registry/apis/dashboard/sub_versions.go
index ca4c06caba1..a38f9947d84 100644
--- a/pkg/registry/apis/dashboard/sub_versions.go
+++ b/pkg/registry/apis/dashboard/sub_versions.go
@@ -13,8 +13,8 @@ import (
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
type VersionsREST struct {
diff --git a/pkg/registry/apis/dashboard/summary_storage.go b/pkg/registry/apis/dashboard/summary_storage.go
index ef9cf3578ec..17311be1372 100644
--- a/pkg/registry/apis/dashboard/summary_storage.go
+++ b/pkg/registry/apis/dashboard/summary_storage.go
@@ -10,7 +10,7 @@ import (
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
)
var (
diff --git a/pkg/registry/apis/dashboardsnapshot/conversions.go b/pkg/registry/apis/dashboardsnapshot/conversions.go
new file mode 100644
index 00000000000..8071799de5a
--- /dev/null
+++ b/pkg/registry/apis/dashboardsnapshot/conversions.go
@@ -0,0 +1,71 @@
+package dashboardsnapshot
+
+import (
+ "fmt"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
+)
+
+func convertDTOToSnapshot(v *dashboardsnapshots.DashboardSnapshotDTO, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot {
+ expires := v.Expires.UnixMilli()
+ if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) {
+ expires = 0 // ignore things expiring long into the future
+ }
+ snap := &dashboardsnapshot.DashboardSnapshot{
+ TypeMeta: resourceInfo.TypeMeta(),
+ ObjectMeta: metav1.ObjectMeta{
+ Name: v.Key,
+ ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
+ CreationTimestamp: metav1.NewTime(v.Created),
+ Namespace: namespacer(v.OrgID),
+ },
+ Spec: dashboardsnapshot.SnapshotInfo{
+ Title: v.Name,
+ ExternalURL: v.ExternalURL,
+ Expires: expires,
+ },
+ }
+ if v.Updated != v.Created {
+ meta, _ := utils.MetaAccessor(snap)
+ meta.SetUpdatedTimestamp(&v.Updated)
+ }
+ return snap
+}
+
+func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot {
+ expires := v.Expires.UnixMilli()
+ if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) {
+ expires = 0 // ignore things expiring long into the future
+ }
+
+ info := dashboardsnapshot.SnapshotInfo{
+ Title: v.Name,
+ ExternalURL: v.ExternalURL,
+ Expires: expires,
+ }
+ s := v.Dashboard.Get("snapshot")
+ if s != nil {
+ info.OriginalUrl, _ = s.Get("originalUrl").String()
+ info.Timestamp, _ = s.Get("timestamp").String()
+ }
+ snap := &dashboardsnapshot.DashboardSnapshot{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: v.Key,
+ ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
+ CreationTimestamp: metav1.NewTime(v.Created),
+ Namespace: namespacer(v.OrgID),
+ },
+ Spec: info,
+ }
+ if v.Updated != v.Created {
+ meta, _ := utils.MetaAccessor(snap)
+ meta.SetUpdatedTimestamp(&v.Updated)
+ }
+ return snap
+}
diff --git a/pkg/registry/apis/dashboardsnapshot/exporter.go b/pkg/registry/apis/dashboardsnapshot/exporter.go
new file mode 100644
index 00000000000..fdf8caab9e1
--- /dev/null
+++ b/pkg/registry/apis/dashboardsnapshot/exporter.go
@@ -0,0 +1,130 @@
+package dashboardsnapshot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "gocloud.dev/blob"
+ "k8s.io/kube-openapi/pkg/spec3"
+
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
+)
+
+type dashExportStatus struct {
+ Count int
+ Index int
+ Started int64
+ Updated int64
+ Finished int64
+ Error string
+}
+
+type dashExporter struct {
+ status dashExportStatus
+
+ service dashboardsnapshots.Service
+ sql db.DB
+}
+
+func (d *dashExporter) getAPIRouteHandler() builder.APIRouteHandler {
+ return builder.APIRouteHandler{
+ Path: "admin/export",
+ Spec: &spec3.PathProps{
+ Summary: "an example at the root level",
+ Description: "longer description here?",
+ Post: &spec3.Operation{
+ OperationProps: spec3.OperationProps{
+ Tags: []string{"export"},
+ Responses: &spec3.Responses{
+ ResponsesProps: spec3.ResponsesProps{
+ StatusCodeResponses: map[int]*spec3.Response{
+ 200: {
+ ResponseProps: spec3.ResponseProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ // Only let it start once
+ if d.status.Started == 0 {
+ go d.doExport()
+ }
+ time.Sleep(time.Second)
+ _ = json.NewEncoder(w).Encode(d.status)
+ },
+ }
+}
+
+// NO way to stop!!!!!!
+func (d *dashExporter) doExport() {
+ defer func() {
+ d.status.Finished = time.Now().UnixMilli()
+ }()
+ d.status = dashExportStatus{
+ Started: time.Now().UnixMilli(),
+ }
+ if d.sql == nil {
+ d.status.Error = "missing dependencies"
+ return
+ }
+
+ ctx := context.Background()
+ keys := []string{}
+ err := d.sql.GetSqlxSession().Select(ctx,
+ &keys, "SELECT key FROM dashboard_snapshot ORDER BY id asc")
+ if err != nil {
+ d.status.Error = err.Error()
+ return
+ }
+ d.status.Count = len(keys)
+
+ bucket, err := blob.OpenBucket(ctx, "mem://?key=foo.txt&prefix=a/subfolder/")
+ if err != nil {
+ d.status.Error = err.Error()
+ return
+ }
+ defer func() {
+ _ = bucket.Close()
+ }()
+
+ for idx, key := range keys {
+ d.status.Index = idx
+ snap, err := d.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
+ Key: key,
+ })
+ if err != nil {
+ d.status.Error = err.Error()
+ return
+ }
+
+ dash, err := snap.Dashboard.ToDB()
+ if err != nil {
+ d.status.Error = err.Error()
+ return
+ }
+
+ fmt.Printf("TODO, export: %s (len: %d)\n", snap.Key, len(dash))
+
+ // w, err := bucket.NewWriter(ctx, "foo.txt", nil)
+ // if err != nil {
+ // d.status.Error = err.Error()
+ // return
+ // }
+
+ time.Sleep(time.Second * 1)
+ d.status.Updated = time.Now().UnixMilli()
+ }
+ fmt.Printf("done!\n")
+}
diff --git a/pkg/registry/apis/dashboardsnapshot/options_storage.go b/pkg/registry/apis/dashboardsnapshot/options_storage.go
new file mode 100644
index 00000000000..571010b9f5c
--- /dev/null
+++ b/pkg/registry/apis/dashboardsnapshot/options_storage.go
@@ -0,0 +1,91 @@
+package dashboardsnapshot
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+var (
+ _ rest.Scoper = (*optionsStorage)(nil)
+ _ rest.SingularNameProvider = (*optionsStorage)(nil)
+ _ rest.Getter = (*optionsStorage)(nil)
+ _ rest.Lister = (*optionsStorage)(nil)
+ _ rest.Storage = (*optionsStorage)(nil)
+)
+
+type sharingOptionsGetter = func(namespace string) (*dashboardsnapshot.SharingOptions, error)
+
+func newSharingOptionsGetter(cfg *setting.Cfg) sharingOptionsGetter {
+ s := &dashboardsnapshot.SharingOptions{
+ ObjectMeta: metav1.ObjectMeta{
+ CreationTimestamp: metav1.Now(),
+ },
+ Spec: dashboardsnapshot.SnapshotSharingOptions{
+ SnapshotsEnabled: cfg.SnapshotEnabled,
+ ExternalSnapshotURL: cfg.ExternalSnapshotUrl,
+ ExternalSnapshotName: cfg.ExternalSnapshotName,
+ ExternalEnabled: cfg.ExternalEnabled,
+ },
+ }
+ return func(namespace string) (*dashboardsnapshot.SharingOptions, error) {
+ return s, nil
+ }
+}
+
+type optionsStorage struct {
+ getter sharingOptionsGetter
+ tableConverter rest.TableConvertor
+}
+
+func (s *optionsStorage) New() runtime.Object {
+ return &dashboardsnapshot.SharingOptions{}
+}
+
+func (s *optionsStorage) Destroy() {}
+
+func (s *optionsStorage) NamespaceScoped() bool {
+ return true
+}
+
+func (s *optionsStorage) GetSingularName() string {
+ return "options"
+}
+
+func (s *optionsStorage) NewList() runtime.Object {
+ return &dashboardsnapshot.SharingOptionsList{}
+}
+
+func (s *optionsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
+}
+
+func (s *optionsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
+ info, err := request.NamespaceInfoFrom(ctx, true)
+ if err != nil {
+ return nil, err
+ }
+ if info.OrgID < 0 {
+ return nil, fmt.Errorf("missing namespace")
+ }
+ v, err := s.getter(info.Value)
+ if err != nil {
+ return nil, err
+ }
+ list := &dashboardsnapshot.SharingOptionsList{
+ Items: []dashboardsnapshot.SharingOptions{*v},
+ }
+ return list, nil
+}
+
+func (s *optionsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
+ return s.getter(name)
+}
diff --git a/pkg/registry/apis/dashboardsnapshot/register.go b/pkg/registry/apis/dashboardsnapshot/register.go
new file mode 100644
index 00000000000..e59601696f3
--- /dev/null
+++ b/pkg/registry/apis/dashboardsnapshot/register.go
@@ -0,0 +1,345 @@
+package dashboardsnapshot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/mux"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/runtime/serializer"
+ "k8s.io/apiserver/pkg/authorization/authorizer"
+ "k8s.io/apiserver/pkg/registry/generic"
+ "k8s.io/apiserver/pkg/registry/rest"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ common "k8s.io/kube-openapi/pkg/common"
+ "k8s.io/kube-openapi/pkg/spec3"
+ "k8s.io/kube-openapi/pkg/validation/spec"
+
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
+ "github.com/grafana/grafana/pkg/util/errutil/errhttp"
+ "github.com/grafana/grafana/pkg/web"
+)
+
+var _ builder.APIGroupBuilder = (*SnapshotsAPIBuilder)(nil)
+
+var resourceInfo = dashboardsnapshot.DashboardSnapshotResourceInfo
+
+// This is used just so wire has something unique to return
+type SnapshotsAPIBuilder struct {
+ service dashboardsnapshots.Service
+ namespacer request.NamespaceMapper
+ options sharingOptionsGetter
+ exporter *dashExporter
+ logger log.Logger
+}
+
+func NewSnapshotsAPIBuilder(
+ p dashboardsnapshots.Service,
+ cfg *setting.Cfg,
+ exporter *dashExporter,
+) *SnapshotsAPIBuilder {
+ return &SnapshotsAPIBuilder{
+ service: p,
+ options: newSharingOptionsGetter(cfg),
+ namespacer: request.GetNamespaceMapper(cfg),
+ exporter: exporter,
+ logger: log.New("snapshots::RawHandlers"),
+ }
+}
+
+func RegisterAPIService(
+ service dashboardsnapshots.Service,
+ apiregistration builder.APIRegistrar,
+ cfg *setting.Cfg,
+ features featuremgmt.FeatureToggles,
+ sql db.DB,
+) *SnapshotsAPIBuilder {
+ if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
+ return nil // skip registration unless opting into experimental apis
+ }
+ builder := NewSnapshotsAPIBuilder(service, cfg, &dashExporter{
+ service: service,
+ sql: sql,
+ })
+ apiregistration.RegisterAPI(builder)
+ return builder
+}
+
+func (b *SnapshotsAPIBuilder) GetGroupVersion() schema.GroupVersion {
+ return resourceInfo.GroupVersion()
+}
+
+func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
+ scheme.AddKnownTypes(gv,
+ &dashboardsnapshot.DashboardSnapshot{},
+ &dashboardsnapshot.DashboardSnapshotList{},
+ &dashboardsnapshot.SharingOptions{},
+ &dashboardsnapshot.SharingOptionsList{},
+ &dashboardsnapshot.FullDashboardSnapshot{},
+ &dashboardsnapshot.DashboardSnapshotWithDeleteKey{},
+ &metav1.Status{},
+ )
+}
+
+func (b *SnapshotsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
+ gv := resourceInfo.GroupVersion()
+ addKnownTypes(scheme, gv)
+
+ // Link this version to the internal representation.
+ // This is used for server-side-apply (PATCH), and avoids the error:
+ // "no kind is registered for the type"
+ addKnownTypes(scheme, schema.GroupVersion{
+ Group: gv.Group,
+ Version: runtime.APIVersionInternal,
+ })
+
+ // If multiple versions exist, then register conversions from zz_generated.conversion.go
+ // if err := playlist.RegisterConversions(scheme); err != nil {
+ // return err
+ // }
+ metav1.AddToGroupVersion(scheme, gv)
+ return scheme.SetVersionPriority(gv)
+}
+
+func (b *SnapshotsAPIBuilder) GetAPIGroupInfo(
+ scheme *runtime.Scheme,
+ codecs serializer.CodecFactory, // pointer?
+ optsGetter generic.RESTOptionsGetter,
+ dualWrite bool,
+) (*genericapiserver.APIGroupInfo, error) {
+ apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(dashboardsnapshot.GROUP, scheme, metav1.ParameterCodec, codecs)
+ storage := map[string]rest.Storage{}
+
+ legacyStore := &legacyStorage{
+ service: b.service,
+ namespacer: b.namespacer,
+ options: b.options,
+ }
+ legacyStore.tableConverter = utils.NewTableConverter(
+ resourceInfo.GroupResource(),
+ []metav1.TableColumnDefinition{
+ {Name: "Name", Type: "string", Format: "name"},
+ {Name: "Title", Type: "string", Format: "string", Description: "The snapshot name"},
+ {Name: "Created At", Type: "date"},
+ },
+ func(obj any) ([]interface{}, error) {
+ m, ok := obj.(*dashboardsnapshot.DashboardSnapshot)
+ if ok {
+ return []interface{}{
+ m.Name,
+ m.Spec.Title,
+ m.CreationTimestamp.UTC().Format(time.RFC3339),
+ }, nil
+ }
+ return nil, fmt.Errorf("expected snapshot")
+ },
+ )
+ storage[resourceInfo.StoragePath()] = legacyStore
+ storage[resourceInfo.StoragePath("body")] = &subBodyREST{
+ service: b.service,
+ namespacer: b.namespacer,
+ }
+
+ storage["options"] = &optionsStorage{
+ getter: b.options,
+ tableConverter: legacyStore.tableConverter,
+ }
+
+ apiGroupInfo.VersionedResourcesStorageMap[dashboardsnapshot.VERSION] = storage
+ return &apiGroupInfo, nil
+}
+
+func (b *SnapshotsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+ return dashboardsnapshot.GetOpenAPIDefinitions
+}
+
+// Register additional routes with the server
+func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
+ prefix := dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource
+ defs := dashboardsnapshot.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
+ createCmd := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand"].Schema
+ createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
+ createRsp := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse"].Schema
+
+ tags := []string{dashboardsnapshot.DashboardSnapshotResourceInfo.GroupVersionKind().Kind}
+ routes := &builder.APIRoutes{
+ Namespace: []builder.APIRouteHandler{
+ {
+ Path: prefix + "/create",
+ Spec: &spec3.PathProps{
+ Summary: "an example at the root level",
+ Description: "longer description here?",
+ Post: &spec3.Operation{
+ OperationProps: spec3.OperationProps{
+ Tags: tags,
+ Parameters: []*spec3.Parameter{
+ {
+ ParameterProps: spec3.ParameterProps{
+ Name: "namespace",
+ In: "path",
+ Required: true,
+ Example: "default",
+ Description: "workspace",
+ Schema: spec.StringProperty(),
+ },
+ },
+ },
+ RequestBody: &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &createCmd,
+ Example: createExample, // raw JSON body
+ },
+ },
+ },
+ },
+ },
+ Responses: &spec3.Responses{
+ ResponsesProps: spec3.ResponsesProps{
+ StatusCodeResponses: map[int]*spec3.Response{
+ 200: {
+ ResponseProps: spec3.ResponseProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &createRsp,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ user, err := appcontext.User(r.Context())
+ if err != nil {
+ errhttp.Write(r.Context(), err, w)
+ return
+ }
+ wrap := &contextmodel.ReqContext{
+ Logger: b.logger,
+ Context: &web.Context{
+ Req: r,
+ Resp: web.NewResponseWriter(r.Method, w),
+ },
+ SignedInUser: user,
+ }
+
+ vars := mux.Vars(r)
+ info, err := request.ParseNamespace(vars["namespace"])
+ if err != nil {
+ wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
+ return
+ }
+ if info.OrgID != user.OrgID {
+ wrap.JsonApiErr(http.StatusBadRequest,
+ fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.OrgID), nil)
+ return
+ }
+ opts, err := b.options(info.Value)
+ if err != nil {
+ wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err)
+ return
+ }
+
+ // Use the existing snapshot service
+ dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, b.service)
+ },
+ },
+ {
+ Path: prefix + "/delete/{deleteKey}",
+ Spec: &spec3.PathProps{
+ Summary: "an example at the root level",
+ Description: "longer description here?",
+ Delete: &spec3.Operation{
+ OperationProps: spec3.OperationProps{
+ Tags: tags,
+ Parameters: []*spec3.Parameter{
+ {
+ ParameterProps: spec3.ParameterProps{
+ Name: "deleteKey",
+ In: "path",
+ Required: true,
+ Description: "unique key returned in create",
+ Schema: spec.StringProperty(),
+ },
+ },
+ },
+ },
+ },
+ },
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ vars := mux.Vars(r)
+ key := vars["deleteKey"]
+
+ err := dashboardsnapshots.DeleteWithKey(ctx, key, b.service)
+ if err != nil {
+ errhttp.Write(ctx, fmt.Errorf("failed to delete external dashboard (%w)", err), w)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(&util.DynMap{
+ "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
+ })
+ },
+ },
+ },
+ }
+
+ // dev environment to export all snapshots to a blob store
+ if b.exporter != nil && false {
+ routes.Root = append(routes.Root, b.exporter.getAPIRouteHandler())
+ }
+ return routes
+}
+
+func (b *SnapshotsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
+ // TODO: this behavior must match the existing logic (it is currently more restrictive)
+ //
+ // https://github.com/grafana/grafana/blob/f63e43c113ac0cf8f78ed96ee2953874139bd2dc/pkg/middleware/auth.go#L203
+ // func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler {
+ // return func(c *contextmodel.ReqContext) {
+ // if cfg.SnapshotPublicMode {
+ // return
+ // }
+
+ // if !c.IsSignedIn {
+ // notAuthorized(c)
+ // return
+ // }
+ // }
+ // }
+
+ return authorizer.AuthorizerFunc(
+ func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
+ // Everyone can view dashsnaps
+ if attr.GetVerb() == "get" && attr.GetResource() == dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource {
+ return authorizer.DecisionAllow, "", err
+ }
+
+ // Fallback to the default behaviors (namespace matches org)
+ return authorizer.DecisionNoOpinion, "", err
+ })
+}
diff --git a/pkg/registry/apis/dashboardsnapshot/sql_storage.go b/pkg/registry/apis/dashboardsnapshot/sql_storage.go
new file mode 100644
index 00000000000..b21231a8613
--- /dev/null
+++ b/pkg/registry/apis/dashboardsnapshot/sql_storage.go
@@ -0,0 +1,147 @@
+package dashboardsnapshot
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
+)
+
+var (
+ _ rest.Scoper = (*legacyStorage)(nil)
+ _ rest.SingularNameProvider = (*legacyStorage)(nil)
+ _ rest.Getter = (*legacyStorage)(nil)
+ _ rest.Lister = (*legacyStorage)(nil)
+ _ rest.Storage = (*legacyStorage)(nil)
+ _ rest.GracefulDeleter = (*legacyStorage)(nil)
+)
+
+type legacyStorage struct {
+ service dashboardsnapshots.Service
+ namespacer request.NamespaceMapper
+ tableConverter rest.TableConvertor
+ options sharingOptionsGetter
+}
+
+func (s *legacyStorage) New() runtime.Object {
+ return resourceInfo.NewFunc()
+}
+
+func (s *legacyStorage) Destroy() {}
+
+func (s *legacyStorage) NamespaceScoped() bool {
+ return true // namespace == org
+}
+
+func (s *legacyStorage) GetSingularName() string {
+ return resourceInfo.GetSingularName()
+}
+
+func (s *legacyStorage) NewList() runtime.Object {
+ return resourceInfo.NewListFunc()
+}
+
+func (s *legacyStorage) checkEnabled(ns string) error {
+ opts, err := s.options(ns)
+ if err != nil {
+ return err
+ }
+ if !opts.Spec.SnapshotsEnabled {
+ return fmt.Errorf("snapshots not enabled")
+ }
+ return nil
+}
+
+func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
+}
+
+func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
+ info, err := request.NamespaceInfoFrom(ctx, true)
+ if err == nil {
+ err = s.checkEnabled(info.Value)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ user, err := appcontext.User(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ limit := 5000
+ if options.Limit > 0 {
+ limit = int(options.Limit)
+ }
+ res, err := s.service.SearchDashboardSnapshots(ctx, &dashboardsnapshots.GetDashboardSnapshotsQuery{
+ OrgID: info.OrgID,
+ SignedInUser: user,
+ Limit: limit,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ list := &dashboardsnapshot.DashboardSnapshotList{}
+ for _, v := range res {
+ list.Items = append(list.Items, *convertDTOToSnapshot(v, s.namespacer))
+ }
+ return list, nil
+}
+
+func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
+ info, err := request.NamespaceInfoFrom(ctx, true)
+ if err == nil {
+ err = s.checkEnabled(info.Value)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ v, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
+ Key: name,
+ })
+ if err != nil || v == nil {
+ // if errors.Is(err, playlistsvc.ErrPlaylistNotFound) || err == nil {
+ // err = k8serrors.NewNotFound(s.SingularQualifiedResource, name)
+ // }
+ return nil, err
+ }
+
+ return convertSnapshotToK8sResource(v, s.namespacer), nil
+}
+
+// GracefulDeleter
+func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
+ snap, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
+ Key: name,
+ })
+ if err != nil || snap == nil {
+ return nil, false, err
+ }
+
+ // Delete the external one first
+ if snap.ExternalDeleteURL != "" {
+ err := dashboardsnapshots.DeleteExternalDashboardSnapshot(snap.ExternalDeleteURL)
+ if err != nil {
+ return nil, false, err
+ }
+ }
+
+ err = s.service.DeleteDashboardSnapshot(ctx, &dashboardsnapshots.DeleteDashboardSnapshotCommand{
+ DeleteKey: snap.DeleteKey,
+ })
+ if err != nil {
+ return nil, false, err
+ }
+ return nil, true, nil
+}
diff --git a/pkg/registry/apis/dashboardsnapshot/sub_body.go b/pkg/registry/apis/dashboardsnapshot/sub_body.go
new file mode 100644
index 00000000000..45614b5e635
--- /dev/null
+++ b/pkg/registry/apis/dashboardsnapshot/sub_body.go
@@ -0,0 +1,60 @@
+package dashboardsnapshot
+
+import (
+ "context"
+ "net/http"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
+)
+
+type subBodyREST struct {
+ service dashboardsnapshots.Service
+ namespacer request.NamespaceMapper
+}
+
+var _ = rest.Connecter(&subBodyREST{})
+
+func (r *subBodyREST) New() runtime.Object {
+ return &dashboardsnapshot.FullDashboardSnapshot{}
+}
+
+func (r *subBodyREST) Destroy() {}
+
+func (r *subBodyREST) ConnectMethods() []string {
+ return []string{"GET"}
+}
+
+func (r *subBodyREST) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (r *subBodyREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ snap, err := r.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
+ Key: name,
+ })
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+
+ data, err := snap.Dashboard.Map()
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+
+ r := convertSnapshotToK8sResource(snap, r.namespacer)
+ responder.Object(200, &dashboardsnapshot.FullDashboardSnapshot{
+ ObjectMeta: r.ObjectMeta,
+ Info: r.Spec,
+ Dashboard: common.Unstructured{Object: data},
+ })
+ }), nil
+}
diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go
index d4c9e01f5b3..1c39aaa9e26 100644
--- a/pkg/registry/apis/datasource/plugincontext.go
+++ b/pkg/registry/apis/datasource/plugincontext.go
@@ -9,9 +9,9 @@ import (
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/datasources"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
)
diff --git a/pkg/registry/apis/datasource/querier.go b/pkg/registry/apis/datasource/querier.go
new file mode 100644
index 00000000000..9d5b9ff4702
--- /dev/null
+++ b/pkg/registry/apis/datasource/querier.go
@@ -0,0 +1,148 @@
+package datasource
+
+import (
+ "context"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+ "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/datasources"
+)
+
+type QuerierFactoryFunc func(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error)
+
+type QuerierProvider interface {
+ Querier(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error)
+}
+
+type DefaultQuerierProvider struct {
+ factory QuerierFactoryFunc
+}
+
+func ProvideDefaultQuerierProvider(pluginClient plugins.Client, dsService datasources.DataSourceService,
+ dsCache datasources.CacheService) *DefaultQuerierProvider {
+ return NewQuerierProvider(func(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error) {
+ return NewDefaultQuerier(ri, pj, pluginClient, dsService, dsCache), nil
+ })
+}
+
+func NewQuerierProvider(factory QuerierFactoryFunc) *DefaultQuerierProvider {
+ return &DefaultQuerierProvider{
+ factory: factory,
+ }
+}
+
+func (p *DefaultQuerierProvider) Querier(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error) {
+ return p.factory(ctx, ri, pj)
+}
+
+// Querier is the interface that wraps the Query method.
+type Querier interface {
+ // Query runs the query on behalf of the user in context.
+ Query(ctx context.Context, query *backend.QueryDataRequest) (*backend.QueryDataResponse, error)
+ // Health checks the health of the plugin.
+ Health(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error)
+ // Resource gets a resource plugin.
+ Resource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error
+ // Datasource gets all data source plugins (with elevated permissions).
+ Datasource(ctx context.Context, name string) (*v0alpha1.DataSourceConnection, error)
+ // Datasources lists all data sources (with elevated permissions).
+ Datasources(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error)
+}
+
+type DefaultQuerier struct {
+ connectionResourceInfo common.ResourceInfo
+ pluginJSON plugins.JSONData
+ pluginClient plugins.Client
+ dsService datasources.DataSourceService
+ dsCache datasources.CacheService
+}
+
+func NewDefaultQuerier(
+ connectionResourceInfo common.ResourceInfo,
+ pluginJSON plugins.JSONData,
+ pluginClient plugins.Client,
+ dsService datasources.DataSourceService,
+ dsCache datasources.CacheService,
+) *DefaultQuerier {
+ return &DefaultQuerier{
+ connectionResourceInfo: connectionResourceInfo,
+ pluginJSON: pluginJSON,
+ pluginClient: pluginClient,
+ dsService: dsService,
+ dsCache: dsCache,
+ }
+}
+
+func (q *DefaultQuerier) Query(ctx context.Context, query *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
+ _, err := request.NamespaceInfoFrom(ctx, true)
+ if err != nil {
+ return nil, err
+ }
+ return q.pluginClient.QueryData(ctx, query)
+}
+
+func (q *DefaultQuerier) Resource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+ _, err := request.NamespaceInfoFrom(ctx, true)
+ if err != nil {
+ return err
+ }
+ return q.pluginClient.CallResource(ctx, req, sender)
+}
+
+func (q *DefaultQuerier) Health(ctx context.Context, query *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
+ _, err := request.NamespaceInfoFrom(ctx, true)
+ if err != nil {
+ return nil, err
+ }
+ return q.pluginClient.CheckHealth(ctx, query)
+}
+
+func (q *DefaultQuerier) Datasource(ctx context.Context, name string) (*v0alpha1.DataSourceConnection, error) {
+ info, err := request.NamespaceInfoFrom(ctx, true)
+ if err != nil {
+ return nil, err
+ }
+ user, err := appcontext.User(ctx)
+ if err != nil {
+ return nil, err
+ }
+ ds, err := q.dsCache.GetDatasourceByUID(ctx, name, user, false)
+ if err != nil {
+ return nil, err
+ }
+ return asConnection(ds, info.Value)
+}
+
+func (q *DefaultQuerier) Datasources(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) {
+ info, err := request.NamespaceInfoFrom(ctx, true)
+ if err != nil {
+ return nil, err
+ }
+
+ ds, err := q.dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{
+ OrgID: info.OrgID,
+ Type: q.pluginJSON.ID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return asConnectionList(q.connectionResourceInfo.TypeMeta(), ds, info.Value)
+}
+
+func asConnectionList(typeMeta metav1.TypeMeta, dss []*datasources.DataSource, ns string) (*v0alpha1.DataSourceConnectionList, error) {
+ result := &v0alpha1.DataSourceConnectionList{
+ Items: []v0alpha1.DataSourceConnection{},
+ }
+ for _, ds := range dss {
+ v, _ := asConnection(ds, ns)
+ result.Items = append(result.Items, *v)
+ }
+
+ return result, nil
+}
diff --git a/pkg/registry/apis/datasource/query.go b/pkg/registry/apis/datasource/query.go
deleted file mode 100644
index 7952bcd561f..00000000000
--- a/pkg/registry/apis/datasource/query.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package datasource
-
-import (
- "encoding/json"
- "fmt"
- "time"
-
- "github.com/grafana/grafana-plugin-sdk-go/backend"
-
- "github.com/grafana/grafana/pkg/tsdb/legacydata"
-)
-
-// Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62
-type rawMetricRequest struct {
- // From Start time in epoch timestamps in milliseconds or relative using Grafana time units.
- // required: true
- // example: now-1h
- From string `json:"from"`
- // To End time in epoch timestamps in milliseconds or relative using Grafana time units.
- // required: true
- // example: now
- To string `json:"to"`
- // queries.refId – Specifies an identifier of the query. Is optional and default to “A”.
- // queries.datasourceId – Specifies the data source to be queried. Each query in the request must have an unique datasourceId.
- // queries.maxDataPoints - Species maximum amount of data points that dashboard panel can render. Is optional and default to 100.
- // queries.intervalMs - Specifies the time interval in milliseconds of time series. Is optional and defaults to 1000.
- // required: true
- // example: [ { "refId": "A", "intervalMs": 86400000, "maxDataPoints": 1092, "datasource":{ "uid":"PD8C576611E62080A" }, "rawSql": "SELECT 1 as valueOne, 2 as valueTwo", "format": "table" } ]
- Queries []rawDataQuery `json:"queries"`
- // required: false
- Debug bool `json:"debug"`
-}
-
-type rawDataQuery = map[string]interface{}
-
-func readQueries(in []byte) ([]backend.DataQuery, error) {
- reqDTO := &rawMetricRequest{}
- err := json.Unmarshal(in, &reqDTO)
- if err != nil {
- return nil, err
- }
-
- if len(reqDTO.Queries) == 0 {
- return nil, fmt.Errorf("expected queries")
- }
-
- tr := legacydata.NewDataTimeRange(reqDTO.From, reqDTO.To)
- backendTr := backend.TimeRange{
- From: tr.MustGetFrom(),
- To: tr.MustGetTo(),
- }
- queries := make([]backend.DataQuery, 0)
-
- for _, query := range reqDTO.Queries {
- dataQuery := backend.DataQuery{
- TimeRange: backendTr,
- }
-
- v, ok := query["refId"]
- if ok {
- dataQuery.RefID, ok = v.(string)
- if !ok {
- return nil, fmt.Errorf("expeted string refId")
- }
- }
-
- v, ok = query["queryType"]
- if ok {
- dataQuery.QueryType, ok = v.(string)
- if !ok {
- return nil, fmt.Errorf("expeted string queryType")
- }
- }
-
- v, ok = query["maxDataPoints"]
- if ok {
- vInt, ok := v.(float64)
- if !ok {
- return nil, fmt.Errorf("expected float64 maxDataPoints")
- }
-
- dataQuery.MaxDataPoints = int64(vInt)
- }
-
- v, ok = query["intervalMs"]
- if ok {
- vInt, ok := v.(float64)
- if !ok {
- return nil, fmt.Errorf("expected float64 intervalMs")
- }
-
- dataQuery.Interval = time.Duration(vInt)
- }
-
- dataQuery.JSON, err = json.Marshal(query)
- if err != nil {
- return nil, err
- }
-
- queries = append(queries, dataQuery)
- }
-
- return queries, nil
-}
diff --git a/pkg/registry/apis/datasource/query_test.go b/pkg/registry/apis/datasource/query_test.go
deleted file mode 100644
index 662bbd2e909..00000000000
--- a/pkg/registry/apis/datasource/query_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package datasource
-
-import (
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestParseQueriesIntoQueryDataRequest(t *testing.T) {
- request := []byte(`{
- "queries": [
- {
- "refId": "A",
- "datasource": {
- "type": "grafana-googlesheets-datasource",
- "uid": "b1808c48-9fc9-4045-82d7-081781f8a553"
- },
- "cacheDurationSeconds": 300,
- "spreadsheet": "spreadsheetID",
- "range": "",
- "datasourceId": 4,
- "intervalMs": 30000,
- "maxDataPoints": 794
- }
- ],
- "from": "1692624667389",
- "to": "1692646267389"
- }`)
-
- parsedDataQuery, err := readQueries(request)
- require.NoError(t, err)
- require.Equal(t, len(parsedDataQuery), 1)
-}
diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go
index 86769661890..b16e9987d4a 100644
--- a/pkg/registry/apis/datasource/register.go
+++ b/pkg/registry/apis/datasource/register.go
@@ -20,15 +20,16 @@ import (
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
+ query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
-var _ grafanaapiserver.APIGroupBuilder = (*DataSourceAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil)
// DataSourceAPIBuilder is used just so wire has something unique to return
type DataSourceAPIBuilder struct {
@@ -43,7 +44,7 @@ type DataSourceAPIBuilder struct {
func RegisterAPIService(
features featuremgmt.FeatureToggles,
- apiRegistrar grafanaapiserver.APIRegistrar,
+ apiRegistrar builder.APIRegistrar,
pluginClient plugins.Client, // access to everything
datasources PluginDatasourceProvider,
contextProvider PluginContextWrapper,
@@ -112,7 +113,8 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
&v0alpha1.DataSourceConnectionList{},
&v0alpha1.HealthCheckResult{},
&unstructured.Unstructured{},
- // Added for subresource stubs
+ // Query handler
+ &query.QueryDataResponse{},
&metav1.Status{},
)
}
@@ -138,7 +140,7 @@ func (b *DataSourceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
}
func resourceFromPluginID(pluginID string) (common.ResourceInfo, error) {
- group, err := getDatasourceGroupNameFromPluginID(pluginID)
+ group, err := plugins.GetDatasourceGroupNameFromPluginID(pluginID)
if err != nil {
return common.ResourceInfo{}, err
}
@@ -149,6 +151,7 @@ func (b *DataSourceAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
_ generic.RESTOptionsGetter,
+ _ bool,
) (*genericapiserver.APIGroupInfo, error) {
storage := map[string]rest.Storage{}
@@ -207,10 +210,16 @@ func (b *DataSourceAPIBuilder) getPluginContext(ctx context.Context, uid string)
}
func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() openapi.GetOpenAPIDefinitions {
- return v0alpha1.GetOpenAPIDefinitions
+ return func(ref openapi.ReferenceCallback) map[string]openapi.OpenAPIDefinition {
+ defs := query.GetOpenAPIDefinitions(ref) // required when running standalone
+ for k, v := range v0alpha1.GetOpenAPIDefinitions(ref) {
+ defs[k] = v
+ }
+ return defs
+ }
}
// Register additional routes with the server
-func (b *DataSourceAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
+func (b *DataSourceAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
return nil
}
diff --git a/pkg/registry/apis/datasource/standalone.go b/pkg/registry/apis/datasource/standalone.go
index 34324bd11b8..fed03c18e13 100644
--- a/pkg/registry/apis/datasource/standalone.go
+++ b/pkg/registry/apis/datasource/standalone.go
@@ -10,8 +10,8 @@ import (
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/setting"
testdatasource "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource"
)
diff --git a/pkg/registry/apis/datasource/sub_query.go b/pkg/registry/apis/datasource/sub_query.go
index 57ee6a58d4f..14c4c410bfb 100644
--- a/pkg/registry/apis/datasource/sub_query.go
+++ b/pkg/registry/apis/datasource/sub_query.go
@@ -3,14 +3,18 @@ package datasource
import (
"context"
"encoding/json"
- "io"
+ "fmt"
"net/http"
- "time"
+ "strconv"
"github.com/grafana/grafana-plugin-sdk-go/backend"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ "github.com/grafana/grafana/pkg/middleware/requestmeta"
+ "github.com/grafana/grafana/pkg/tsdb/legacydata"
+ "github.com/grafana/grafana/pkg/web"
)
type subQueryREST struct {
@@ -20,11 +24,10 @@ type subQueryREST struct {
var _ = rest.Connecter(&subQueryREST{})
func (r *subQueryREST) New() runtime.Object {
- return &metav1.Status{}
+ return &v0alpha1.QueryDataResponse{}
}
-func (r *subQueryREST) Destroy() {
-}
+func (r *subQueryREST) Destroy() {}
func (r *subQueryREST) ConnectMethods() []string {
return []string{"POST", "GET"}
@@ -34,40 +37,39 @@ func (r *subQueryREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
-func (r *subQueryREST) readQueries(req *http.Request) ([]backend.DataQuery, error) {
+func (r *subQueryREST) readQueries(req *http.Request) ([]backend.DataQuery, *v0alpha1.DataSourceRef, error) {
+ reqDTO := v0alpha1.GenericQueryRequest{}
// Simple URL to JSON mapping
if req.Method == http.MethodGet {
- body := make(map[string]any, 0)
- for k, v := range req.URL.Query() {
- switch len(v) {
- case 0:
- body[k] = true
- case 1:
- body[k] = v[0] // TODO, convert numbers
+ query := v0alpha1.GenericDataQuery{
+ RefID: "A",
+ MaxDataPoints: 1000,
+ IntervalMS: 10,
+ }
+ params := req.URL.Query()
+ for k := range params {
+ v := params.Get(k) // the singular value
+ switch k {
+ case "to":
+ reqDTO.To = v
+ case "from":
+ reqDTO.From = v
+ case "maxDataPoints":
+ query.MaxDataPoints, _ = strconv.ParseInt(v, 10, 64)
+ case "intervalMs":
+ query.IntervalMS, _ = strconv.ParseFloat(v, 64)
+ case "queryType":
+ query.QueryType = v
default:
- body[k] = v // TODO, convert numbers
+ query.AdditionalProperties()[k] = v
}
}
-
- var err error
- dq := backend.DataQuery{
- RefID: "A",
- TimeRange: backend.TimeRange{
- From: time.Now().Add(-1 * time.Hour), // last hour
- To: time.Now(),
- },
- MaxDataPoints: 1000,
- Interval: time.Second * 10,
- }
- dq.JSON, err = json.Marshal(body)
- return []backend.DataQuery{dq}, err
+ reqDTO.Queries = []v0alpha1.GenericDataQuery{query}
+ } else if err := web.Bind(req, &reqDTO); err != nil {
+ return nil, nil, err
}
- body, err := io.ReadAll(req.Body)
- if err != nil {
- return nil, err
- }
- return readQueries(body)
+ return legacydata.ToDataSourceQueries(reqDTO)
}
func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
@@ -77,27 +79,56 @@ func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Ob
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
- queries, err := r.readQueries(req)
+ queries, dsRef, err := r.readQueries(req)
if err != nil {
responder.Error(err)
return
}
+ if dsRef != nil && dsRef.UID != name {
+ responder.Error(fmt.Errorf("expected the datasource in the request url and body to match"))
+ return
+ }
- queryResponse, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{
+ qdr, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{
PluginContext: pluginCtx,
Queries: queries,
- // Headers: // from context
})
- if err != nil {
- return
- }
-
- jsonRsp, err := json.Marshal(queryResponse)
if err != nil {
responder.Error(err)
return
}
- w.WriteHeader(200)
- _, _ = w.Write(jsonRsp)
+
+ statusCode := http.StatusOK
+ for _, res := range qdr.Responses {
+ if res.Error != nil {
+ statusCode = http.StatusMultiStatus
+ }
+ }
+ if statusCode != http.StatusOK {
+ requestmeta.WithDownstreamStatusSource(ctx)
+ }
+
+ // TODO... someday :) can return protobuf for machine-machine communication
+ // will avoid some hops the current response workflow (for external plugins)
+ // 1. Plugin:
+ // creates: golang structs
+ // returns: arrow + protobuf |
+ // 2. Client: | direct when local/non grpc
+ // reads: protobuf+arrow V
+ // returns: golang structs
+ // 3. Datasource Server (eg right here):
+ // reads: golang structs
+ // returns: JSON
+ // 4. Query service (alerting etc):
+ // reads: JSON? (TODO! raw output from 1???)
+ // returns: JSON (after more operations)
+ // 5. Browser
+ // reads: JSON
+ w.WriteHeader(statusCode)
+ w.Header().Set("Content-Type", "application/json")
+ err = json.NewEncoder(w).Encode(qdr)
+ if err != nil {
+ responder.Error(err)
+ }
}), nil
}
diff --git a/pkg/registry/apis/example/dummy_storage.go b/pkg/registry/apis/example/dummy_storage.go
index 290623771cb..ac4f823dd13 100644
--- a/pkg/registry/apis/example/dummy_storage.go
+++ b/pkg/registry/apis/example/dummy_storage.go
@@ -13,8 +13,8 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
example "github.com/grafana/grafana/pkg/apis/example/v0alpha1"
- grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
+ grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ grafanaregistry "github.com/grafana/grafana/pkg/services/apiserver/registry/generic"
)
var (
diff --git a/pkg/registry/apis/example/register.go b/pkg/registry/apis/example/register.go
index bd1277615a1..69a4add5d58 100644
--- a/pkg/registry/apis/example/register.go
+++ b/pkg/registry/apis/example/register.go
@@ -22,11 +22,11 @@ import (
example "github.com/grafana/grafana/pkg/apis/example/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
)
-var _ grafanaapiserver.APIGroupBuilder = (*TestingAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*TestingAPIBuilder)(nil)
// This is used just so wire has something unique to return
type TestingAPIBuilder struct {
@@ -40,7 +40,7 @@ func NewTestingAPIBuilder() *TestingAPIBuilder {
}
}
-func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration grafanaapiserver.APIRegistrar) *TestingAPIBuilder {
+func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *TestingAPIBuilder {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil // skip registration unless opting into experimental apis
}
@@ -84,7 +84,8 @@ func (b *TestingAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
func (b *TestingAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
- optsGetter generic.RESTOptionsGetter,
+ _ generic.RESTOptionsGetter,
+ _ bool,
) (*genericapiserver.APIGroupInfo, error) {
b.codecs = codecs
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(b.gv.Group, scheme, metav1.ParameterCodec, codecs)
@@ -102,9 +103,9 @@ func (b *TestingAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions
}
// Register additional routes with the server
-func (b *TestingAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
- return &grafanaapiserver.APIRoutes{
- Root: []grafanaapiserver.APIRouteHandler{
+func (b *TestingAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
+ return &builder.APIRoutes{
+ Root: []builder.APIRouteHandler{
{
Path: "aaa",
Spec: &spec3.PathProps{
@@ -166,7 +167,7 @@ func (b *TestingAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
},
},
},
- Namespace: []grafanaapiserver.APIRouteHandler{
+ Namespace: []builder.APIRouteHandler{
{
Path: "ccc",
Spec: &spec3.PathProps{
diff --git a/pkg/registry/apis/example/storage.go b/pkg/registry/apis/example/storage.go
index fbec2ad7316..e2e198fa0be 100644
--- a/pkg/registry/apis/example/storage.go
+++ b/pkg/registry/apis/example/storage.go
@@ -12,7 +12,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
example "github.com/grafana/grafana/pkg/apis/example/v0alpha1"
- grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
+ grafanaregistry "github.com/grafana/grafana/pkg/services/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/setting"
)
diff --git a/pkg/registry/apis/example/subresource.go b/pkg/registry/apis/example/subresource.go
index d7d41186b54..63fdcab5f02 100644
--- a/pkg/registry/apis/example/subresource.go
+++ b/pkg/registry/apis/example/subresource.go
@@ -10,7 +10,7 @@ import (
example "github.com/grafana/grafana/pkg/apis/example/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
)
type dummySubresourceREST struct{}
diff --git a/pkg/registry/apis/featuretoggle/current.go b/pkg/registry/apis/featuretoggle/current.go
index 6813be4d0de..7bc5661c75b 100644
--- a/pkg/registry/apis/featuretoggle/current.go
+++ b/pkg/registry/apis/featuretoggle/current.go
@@ -1,25 +1,37 @@
package featuretoggle
import (
+ "bytes"
"context"
"encoding/json"
+ "fmt"
+ "io"
"net/http"
+ "strconv"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
+ "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
+ "github.com/grafana/grafana/pkg/infra/appcontext"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/user"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util/errutil"
+ "github.com/grafana/grafana/pkg/util/errutil/errhttp"
"github.com/grafana/grafana/pkg/web"
)
-func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureManager) v0alpha1.ResolvedToggleState {
+func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState {
state := v0alpha1.ResolvedToggleState{
TypeMeta: v1.TypeMeta{
APIVersion: v0alpha1.APIVERSION,
Kind: "ResolvedToggleState",
},
- Enabled: features.GetEnabled(ctx),
+ Enabled: b.features.GetEnabled(ctx),
+ RestartRequired: b.features.IsRestartRequired(),
}
// Reference to the object that defined the values
@@ -28,11 +40,11 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: "startup",
}
- startup := features.GetStartupFlags()
- warnings := features.GetWarning()
- for _, f := range features.GetFlags() {
+ startup := b.features.GetStartupFlags()
+ warnings := b.features.GetWarning()
+ for _, f := range b.features.GetFlags() {
name := f.Name
- if features.IsHiddenFromAdminPage(name, true) {
+ if b.features.IsHiddenFromAdminPage(name, false) {
continue
}
@@ -40,7 +52,7 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: name,
Description: f.Description, // simplify the UI changes
Enabled: state.Enabled[name],
- Writeable: features.IsEditableFromAdminPage(name),
+ Writeable: b.features.IsEditableFromAdminPage(name),
Source: startupRef,
Warning: warnings[name],
}
@@ -51,55 +63,87 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup {
state.Toggles = append(state.Toggles, toggle)
}
+
+ if toggle.Writeable {
+ state.AllowEditing = true
+ }
+ }
+
+ // Make sure the user can actually write values
+ if state.AllowEditing {
+ state.AllowEditing = b.features.IsFeatureEditingAllowed() && b.userCanWrite(ctx, nil)
}
return state
}
+func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool {
+ if u == nil {
+ u, _ = appcontext.User(ctx)
+ if u == nil {
+ return false
+ }
+ }
+ ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementWrite))
+ return ok && err == nil
+}
+
func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
b.handlePatchCurrent(w, r)
return
}
- state := getResolvedToggleState(r.Context(), b.features)
-
- err := json.NewEncoder(w).Encode(state)
- if err != nil {
- w.WriteHeader(500)
- }
+ state := b.getResolvedToggleState(r.Context())
+ _ = json.NewEncoder(w).Encode(state)
}
// NOTE: authz is already handled by the authorizer
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
if !b.features.IsFeatureEditingAllowed() {
- _, _ = w.Write([]byte("Feature editing is disabled"))
+ errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w)
return
}
- ctx := r.Context()
- request := v0alpha1.ResolvedToggleState{}
- err := web.Bind(r, &request)
+ user, err := appcontext.User(ctx)
if err != nil {
- _, _ = w.Write([]byte("ERROR!!! " + err.Error()))
+ errhttp.Write(ctx, err, w)
+ return
+ }
+
+ if !b.userCanWrite(ctx, user) {
+ err = errutil.Unauthorized("featuretoggle.canNotWrite",
+ errutil.WithPublicMessage("missing write permission"))
+ errhttp.Write(ctx, err, w)
+ return
+ }
+
+ request := v0alpha1.ResolvedToggleState{}
+ err = web.Bind(r, &request)
+ if err != nil {
+ errhttp.Write(ctx, err, w)
return
}
if len(request.Toggles) > 0 {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("can only patch the enabled"))
+ err = errutil.BadRequest("featuretoggle.badRequest",
+ errutil.WithPublicMessage("can only path the enabled section"))
+ errhttp.Write(ctx, err, w)
return
}
- changes := map[string]bool{}
+ changes := map[string]string{} // TODO would be nice to have this be a bool on the HG side
for k, v := range request.Enabled {
current := b.features.IsEnabled(ctx, k)
if current != v {
if !b.features.IsEditableFromAdminPage(k) {
+ err = errutil.BadRequest("featuretoggle.badRequest",
+ errutil.WithPublicMessage("can not edit toggle: "+k))
+ errhttp.Write(ctx, err, w)
w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("can not edit toggle: " + k))
return
}
- changes[k] = v
+ changes[k] = strconv.FormatBool(v)
}
}
@@ -108,5 +152,55 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
return
}
- _, _ = w.Write([]byte("TODO... actually UPDATE/call webhook: "))
+ payload := featuremgmt.FeatureToggleWebhookPayload{
+ FeatureToggles: changes,
+ User: user.Email,
+ }
+
+ err = sendWebhookUpdate(b.features.Settings, payload)
+ if err != nil {
+ errhttp.Write(ctx, err, w)
+ return
+ }
+
+ b.features.SetRestartRequired()
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("feature toggles updated successfully"))
+}
+
+func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload featuremgmt.FeatureToggleWebhookPayload) error {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken)
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Warn("Failed to close response body", "err", err)
+ }
+ }()
+
+ if resp.StatusCode >= http.StatusBadRequest {
+ if body, err := io.ReadAll(resp.Body); err != nil {
+ return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body))
+ } else {
+ return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err)
+ }
+ }
+
+ return nil
}
diff --git a/pkg/registry/apis/featuretoggle/features.go b/pkg/registry/apis/featuretoggle/features.go
index b001d51d222..23808272b73 100644
--- a/pkg/registry/apis/featuretoggle/features.go
+++ b/pkg/registry/apis/featuretoggle/features.go
@@ -12,8 +12,8 @@ import (
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
)
var (
diff --git a/pkg/registry/apis/featuretoggle/register.go b/pkg/registry/apis/featuretoggle/register.go
index 1a77fba7a3a..f5ae2466c42 100644
--- a/pkg/registry/apis/featuretoggle/register.go
+++ b/pkg/registry/apis/featuretoggle/register.go
@@ -14,30 +14,30 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
)
-var _ grafanaapiserver.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil)
var gv = v0alpha1.SchemeGroupVersion
// This is used just so wire has something unique to return
type FeatureFlagAPIBuilder struct {
- features *featuremgmt.FeatureManager
+ features *featuremgmt.FeatureManager
+ accessControl accesscontrol.AccessControl
}
-func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager) *FeatureFlagAPIBuilder {
- return &FeatureFlagAPIBuilder{features}
+func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl) *FeatureFlagAPIBuilder {
+ return &FeatureFlagAPIBuilder{features, accessControl}
}
func RegisterAPIService(features *featuremgmt.FeatureManager,
- apiregistration grafanaapiserver.APIRegistrar,
+ accessControl accesscontrol.AccessControl,
+ apiregistration builder.APIRegistrar,
) *FeatureFlagAPIBuilder {
- if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
- return nil // skip registration unless opting into experimental apis
- }
- builder := NewFeatureFlagAPIBuilder(features)
+ builder := NewFeatureFlagAPIBuilder(features, accessControl)
apiregistration.RegisterAPI(builder)
return builder
}
@@ -78,7 +78,8 @@ func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
func (b *FeatureFlagAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
- optsGetter generic.RESTOptionsGetter,
+ _ generic.RESTOptionsGetter,
+ _ bool,
) (*genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
@@ -102,13 +103,13 @@ func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer {
}
// Register additional routes with the server
-func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
+func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema
tags := []string{"Editor"}
- return &grafanaapiserver.APIRoutes{
- Root: []grafanaapiserver.APIRouteHandler{
+ return &builder.APIRoutes{
+ Root: []builder.APIRouteHandler{
{
Path: "current",
Spec: &spec3.PathProps{
@@ -160,7 +161,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
"enable-auto-migrate": {
ExampleProps: spec3.ExampleProps{
Summary: "enable auto-migrate panels",
- Description: "example descr",
+ Description: "enable description",
Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: true,
@@ -171,7 +172,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
"disable-auto-migrate": {
ExampleProps: spec3.ExampleProps{
Summary: "disable auto-migrate panels",
- Description: "disable descr",
+ Description: "disable description",
Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: false,
diff --git a/pkg/registry/apis/featuretoggle/toggles.go b/pkg/registry/apis/featuretoggle/toggles.go
index b4671104c4a..81f5c092a3e 100644
--- a/pkg/registry/apis/featuretoggle/toggles.go
+++ b/pkg/registry/apis/featuretoggle/toggles.go
@@ -11,8 +11,8 @@ import (
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
var (
diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go
index d14febaaaa9..11ec26f28fb 100644
--- a/pkg/registry/apis/folders/conversions.go
+++ b/pkg/registry/apis/folders/conversions.go
@@ -6,9 +6,9 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
)
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder {
diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go
index e6515b13beb..ac258217ccc 100644
--- a/pkg/registry/apis/folders/legacy_storage.go
+++ b/pkg/registry/apis/folders/legacy_storage.go
@@ -13,11 +13,11 @@ import (
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/storage/entity"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/storage/entity"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/util"
)
diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go
index 4e385b2bded..15544b9f68b 100644
--- a/pkg/registry/apis/folders/register.go
+++ b/pkg/registry/apis/folders/register.go
@@ -14,16 +14,16 @@ import (
common "k8s.io/kube-openapi/pkg/common"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/setting"
)
-var _ grafanaapiserver.APIGroupBuilder = (*FolderAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil)
var resourceInfo = v0alpha1.FolderResourceInfo
@@ -37,7 +37,7 @@ type FolderAPIBuilder struct {
func RegisterAPIService(cfg *setting.Cfg,
features *featuremgmt.FeatureManager,
- apiregistration grafanaapiserver.APIRegistrar,
+ apiregistration builder.APIRegistrar,
folderSvc folder.Service,
) *FolderAPIBuilder {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
@@ -89,6 +89,7 @@ func (b *FolderAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
optsGetter generic.RESTOptionsGetter,
+ dualWrite bool,
) (*genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
@@ -122,7 +123,7 @@ func (b *FolderAPIBuilder) GetAPIGroupInfo(
storage[resourceInfo.StoragePath("children")] = &subChildrenREST{b.folderSvc}
// enable dual writes if a RESTOptionsGetter is provided
- if optsGetter != nil {
+ if dualWrite && optsGetter != nil {
store, err := newStorage(scheme, optsGetter, legacyStore)
if err != nil {
return nil, err
@@ -138,7 +139,7 @@ func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions
return v0alpha1.GetOpenAPIDefinitions
}
-func (b *FolderAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
+func (b *FolderAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
return nil // no custom API routes
}
diff --git a/pkg/registry/apis/folders/storage.go b/pkg/registry/apis/folders/storage.go
index b3f7ee2d073..dc90e9ff792 100644
--- a/pkg/registry/apis/folders/storage.go
+++ b/pkg/registry/apis/folders/storage.go
@@ -6,8 +6,8 @@ import (
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
- grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
- grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
+ grafanaregistry "github.com/grafana/grafana/pkg/services/apiserver/registry/generic"
+ grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest"
)
var _ grafanarest.Storage = (*storage)(nil)
diff --git a/pkg/registry/apis/folders/sub_children.go b/pkg/registry/apis/folders/sub_children.go
index 8e9be8b0813..9d5deb40712 100644
--- a/pkg/registry/apis/folders/sub_children.go
+++ b/pkg/registry/apis/folders/sub_children.go
@@ -9,8 +9,8 @@ import (
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
type subChildrenREST struct {
diff --git a/pkg/registry/apis/folders/sub_parents.go b/pkg/registry/apis/folders/sub_parents.go
index 95ff32f42a4..36f67032d60 100644
--- a/pkg/registry/apis/folders/sub_parents.go
+++ b/pkg/registry/apis/folders/sub_parents.go
@@ -8,8 +8,8 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
type subParentsREST struct {
diff --git a/pkg/registry/apis/playlist/conversions.go b/pkg/registry/apis/playlist/conversions.go
index 43317aac080..c301cf9c04f 100644
--- a/pkg/registry/apis/playlist/conversions.go
+++ b/pkg/registry/apis/playlist/conversions.go
@@ -11,8 +11,8 @@ import (
"k8s.io/apimachinery/pkg/types"
playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
)
diff --git a/pkg/registry/apis/playlist/conversions_test.go b/pkg/registry/apis/playlist/conversions_test.go
index 3bb8284bb8e..83188572030 100644
--- a/pkg/registry/apis/playlist/conversions_test.go
+++ b/pkg/registry/apis/playlist/conversions_test.go
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/playlist"
)
diff --git a/pkg/registry/apis/playlist/legacy_storage.go b/pkg/registry/apis/playlist/legacy_storage.go
index 699d6e5371c..316c0f52462 100644
--- a/pkg/registry/apis/playlist/legacy_storage.go
+++ b/pkg/registry/apis/playlist/legacy_storage.go
@@ -11,7 +11,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
)
diff --git a/pkg/registry/apis/playlist/register.go b/pkg/registry/apis/playlist/register.go
index b1dab9754f0..4bb5998ca9b 100644
--- a/pkg/registry/apis/playlist/register.go
+++ b/pkg/registry/apis/playlist/register.go
@@ -15,15 +15,15 @@ import (
common "k8s.io/kube-openapi/pkg/common"
playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
- grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/setting"
)
-var _ grafanaapiserver.APIGroupBuilder = (*PlaylistAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*PlaylistAPIBuilder)(nil)
// This is used just so wire has something unique to return
type PlaylistAPIBuilder struct {
@@ -33,7 +33,7 @@ type PlaylistAPIBuilder struct {
}
func RegisterAPIService(p playlistsvc.Service,
- apiregistration grafanaapiserver.APIRegistrar,
+ apiregistration builder.APIRegistrar,
cfg *setting.Cfg,
) *PlaylistAPIBuilder {
builder := &PlaylistAPIBuilder{
@@ -79,6 +79,7 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
optsGetter generic.RESTOptionsGetter,
+ dualWrite bool,
) (*genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(playlist.GROUP, scheme, metav1.ParameterCodec, codecs)
storage := map[string]rest.Storage{}
@@ -128,7 +129,7 @@ func (b *PlaylistAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinition
return playlist.GetOpenAPIDefinitions
}
-func (b *PlaylistAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
+func (b *PlaylistAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
return nil // no custom API routes
}
diff --git a/pkg/registry/apis/playlist/storage.go b/pkg/registry/apis/playlist/storage.go
index b0f848cc11f..eb7512c717b 100644
--- a/pkg/registry/apis/playlist/storage.go
+++ b/pkg/registry/apis/playlist/storage.go
@@ -6,8 +6,8 @@ import (
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
- grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
- grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
+ grafanaregistry "github.com/grafana/grafana/pkg/services/apiserver/registry/generic"
+ grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest"
)
var _ grafanarest.Storage = (*storage)(nil)
diff --git a/pkg/registry/apis/query/parser.go b/pkg/registry/apis/query/parser.go
new file mode 100644
index 00000000000..bc60a5b451c
--- /dev/null
+++ b/pkg/registry/apis/query/parser.go
@@ -0,0 +1,83 @@
+package query
+
+import (
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ "github.com/grafana/grafana/pkg/expr"
+)
+
+type parsedQueryRequest struct {
+ // The queries broken into requests
+ Requests []groupedQueries
+
+ // Optionally show the additional query properties
+ Expressions []v0alpha1.GenericDataQuery
+}
+
+type groupedQueries struct {
+ // the plugin type
+ pluginId string
+
+ // The datasource name/uid
+ uid string
+
+ // The raw backend query objects
+ query []v0alpha1.GenericDataQuery
+}
+
+// Internally define what makes this request unique (eventually may include the apiVersion)
+func (d *groupedQueries) key() string {
+ return fmt.Sprintf("%s/%s", d.pluginId, d.uid)
+}
+
+func parseQueryRequest(raw v0alpha1.GenericQueryRequest) (parsedQueryRequest, error) {
+ mixed := make(map[string]*groupedQueries)
+ parsed := parsedQueryRequest{}
+ refIds := make(map[string]bool)
+
+ for _, original := range raw.Queries {
+ if refIds[original.RefID] {
+ return parsed, fmt.Errorf("invalid query, duplicate refId: " + original.RefID)
+ }
+
+ refIds[original.RefID] = true
+ q := original
+
+ if q.TimeRange == nil && raw.From != "" {
+ q.TimeRange = &v0alpha1.TimeRange{
+ From: raw.From,
+ To: raw.To,
+ }
+ }
+
+ // Extract out the expressions queries earlier
+ if expr.IsDataSource(q.Datasource.Type) || expr.IsDataSource(q.Datasource.UID) {
+ parsed.Expressions = append(parsed.Expressions, q)
+ continue
+ }
+
+ g := &groupedQueries{pluginId: q.Datasource.Type, uid: q.Datasource.UID}
+ group, ok := mixed[g.key()]
+ if !ok || group == nil {
+ group = g
+ mixed[g.key()] = g
+ }
+ group.query = append(group.query, q)
+ }
+
+ for _, q := range parsed.Expressions {
+ // TODO: parse and build tree, for now just fail fast on unknown commands
+ _, err := expr.GetExpressionCommandType(q.AdditionalProperties())
+ if err != nil {
+ return parsed, err
+ }
+ }
+
+ // Add each request
+ for _, v := range mixed {
+ parsed.Requests = append(parsed.Requests, *v)
+ }
+
+ return parsed, nil
+}
diff --git a/pkg/registry/apis/query/plugins.go b/pkg/registry/apis/query/plugins.go
new file mode 100644
index 00000000000..786ba1a5c60
--- /dev/null
+++ b/pkg/registry/apis/query/plugins.go
@@ -0,0 +1,62 @@
+package query
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+ example "github.com/grafana/grafana/pkg/apis/example/v0alpha1"
+ query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+)
+
+var (
+ _ rest.Storage = (*pluginsStorage)(nil)
+ _ rest.Scoper = (*pluginsStorage)(nil)
+ _ rest.SingularNameProvider = (*pluginsStorage)(nil)
+ _ rest.Lister = (*pluginsStorage)(nil)
+)
+
+type pluginsStorage struct {
+ resourceInfo *common.ResourceInfo
+ tableConverter rest.TableConvertor
+ registry query.DataSourceApiServerRegistry
+}
+
+func newPluginsStorage(reg query.DataSourceApiServerRegistry) *pluginsStorage {
+ var resourceInfo = query.DataSourceApiServerResourceInfo
+ return &pluginsStorage{
+ resourceInfo: &resourceInfo,
+ tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()),
+ registry: reg,
+ }
+}
+
+func (s *pluginsStorage) New() runtime.Object {
+ return s.resourceInfo.NewFunc()
+}
+
+func (s *pluginsStorage) Destroy() {}
+
+func (s *pluginsStorage) NamespaceScoped() bool {
+ return false
+}
+
+func (s *pluginsStorage) GetSingularName() string {
+ return example.DummyResourceInfo.GetSingularName()
+}
+
+func (s *pluginsStorage) NewList() runtime.Object {
+ return s.resourceInfo.NewListFunc()
+}
+
+func (s *pluginsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
+}
+
+func (s *pluginsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
+ return s.registry.GetDatasourceApiServers(ctx)
+}
diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go
new file mode 100644
index 00000000000..e5278141379
--- /dev/null
+++ b/pkg/registry/apis/query/query.go
@@ -0,0 +1,157 @@
+package query
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ "golang.org/x/sync/errgroup"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/middleware/requestmeta"
+ "github.com/grafana/grafana/pkg/util/errutil/errhttp"
+ "github.com/grafana/grafana/pkg/web"
+)
+
+func (b *QueryAPIBuilder) handleQuery(w http.ResponseWriter, r *http.Request) {
+ reqDTO := v0alpha1.GenericQueryRequest{}
+ if err := web.Bind(r, &reqDTO); err != nil {
+ errhttp.Write(r.Context(), err, w)
+ return
+ }
+
+ parsed, err := parseQueryRequest(reqDTO)
+ if err != nil {
+ errhttp.Write(r.Context(), err, w)
+ return
+ }
+
+ ctx := r.Context()
+ qdr, err := b.processRequest(ctx, parsed)
+ if err != nil {
+ errhttp.Write(r.Context(), err, w)
+ return
+ }
+
+ statusCode := http.StatusOK
+ for _, res := range qdr.Responses {
+ if res.Error != nil {
+ statusCode = http.StatusBadRequest
+ if b.returnMultiStatus {
+ statusCode = http.StatusMultiStatus
+ }
+ }
+ }
+ if statusCode != http.StatusOK {
+ requestmeta.WithDownstreamStatusSource(ctx)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ err = json.NewEncoder(w).Encode(qdr)
+ if err != nil {
+ errhttp.Write(r.Context(), err, w)
+ }
+}
+
+// See:
+// https://github.com/grafana/grafana/blob/v10.2.3/pkg/services/query/query.go#L88
+func (b *QueryAPIBuilder) processRequest(ctx context.Context, req parsedQueryRequest) (qdr *backend.QueryDataResponse, err error) {
+ switch len(req.Requests) {
+ case 0:
+ break // nothing to do
+ case 1:
+ qdr, err = b.handleQuerySingleDatasource(ctx, req.Requests[0])
+ default:
+ qdr, err = b.executeConcurrentQueries(ctx, req.Requests)
+ }
+
+ if len(req.Expressions) > 0 {
+ return b.handleExpressions(ctx, qdr, req.Expressions)
+ }
+ return qdr, err
+}
+
+// Process a single request
+// See: https://github.com/grafana/grafana/blob/v10.2.3/pkg/services/query/query.go#L242
+func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req groupedQueries) (*backend.QueryDataResponse, error) {
+ gv, err := b.registry.GetDatasourceGroupVersion(req.pluginId)
+ if err != nil {
+ return nil, err
+ }
+ return b.runner.ExecuteQueryData(ctx, gv, req.uid, req.query)
+}
+
+// buildErrorResponses applies the provided error to each query response in the list. These queries should all belong to the same datasource.
+func buildErrorResponse(err error, req groupedQueries) *backend.QueryDataResponse {
+ rsp := backend.NewQueryDataResponse()
+ for _, query := range req.query {
+ rsp.Responses[query.RefID] = backend.DataResponse{
+ Error: err,
+ }
+ }
+ return rsp
+}
+
+// executeConcurrentQueries executes queries to multiple datasources concurrently and returns the aggregate result.
+func (b *QueryAPIBuilder) executeConcurrentQueries(ctx context.Context, requests []groupedQueries) (*backend.QueryDataResponse, error) {
+ g, ctx := errgroup.WithContext(ctx)
+ g.SetLimit(b.concurrentQueryLimit) // prevent too many concurrent requests
+ rchan := make(chan *backend.QueryDataResponse, len(requests))
+
+ // Create panic recovery function for loop below
+ recoveryFn := func(req groupedQueries) {
+ if r := recover(); r != nil {
+ var err error
+ b.log.Error("query datasource panic", "error", r, "stack", log.Stack(1))
+ if theErr, ok := r.(error); ok {
+ err = theErr
+ } else if theErrString, ok := r.(string); ok {
+ err = fmt.Errorf(theErrString)
+ } else {
+ err = fmt.Errorf("unexpected error - %s", b.userFacingDefaultError)
+ }
+ // Due to the panic, there is no valid response for any query for this datasource. Append an error for each one.
+ rchan <- buildErrorResponse(err, req)
+ }
+ }
+
+ // Query each datasource concurrently
+ for idx := range requests {
+ req := requests[idx]
+ g.Go(func() error {
+ defer recoveryFn(req)
+
+ dqr, err := b.handleQuerySingleDatasource(ctx, req)
+ if err == nil {
+ rchan <- dqr
+ } else {
+ rchan <- buildErrorResponse(err, req)
+ }
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+ close(rchan)
+
+ // Merge the results from each response
+ resp := backend.NewQueryDataResponse()
+ for result := range rchan {
+ for refId, dataResponse := range result.Responses {
+ resp.Responses[refId] = dataResponse
+ }
+ }
+
+ return resp, nil
+}
+
+// NOTE the upstream queries have already been executed
+// https://github.com/grafana/grafana/blob/v10.2.3/pkg/services/query/query.go#L242
+func (b *QueryAPIBuilder) handleExpressions(ctx context.Context, qdr *backend.QueryDataResponse, expressions []v0alpha1.GenericDataQuery) (*backend.QueryDataResponse, error) {
+ return qdr, fmt.Errorf("expressions are not implemented yet")
+}
diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go
new file mode 100644
index 00000000000..e1f3fa02cd2
--- /dev/null
+++ b/pkg/registry/apis/query/register.go
@@ -0,0 +1,261 @@
+package query
+
+import (
+ "encoding/json"
+ "net/http"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/runtime/serializer"
+ "k8s.io/apiserver/pkg/authorization/authorizer"
+ "k8s.io/apiserver/pkg/registry/generic"
+ "k8s.io/apiserver/pkg/registry/rest"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ common "k8s.io/kube-openapi/pkg/common"
+ "k8s.io/kube-openapi/pkg/spec3"
+ "k8s.io/kube-openapi/pkg/validation/spec"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/registry/apis/query/runner"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
+ "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
+)
+
+var _ builder.APIGroupBuilder = (*QueryAPIBuilder)(nil)
+
+type QueryAPIBuilder struct {
+ log log.Logger
+ concurrentQueryLimit int
+ userFacingDefaultError string
+ returnMultiStatus bool // from feature toggle
+
+ runner v0alpha1.QueryRunner
+ registry v0alpha1.DataSourceApiServerRegistry
+}
+
+func NewQueryAPIBuilder(features featuremgmt.FeatureToggles,
+ runner v0alpha1.QueryRunner,
+ registry v0alpha1.DataSourceApiServerRegistry,
+) *QueryAPIBuilder {
+ return &QueryAPIBuilder{
+ concurrentQueryLimit: 4, // from config?
+ log: log.New("query_apiserver"),
+ returnMultiStatus: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryMultiStatus),
+ runner: runner,
+ registry: registry,
+ }
+}
+
+func RegisterAPIService(features featuremgmt.FeatureToggles,
+ apiregistration builder.APIRegistrar,
+ dataSourcesService datasources.DataSourceService,
+ pluginStore pluginstore.Store,
+ accessControl accesscontrol.AccessControl,
+ pluginClient plugins.Client,
+ pCtxProvider *plugincontext.Provider,
+) *QueryAPIBuilder {
+ if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
+ return nil // skip registration unless opting into experimental apis
+ }
+
+ builder := NewQueryAPIBuilder(
+ features,
+ runner.NewDirectQueryRunner(pluginClient, pCtxProvider),
+ runner.NewDirectRegistry(pluginStore, dataSourcesService),
+ )
+
+ // ONLY testdata...
+ if false {
+ builder = NewQueryAPIBuilder(
+ features,
+ runner.NewDummyTestRunner(),
+ runner.NewDummyRegistry(),
+ )
+ }
+
+ apiregistration.RegisterAPI(builder)
+ return builder
+}
+
+func (b *QueryAPIBuilder) GetGroupVersion() schema.GroupVersion {
+ return v0alpha1.SchemeGroupVersion
+}
+
+func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
+ scheme.AddKnownTypes(gv,
+ &v0alpha1.DataSourceApiServer{},
+ &v0alpha1.DataSourceApiServerList{},
+ &v0alpha1.QueryDataResponse{},
+ )
+}
+
+func (b *QueryAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
+ addKnownTypes(scheme, v0alpha1.SchemeGroupVersion)
+ metav1.AddToGroupVersion(scheme, v0alpha1.SchemeGroupVersion)
+ return scheme.SetVersionPriority(v0alpha1.SchemeGroupVersion)
+}
+
+func (b *QueryAPIBuilder) GetAPIGroupInfo(
+ scheme *runtime.Scheme,
+ codecs serializer.CodecFactory, // pointer?
+ optsGetter generic.RESTOptionsGetter,
+ _ bool,
+) (*genericapiserver.APIGroupInfo, error) {
+ gv := v0alpha1.SchemeGroupVersion
+ apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(gv.Group, scheme, metav1.ParameterCodec, codecs)
+
+ plugins := newPluginsStorage(b.registry)
+
+ storage := map[string]rest.Storage{}
+ storage[plugins.resourceInfo.StoragePath()] = plugins
+
+ apiGroupInfo.VersionedResourcesStorageMap[gv.Version] = storage
+ return &apiGroupInfo, nil
+}
+
+func (b *QueryAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+ return v0alpha1.GetOpenAPIDefinitions
+}
+
+// Register additional routes with the server
+func (b *QueryAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
+ defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
+ querySchema := defs["github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryRequest"].Schema
+ responseSchema := defs["github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse"].Schema
+
+ var randomWalkQuery any
+ var randomWalkTable any
+ _ = json.Unmarshal([]byte(`{
+ "queries": [
+ {
+ "refId": "A",
+ "scenarioId": "random_walk",
+ "seriesCount": 1,
+ "datasource": {
+ "type": "grafana-testdata-datasource",
+ "uid": "PD8C576611E62080A"
+ },
+ "intervalMs": 60000,
+ "maxDataPoints": 20
+ }
+ ],
+ "from": "1704893381544",
+ "to": "1704914981544"
+ }`), &randomWalkQuery)
+
+ _ = json.Unmarshal([]byte(`{
+ "queries": [
+ {
+ "refId": "A",
+ "scenarioId": "random_walk_table",
+ "seriesCount": 1,
+ "datasource": {
+ "type": "grafana-testdata-datasource",
+ "uid": "PD8C576611E62080A"
+ },
+ "intervalMs": 60000,
+ "maxDataPoints": 20
+ }
+ ],
+ "from": "1704893381544",
+ "to": "1704914981544"
+ }`), &randomWalkTable)
+
+ return &builder.APIRoutes{
+ Root: []builder.APIRouteHandler{},
+ Namespace: []builder.APIRouteHandler{
+ {
+ Path: "query",
+ Spec: &spec3.PathProps{
+ Post: &spec3.Operation{
+ OperationProps: spec3.OperationProps{
+ Tags: []string{"query"},
+ Description: "query across multiple datasources with expressions. This api matches the legacy /ds/query endpoint",
+ Parameters: []*spec3.Parameter{
+ {
+ ParameterProps: spec3.ParameterProps{
+ Name: "namespace",
+ Description: "object name and auth scope, such as for teams and projects",
+ In: "path",
+ Required: true,
+ Schema: spec.StringProperty(),
+ Example: "default",
+ },
+ },
+ },
+ RequestBody: &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Required: true,
+ Description: "the query array",
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: querySchema.WithExample(randomWalkQuery),
+ Examples: map[string]*spec3.Example{
+ "random_walk": {
+ ExampleProps: spec3.ExampleProps{
+ Summary: "random walk",
+ Value: randomWalkQuery,
+ },
+ },
+ "random_walk_table": {
+ ExampleProps: spec3.ExampleProps{
+ Summary: "random walk (table)",
+ Value: randomWalkTable,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Responses: &spec3.Responses{
+ ResponsesProps: spec3.ResponsesProps{
+ StatusCodeResponses: map[int]*spec3.Response{
+ http.StatusOK: {
+ ResponseProps: spec3.ResponseProps{
+ Description: "Query results",
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &responseSchema,
+ },
+ },
+ },
+ },
+ },
+ http.StatusMultiStatus: {
+ ResponseProps: spec3.ResponseProps{
+ Description: "Errors exist in the downstream results",
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &responseSchema,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Handler: b.handleQuery,
+ },
+ },
+ }
+}
+
+func (b *QueryAPIBuilder) GetAuthorizer() authorizer.Authorizer {
+ return nil // default is OK
+}
diff --git a/pkg/registry/apis/query/runner/direct.go b/pkg/registry/apis/query/runner/direct.go
new file mode 100644
index 00000000000..e36a7e6188e
--- /dev/null
+++ b/pkg/registry/apis/query/runner/direct.go
@@ -0,0 +1,179 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
+ "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/tsdb/legacydata"
+)
+
+type directRunner struct {
+ pluginClient plugins.Client
+ pCtxProvider *plugincontext.Provider
+}
+
+type directRegistry struct {
+ pluginsMu sync.Mutex
+ plugins *v0alpha1.DataSourceApiServerList
+ apis map[string]schema.GroupVersion
+ groupToPlugin map[string]string
+ pluginStore pluginstore.Store
+
+ // called on demand
+ dataSourcesService datasources.DataSourceService
+}
+
+var _ v0alpha1.QueryRunner = (*directRunner)(nil)
+var _ v0alpha1.DataSourceApiServerRegistry = (*directRegistry)(nil)
+
+// NewDummyTestRunner creates a runner that only works with testdata
+func NewDirectQueryRunner(
+ pluginClient plugins.Client,
+ pCtxProvider *plugincontext.Provider) v0alpha1.QueryRunner {
+ return &directRunner{
+ pluginClient: pluginClient,
+ pCtxProvider: pCtxProvider,
+ }
+}
+
+func NewDirectRegistry(pluginStore pluginstore.Store,
+ dataSourcesService datasources.DataSourceService,
+) v0alpha1.DataSourceApiServerRegistry {
+ return &directRegistry{
+ pluginStore: pluginStore,
+ dataSourcesService: dataSourcesService,
+ }
+}
+
+// ExecuteQueryData implements QueryHelper.
+func (d *directRunner) ExecuteQueryData(ctx context.Context,
+ // The k8s group for the datasource (pluginId)
+ datasource schema.GroupVersion,
+
+ // The datasource name/uid
+ name string,
+
+ // The raw backend query objects
+ query []v0alpha1.GenericDataQuery,
+) (*backend.QueryDataResponse, error) {
+ queries, dsRef, err := legacydata.ToDataSourceQueries(v0alpha1.GenericQueryRequest{
+ Queries: query,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if dsRef != nil && dsRef.UID != name {
+ return nil, fmt.Errorf("expected query body datasource and request to match")
+ }
+
+ // NOTE: this depends on uid unique across datasources
+ settings, err := d.pCtxProvider.GetDataSourceInstanceSettings(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ pCtx, err := d.pCtxProvider.PluginContextForDataSource(ctx, settings)
+ if err != nil {
+ return nil, err
+ }
+
+ return d.pluginClient.QueryData(ctx, &backend.QueryDataRequest{
+ PluginContext: pCtx,
+ Queries: queries,
+ })
+}
+
+// GetDatasourceAPI implements DataSourceRegistry.
+func (d *directRegistry) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) {
+ d.pluginsMu.Lock()
+ defer d.pluginsMu.Unlock()
+
+ if d.plugins == nil {
+ err := d.updatePlugins()
+ if err != nil {
+ return schema.GroupVersion{}, err
+ }
+ }
+
+ var err error
+ gv, ok := d.apis[pluginId]
+ if !ok {
+ err = fmt.Errorf("no API found for id: " + pluginId)
+ }
+ return gv, err
+}
+
+// GetDatasourcePlugins no namespace? everything that is available
+func (d *directRegistry) GetDatasourceApiServers(ctx context.Context) (*v0alpha1.DataSourceApiServerList, error) {
+ d.pluginsMu.Lock()
+ defer d.pluginsMu.Unlock()
+
+ if d.plugins == nil {
+ err := d.updatePlugins()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return d.plugins, nil
+}
+
+// This should be called when plugins change
+func (d *directRegistry) updatePlugins() error {
+ groupToPlugin := map[string]string{}
+ apis := map[string]schema.GroupVersion{}
+ result := &v0alpha1.DataSourceApiServerList{
+ ListMeta: metav1.ListMeta{
+ ResourceVersion: fmt.Sprintf("%d", time.Now().UnixMilli()),
+ },
+ }
+
+ // TODO? only backend plugins
+ for _, dsp := range d.pluginStore.Plugins(context.Background(), plugins.TypeDataSource) {
+ ts := setting.BuildStamp * 1000
+ if dsp.Info.Build.Time > 0 {
+ ts = dsp.Info.Build.Time
+ }
+
+ group, err := plugins.GetDatasourceGroupNameFromPluginID(dsp.ID)
+ if err != nil {
+ return err
+ }
+ gv := schema.GroupVersion{Group: group, Version: "v0alpha1"} // default version
+ apis[dsp.ID] = gv
+ for _, alias := range dsp.AliasIDs {
+ apis[alias] = gv
+ }
+ groupToPlugin[group] = dsp.ID
+
+ ds := v0alpha1.DataSourceApiServer{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: dsp.ID,
+ CreationTimestamp: metav1.NewTime(time.UnixMilli(ts)),
+ },
+ Title: dsp.Name,
+ AliasIDs: dsp.AliasIDs,
+ GroupVersion: gv.String(),
+ Description: dsp.Info.Description,
+ }
+ result.Items = append(result.Items, ds)
+ }
+
+ d.plugins = result
+ d.apis = apis
+ d.groupToPlugin = groupToPlugin
+ return nil
+}
diff --git a/pkg/registry/apis/query/runner/dummy.go b/pkg/registry/apis/query/runner/dummy.go
new file mode 100644
index 00000000000..4937cb81241
--- /dev/null
+++ b/pkg/registry/apis/query/runner/dummy.go
@@ -0,0 +1,88 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ testdata "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource"
+ "github.com/grafana/grafana/pkg/tsdb/legacydata"
+)
+
+type testdataDummy struct{}
+
+var _ v0alpha1.QueryRunner = (*testdataDummy)(nil)
+var _ v0alpha1.DataSourceApiServerRegistry = (*testdataDummy)(nil)
+
+// NewDummyTestRunner creates a runner that only works with testdata
+func NewDummyTestRunner() v0alpha1.QueryRunner {
+ return &testdataDummy{}
+}
+
+func NewDummyRegistry() v0alpha1.DataSourceApiServerRegistry {
+ return &testdataDummy{}
+}
+
+// ExecuteQueryData implements QueryHelper.
+func (d *testdataDummy) ExecuteQueryData(ctx context.Context,
+ // The k8s group for the datasource (pluginId)
+ datasource schema.GroupVersion,
+
+ // The datasource name/uid
+ name string,
+
+ // The raw backend query objects
+ query []v0alpha1.GenericDataQuery,
+) (*backend.QueryDataResponse, error) {
+ if datasource.Group != "testdata.datasource.grafana.app" {
+ return nil, fmt.Errorf("expecting testdata requests")
+ }
+
+ queries, _, err := legacydata.ToDataSourceQueries(v0alpha1.GenericQueryRequest{
+ Queries: query,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return testdata.ProvideService().QueryData(ctx, &backend.QueryDataRequest{
+ Queries: queries,
+ })
+}
+
+// GetDatasourceAPI implements DataSourceRegistry.
+func (*testdataDummy) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) {
+ if pluginId == "testdata" || pluginId == "grafana-testdata-datasource" {
+ return schema.GroupVersion{
+ Group: "testdata.datasource.grafana.app",
+ Version: "v0alpha1",
+ }, nil
+ }
+ return schema.GroupVersion{}, fmt.Errorf("unsupported plugin (only testdata for now)")
+}
+
+// GetDatasourcePlugins implements QueryHelper.
+func (d *testdataDummy) GetDatasourceApiServers(ctx context.Context) (*v0alpha1.DataSourceApiServerList, error) {
+ return &v0alpha1.DataSourceApiServerList{
+ ListMeta: metav1.ListMeta{
+ ResourceVersion: fmt.Sprintf("%d", time.Now().UnixMilli()),
+ },
+ Items: []v0alpha1.DataSourceApiServer{
+ {
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "grafana-testdata-datasource",
+ CreationTimestamp: metav1.Now(),
+ },
+ Title: "Test Data",
+ GroupVersion: "testdata.datasource.grafana.app/v0alpha1",
+ AliasIDs: []string{"testdata"},
+ },
+ },
+ }, nil
+}
diff --git a/pkg/registry/apis/service/register.go b/pkg/registry/apis/service/register.go
index c2cc3e57584..f7366b3badf 100644
--- a/pkg/registry/apis/service/register.go
+++ b/pkg/registry/apis/service/register.go
@@ -12,11 +12,11 @@ import (
"k8s.io/kube-openapi/pkg/common"
service "github.com/grafana/grafana/pkg/apis/service/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
)
-var _ grafanaapiserver.APIGroupBuilder = (*ServiceAPIBuilder)(nil)
+var _ builder.APIGroupBuilder = (*ServiceAPIBuilder)(nil)
// This is used just so wire has something unique to return
type ServiceAPIBuilder struct{}
@@ -25,10 +25,7 @@ func NewServiceAPIBuilder() *ServiceAPIBuilder {
return &ServiceAPIBuilder{}
}
-func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration grafanaapiserver.APIRegistrar) *ServiceAPIBuilder {
- if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
- return nil // skip registration unless opting into experimental apis
- }
+func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ServiceAPIBuilder {
builder := NewServiceAPIBuilder()
apiregistration.RegisterAPI(NewServiceAPIBuilder())
return builder
@@ -64,6 +61,7 @@ func (b *ServiceAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory,
optsGetter generic.RESTOptionsGetter,
+ _ bool,
) (*genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(service.GROUP, scheme, metav1.ParameterCodec, codecs)
@@ -83,6 +81,6 @@ func (b *ServiceAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions
}
// Register additional routes with the server
-func (b *ServiceAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
+func (b *ServiceAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
return nil
}
diff --git a/pkg/registry/apis/service/storage.go b/pkg/registry/apis/service/storage.go
index db4dc56daf3..bf0d8551b80 100644
--- a/pkg/registry/apis/service/storage.go
+++ b/pkg/registry/apis/service/storage.go
@@ -10,9 +10,9 @@ import (
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
service "github.com/grafana/grafana/pkg/apis/service/v0alpha1"
- grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
- grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
+ grafanaregistry "github.com/grafana/grafana/pkg/services/apiserver/registry/generic"
+ grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
)
var _ grafanarest.Storage = (*storage)(nil)
diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go
index 3497faba067..3c5758da87b 100644
--- a/pkg/registry/apis/wireset.go
+++ b/pkg/registry/apis/wireset.go
@@ -4,11 +4,14 @@ import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/playlist"
+ "github.com/grafana/grafana/pkg/registry/apis/query"
+ "github.com/grafana/grafana/pkg/registry/apis/service"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
)
@@ -24,7 +27,10 @@ var WireSet = wire.NewSet(
playlist.RegisterAPIService,
dashboard.RegisterAPIService,
example.RegisterAPIService,
+ dashboardsnapshot.RegisterAPIService,
featuretoggle.RegisterAPIService,
datasource.RegisterAPIService,
folders.RegisterAPIService,
+ service.RegisterAPIService,
+ query.RegisterAPIService,
)
diff --git a/pkg/registry/backgroundsvcs/background_services.go b/pkg/registry/backgroundsvcs/background_services.go
index 7c8a0821833..3f323ab09ae 100644
--- a/pkg/registry/backgroundsvcs/background_services.go
+++ b/pkg/registry/backgroundsvcs/background_services.go
@@ -11,10 +11,10 @@ import (
apiregistry "github.com/grafana/grafana/pkg/registry/apis"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl"
+ grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/services/guardian"
ldapapi "github.com/grafana/grafana/pkg/services/ldap/api"
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index 65458a41d2f..6e7c1629129 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -44,6 +44,7 @@ import (
"github.com/grafana/grafana/pkg/services/annotations/annotationsimpl"
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
"github.com/grafana/grafana/pkg/services/apikey/apikeyimpl"
+ grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/idimpl"
"github.com/grafana/grafana/pkg/services/auth/jwt"
@@ -72,7 +73,6 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
- grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
"github.com/grafana/grafana/pkg/services/grpcserver"
grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context"
"github.com/grafana/grafana/pkg/services/grpcserver/interceptors"
diff --git a/pkg/services/accesscontrol/acimpl/accesscontrol.go b/pkg/services/accesscontrol/acimpl/accesscontrol.go
index f99e4edbe82..f5d8774292d 100644
--- a/pkg/services/accesscontrol/acimpl/accesscontrol.go
+++ b/pkg/services/accesscontrol/acimpl/accesscontrol.go
@@ -39,13 +39,19 @@ func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, e
}
namespace, identifier := user.GetNamespacedID()
- if len(user.GetPermissions()) == 0 {
+
+ // If the user is in no organization, then the evaluation must happen based on the user's global permissions
+ permissions := user.GetPermissions()
+ if user.GetOrgID() == accesscontrol.NoOrgID {
+ permissions = user.GetGlobalPermissions()
+ }
+ if len(permissions) == 0 {
a.log.Debug("No permissions set for entity", "namespace", namespace, "id", identifier, "orgID", user.GetOrgID(), "login", user.GetLogin())
return false, nil
}
// Test evaluation without scope resolver first, this will prevent 403 for wildcard scopes when resource does not exist
- if evaluator.Evaluate(user.GetPermissions()) {
+ if evaluator.Evaluate(permissions) {
return true, nil
}
@@ -57,7 +63,7 @@ func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, e
return false, err
}
- return resolvedEvaluator.Evaluate(user.GetPermissions()), nil
+ return resolvedEvaluator.Evaluate(permissions), nil
}
func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
diff --git a/pkg/services/accesscontrol/authorize_in_org_test.go b/pkg/services/accesscontrol/authorize_in_org_test.go
index 812f56634fa..8a9f6d2b814 100644
--- a/pkg/services/accesscontrol/authorize_in_org_test.go
+++ b/pkg/services/accesscontrol/authorize_in_org_test.go
@@ -202,6 +202,24 @@ func TestAuthorizeInOrgMiddleware(t *testing.T) {
teamService: &teamtest.FakeService{},
expectedStatus: http.StatusForbidden,
},
+ {
+ name: "should fetch global user permissions when user is not a member of the target org",
+ orgIDGetter: func(c *contextmodel.ReqContext) (int64, error) {
+ return 2, nil
+ },
+ evaluator: accesscontrol.EvalPermission("users:read", "users:*"),
+ accessControl: ac,
+ acService: &actest.FakeService{
+ ExpectedPermissions: []accesscontrol.Permission{{Action: "users:read", Scope: "users:*"}},
+ },
+ userCache: &usertest.FakeUserService{
+ GetSignedInUserFn: func(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) {
+ return &user.SignedInUser{UserID: 1, OrgID: -1, Permissions: map[int64]map[string][]string{}}, nil
+ },
+ },
+ ctxSignedInUser: &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{1: {"users:write": {"users:*"}}}},
+ expectedStatus: http.StatusOK,
+ },
}
// Run test cases
diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go
index 6c76060542d..5ef36a8a216 100644
--- a/pkg/services/accesscontrol/middleware.go
+++ b/pkg/services/accesscontrol/middleware.go
@@ -260,23 +260,32 @@ func makeTmpUser(ctx context.Context, service Service, cache userCache,
tmpUser.OrgName = queryResult.OrgName
tmpUser.OrgRole = queryResult.OrgRole
- if teamService != nil {
- teamIDs, err := teamService.GetTeamIDsByUser(ctx, &team.GetTeamIDsByUserQuery{OrgID: targetOrgID, UserID: tmpUser.UserID})
- if err != nil {
- return nil, err
+ // Only fetch the team membership is the user is a member of the organization
+ if queryResult.OrgID == targetOrgID {
+ if teamService != nil {
+ teamIDs, err := teamService.GetTeamIDsByUser(ctx, &team.GetTeamIDsByUserQuery{OrgID: targetOrgID, UserID: tmpUser.UserID})
+ if err != nil {
+ return nil, err
+ }
+ tmpUser.Teams = teamIDs
}
- tmpUser.Teams = teamIDs
}
}
}
- if tmpUser.Permissions[targetOrgID] == nil || len(tmpUser.Permissions[targetOrgID]) == 0 {
+ // If the user is not a member of the organization
+ // evaluation must happen based on global permissions.
+ evaluationOrg := targetOrgID
+ if tmpUser.OrgID == NoOrgID {
+ evaluationOrg = GlobalOrgID
+ }
+ if tmpUser.Permissions[evaluationOrg] == nil || len(tmpUser.Permissions[evaluationOrg]) == 0 {
permissions, err := service.GetUserPermissions(ctx, tmpUser, Options{})
if err != nil {
return nil, err
}
- tmpUser.Permissions[targetOrgID] = GroupScopesByAction(permissions)
+ tmpUser.Permissions[evaluationOrg] = GroupScopesByAction(permissions)
}
return tmpUser, nil
diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go
index ce353e721c5..b3ee788c439 100644
--- a/pkg/services/accesscontrol/models.go
+++ b/pkg/services/accesscontrol/models.go
@@ -330,6 +330,7 @@ func (cmd *SaveExternalServiceRoleCommand) Validate() error {
const (
GlobalOrgID = 0
+ NoOrgID = int64(-1)
GeneralFolderUID = "general"
RoleGrafanaAdmin = "Grafana Admin"
@@ -444,6 +445,10 @@ const (
ActionAlertingNotificationsRead = "alert.notifications:read"
ActionAlertingNotificationsWrite = "alert.notifications:write"
+ // Alerting notifications time interval actions
+ ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read"
+ ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write"
+
// External alerting rule actions. We can only narrow it down to writes or reads, as we don't control the atomicity in the external system.
ActionAlertingRuleExternalWrite = "alert.rules.external:write"
ActionAlertingRuleExternalRead = "alert.rules.external:read"
diff --git a/pkg/services/anonymous/anonimpl/impl_test.go b/pkg/services/anonymous/anonimpl/impl_test.go
index 51e28febacb..e3c93db2ff2 100644
--- a/pkg/services/anonymous/anonimpl/impl_test.go
+++ b/pkg/services/anonymous/anonimpl/impl_test.go
@@ -206,6 +206,8 @@ func TestIntegrationDeviceService_SearchDevice(t *testing.T) {
Query: "",
Page: 1,
Limit: 1,
+ From: fixedTime,
+ To: fixedTime.Add(1 * time.Hour),
},
expectedCount: 1,
},
@@ -227,6 +229,8 @@ func TestIntegrationDeviceService_SearchDevice(t *testing.T) {
Query: "192.1",
Page: 1,
Limit: 50,
+ From: fixedTime,
+ To: fixedTime.Add(1 * time.Hour),
},
expectedCount: 1,
expectedDevice: &anonstore.Device{
diff --git a/pkg/services/grafana-apiserver/README.md b/pkg/services/apiserver/README.md
similarity index 100%
rename from pkg/services/grafana-apiserver/README.md
rename to pkg/services/apiserver/README.md
diff --git a/pkg/aggregator/README.md b/pkg/services/apiserver/aggregator/README.md
similarity index 100%
rename from pkg/aggregator/README.md
rename to pkg/services/apiserver/aggregator/README.md
diff --git a/pkg/services/apiserver/aggregator/aggregator.go b/pkg/services/apiserver/aggregator/aggregator.go
new file mode 100644
index 00000000000..b62e106446e
--- /dev/null
+++ b/pkg/services/apiserver/aggregator/aggregator.go
@@ -0,0 +1,285 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/aggregator.go
+// Provenance-includes-license: Apache-2.0
+// Provenance-includes-copyright: The Kubernetes Authors.
+// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go
+// Provenance-includes-license: Apache-2.0
+// Provenance-includes-copyright: The Kubernetes Authors.
+// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/pkg/controlplane/apiserver/apiextensions.go
+// Provenance-includes-license: Apache-2.0
+// Provenance-includes-copyright: The Kubernetes Authors.
+
+package aggregator
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ utilnet "k8s.io/apimachinery/pkg/util/net"
+ "k8s.io/apimachinery/pkg/util/sets"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ "k8s.io/apiserver/pkg/server/healthz"
+ "k8s.io/client-go/informers"
+ "k8s.io/client-go/kubernetes/fake"
+ "k8s.io/client-go/tools/cache"
+ "k8s.io/klog/v2"
+ v1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
+ v1helper "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1/helper"
+ aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver"
+ apiregistrationclientset "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
+ apiregistrationclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1"
+ apiregistrationInformers "k8s.io/kube-aggregator/pkg/client/informers/externalversions/apiregistration/v1"
+ "k8s.io/kube-aggregator/pkg/controllers/autoregister"
+
+ serviceclientset "github.com/grafana/grafana/pkg/generated/clientset/versioned"
+ informersv0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions"
+ "github.com/grafana/grafana/pkg/services/apiserver/options"
+)
+
+func CreateAggregatorConfig(commandOptions *options.Options, sharedConfig genericapiserver.RecommendedConfig) (*aggregatorapiserver.Config, informersv0alpha1.SharedInformerFactory, error) {
+ // Create a fake clientset and informers for the k8s v1 API group.
+ // These are not used in grafana's aggregator because v1 APIs are not available.
+ fakev1Informers := informers.NewSharedInformerFactory(fake.NewSimpleClientset(), 10*time.Minute)
+
+ serviceClient, err := serviceclientset.NewForConfig(sharedConfig.LoopbackClientConfig)
+ if err != nil {
+ return nil, nil, err
+ }
+ sharedInformerFactory := informersv0alpha1.NewSharedInformerFactory(
+ serviceClient,
+ 5*time.Minute, // this is effectively used as a refresh interval right now. Might want to do something nicer later on.
+ )
+ serviceResolver := NewExternalNameResolver(sharedInformerFactory.Service().V0alpha1().ExternalNames().Lister())
+
+ aggregatorConfig := &aggregatorapiserver.Config{
+ GenericConfig: &genericapiserver.RecommendedConfig{
+ Config: sharedConfig.Config,
+ SharedInformerFactory: fakev1Informers,
+ ClientConfig: sharedConfig.LoopbackClientConfig,
+ },
+ ExtraConfig: aggregatorapiserver.ExtraConfig{
+ ProxyClientCertFile: commandOptions.AggregatorOptions.ProxyClientCertFile,
+ ProxyClientKeyFile: commandOptions.AggregatorOptions.ProxyClientKeyFile,
+ // NOTE: while ProxyTransport can be skipped in the configuration, it allows honoring
+ // DISABLE_HTTP2, HTTPS_PROXY and NO_PROXY env vars as needed
+ ProxyTransport: createProxyTransport(),
+ ServiceResolver: serviceResolver,
+ },
+ }
+
+ if err := commandOptions.AggregatorOptions.ApplyTo(aggregatorConfig, commandOptions.RecommendedOptions.Etcd, commandOptions.StorageOptions.DataPath); err != nil {
+ return nil, nil, err
+ }
+
+ return aggregatorConfig, sharedInformerFactory, nil
+}
+
+func CreateAggregatorServer(aggregatorConfig *aggregatorapiserver.Config, sharedInformerFactory informersv0alpha1.SharedInformerFactory, delegateAPIServer genericapiserver.DelegationTarget) (*aggregatorapiserver.APIAggregator, error) {
+ completedConfig := aggregatorConfig.Complete()
+ aggregatorServer, err := completedConfig.NewWithDelegate(delegateAPIServer)
+ if err != nil {
+ return nil, err
+ }
+
+ // create controllers for auto-registration
+ apiRegistrationClient, err := apiregistrationclient.NewForConfig(completedConfig.GenericConfig.LoopbackClientConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ autoRegistrationController := autoregister.NewAutoRegisterController(aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(), apiRegistrationClient)
+ apiServices := apiServicesToRegister(delegateAPIServer, autoRegistrationController)
+
+ // Imbue all builtin group-priorities onto the aggregated discovery
+ if completedConfig.GenericConfig.AggregatedDiscoveryGroupManager != nil {
+ for gv, entry := range APIVersionPriorities {
+ completedConfig.GenericConfig.AggregatedDiscoveryGroupManager.SetGroupVersionPriority(metav1.GroupVersion(gv), int(entry.Group), int(entry.Version))
+ }
+ }
+
+ err = aggregatorServer.GenericAPIServer.AddPostStartHook("grafana-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error {
+ go func() {
+ autoRegistrationController.Run(5, context.StopCh)
+ }()
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ err = aggregatorServer.GenericAPIServer.AddBootSequenceHealthChecks(
+ makeAPIServiceAvailableHealthCheck(
+ "autoregister-completion",
+ apiServices,
+ aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(),
+ ),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ apiregistrationClient, err := apiregistrationclientset.NewForConfig(completedConfig.GenericConfig.LoopbackClientConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ availableController, err := NewAvailableConditionController(
+ aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(),
+ sharedInformerFactory.Service().V0alpha1().ExternalNames(),
+ apiregistrationClient.ApiregistrationV1(),
+ nil,
+ (func() ([]byte, []byte))(nil),
+ completedConfig.ExtraConfig.ServiceResolver,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ aggregatorServer.GenericAPIServer.AddPostStartHookOrDie("apiservice-status-override-available-controller", func(context genericapiserver.PostStartHookContext) error {
+ // if we end up blocking for long periods of time, we may need to increase workers.
+ go availableController.Run(5, context.StopCh)
+ return nil
+ })
+
+ aggregatorServer.GenericAPIServer.AddPostStartHookOrDie("start-grafana-aggregator-informers", func(context genericapiserver.PostStartHookContext) error {
+ sharedInformerFactory.Start(context.StopCh)
+ aggregatorServer.APIRegistrationInformers.Start(context.StopCh)
+ return nil
+ })
+
+ return aggregatorServer, nil
+}
+
+func makeAPIService(gv schema.GroupVersion) *v1.APIService {
+ apiServicePriority, ok := APIVersionPriorities[gv]
+ if !ok {
+ // if we aren't found, then we shouldn't register ourselves because it could result in a CRD group version
+ // being permanently stuck in the APIServices list.
+ klog.Infof("Skipping APIService creation for %v", gv)
+ return nil
+ }
+ return &v1.APIService{
+ ObjectMeta: metav1.ObjectMeta{Name: gv.Version + "." + gv.Group},
+ Spec: v1.APIServiceSpec{
+ Group: gv.Group,
+ Version: gv.Version,
+ GroupPriorityMinimum: apiServicePriority.Group,
+ VersionPriority: apiServicePriority.Version,
+ },
+ }
+}
+
+// makeAPIServiceAvailableHealthCheck returns a healthz check that returns healthy
+// once all of the specified services have been observed to be available at least once.
+func makeAPIServiceAvailableHealthCheck(name string, apiServices []*v1.APIService, apiServiceInformer apiregistrationInformers.APIServiceInformer) healthz.HealthChecker {
+ // Track the auto-registered API services that have not been observed to be available yet
+ pendingServiceNamesLock := &sync.RWMutex{}
+ pendingServiceNames := sets.NewString()
+ for _, service := range apiServices {
+ pendingServiceNames.Insert(service.Name)
+ }
+
+ // When an APIService in the list is seen as available, remove it from the pending list
+ handleAPIServiceChange := func(service *v1.APIService) {
+ pendingServiceNamesLock.Lock()
+ defer pendingServiceNamesLock.Unlock()
+ if !pendingServiceNames.Has(service.Name) {
+ return
+ }
+ if v1helper.IsAPIServiceConditionTrue(service, v1.Available) {
+ pendingServiceNames.Delete(service.Name)
+ }
+ }
+
+ // Watch add/update events for APIServices
+ _, _ = apiServiceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+ AddFunc: func(obj interface{}) { handleAPIServiceChange(obj.(*v1.APIService)) },
+ UpdateFunc: func(old, new interface{}) { handleAPIServiceChange(new.(*v1.APIService)) },
+ })
+
+ // Don't return healthy until the pending list is empty
+ return healthz.NamedCheck(name, func(r *http.Request) error {
+ pendingServiceNamesLock.RLock()
+ defer pendingServiceNamesLock.RUnlock()
+ if pendingServiceNames.Len() > 0 {
+ return fmt.Errorf("missing APIService: %v", pendingServiceNames.List())
+ }
+ return nil
+ })
+}
+
+// Priority defines group Priority that is used in discovery. This controls
+// group position in the kubectl output.
+type Priority struct {
+ // Group indicates the order of the Group relative to other groups.
+ Group int32
+ // Version indicates the relative order of the Version inside of its group.
+ Version int32
+}
+
+// APIVersionPriorities are the proper way to resolve this letting the aggregator know the desired group and version-within-group order of the underlying servers
+// is to refactor the genericapiserver.DelegationTarget to include a list of priorities based on which APIs were installed.
+// This requires the APIGroupInfo struct to evolve and include the concept of priorities and to avoid mistakes, the core storage map there needs to be updated.
+// That ripples out every bit as far as you'd expect, so for 1.7 we'll include the list here instead of being built up during storage.
+var APIVersionPriorities = map[schema.GroupVersion]Priority{
+ {Group: "", Version: "v1"}: {Group: 18000, Version: 1},
+ // to my knowledge, nothing below here collides
+ {Group: "admissionregistration.k8s.io", Version: "v1"}: {Group: 16700, Version: 15},
+ {Group: "admissionregistration.k8s.io", Version: "v1beta1"}: {Group: 16700, Version: 12},
+ {Group: "admissionregistration.k8s.io", Version: "v1alpha1"}: {Group: 16700, Version: 9},
+ // Append a new group to the end of the list if unsure.
+ // You can use min(existing group)-100 as the initial value for a group.
+ // Version can be set to 9 (to have space around) for a new group.
+}
+
+func apiServicesToRegister(delegateAPIServer genericapiserver.DelegationTarget, registration autoregister.AutoAPIServiceRegistration) []*v1.APIService {
+ apiServices := []*v1.APIService{}
+
+ for _, curr := range delegateAPIServer.ListedPaths() {
+ if curr == "/api/v1" {
+ apiService := makeAPIService(schema.GroupVersion{Group: "", Version: "v1"})
+ registration.AddAPIServiceToSyncOnStart(apiService)
+ apiServices = append(apiServices, apiService)
+ continue
+ }
+
+ if !strings.HasPrefix(curr, "/apis/") {
+ continue
+ }
+ // this comes back in a list that looks like /apis/rbac.authorization.k8s.io/v1alpha1
+ tokens := strings.Split(curr, "/")
+ if len(tokens) != 4 {
+ continue
+ }
+
+ apiService := makeAPIService(schema.GroupVersion{Group: tokens[2], Version: tokens[3]})
+ if apiService == nil {
+ continue
+ }
+ registration.AddAPIServiceToSyncOnStart(apiService)
+ apiServices = append(apiServices, apiService)
+ }
+
+ return apiServices
+}
+
+// NOTE: below function imported from https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go#L197
+// createProxyTransport creates the dialer infrastructure to connect to the api servers.
+func createProxyTransport() *http.Transport {
+ // NOTE: We don't set proxyDialerFn but the below SetTransportDefaults will
+ // See https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/net/http.go#L109
+ var proxyDialerFn utilnet.DialFunc
+ // Proxying to services is IP-based... don't expect to be able to verify the hostname
+ proxyTLSClientConfig := &tls.Config{InsecureSkipVerify: true}
+ proxyTransport := utilnet.SetTransportDefaults(&http.Transport{
+ DialContext: proxyDialerFn,
+ TLSClientConfig: proxyTLSClientConfig,
+ })
+ return proxyTransport
+}
diff --git a/pkg/aggregator/availableController.go b/pkg/services/apiserver/aggregator/availableController.go
similarity index 100%
rename from pkg/aggregator/availableController.go
rename to pkg/services/apiserver/aggregator/availableController.go
diff --git a/pkg/aggregator/resolver.go b/pkg/services/apiserver/aggregator/resolver.go
similarity index 100%
rename from pkg/aggregator/resolver.go
rename to pkg/services/apiserver/aggregator/resolver.go
diff --git a/pkg/services/grafana-apiserver/auth/authorizer/impersonation.go b/pkg/services/apiserver/auth/authorizer/impersonation.go
similarity index 100%
rename from pkg/services/grafana-apiserver/auth/authorizer/impersonation.go
rename to pkg/services/apiserver/auth/authorizer/impersonation.go
diff --git a/pkg/services/grafana-apiserver/auth/authorizer/impersonation_test.go b/pkg/services/apiserver/auth/authorizer/impersonation_test.go
similarity index 100%
rename from pkg/services/grafana-apiserver/auth/authorizer/impersonation_test.go
rename to pkg/services/apiserver/auth/authorizer/impersonation_test.go
diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org_id.go b/pkg/services/apiserver/auth/authorizer/org_id.go
similarity index 95%
rename from pkg/services/grafana-apiserver/auth/authorizer/org_id.go
rename to pkg/services/apiserver/auth/authorizer/org_id.go
index 4d6efdfc88a..e9d4a6786a8 100644
--- a/pkg/services/grafana-apiserver/auth/authorizer/org_id.go
+++ b/pkg/services/apiserver/auth/authorizer/org_id.go
@@ -8,7 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/infra/log"
- grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/org"
)
diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org_role.go b/pkg/services/apiserver/auth/authorizer/org_role.go
similarity index 100%
rename from pkg/services/grafana-apiserver/auth/authorizer/org_role.go
rename to pkg/services/apiserver/auth/authorizer/org_role.go
diff --git a/pkg/services/grafana-apiserver/auth/authorizer/provider.go b/pkg/services/apiserver/auth/authorizer/provider.go
similarity index 100%
rename from pkg/services/grafana-apiserver/auth/authorizer/provider.go
rename to pkg/services/apiserver/auth/authorizer/provider.go
diff --git a/pkg/services/grafana-apiserver/auth/authorizer/stack_id.go b/pkg/services/apiserver/auth/authorizer/stack_id.go
similarity index 94%
rename from pkg/services/grafana-apiserver/auth/authorizer/stack_id.go
rename to pkg/services/apiserver/auth/authorizer/stack_id.go
index 32887dd8142..0098c1dfd21 100644
--- a/pkg/services/grafana-apiserver/auth/authorizer/stack_id.go
+++ b/pkg/services/apiserver/auth/authorizer/stack_id.go
@@ -8,7 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/infra/log"
- grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/setting"
)
diff --git a/pkg/services/grafana-apiserver/common.go b/pkg/services/apiserver/builder/common.go
similarity index 94%
rename from pkg/services/grafana-apiserver/common.go
rename to pkg/services/apiserver/builder/common.go
index 5845bfc3c22..39d2c5dccee 100644
--- a/pkg/services/grafana-apiserver/common.go
+++ b/pkg/services/apiserver/builder/common.go
@@ -1,4 +1,4 @@
-package grafanaapiserver
+package builder
import (
"net/http"
@@ -27,6 +27,7 @@ type APIGroupBuilder interface {
scheme *runtime.Scheme,
codecs serializer.CodecFactory,
optsGetter generic.RESTOptionsGetter,
+ dualWrite bool,
) (*genericapiserver.APIGroupInfo, error)
// Get OpenAPI definitions
@@ -57,3 +58,7 @@ type APIRoutes struct {
// Namespace handlers are mounted under the namespace
Namespace []APIRouteHandler
}
+
+type APIRegistrar interface {
+ RegisterAPI(builder APIGroupBuilder)
+}
diff --git a/pkg/services/grafana-apiserver/helper.go b/pkg/services/apiserver/builder/helper.go
similarity index 65%
rename from pkg/services/grafana-apiserver/helper.go
rename to pkg/services/apiserver/builder/helper.go
index 8205715487f..f6987752365 100644
--- a/pkg/services/grafana-apiserver/helper.go
+++ b/pkg/services/apiserver/builder/helper.go
@@ -1,34 +1,45 @@
-package grafanaapiserver
+package builder
import (
"fmt"
"net/http"
goruntime "runtime"
+ "runtime/debug"
+ "strconv"
"strings"
"time"
+ "golang.org/x/mod/semver"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/version"
openapinamer "k8s.io/apiserver/pkg/endpoints/openapi"
"k8s.io/apiserver/pkg/registry/generic"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/apiserver/pkg/util/openapi"
- "k8s.io/client-go/kubernetes/scheme"
+ k8sscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/kube-openapi/pkg/common"
"github.com/grafana/grafana/pkg/setting"
)
-func SetupConfig(serverConfig *genericapiserver.RecommendedConfig, builders []APIGroupBuilder) error {
+func SetupConfig(
+ scheme *runtime.Scheme,
+ serverConfig *genericapiserver.RecommendedConfig,
+ builders []APIGroupBuilder,
+) error {
defsGetter := GetOpenAPIDefinitions(builders)
serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(
openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter),
- openapinamer.NewDefinitionNamer(Scheme, scheme.Scheme))
+ openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme))
serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(
openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter),
- openapinamer.NewDefinitionNamer(Scheme, scheme.Scheme))
+ openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme))
+ // Add the custom routes to service discovery
+ serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(builders)
serverConfig.OpenAPIV3Config.GetOperationIDAndTagsFromRoute = func(r common.Route) (string, []string, error) {
tags := []string{}
prop, ok := r.Metadata()["x-kubernetes-group-version-kind"]
@@ -41,9 +52,6 @@ func SetupConfig(serverConfig *genericapiserver.RecommendedConfig, builders []AP
return r.OperationName(), tags, nil
}
- // Add the custom routes to service discovery
- serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(builders)
-
// Set the swagger build versions
serverConfig.OpenAPIConfig.Info.Version = setting.BuildVersion
serverConfig.OpenAPIV3Config.Info.Version = setting.BuildVersion
@@ -82,12 +90,16 @@ func SetupConfig(serverConfig *genericapiserver.RecommendedConfig, builders []AP
return nil
}
-func InstallAPIs(server *genericapiserver.GenericAPIServer,
+func InstallAPIs(
+ scheme *runtime.Scheme,
+ codecs serializer.CodecFactory,
+ server *genericapiserver.GenericAPIServer,
optsGetter generic.RESTOptionsGetter,
builders []APIGroupBuilder,
+ dualWrite bool,
) error {
for _, b := range builders {
- g, err := b.GetAPIGroupInfo(Scheme, Codecs, optsGetter)
+ g, err := b.GetAPIGroupInfo(scheme, codecs, optsGetter, dualWrite)
if err != nil {
return err
}
@@ -101,3 +113,33 @@ func InstallAPIs(server *genericapiserver.GenericAPIServer,
}
return nil
}
+
+// find the k8s version according to build info
+func getK8sApiserverVersion() (string, error) {
+ bi, ok := debug.ReadBuildInfo()
+ if !ok {
+ return "", fmt.Errorf("debug.ReadBuildInfo() failed")
+ }
+
+ if len(bi.Deps) == 0 {
+ return "v?.?", nil // this is normal while debugging
+ }
+
+ for _, dep := range bi.Deps {
+ if dep.Path == "k8s.io/apiserver" {
+ if !semver.IsValid(dep.Version) {
+ return "", fmt.Errorf("invalid semantic version for k8s.io/apiserver")
+ }
+ // v0 => v1
+ majorVersion := strings.TrimPrefix(semver.Major(dep.Version), "v")
+ majorInt, err := strconv.Atoi(majorVersion)
+ if err != nil {
+ return "", fmt.Errorf("could not convert majorVersion to int. majorVersion: %s", majorVersion)
+ }
+ newMajor := fmt.Sprintf("v%d", majorInt+1)
+ return strings.Replace(dep.Version, semver.Major(dep.Version), newMajor, 1), nil
+ }
+ }
+
+ return "", fmt.Errorf("could not find k8s.io/apiserver in build info")
+}
diff --git a/pkg/services/grafana-apiserver/openapi.go b/pkg/services/apiserver/builder/openapi.go
similarity index 99%
rename from pkg/services/grafana-apiserver/openapi.go
rename to pkg/services/apiserver/builder/openapi.go
index f90b6bfdc46..e4d6a2ac149 100644
--- a/pkg/services/grafana-apiserver/openapi.go
+++ b/pkg/services/apiserver/builder/openapi.go
@@ -1,4 +1,4 @@
-package grafanaapiserver
+package builder
import (
"maps"
@@ -15,8 +15,8 @@ func GetOpenAPIDefinitions(builders []APIGroupBuilder) common.GetOpenAPIDefiniti
return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
defs := getStandardOpenAPIDefinitions(ref)
maps.Copy(defs, v0alpha1.GetOpenAPIDefinitions(ref)) // common grafana apis
- for _, builder := range builders {
- g := builder.GetOpenAPIDefinitions()
+ for _, b := range builders {
+ g := b.GetOpenAPIDefinitions()
if g != nil {
out := g(ref)
maps.Copy(defs, out)
diff --git a/pkg/services/grafana-apiserver/request_handler.go b/pkg/services/apiserver/builder/request_handler.go
similarity index 96%
rename from pkg/services/grafana-apiserver/request_handler.go
rename to pkg/services/apiserver/builder/request_handler.go
index 48199878a67..b88c221b57f 100644
--- a/pkg/services/grafana-apiserver/request_handler.go
+++ b/pkg/services/apiserver/builder/request_handler.go
@@ -1,4 +1,4 @@
-package grafanaapiserver
+package builder
import (
"fmt"
@@ -125,9 +125,9 @@ func getOpenAPIPostProcessor(builders []APIGroupBuilder) func(*spec3.OpenAPI) (*
if s.Paths == nil {
return s, nil
}
- for _, builder := range builders {
- routes := builder.GetAPIRoutes()
- gv := builder.GetGroupVersion()
+ for _, b := range builders {
+ routes := b.GetAPIRoutes()
+ gv := b.GetGroupVersion()
prefix := "/apis/" + gv.String() + "/"
if s.Paths.Paths[prefix] != nil {
copy := spec3.OpenAPI{
diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go
new file mode 100644
index 00000000000..3cc6fc82d07
--- /dev/null
+++ b/pkg/services/apiserver/config.go
@@ -0,0 +1,48 @@
+package apiserver
+
+import (
+ "fmt"
+ "net"
+ "path/filepath"
+ "strconv"
+
+ "github.com/grafana/grafana/pkg/services/apiserver/options"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o *options.Options) {
+ defaultLogLevel := 0
+ ip := net.ParseIP(cfg.HTTPAddr)
+ apiURL := cfg.AppURL
+ port, err := strconv.Atoi(cfg.HTTPPort)
+ if err != nil {
+ port = 3000
+ }
+
+ if cfg.Env == setting.Dev {
+ defaultLogLevel = 10
+ port = 6443
+ ip = net.ParseIP("127.0.0.1")
+ apiURL = fmt.Sprintf("https://%s:%d", ip, port)
+ }
+
+ host := fmt.Sprintf("%s:%d", ip, port)
+
+ o.RecommendedOptions.Etcd.StorageConfig.Transport.ServerList = cfg.SectionWithEnvOverrides("grafana-apiserver").Key("etcd_servers").Strings(",")
+
+ o.RecommendedOptions.SecureServing.BindAddress = ip
+ o.RecommendedOptions.SecureServing.BindPort = port
+ o.RecommendedOptions.Authentication.RemoteKubeConfigFileOptional = true
+ o.RecommendedOptions.Authorization.RemoteKubeConfigFileOptional = true
+
+ o.RecommendedOptions.Admission = nil
+ o.RecommendedOptions.CoreAPI = nil
+
+ o.StorageOptions.StorageType = options.StorageType(cfg.SectionWithEnvOverrides("grafana-apiserver").Key("storage_type").MustString(string(options.StorageTypeLegacy)))
+ o.StorageOptions.DataPath = filepath.Join(cfg.DataPath, "grafana-apiserver")
+ o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess)
+ o.ExtraOptions.ExternalAddress = host
+ o.ExtraOptions.APIURL = apiURL
+ o.ExtraOptions.Verbosity = defaultLogLevel
+}
diff --git a/pkg/services/grafana-apiserver/endpoints/request/namespace.go b/pkg/services/apiserver/endpoints/request/namespace.go
similarity index 100%
rename from pkg/services/grafana-apiserver/endpoints/request/namespace.go
rename to pkg/services/apiserver/endpoints/request/namespace.go
diff --git a/pkg/services/grafana-apiserver/endpoints/request/namespace_test.go b/pkg/services/apiserver/endpoints/request/namespace_test.go
similarity index 97%
rename from pkg/services/grafana-apiserver/endpoints/request/namespace_test.go
rename to pkg/services/apiserver/endpoints/request/namespace_test.go
index 4f9dc7e9281..cd5a8e58394 100644
--- a/pkg/services/grafana-apiserver/endpoints/request/namespace_test.go
+++ b/pkg/services/apiserver/endpoints/request/namespace_test.go
@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/setting"
)
diff --git a/pkg/services/apiserver/options/aggregator.go b/pkg/services/apiserver/options/aggregator.go
new file mode 100644
index 00000000000..51c67cea117
--- /dev/null
+++ b/pkg/services/apiserver/options/aggregator.go
@@ -0,0 +1,112 @@
+package options
+
+import (
+ "github.com/spf13/pflag"
+ v1 "k8s.io/api/apps/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ openapinamer "k8s.io/apiserver/pkg/endpoints/openapi"
+ genericfeatures "k8s.io/apiserver/pkg/features"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ "k8s.io/apiserver/pkg/server/options"
+ "k8s.io/apiserver/pkg/server/resourceconfig"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1"
+ aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver"
+ aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme"
+ aggregatoropenapi "k8s.io/kube-aggregator/pkg/generated/openapi"
+ "k8s.io/kube-openapi/pkg/common"
+
+ servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1"
+ filestorage "github.com/grafana/grafana/pkg/services/apiserver/storage/file"
+)
+
+// AggregatorServerOptions contains the state for the aggregator apiserver
+type AggregatorServerOptions struct {
+ AlternateDNS []string
+ ProxyClientCertFile string
+ ProxyClientKeyFile string
+}
+
+func NewAggregatorServerOptions() *AggregatorServerOptions {
+ return &AggregatorServerOptions{}
+}
+
+func (o *AggregatorServerOptions) getMergedOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
+ aggregatorAPIs := aggregatoropenapi.GetOpenAPIDefinitions(ref)
+ return aggregatorAPIs
+}
+
+func (o *AggregatorServerOptions) AddFlags(fs *pflag.FlagSet) {
+ if o == nil {
+ return
+ }
+
+ fs.StringVar(&o.ProxyClientCertFile, "proxy-client-cert-file", o.ProxyClientCertFile,
+ "path to proxy client cert file")
+
+ fs.StringVar(&o.ProxyClientKeyFile, "proxy-client-key-file", o.ProxyClientKeyFile,
+ "path to proxy client cert file")
+}
+
+func (o *AggregatorServerOptions) Validate() []error {
+ if o == nil {
+ return nil
+ }
+
+ // TODO: do we need to validate anything here?
+ return nil
+}
+
+func (o *AggregatorServerOptions) ApplyTo(aggregatorConfig *aggregatorapiserver.Config, etcdOpts *options.EtcdOptions, dataPath string) error {
+ genericConfig := aggregatorConfig.GenericConfig
+
+ genericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{}
+ genericConfig.RESTOptionsGetter = nil
+
+ if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.StorageVersionAPI) &&
+ utilfeature.DefaultFeatureGate.Enabled(genericfeatures.APIServerIdentity) {
+ // Add StorageVersionPrecondition handler to aggregator-apiserver.
+ // The handler will block write requests to built-in resources until the
+ // target resources' storage versions are up-to-date.
+ genericConfig.BuildHandlerChainFunc = genericapiserver.BuildHandlerChainWithStorageVersionPrecondition
+ }
+
+ // copy the etcd options so we don't mutate originals.
+ // we assume that the etcd options have been completed already. avoid messing with anything outside
+ // of changes to StorageConfig as that may lead to unexpected behavior when the options are applied.
+ etcdOptions := *etcdOpts
+ etcdOptions.StorageConfig.Codec = aggregatorscheme.Codecs.LegacyCodec(v1.SchemeGroupVersion,
+ apiregistrationv1beta1.SchemeGroupVersion,
+ servicev0alpha1.SchemeGroupVersion)
+ etcdOptions.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(v1.SchemeGroupVersion,
+ schema.GroupKind{Group: apiregistrationv1beta1.GroupName},
+ schema.GroupKind{Group: servicev0alpha1.GROUP})
+ etcdOptions.SkipHealthEndpoints = true // avoid double wiring of health checks
+ if err := etcdOptions.ApplyTo(&genericConfig.Config); err != nil {
+ return err
+ }
+ // override the RESTOptionsGetter to use the file storage options getter
+ aggregatorConfig.GenericConfig.RESTOptionsGetter = filestorage.NewRESTOptionsGetter(dataPath, etcdOptions.StorageConfig)
+
+ // prevent generic API server from installing the OpenAPI handler. Aggregator server has its own customized OpenAPI handler.
+ genericConfig.SkipOpenAPIInstallation = true
+ mergedResourceConfig, err := resourceconfig.MergeAPIResourceConfigs(aggregatorapiserver.DefaultAPIResourceConfigSource(), nil, aggregatorscheme.Scheme)
+ if err != nil {
+ return err
+ }
+ genericConfig.MergedResourceConfig = mergedResourceConfig
+
+ namer := openapinamer.NewDefinitionNamer(aggregatorscheme.Scheme)
+ genericConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(o.getMergedOpenAPIDefinitions, namer)
+ genericConfig.OpenAPIV3Config.Info.Title = "Kubernetes"
+ genericConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(o.getMergedOpenAPIDefinitions, namer)
+ genericConfig.OpenAPIConfig.Info.Title = "Kubernetes"
+ genericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{}
+
+ // These hooks use v1 informers, which are not available in the grafana aggregator.
+ genericConfig.DisabledPostStartHooks = genericConfig.DisabledPostStartHooks.Insert("apiservice-status-available-controller")
+ genericConfig.DisabledPostStartHooks = genericConfig.DisabledPostStartHooks.Insert("start-kube-aggregator-informers")
+
+ return nil
+}
diff --git a/pkg/services/apiserver/options/extra.go b/pkg/services/apiserver/options/extra.go
new file mode 100644
index 00000000000..fdb394d4d28
--- /dev/null
+++ b/pkg/services/apiserver/options/extra.go
@@ -0,0 +1,46 @@
+package options
+
+import (
+ "strconv"
+
+ "github.com/go-logr/logr"
+ "github.com/spf13/pflag"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ "k8s.io/component-base/logs"
+ "k8s.io/klog/v2"
+)
+
+type ExtraOptions struct {
+ DevMode bool
+ ExternalAddress string
+ APIURL string
+ Verbosity int
+}
+
+func NewExtraOptions() *ExtraOptions {
+ return &ExtraOptions{
+ DevMode: false,
+ Verbosity: 0,
+ }
+}
+
+func (o *ExtraOptions) AddFlags(fs *pflag.FlagSet) {
+ fs.BoolVar(&o.DevMode, "grafana-apiserver-dev-mode", o.DevMode, "Enable dev mode")
+ fs.StringVar(&o.ExternalAddress, "grafana-apiserver-host", o.ExternalAddress, "Host")
+ fs.StringVar(&o.APIURL, "grafana-apiserver-api-url", o.APIURL, "API URL")
+ fs.IntVar(&o.Verbosity, "verbosity", o.Verbosity, "Verbosity")
+}
+
+func (o *ExtraOptions) Validate() []error {
+ return nil
+}
+
+func (o *ExtraOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error {
+ logger := logr.New(newLogAdapter(o.Verbosity))
+ klog.SetLoggerWithOptions(logger, klog.ContextualLogger(true))
+ if _, err := logs.GlogSetter(strconv.Itoa(o.Verbosity)); err != nil {
+ logger.Error(err, "failed to set log level")
+ }
+ c.ExternalAddress = o.ExternalAddress
+ return nil
+}
diff --git a/pkg/services/grafana-apiserver/log.go b/pkg/services/apiserver/options/log.go
similarity index 97%
rename from pkg/services/grafana-apiserver/log.go
rename to pkg/services/apiserver/options/log.go
index edf10c0d3d3..9e32f66e3b3 100644
--- a/pkg/services/grafana-apiserver/log.go
+++ b/pkg/services/apiserver/options/log.go
@@ -1,4 +1,4 @@
-package grafanaapiserver
+package options
import (
"strings"
diff --git a/pkg/services/apiserver/options/options.go b/pkg/services/apiserver/options/options.go
new file mode 100644
index 00000000000..10c320e8925
--- /dev/null
+++ b/pkg/services/apiserver/options/options.go
@@ -0,0 +1,126 @@
+package options
+
+import (
+ "net"
+
+ "github.com/spf13/pflag"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/endpoints/discovery/aggregated"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ genericoptions "k8s.io/apiserver/pkg/server/options"
+)
+
+const defaultEtcdPathPrefix = "/registry/grafana.app"
+
+type Options struct {
+ RecommendedOptions *genericoptions.RecommendedOptions
+ AggregatorOptions *AggregatorServerOptions
+ StorageOptions *StorageOptions
+ ExtraOptions *ExtraOptions
+}
+
+func NewOptions(codec runtime.Codec) *Options {
+ return &Options{
+ RecommendedOptions: genericoptions.NewRecommendedOptions(
+ defaultEtcdPathPrefix,
+ codec,
+ ),
+ AggregatorOptions: NewAggregatorServerOptions(),
+ StorageOptions: NewStorageOptions(),
+ ExtraOptions: NewExtraOptions(),
+ }
+}
+
+func (o *Options) AddFlags(fs *pflag.FlagSet) {
+ o.RecommendedOptions.AddFlags(fs)
+ o.AggregatorOptions.AddFlags(fs)
+ o.StorageOptions.AddFlags(fs)
+ o.ExtraOptions.AddFlags(fs)
+}
+
+func (o *Options) Validate() []error {
+ if errs := o.ExtraOptions.Validate(); len(errs) != 0 {
+ return errs
+ }
+
+ if errs := o.StorageOptions.Validate(); len(errs) != 0 {
+ return errs
+ }
+
+ if errs := o.AggregatorOptions.Validate(); len(errs) != 0 {
+ return errs
+ }
+
+ if errs := o.RecommendedOptions.SecureServing.Validate(); len(errs) != 0 {
+ return errs
+ }
+
+ if errs := o.RecommendedOptions.Authentication.Validate(); len(errs) != 0 {
+ return errs
+ }
+
+ if o.StorageOptions.StorageType == StorageTypeEtcd {
+ if errs := o.RecommendedOptions.Etcd.Validate(); len(errs) != 0 {
+ return errs
+ }
+ }
+
+ return nil
+}
+
+func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) error {
+ serverConfig.AggregatedDiscoveryGroupManager = aggregated.NewResourceManager("apis")
+
+ if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil {
+ return err
+ }
+
+ if !o.ExtraOptions.DevMode {
+ o.RecommendedOptions.SecureServing.Listener = newFakeListener()
+ }
+
+ if err := o.RecommendedOptions.SecureServing.ApplyTo(&serverConfig.SecureServing, &serverConfig.LoopbackClientConfig); err != nil {
+ return err
+ }
+
+ if err := o.RecommendedOptions.Authentication.ApplyTo(&serverConfig.Authentication, serverConfig.SecureServing, serverConfig.OpenAPIConfig); err != nil {
+ return err
+ }
+
+ if !o.ExtraOptions.DevMode {
+ if err := serverConfig.SecureServing.Listener.Close(); err != nil {
+ return err
+ }
+ serverConfig.SecureServing = nil
+ }
+
+ return nil
+}
+
+type fakeListener struct {
+ server net.Conn
+ client net.Conn
+}
+
+func newFakeListener() *fakeListener {
+ server, client := net.Pipe()
+ return &fakeListener{
+ server: server,
+ client: client,
+ }
+}
+
+func (f *fakeListener) Accept() (net.Conn, error) {
+ return f.server, nil
+}
+
+func (f *fakeListener) Close() error {
+ if err := f.client.Close(); err != nil {
+ return err
+ }
+ return f.server.Close()
+}
+
+func (f *fakeListener) Addr() net.Addr {
+ return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 3000, Zone: ""}
+}
diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go
new file mode 100644
index 00000000000..51ccb84efe9
--- /dev/null
+++ b/pkg/services/apiserver/options/storage.go
@@ -0,0 +1,51 @@
+package options
+
+import (
+ "fmt"
+
+ "github.com/spf13/pflag"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ "k8s.io/apiserver/pkg/server/options"
+)
+
+type StorageType string
+
+const (
+ StorageTypeFile StorageType = "file"
+ StorageTypeEtcd StorageType = "etcd"
+ StorageTypeLegacy StorageType = "legacy"
+ StorageTypeUnified StorageType = "unified"
+ StorageTypeUnifiedGrpc StorageType = "unified-grpc"
+)
+
+type StorageOptions struct {
+ StorageType StorageType
+ DataPath string
+}
+
+func NewStorageOptions() *StorageOptions {
+ return &StorageOptions{
+ StorageType: StorageTypeLegacy,
+ }
+}
+
+func (o *StorageOptions) AddFlags(fs *pflag.FlagSet) {
+ fs.StringVar((*string)(&o.StorageType), "grafana-apiserver-storage-type", string(o.StorageType), "Storage type")
+ fs.StringVar((*string)(&o.StorageType), "grafana-apiserver-storage-path", string(o.StorageType), "Storage path for file storage")
+}
+
+func (o *StorageOptions) Validate() []error {
+ errs := []error{}
+ switch o.StorageType {
+ case StorageTypeFile, StorageTypeEtcd, StorageTypeLegacy, StorageTypeUnified, StorageTypeUnifiedGrpc:
+ // no-op
+ default:
+ errs = append(errs, fmt.Errorf("--grafana-apiserver-storage-type must be one of %s, %s, %s, %s, %s", StorageTypeFile, StorageTypeEtcd, StorageTypeLegacy, StorageTypeUnified, StorageTypeUnifiedGrpc))
+ }
+ return errs
+}
+
+func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfig, etcdOptions *options.EtcdOptions) error {
+ // TODO: move storage setup here
+ return nil
+}
diff --git a/pkg/services/grafana-apiserver/registry/generic/strategy.go b/pkg/services/apiserver/registry/generic/strategy.go
similarity index 100%
rename from pkg/services/grafana-apiserver/registry/generic/strategy.go
rename to pkg/services/apiserver/registry/generic/strategy.go
diff --git a/pkg/services/grafana-apiserver/rest/dualwriter.go b/pkg/services/apiserver/rest/dualwriter.go
similarity index 100%
rename from pkg/services/grafana-apiserver/rest/dualwriter.go
rename to pkg/services/apiserver/rest/dualwriter.go
diff --git a/pkg/services/grafana-apiserver/service.go b/pkg/services/apiserver/service.go
similarity index 62%
rename from pkg/services/grafana-apiserver/service.go
rename to pkg/services/apiserver/service.go
index 30519427505..3f47779488c 100644
--- a/pkg/services/grafana-apiserver/service.go
+++ b/pkg/services/apiserver/service.go
@@ -1,4 +1,4 @@
-package grafanaapiserver
+package apiserver
import (
"context"
@@ -6,13 +6,8 @@ import (
"net/http"
"net/http/httptest"
"path"
- "runtime/debug"
- "strconv"
- "strings"
- "github.com/go-logr/logr"
"github.com/grafana/dskit/services"
- "golang.org/x/mod/semver"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -21,15 +16,8 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/endpoints/responsewriter"
genericapiserver "k8s.io/apiserver/pkg/server"
- "k8s.io/apiserver/pkg/server/options"
clientrest "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
- "k8s.io/component-base/logs"
- "k8s.io/klog/v2"
-
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/auth/authorizer"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
- "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/appcontext"
@@ -38,26 +26,21 @@ import (
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/modules"
"github.com/grafana/grafana/pkg/registry"
+ "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options"
+ entitystorage "github.com/grafana/grafana/pkg/services/apiserver/storage/entity"
+ filestorage "github.com/grafana/grafana/pkg/services/apiserver/storage/file"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- entitystorage "github.com/grafana/grafana/pkg/services/grafana-apiserver/storage/entity"
- filestorage "github.com/grafana/grafana/pkg/services/grafana-apiserver/storage/file"
+ "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash"
"github.com/grafana/grafana/pkg/setting"
)
-type StorageType string
-
-const (
- StorageTypeFile StorageType = "file"
- StorageTypeEtcd StorageType = "etcd"
- StorageTypeLegacy StorageType = "legacy"
- StorageTypeUnified StorageType = "unified"
- StorageTypeUnifiedGrpc StorageType = "unified-grpc"
-)
-
var (
_ Service = (*service)(nil)
_ RestConfigProvider = (*service)(nil)
@@ -90,10 +73,6 @@ type Service interface {
registry.CanBeDisabled
}
-type APIRegistrar interface {
- RegisterAPI(builder APIGroupBuilder)
-}
-
type RestConfigProvider interface {
GetRestConfig() *clientrest.Config
}
@@ -103,12 +82,15 @@ type DirectRestConfigProvider interface {
// logged logged in user as the current request context. This is useful when
// creating clients that map legacy API handlers to k8s backed services
GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config
+
+ // This can be used to rewrite incoming requests to path now supported under /apis
+ DirectlyServeHTTP(w http.ResponseWriter, r *http.Request)
}
type service struct {
*services.BasicService
- config *config
+ options *grafanaapiserveroptions.Options
restConfig *clientrest.Config
cfg *setting.Cfg
@@ -120,7 +102,7 @@ type service struct {
db db.DB
rr routing.RouteRegister
handler http.Handler
- builders []APIGroupBuilder
+ builders []builder.APIGroupBuilder
tracing *tracing.TracingService
@@ -136,12 +118,11 @@ func ProvideService(
db db.DB,
) (*service, error) {
s := &service{
- config: newConfig(cfg, features),
cfg: cfg,
features: features,
rr: rr,
stopCh: make(chan struct{}),
- builders: []APIGroupBuilder{},
+ builders: []builder.APIGroupBuilder{},
authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService),
tracing: tracing,
db: db, // For Unified storage
@@ -179,7 +160,9 @@ func ProvideService(
}
s.rr.Group("/apis", proxyHandler)
- s.rr.Group("/apiserver-metrics", proxyHandler)
+ s.rr.Group("/livez", proxyHandler)
+ s.rr.Group("/readyz", proxyHandler)
+ s.rr.Group("/healthz", proxyHandler)
s.rr.Group("/openapi", proxyHandler)
return s, nil
@@ -190,7 +173,7 @@ func (s *service) GetRestConfig() *clientrest.Config {
}
func (s *service) IsDisabled() bool {
- return !s.config.enabled
+ return false
}
// Run is an adapter for the BackgroundService interface.
@@ -201,17 +184,11 @@ func (s *service) Run(ctx context.Context) error {
return s.running(ctx)
}
-func (s *service) RegisterAPI(builder APIGroupBuilder) {
- s.builders = append(s.builders, builder)
+func (s *service) RegisterAPI(b builder.APIGroupBuilder) {
+ s.builders = append(s.builders, b)
}
func (s *service) start(ctx context.Context) error {
- logger := logr.New(newLogAdapter(s.config.logLevel))
- klog.SetLoggerWithOptions(logger, klog.ContextualLogger(true))
- if _, err := logs.GlogSetter(strconv.Itoa(s.config.logLevel)); err != nil {
- logger.Error(err, "failed to set log level")
- }
-
// Get the list of groups the server will support
builders := s.builders
@@ -223,59 +200,42 @@ func (s *service) start(ctx context.Context) error {
return err
}
- // Optionally register a custom authorizer
auth := b.GetAuthorizer()
if auth != nil {
s.authorizer.Register(b.GetGroupVersion(), auth)
}
}
- o := options.NewRecommendedOptions("/registry/grafana.app", Codecs.LegacyCodec(groupVersions...))
- o.SecureServing.BindAddress = s.config.ip
- o.SecureServing.BindPort = s.config.port
- o.Authentication.RemoteKubeConfigFileOptional = true
- o.Authorization.RemoteKubeConfigFileOptional = true
+ o := grafanaapiserveroptions.NewOptions(Codecs.LegacyCodec(groupVersions...))
+ applyGrafanaConfig(s.cfg, s.features, o)
- o.Admission = nil
- o.CoreAPI = nil
-
- serverConfig := genericapiserver.NewRecommendedConfig(Codecs)
- serverConfig.ExternalAddress = s.config.host
-
- if s.config.devMode {
- // SecureServingOptions is used when the apiserver needs it's own listener.
- // this is not needed in production, but it's useful for development kubectl access.
- if err := o.SecureServing.ApplyTo(&serverConfig.SecureServing, &serverConfig.LoopbackClientConfig); err != nil {
- return err
- }
- // AuthenticationOptions is needed to authenticate requests from kubectl in dev mode.
- if err := o.Authentication.ApplyTo(&serverConfig.Authentication, serverConfig.SecureServing, serverConfig.OpenAPIConfig); err != nil {
- return err
- }
- } else {
- // In production mode, override ExternalAddress and LoopbackClientConfig.
- // In dev mode we want to use the loopback client config
- // and address provided by SecureServingOptions.
- serverConfig.ExternalAddress = s.config.host
- serverConfig.LoopbackClientConfig = &clientrest.Config{
- Host: s.config.apiURL,
- TLSClientConfig: clientrest.TLSClientConfig{
- Insecure: true,
- },
- }
+ if errs := o.Validate(); len(errs) != 0 {
+ // TODO: handle multiple errors
+ return errs[0]
}
- switch s.config.storageType {
- case StorageTypeEtcd:
- o.Etcd.StorageConfig.Transport.ServerList = s.config.etcdServers
- if err := o.Etcd.Validate(); len(err) > 0 {
+ serverConfig := genericapiserver.NewRecommendedConfig(Codecs)
+ if err := o.ApplyTo(serverConfig); err != nil {
+ return err
+ }
+ serverConfig.Authorization.Authorizer = s.authorizer
+ serverConfig.TracerProvider = s.tracing.GetTracerProvider()
+
+ // setup loopback transport
+ transport := &roundTripperFunc{ready: make(chan struct{})}
+ serverConfig.LoopbackClientConfig.Transport = transport
+ serverConfig.LoopbackClientConfig.TLSClientConfig = clientrest.TLSClientConfig{}
+
+ switch o.StorageOptions.StorageType {
+ case grafanaapiserveroptions.StorageTypeEtcd:
+ if err := o.RecommendedOptions.Etcd.Validate(); len(err) > 0 {
return err[0]
}
- if err := o.Etcd.ApplyTo(&serverConfig.Config); err != nil {
+ if err := o.RecommendedOptions.Etcd.ApplyTo(&serverConfig.Config); err != nil {
return err
}
- case StorageTypeUnified:
+ case grafanaapiserveroptions.StorageTypeUnified:
if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) {
return fmt.Errorf("unified storage requires the unifiedStorage feature flag (and app_mode = development)")
}
@@ -290,9 +250,9 @@ func (s *service) start(ctx context.Context) error {
return err
}
- serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.Etcd.StorageConfig.Codec)
+ serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.RecommendedOptions.Etcd.StorageConfig.Codec)
- case StorageTypeUnifiedGrpc:
+ case grafanaapiserveroptions.StorageTypeUnifiedGrpc:
// Create a connection to the gRPC server
// TODO: support configuring the gRPC server address
conn, err := grpc.Dial("localhost:10000", grpc.WithTransportCredentials(insecure.NewCredentials()))
@@ -306,20 +266,16 @@ func (s *service) start(ctx context.Context) error {
// Create a client instance
store := entity.NewEntityStoreClientWrapper(conn)
- serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.Etcd.StorageConfig.Codec)
+ serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.RecommendedOptions.Etcd.StorageConfig.Codec)
- case StorageTypeFile:
- serverConfig.RESTOptionsGetter = filestorage.NewRESTOptionsGetter(s.config.dataPath, o.Etcd.StorageConfig)
-
- case StorageTypeLegacy:
- // do nothing?
+ case grafanaapiserveroptions.StorageTypeLegacy:
+ fallthrough
+ case grafanaapiserveroptions.StorageTypeFile:
+ serverConfig.RESTOptionsGetter = filestorage.NewRESTOptionsGetter(o.StorageOptions.DataPath, o.RecommendedOptions.Etcd.StorageConfig)
}
- serverConfig.Authorization.Authorizer = s.authorizer
- serverConfig.TracerProvider = s.tracing.GetTracerProvider()
-
// Add OpenAPI specs for each group+version
- err := SetupConfig(serverConfig, builders)
+ err := builder.SetupConfig(Scheme, serverConfig, builders)
if err != nil {
return err
}
@@ -336,28 +292,37 @@ func (s *service) start(ctx context.Context) error {
return err
}
+ dualWriteEnabled := o.StorageOptions.StorageType != grafanaapiserveroptions.StorageTypeLegacy
+
// Install the API Group+version
- err = InstallAPIs(server, serverConfig.RESTOptionsGetter, builders)
+ err = builder.InstallAPIs(Scheme, Codecs, server, serverConfig.RESTOptionsGetter, builders, dualWriteEnabled)
if err != nil {
return err
}
+ // set the transport function and signal that it's ready
+ transport.fn = func(req *http.Request) (*http.Response, error) {
+ w := newWrappedResponseWriter()
+ resp := responsewriter.WrapForHTTP1Or2(w)
+ server.Handler.ServeHTTP(resp, req)
+ return w.Result(), nil
+ }
+ close(transport.ready)
+
+ // only write kubeconfig in dev mode
+ if o.ExtraOptions.DevMode {
+ if err := ensureKubeConfig(server.LoopbackClientConfig, o.StorageOptions.DataPath); err != nil {
+ return err
+ }
+ }
+
// Used by the proxy wrapper registered in ProvideService
s.handler = server.Handler
s.restConfig = server.LoopbackClientConfig
+ s.options = o
prepared := server.PrepareRun()
- // When running in production, do not start a standalone https server
- if !s.config.devMode {
- return nil
- }
-
- // only write kubeconfig in dev mode
- if err := s.ensureKubeConfig(); err != nil {
- return err
- }
-
go func() {
s.stoppedCh <- prepared.Run(s.stopCh)
}()
@@ -377,13 +342,11 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co
}
}
-func (s *service) running(ctx context.Context) error {
- // skip waiting for the server in prod mode
- if !s.config.devMode {
- <-ctx.Done()
- return nil
- }
+func (s *service) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.handler.ServeHTTP(w, r)
+}
+func (s *service) running(ctx context.Context) error {
select {
case err := <-s.stoppedCh:
if err != nil {
@@ -395,47 +358,42 @@ func (s *service) running(ctx context.Context) error {
return nil
}
-func (s *service) ensureKubeConfig() error {
+func ensureKubeConfig(restConfig *clientrest.Config, dir string) error {
return clientcmd.WriteToFile(
- utils.FormatKubeConfig(s.restConfig),
- path.Join(s.config.dataPath, "grafana.kubeconfig"),
+ utils.FormatKubeConfig(restConfig),
+ path.Join(dir, "grafana.kubeconfig"),
)
}
type roundTripperFunc struct {
- fn func(req *http.Request) (*http.Response, error)
+ ready chan struct{}
+ fn func(req *http.Request) (*http.Response, error)
}
func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ if f.fn == nil {
+ <-f.ready
+ }
return f.fn(req)
}
-// find the k8s version according to build info
-func getK8sApiserverVersion() (string, error) {
- bi, ok := debug.ReadBuildInfo()
- if !ok {
- return "", fmt.Errorf("debug.ReadBuildInfo() failed")
- }
+var _ http.ResponseWriter = (*wrappedResponseWriter)(nil)
+var _ responsewriter.UserProvidedDecorator = (*wrappedResponseWriter)(nil)
- if len(bi.Deps) == 0 {
- return "v?.?", nil // this is normal while debugging
- }
+type wrappedResponseWriter struct {
+ *httptest.ResponseRecorder
+}
- for _, dep := range bi.Deps {
- if dep.Path == "k8s.io/apiserver" {
- if !semver.IsValid(dep.Version) {
- return "", fmt.Errorf("invalid semantic version for k8s.io/apiserver")
- }
- // v0 => v1
- majorVersion := strings.TrimPrefix(semver.Major(dep.Version), "v")
- majorInt, err := strconv.Atoi(majorVersion)
- if err != nil {
- return "", fmt.Errorf("could not convert majorVersion to int. majorVersion: %s", majorVersion)
- }
- newMajor := fmt.Sprintf("v%d", majorInt+1)
- return strings.Replace(dep.Version, semver.Major(dep.Version), newMajor, 1), nil
- }
- }
+func newWrappedResponseWriter() *wrappedResponseWriter {
+ w := httptest.NewRecorder()
+ return &wrappedResponseWriter{w}
+}
- return "", fmt.Errorf("could not find k8s.io/apiserver in build info")
+func (w *wrappedResponseWriter) Unwrap() http.ResponseWriter {
+ return w.ResponseRecorder
+}
+
+func (w *wrappedResponseWriter) CloseNotify() <-chan bool {
+ // TODO: this is probably not the right thing to do here
+ return make(<-chan bool)
}
diff --git a/pkg/services/grafana-apiserver/storage/entity/fieldRequirements.go b/pkg/services/apiserver/storage/entity/fieldRequirements.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/entity/fieldRequirements.go
rename to pkg/services/apiserver/storage/entity/fieldRequirements.go
diff --git a/pkg/services/grafana-apiserver/storage/entity/restoptions.go b/pkg/services/apiserver/storage/entity/restoptions.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/entity/restoptions.go
rename to pkg/services/apiserver/storage/entity/restoptions.go
diff --git a/pkg/services/grafana-apiserver/storage/entity/storage.go b/pkg/services/apiserver/storage/entity/storage.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/entity/storage.go
rename to pkg/services/apiserver/storage/entity/storage.go
diff --git a/pkg/services/grafana-apiserver/storage/entity/utils.go b/pkg/services/apiserver/storage/entity/utils.go
similarity index 98%
rename from pkg/services/grafana-apiserver/storage/entity/utils.go
rename to pkg/services/apiserver/storage/entity/utils.go
index b20e40c9ebe..22b122e43e6 100644
--- a/pkg/services/grafana-apiserver/storage/entity/utils.go
+++ b/pkg/services/apiserver/storage/entity/utils.go
@@ -15,7 +15,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/endpoints/request"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
entityStore "github.com/grafana/grafana/pkg/services/store/entity"
)
diff --git a/pkg/services/grafana-apiserver/storage/entity/utils_test.go b/pkg/services/apiserver/storage/entity/utils_test.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/entity/utils_test.go
rename to pkg/services/apiserver/storage/entity/utils_test.go
diff --git a/pkg/services/grafana-apiserver/storage/file/file.go b/pkg/services/apiserver/storage/file/file.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/file/file.go
rename to pkg/services/apiserver/storage/file/file.go
diff --git a/pkg/services/grafana-apiserver/storage/file/restoptions.go b/pkg/services/apiserver/storage/file/restoptions.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/file/restoptions.go
rename to pkg/services/apiserver/storage/file/restoptions.go
diff --git a/pkg/services/grafana-apiserver/storage/file/util.go b/pkg/services/apiserver/storage/file/util.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/file/util.go
rename to pkg/services/apiserver/storage/file/util.go
diff --git a/pkg/services/grafana-apiserver/storage/file/watchset.go b/pkg/services/apiserver/storage/file/watchset.go
similarity index 100%
rename from pkg/services/grafana-apiserver/storage/file/watchset.go
rename to pkg/services/apiserver/storage/file/watchset.go
diff --git a/pkg/services/grafana-apiserver/utils/clientConfig.go b/pkg/services/apiserver/utils/clientConfig.go
similarity index 100%
rename from pkg/services/grafana-apiserver/utils/clientConfig.go
rename to pkg/services/apiserver/utils/clientConfig.go
diff --git a/pkg/services/grafana-apiserver/utils/meta.go b/pkg/services/apiserver/utils/meta.go
similarity index 100%
rename from pkg/services/grafana-apiserver/utils/meta.go
rename to pkg/services/apiserver/utils/meta.go
diff --git a/pkg/services/grafana-apiserver/utils/meta_test.go b/pkg/services/apiserver/utils/meta_test.go
similarity index 98%
rename from pkg/services/grafana-apiserver/utils/meta_test.go
rename to pkg/services/apiserver/utils/meta_test.go
index 9d9cfedd3ce..e8555bdff6e 100644
--- a/pkg/services/grafana-apiserver/utils/meta_test.go
+++ b/pkg/services/apiserver/utils/meta_test.go
@@ -8,7 +8,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
)
type TestResource struct {
diff --git a/pkg/services/grafana-apiserver/utils/tableConverter.go b/pkg/services/apiserver/utils/tableConverter.go
similarity index 100%
rename from pkg/services/grafana-apiserver/utils/tableConverter.go
rename to pkg/services/apiserver/utils/tableConverter.go
diff --git a/pkg/services/grafana-apiserver/utils/tableConverter_test.go b/pkg/services/apiserver/utils/tableConverter_test.go
similarity index 98%
rename from pkg/services/grafana-apiserver/utils/tableConverter_test.go
rename to pkg/services/apiserver/utils/tableConverter_test.go
index ec1310475c9..7c7618d4f0d 100644
--- a/pkg/services/grafana-apiserver/utils/tableConverter_test.go
+++ b/pkg/services/apiserver/utils/tableConverter_test.go
@@ -11,7 +11,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
+ "github.com/grafana/grafana/pkg/services/apiserver/utils"
)
func TestTableConverter(t *testing.T) {
diff --git a/pkg/services/grafana-apiserver/utils/uids.go b/pkg/services/apiserver/utils/uids.go
similarity index 100%
rename from pkg/services/grafana-apiserver/utils/uids.go
rename to pkg/services/apiserver/utils/uids.go
diff --git a/pkg/services/grafana-apiserver/wireset.go b/pkg/services/apiserver/wireset.go
similarity index 63%
rename from pkg/services/grafana-apiserver/wireset.go
rename to pkg/services/apiserver/wireset.go
index 25a02e898ef..f7163f1ad6f 100644
--- a/pkg/services/grafana-apiserver/wireset.go
+++ b/pkg/services/apiserver/wireset.go
@@ -1,13 +1,15 @@
-package grafanaapiserver
+package apiserver
import (
"github.com/google/wire"
+
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
)
var WireSet = wire.NewSet(
ProvideService,
wire.Bind(new(RestConfigProvider), new(*service)),
wire.Bind(new(Service), new(*service)),
- wire.Bind(new(APIRegistrar), new(*service)),
wire.Bind(new(DirectRestConfigProvider), new(*service)),
+ wire.Bind(new(builder.APIRegistrar), new(*service)),
)
diff --git a/pkg/services/auth/identity/requester.go b/pkg/services/auth/identity/requester.go
index b88120e88f2..aa7670d6c79 100644
--- a/pkg/services/auth/identity/requester.go
+++ b/pkg/services/auth/identity/requester.go
@@ -40,6 +40,8 @@ type Requester interface {
GetOrgRole() roletype.RoleType
// GetPermissions returns the permissions of the active entity.
GetPermissions() map[string][]string
+ // GetGlobalPermissions returns the permissions of the active entity that are available across all organizations.
+ GetGlobalPermissions() map[string][]string
// DEPRECATED: GetTeams returns the teams the entity is a member of.
// Retrieve the teams from the team service instead of using this method.
GetTeams() []int64
diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go
index dc8f8cce04c..4f618cd4424 100644
--- a/pkg/services/authn/identity.go
+++ b/pkg/services/authn/identity.go
@@ -31,6 +31,7 @@ const (
const (
AnonymousNamespaceID = NamespaceAnonymous + ":0"
+ GlobalOrgID = int64(0)
)
var _ identity.Requester = (*Identity)(nil)
@@ -164,6 +165,19 @@ func (i *Identity) GetPermissions() map[string][]string {
return i.Permissions[i.GetOrgID()]
}
+// GetGlobalPermissions returns the permissions of the active entity that are available across all organizations
+func (i *Identity) GetGlobalPermissions() map[string][]string {
+ if i.Permissions == nil {
+ return make(map[string][]string)
+ }
+
+ if i.Permissions[GlobalOrgID] == nil {
+ return make(map[string][]string)
+ }
+
+ return i.Permissions[GlobalOrgID]
+}
+
func (i *Identity) GetTeams() []int64 {
return i.Teams
}
diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go
index 487bf6a97e7..906c9776f5f 100644
--- a/pkg/services/dashboards/database/database_test.go
+++ b/pkg/services/dashboards/database/database_test.go
@@ -502,7 +502,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) {
})
require.NoError(t, err)
- // nolint:staticcheck
count, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{folder.UID}, OrgID: 1})
require.NoError(t, err)
require.Equal(t, count, int64(0))
@@ -519,9 +518,10 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test
require.NoError(t, err)
pluginId := "test-app"
- appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId)
- insertTestDashboardForPlugin(t, dashboardStore, "app-dash1", 1, appFolder.ID, false, pluginId)
- insertTestDashboardForPlugin(t, dashboardStore, "app-dash2", 1, appFolder.ID, false, pluginId)
+ insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, "", true, pluginId)
+ insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, "", true, pluginId)
+ insertTestDashboardForPlugin(t, dashboardStore, "app-dash1", 1, "", false, pluginId)
+ insertTestDashboardForPlugin(t, dashboardStore, "app-dash2", 1, "", false, pluginId)
query := dashboards.GetDashboardsByPluginIDQuery{
PluginID: pluginId,
@@ -658,8 +658,7 @@ func TestGetExistingDashboardByTitleAndFolder(t *testing.T) {
savedFolder := insertTestDashboard(t, dashboardStore, "test dash folder", 1, 0, "", true, "prod", "webapp")
savedDash := insertTestDashboard(t, dashboardStore, "test dash", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp")
err = sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
- // nolint:staticcheck
- _, err = getExistingDashboardByTitleAndFolder(sess, &dashboards.Dashboard{Title: savedDash.Title, FolderID: savedFolder.ID, FolderUID: savedFolder.UID, OrgID: 1}, sqlStore.GetDialect(), false, false)
+ _, err = getExistingDashboardByTitleAndFolder(sess, &dashboards.Dashboard{Title: savedDash.Title, FolderUID: savedFolder.UID, OrgID: 1}, sqlStore.GetDialect(), false, false)
return err
})
require.ErrorIs(t, err, dashboards.ErrDashboardWithSameNameInFolderExists)
@@ -713,8 +712,8 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) {
SignedInUser: user,
})
require.NoError(t, err)
- // nolint:staticcheck
- insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, f0.ID, f0.UID, false)
+
+ insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, 0, f0.UID, false)
subfolder, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{
OrgID: orgID,
@@ -723,8 +722,8 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) {
SignedInUser: user,
})
require.NoError(t, err)
- // nolint:staticcheck
- insertTestDashboard(t, dashboardStore, "dashboard under subfolder", orgID, subfolder.ID, subfolder.UID, false)
+
+ insertTestDashboard(t, dashboardStore, "dashboard under subfolder", orgID, 0, subfolder.UID, false)
type res struct {
title string
@@ -1065,12 +1064,12 @@ func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title st
}
func insertTestDashboardForPlugin(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64,
- folderId int64, isFolder bool, pluginId string) *dashboards.Dashboard {
+ folderUID string, isFolder bool, pluginId string) *dashboards.Dashboard {
t.Helper()
cmd := dashboards.SaveDashboardCommand{
- OrgID: orgId,
- FolderID: folderId, // nolint:staticcheck
- IsFolder: isFolder,
+ OrgID: orgId,
+ IsFolder: isFolder,
+ FolderUID: folderUID,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": title,
diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go
index b61042c7424..12b459d478f 100644
--- a/pkg/services/dashboards/service/dashboard_service.go
+++ b/pkg/services/dashboards/service/dashboard_service.go
@@ -598,35 +598,29 @@ func (dr *DashboardServiceImpl) filterUserSharedDashboards(ctx context.Context,
folderUIDs = append(folderUIDs, dashboard.FolderUID)
}
- dashFolders, err := dr.folderStore.GetFolders(ctx, user.GetOrgID(), folderUIDs)
+ // GetFolders return only folders available to user. So we can use is to check access.
+ userDashFolders, err := dr.folderService.GetFolders(ctx, folder.GetFoldersQuery{
+ UIDs: folderUIDs,
+ OrgID: user.GetOrgID(),
+ SignedInUser: user,
+ })
if err != nil {
return nil, folder.ErrInternal.Errorf("failed to fetch parent folders from store: %w", err)
}
+ dashFoldersMap := make(map[string]*folder.Folder, 0)
+ for _, f := range userDashFolders {
+ dashFoldersMap[f.UID] = f
+ }
+
for _, dashboard := range userDashboards {
// Filter out dashboards if user has access to parent folder
if dashboard.FolderUID == "" {
continue
}
- dashFolder, ok := dashFolders[dashboard.FolderUID]
- if !ok {
- dr.log.Error("failed to fetch folder by UID from store", "uid", dashboard.FolderUID)
- continue
- }
-
- g, err := guardian.NewByFolder(ctx, dashFolder, user.GetOrgID(), user)
- if err != nil {
- dr.log.Error("failed to check folder permissions", "folder uid", dashboard.FolderUID, "error", err)
- continue
- }
-
- canView, err := g.CanView()
- if err != nil {
- dr.log.Error("failed to fetch dashboard", "uid", dashboard.UID, "error", err)
- continue
- }
- if !canView {
+ _, hasAccess := dashFoldersMap[dashboard.FolderUID]
+ if !hasAccess {
filteredDashboards = append(filteredDashboards, dashboard)
}
}
diff --git a/pkg/services/dashboardsnapshots/database/database.go b/pkg/services/dashboardsnapshots/database/database.go
index 4d1bb798df9..0acd68838bf 100644
--- a/pkg/services/dashboardsnapshots/database/database.go
+++ b/pkg/services/dashboardsnapshots/database/database.go
@@ -16,26 +16,36 @@ import (
type DashboardSnapshotStore struct {
store db.DB
log log.Logger
- cfg *setting.Cfg
+
+ // deprecated behavior
+ skipDeleteExpired bool
}
// DashboardStore implements the Store interface
var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil)
func ProvideStore(db db.DB, cfg *setting.Cfg) *DashboardSnapshotStore {
- return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store"), cfg: cfg}
+ // nolint:staticcheck
+ return NewStore(db, !cfg.SnapShotRemoveExpired)
+}
+
+func NewStore(db db.DB, skipDeleteExpired bool) *DashboardSnapshotStore {
+ log := log.New("dashboardsnapshot.store")
+ if skipDeleteExpired {
+ log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
+ }
+ return &DashboardSnapshotStore{store: db, skipDeleteExpired: skipDeleteExpired}
}
// DeleteExpiredSnapshots removes snapshots with old expiry dates.
// SnapShotRemoveExpired is deprecated and should be removed in the future.
// Snapshot expiry is decided by the user when they share the snapshot.
func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error {
+ if d.skipDeleteExpired {
+ d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
+ return nil
+ }
return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
- if !d.cfg.SnapShotRemoveExpired {
- d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
- return nil
- }
-
deleteExpiredSQL := "DELETE FROM dashboard_snapshot WHERE expires < ?"
expiredResponse, err := sess.Exec(deleteExpiredSQL, time.Now())
if err != nil {
diff --git a/pkg/services/dashboardsnapshots/database/database_test.go b/pkg/services/dashboardsnapshots/database/database_test.go
index 6982d241776..f19461d4fdb 100644
--- a/pkg/services/dashboardsnapshots/database/database_test.go
+++ b/pkg/services/dashboardsnapshots/database/database_test.go
@@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
@@ -116,9 +118,11 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) {
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{
Key: "strangesnapshotwithuserid0",
DeleteKey: "adeletekey",
- Dashboard: simplejson.NewFromAny(map[string]any{
- "hello": "mupp",
- }),
+ DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{
+ Dashboard: &common.Unstructured{Object: map[string]any{
+ "hello": "mupp",
+ }},
+ },
UserID: 0,
OrgID: 1,
}
@@ -155,11 +159,9 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) {
t.Skip("skipping integration test")
}
sqlstore := db.InitTestDB(t)
- dashStore := ProvideStore(sqlstore, setting.NewCfg())
+ dashStore := NewStore(sqlstore, false)
t.Run("Testing dashboard snapshots clean up", func(t *testing.T) {
- dashStore.cfg.SnapShotRemoveExpired = true
-
nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000)
createTestSnapshot(t, dashStore, "key2", -1200)
createTestSnapshot(t, dashStore, "key3", -1200)
@@ -196,12 +198,14 @@ func createTestSnapshot(t *testing.T, dashStore *DashboardSnapshotStore, key str
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{
Key: key,
DeleteKey: "delete" + key,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "hello": "mupp",
- }),
- UserID: 1000,
- OrgID: 1,
- Expires: expires,
+ DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{
+ Expires: expires,
+ Dashboard: &common.Unstructured{Object: map[string]any{
+ "hello": "mupp",
+ }},
+ },
+ UserID: 1000,
+ OrgID: 1,
}
result, err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd)
require.NoError(t, err)
diff --git a/pkg/services/dashboardsnapshots/models.go b/pkg/services/dashboardsnapshots/models.go
index 36c155b52ea..7e4becec46b 100644
--- a/pkg/services/dashboardsnapshots/models.go
+++ b/pkg/services/dashboardsnapshots/models.go
@@ -3,6 +3,7 @@ package dashboardsnapshots
import (
"time"
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/auth/identity"
)
@@ -47,28 +48,17 @@ type DashboardSnapshotDTO struct {
// swagger:model
type CreateDashboardSnapshotCommand struct {
- // The complete dashboard model.
- // required:true
- Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
- // Snapshot name
- // required:false
- Name string `json:"name"`
- // When the snapshot should expire in seconds in seconds. Default is never to expire.
- // required:false
- // default:0
- Expires int64 `json:"expires"`
+ // The "public" fields are defined in this struct while the private/SQL/response params are
+ // defied in the rest of this command
+ dashboardsnapshot.DashboardCreateCommand
- // these are passed when storing an external snapshot ref
- // Save the snapshot on an external server rather than locally.
- // required:false
- // default: false
- External bool `json:"external"`
ExternalURL string `json:"-"`
ExternalDeleteURL string `json:"-"`
// Define the unique key. Required if `external` is `true`.
// required:false
Key string `json:"key"`
+
// Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.
// required:false
DeleteKey string `json:"deleteKey"`
@@ -100,3 +90,10 @@ type GetDashboardSnapshotsQuery struct {
OrgID int64
SignedInUser identity.Requester
}
+
+type CreateExternalSnapshotResponse struct {
+ Key string `json:"key"`
+ DeleteKey string `json:"deleteKey"`
+ Url string `json:"url"`
+ DeleteUrl string `json:"deleteUrl"`
+}
diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go
index ae229d27fed..07bcea602c1 100644
--- a/pkg/services/dashboardsnapshots/service.go
+++ b/pkg/services/dashboardsnapshots/service.go
@@ -1,7 +1,23 @@
package dashboardsnapshots
import (
+ "bytes"
"context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/metrics"
+ "github.com/grafana/grafana/pkg/services/auth/identity"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
+ "github.com/grafana/grafana/pkg/web"
)
//go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go
@@ -12,3 +28,198 @@ type Service interface {
GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error)
SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error)
}
+
+var client = &http.Client{
+ Timeout: time.Second * 5,
+ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
+}
+
+func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.SnapshotSharingOptions, svc Service) {
+ if !cfg.SnapshotsEnabled {
+ c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
+ return
+ }
+
+ cmd := CreateDashboardSnapshotCommand{}
+ if err := web.Bind(c.Req, &cmd); err != nil {
+ c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
+ return
+ }
+ if cmd.Name == "" {
+ cmd.Name = "Unnamed snapshot"
+ }
+
+ userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
+ if err != nil {
+ c.JsonApiErr(http.StatusInternalServerError,
+ "Failed to create external snapshot", err)
+ return
+ }
+
+ var snapshotUrl string
+ cmd.ExternalURL = ""
+ cmd.OrgID = c.SignedInUser.GetOrgID()
+ cmd.UserID = userID
+ originalDashboardURL, err := createOriginalDashboardURL(&cmd)
+ if err != nil {
+ c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
+ return
+ }
+
+ if cmd.External {
+ if !cfg.ExternalEnabled {
+ c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
+ return
+ }
+
+ resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL)
+ if err != nil {
+ c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
+ return
+ }
+
+ snapshotUrl = resp.Url
+ cmd.Key = resp.Key
+ cmd.DeleteKey = resp.DeleteKey
+ cmd.ExternalURL = resp.Url
+ cmd.ExternalDeleteURL = resp.DeleteUrl
+ cmd.Dashboard = &common.Unstructured{}
+
+ metrics.MApiDashboardSnapshotExternal.Inc()
+ } else {
+ cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl")
+
+ if cmd.Key == "" {
+ var err error
+ cmd.Key, err = util.GetRandomString(32)
+ if err != nil {
+ c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
+ return
+ }
+ }
+
+ if cmd.DeleteKey == "" {
+ var err error
+ cmd.DeleteKey, err = util.GetRandomString(32)
+ if err != nil {
+ c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
+ return
+ }
+ }
+
+ snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
+
+ metrics.MApiDashboardSnapshotCreate.Inc()
+ }
+
+ result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd)
+ if err != nil {
+ c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
+ return
+ }
+
+ c.JSON(http.StatusOK, dashboardsnapshot.DashboardCreateResponse{
+ Key: result.Key,
+ DeleteKey: result.DeleteKey,
+ URL: snapshotUrl,
+ DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey),
+ })
+}
+
+var plog = log.New("external-snapshot")
+
+func DeleteExternalDashboardSnapshot(externalUrl string) error {
+ resp, err := client.Get(externalUrl)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ plog.Warn("Failed to close response body", "err", err)
+ }
+ }()
+
+ if resp.StatusCode == 200 {
+ return nil
+ }
+
+ // Gracefully ignore "snapshot not found" errors as they could have already
+ // been removed either via the cleanup script or by request.
+ if resp.StatusCode == 500 {
+ var respJson map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil {
+ return err
+ }
+
+ if respJson["message"] == "Failed to get dashboard snapshot" {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode)
+}
+
+func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) {
+ var createSnapshotResponse CreateExternalSnapshotResponse
+ message := map[string]any{
+ "name": cmd.Name,
+ "expires": cmd.Expires,
+ "dashboard": cmd.Dashboard,
+ "key": cmd.Key,
+ "deleteKey": cmd.DeleteKey,
+ }
+
+ messageBytes, err := simplejson.NewFromAny(message).Encode()
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ plog.Warn("Failed to close response body", "err", err)
+ }
+ }()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode)
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil {
+ return nil, err
+ }
+
+ return &createSnapshotResponse, nil
+}
+
+func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
+ dashUID := cmd.Dashboard.GetNestedString("uid")
+ if ok := util.IsValidShortUID(dashUID); !ok {
+ return "", fmt.Errorf("invalid dashboard UID")
+ }
+
+ return fmt.Sprintf("/d/%v", dashUID), nil
+}
+
+func DeleteWithKey(ctx context.Context, key string, svc Service) error {
+ query := &GetDashboardSnapshotQuery{DeleteKey: key}
+ queryResult, err := svc.GetDashboardSnapshot(ctx, query)
+ if err != nil {
+ return err
+ }
+
+ if queryResult.External {
+ err := DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
+ if err != nil {
+ return err
+ }
+ }
+
+ cmd := &DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
+
+ return svc.DeleteDashboardSnapshot(ctx, cmd)
+}
diff --git a/pkg/services/dashboardsnapshots/service/service.go b/pkg/services/dashboardsnapshots/service/service.go
index 247f0e7aec9..c26f00e7d23 100644
--- a/pkg/services/dashboardsnapshots/service/service.go
+++ b/pkg/services/dashboardsnapshots/service/service.go
@@ -26,7 +26,7 @@ func ProvideService(store dashboardsnapshots.Store, secretsService secrets.Servi
}
func (s *ServiceImpl) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (*dashboardsnapshots.DashboardSnapshot, error) {
- marshalledData, err := cmd.Dashboard.Encode()
+ marshalledData, err := cmd.Dashboard.MarshalJSON()
if err != nil {
return nil, err
}
diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go
index 638a584d910..f4b80d9d9c9 100644
--- a/pkg/services/dashboardsnapshots/service/service_test.go
+++ b/pkg/services/dashboardsnapshots/service/service_test.go
@@ -2,11 +2,13 @@ package service
import (
"context"
+ "encoding/json"
"testing"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/components/simplejson"
+ common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
+ dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
dashsnapdb "github.com/grafana/grafana/pkg/services/dashboardsnapshots/database"
@@ -30,8 +32,9 @@ func TestDashboardSnapshotsService(t *testing.T) {
dashboardKey := "12345"
+ dashboard := &common.Unstructured{}
rawDashboard := []byte(`{"id":123}`)
- dashboard, err := simplejson.NewJson(rawDashboard)
+ err := json.Unmarshal(rawDashboard, dashboard)
require.NoError(t, err)
t.Run("create dashboard snapshot should encrypt the dashboard", func(t *testing.T) {
@@ -40,7 +43,9 @@ func TestDashboardSnapshotsService(t *testing.T) {
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{
Key: dashboardKey,
DeleteKey: dashboardKey,
- Dashboard: dashboard,
+ DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{
+ Dashboard: dashboard,
+ },
}
result, err := s.CreateDashboardSnapshot(ctx, &cmd)
diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go
index 80de907ff77..74ea3350412 100644
--- a/pkg/services/featuremgmt/manager.go
+++ b/pkg/services/featuremgmt/manager.go
@@ -131,18 +131,16 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
return v
}
-func (fm *FeatureManager) GetState() *FeatureManagerState {
- return &FeatureManagerState{
- RestartRequired: fm.restartRequired,
- AllowEditing: fm.Settings.AllowEditing,
- }
-}
-
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
func (fm *FeatureManager) IsFeatureEditingAllowed() bool {
return fm.Settings.AllowEditing && fm.Settings.UpdateWebhook != ""
}
+// indicate if a change has been made (not that accurate, but better than nothing)
+func (fm *FeatureManager) IsRestartRequired() bool {
+ return fm.restartRequired
+}
+
// Flags that can be edited
func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool {
flag, ok := fm.flags[key]
diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go
index 49d6a353b3b..b9c8b9ff7ea 100644
--- a/pkg/services/featuremgmt/models.go
+++ b/pkg/services/featuremgmt/models.go
@@ -134,18 +134,7 @@ type FeatureFlag struct {
RequiresRestart bool `json:"requiresRestart,omitempty"`
}
-type UpdateFeatureTogglesCommand struct {
- FeatureToggles []FeatureToggleDTO `json:"featureToggles"`
-}
-
-type FeatureToggleDTO struct {
- Name string `json:"name" binding:"Required"`
- Description string `json:"description"`
- Enabled bool `json:"enabled"`
- ReadOnly bool `json:"readOnly,omitempty"`
-}
-
-type FeatureManagerState struct {
- RestartRequired bool `json:"restartRequired"`
- AllowEditing bool `json:"allowEditing"`
+type FeatureToggleWebhookPayload struct {
+ FeatureToggles map[string]string `json:"feature_toggles"`
+ User string `json:"user"`
}
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 44d5b325147..93c1b83dccb 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -389,6 +389,14 @@ var (
Owner: grafanaObservabilityMetricsSquad,
Created: time.Date(2023, time.November, 29, 12, 0, 0, 0, time.UTC),
},
+ {
+ Name: "influxdbRunQueriesInParallel",
+ Description: "Enables running InfluxDB Influxql queries in parallel",
+ Stage: FeatureStagePrivatePreview,
+ FrontendOnly: false,
+ Owner: grafanaObservabilityMetricsSquad,
+ Created: time.Date(2024, time.January, 29, 12, 0, 0, 0, time.UTC),
+ },
{
Name: "clientTokenRotation",
Description: "Replaces the current in-request token rotation so that the client initiates the rotation",
@@ -949,6 +957,15 @@ var (
RequiresRestart: true, // changes the API routing
Created: time.Date(2023, time.December, 4, 12, 0, 0, 0, time.UTC),
},
+ {
+ Name: "kubernetesQueryServiceRewrite",
+ Description: "Rewrite requests targeting /ds/query to the query service",
+ Stage: FeatureStageExperimental,
+ Owner: grafanaAppPlatformSquad,
+ RequiresRestart: true, // changes the API routing
+ RequiresDevMode: true,
+ Created: time.Date(2024, time.January, 28, 12, 0, 0, 0, time.UTC),
+ },
{
Name: "cloudWatchBatchQueries",
Description: "Runs CloudWatch metrics queries as separate batches",
@@ -1321,13 +1338,12 @@ var (
Created: time.Date(2024, time.January, 29, 0, 0, 0, 0, time.UTC),
},
{
- Name: "slateAutocomplete",
- Description: "Adjusts the behaviour of the slate editor to properly handle autocomplete. Feature toggled for safety.",
- Stage: FeatureStageGeneralAvailability,
- Expression: "true", // enabled by default
+ Name: "nodeGraphDotLayout",
+ Description: "Changed the layout algorithm for the node graph",
+ Stage: FeatureStageExperimental,
FrontendOnly: true,
- Owner: grafanaFrontendPlatformSquad,
- Created: time.Date(2024, time.January, 29, 12, 0, 0, 0, time.UTC),
+ Owner: grafanaObservabilityTracesAndProfilingSquad,
+ Created: time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC),
},
}
)
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 18ac3654d23..5777ab5db8c 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -44,6 +44,7 @@ individualCookiePreferences,experimental,@grafana/backend-platform,2023-02-23,fa
prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,2023-03-07,false,false,true
influxdbBackendMigration,GA,@grafana/observability-metrics,2023-03-15,false,false,true
influxqlStreamingParser,experimental,@grafana/observability-metrics,2023-11-29,false,false,false
+influxdbRunQueriesInParallel,privatePreview,@grafana/observability-metrics,2024-01-29,false,false,false
clientTokenRotation,GA,@grafana/identity-access-team,2023-03-23,false,false,false
prometheusDataplane,GA,@grafana/observability-metrics,2023-03-29,false,false,false
lokiMetricDataplane,GA,@grafana/observability-logs,2023-04-13,false,false,false
@@ -111,6 +112,7 @@ formatString,preview,@grafana/grafana-bi-squad,2023-10-13,false,false,true
transformationsVariableSupport,preview,@grafana/grafana-bi-squad,2023-10-04,false,false,true
kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,2023-11-08,false,true,false
kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,2023-12-04,false,true,false
+kubernetesQueryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,2024-01-28,true,true,false
cloudWatchBatchQueries,preview,@grafana/aws-datasources,2023-10-20,false,false,false
recoveryThreshold,GA,@grafana/alerting-squad,2023-10-10,false,true,false
lokiStructuredMetadata,experimental,@grafana/observability-logs,2023-11-16,false,false,false
@@ -155,4 +157,4 @@ jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,2024-01-17,false,tr
onPremToCloudMigrations,experimental,@grafana/grafana-operator-experience-squad,2024-01-22,false,false,false
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,2024-01-22,false,false,false
promQLScope,experimental,@grafana/observability-metrics,2024-01-29,false,false,false
-slateAutocomplete,GA,@grafana/grafana-frontend-platform,2024-01-29,false,false,true
+nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,2024-01-02,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 150ec059934..0c7f0594ec6 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -187,6 +187,10 @@ const (
// Enable streaming JSON parser for InfluxDB datasource InfluxQL query language
FlagInfluxqlStreamingParser = "influxqlStreamingParser"
+ // FlagInfluxdbRunQueriesInParallel
+ // Enables running InfluxDB Influxql queries in parallel
+ FlagInfluxdbRunQueriesInParallel = "influxdbRunQueriesInParallel"
+
// FlagClientTokenRotation
// Replaces the current in-request token rotation so that the client initiates the rotation
FlagClientTokenRotation = "clientTokenRotation"
@@ -455,6 +459,10 @@ const (
// Use the kubernetes API in the frontend to support playlists
FlagKubernetesSnapshots = "kubernetesSnapshots"
+ // FlagKubernetesQueryServiceRewrite
+ // Rewrite requests targeting /ds/query to the query service
+ FlagKubernetesQueryServiceRewrite = "kubernetesQueryServiceRewrite"
+
// FlagCloudWatchBatchQueries
// Runs CloudWatch metrics queries as separate batches
FlagCloudWatchBatchQueries = "cloudWatchBatchQueries"
@@ -631,7 +639,7 @@ const (
// In-development feature that will allow injection of labels into prometheus queries.
FlagPromQLScope = "promQLScope"
- // FlagSlateAutocomplete
- // Adjusts the behaviour of the slate editor to properly handle autocomplete. Feature toggled for safety.
- FlagSlateAutocomplete = "slateAutocomplete"
+ // FlagNodeGraphDotLayout
+ // Changed the layout algorithm for the node graph
+ FlagNodeGraphDotLayout = "nodeGraphDotLayout"
)
diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go
index 85ba55c66c0..f88ba017862 100644
--- a/pkg/services/folder/folderimpl/folder_test.go
+++ b/pkg/services/folder/folderimpl/folder_test.go
@@ -1293,7 +1293,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
acmock.NewMockedPermissionsService(),
dashboardPermissions,
actest.FakeAccessControl{},
- foldertest.NewFakeService(),
+ serviceWithFlagOn,
nil,
)
require.NoError(t, err)
@@ -1318,13 +1318,13 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
SignedInUser: &signedInAdminUser,
}
+ guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+ CanSaveValue: true,
+ CanViewValue: true,
+ })
+
t.Run("Should get folders shared with given user", func(t *testing.T) {
depth := 3
- origNewGuardian := guardian.New
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
- CanSaveValue: true,
- CanViewValue: true,
- })
ancestorFoldersWithPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withPermissions", createCmd)
ancestorFoldersWithoutPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withoutPermissions", createCmd)
@@ -1338,17 +1338,6 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
// nolint:staticcheck
dash2 := insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in subfolder", orgID, subfolder.ID, subfolder.UID, "prod")
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
- CanSaveValue: true,
- CanViewValue: true,
- CanViewUIDs: []string{
- ancestorFoldersWithPermissions[0].UID,
- ancestorFoldersWithPermissions[1].UID,
- ancestorFoldersWithoutPermissions[1].UID,
- dash1.UID,
- dash2.UID,
- },
- })
signedInUser.Permissions[orgID][dashboards.ActionFoldersRead] = []string{
dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithPermissions[0].UID),
// Add permission to the subfolder of folder with permission (to check deduplication)
@@ -1390,7 +1379,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
require.NotContains(t, sharedDashboardsUIDs, dash2.UID)
t.Cleanup(func() {
- guardian.New = origNewGuardian
+ //guardian.New = origNewGuardian
toDelete := make([]string, 0, len(ancestorFoldersWithPermissions)+len(ancestorFoldersWithoutPermissions))
for _, ancestor := range append(ancestorFoldersWithPermissions, ancestorFoldersWithoutPermissions...) {
toDelete = append(toDelete, ancestor.UID)
@@ -1402,11 +1391,6 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
t.Run("Should get org folders visible", func(t *testing.T) {
depth := 3
- origNewGuardian := guardian.New
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
- CanSaveValue: true,
- CanViewValue: true,
- })
// create folder sctructure like this:
// tree1-folder-0
@@ -1428,7 +1412,6 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
}
t.Cleanup(func() {
- guardian.New = origNewGuardian
toDelete := make([]string, 0, len(tree1)+len(tree2))
for _, f := range append(tree1, tree2...) {
toDelete = append(toDelete, f.UID)
diff --git a/pkg/services/grafana-apiserver/config.go b/pkg/services/grafana-apiserver/config.go
deleted file mode 100644
index df30eb12eb9..00000000000
--- a/pkg/services/grafana-apiserver/config.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package grafanaapiserver
-
-import (
- "fmt"
- "net"
- "path/filepath"
- "strconv"
-
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-type config struct {
- enabled bool
- devMode bool
-
- ip net.IP
- port int
- host string
- apiURL string
-
- storageType StorageType
-
- etcdServers []string
- dataPath string
-
- logLevel int
-}
-
-func newConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *config {
- defaultLogLevel := 0
- ip := net.ParseIP(cfg.HTTPAddr)
- apiURL := cfg.AppURL
- port, err := strconv.Atoi(cfg.HTTPPort)
- if err != nil {
- port = 3000
- }
-
- if cfg.Env == setting.Dev {
- defaultLogLevel = 10
- port = 6443
- ip = net.ParseIP("127.0.0.1")
- apiURL = fmt.Sprintf("https://%s:%d", ip, port)
- }
-
- host := fmt.Sprintf("%s:%d", ip, port)
-
- return &config{
- enabled: true,
- devMode: features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess),
- dataPath: filepath.Join(cfg.DataPath, "grafana-apiserver"),
- ip: ip,
- port: port,
- host: host,
- logLevel: cfg.SectionWithEnvOverrides("grafana-apiserver").Key("log_level").MustInt(defaultLogLevel),
- etcdServers: cfg.SectionWithEnvOverrides("grafana-apiserver").Key("etcd_servers").Strings(","),
- storageType: StorageType(cfg.SectionWithEnvOverrides("grafana-apiserver").Key("storage_type").MustString(string(StorageTypeLegacy))),
- apiURL: apiURL,
- }
-}
diff --git a/pkg/services/grafana-apiserver/config_test.go b/pkg/services/grafana-apiserver/config_test.go
deleted file mode 100644
index e76fe5e7f93..00000000000
--- a/pkg/services/grafana-apiserver/config_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package grafanaapiserver
-
-import (
- "net"
- "testing"
-
- "github.com/stretchr/testify/require"
-
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-func TestNewConfig(t *testing.T) {
- cfg := setting.NewCfg()
- cfg.Env = setting.Prod
- cfg.DataPath = "/tmp/grafana"
- cfg.HTTPAddr = "10.0.0.1"
- cfg.HTTPPort = "4000"
- cfg.AppURL = "http://test:4000"
-
- section := cfg.Raw.Section("grafana-apiserver")
- section.Key("log_level").SetValue("5")
- section.Key("etcd_servers").SetValue("http://localhost:2379")
-
- actual := newConfig(cfg, featuremgmt.WithFeatures())
-
- expected := &config{
- enabled: true,
- devMode: false,
- storageType: StorageTypeLegacy,
- etcdServers: []string{"http://localhost:2379"},
- apiURL: "http://test:4000",
- ip: net.ParseIP("10.0.0.1"),
- port: 4000,
- host: "10.0.0.1:4000",
- dataPath: "/tmp/grafana/grafana-apiserver",
- logLevel: 5,
- }
- require.Equal(t, expected, actual)
-}
diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go
index 3e9ce4a3f73..a8ab39c0111 100644
--- a/pkg/services/ngalert/accesscontrol.go
+++ b/pkg/services/ngalert/accesscontrol.go
@@ -25,6 +25,9 @@ var (
Action: accesscontrol.ActionAlertingRuleExternalRead,
Scope: datasources.ScopeAll,
},
+ {
+ Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, // This is needed for simplified notification policies
+ },
},
},
}
@@ -109,6 +112,9 @@ var (
Action: accesscontrol.ActionAlertingNotificationsExternalRead,
Scope: datasources.ScopeAll,
},
+ {
+ Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead,
+ },
},
},
}
diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go
index dcb8aec5e48..58bf3a0635d 100644
--- a/pkg/services/ngalert/api/api.go
+++ b/pkg/services/ngalert/api/api.go
@@ -152,6 +152,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
hist: api.Historian,
}), m)
+ api.RegisterNotificationsApiEndpoints(NewNotificationsApi(api.MuteTimings), m)
+
// Inject upgrade endpoints if legacy alerting is enabled and the feature flag is enabled.
if !api.Cfg.UnifiedAlerting.IsEnabled() && api.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingPreviewUpgrade) {
api.RegisterUpgradeApiEndpoints(NewUpgradeApi(NewUpgradeSrc(
diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go
index ded0b5196d6..376d838de7f 100644
--- a/pkg/services/ngalert/api/api_provisioning_test.go
+++ b/pkg/services/ngalert/api/api_provisioning_test.go
@@ -1614,10 +1614,11 @@ func createProvisioningSrvSut(t *testing.T) ProvisioningSrv {
func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) ProvisioningSrv {
t.Helper()
+ receiverSvc := notifier.NewReceiverService(env.ac, env.configs, env.prov, env.secrets, env.xact, env.log)
return ProvisioningSrv{
log: env.log,
policies: newFakeNotificationPolicyService(),
- contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, env.log, env.ac),
+ contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, receiverSvc, env.log),
templates: provisioning.NewTemplateService(env.configs, env.prov, env.xact, env.log),
muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log),
alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.dashboardService, env.quotas, env.xact, 60, 10, env.log),
diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go
index d27ce6ca8f9..95fd74e1b4d 100644
--- a/pkg/services/ngalert/api/authorization.go
+++ b/pkg/services/ngalert/api/authorization.go
@@ -238,6 +238,9 @@ func (api *API) authorize(method, path string) web.Handler {
http.MethodDelete + "/api/v1/provisioning/alert-rules/{UID}",
http.MethodPut + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}":
eval = ac.EvalPermission(ac.ActionAlertingProvisioningWrite) // organization scope
+ case http.MethodGet + "/api/v1/notifications/time-intervals/{name}",
+ http.MethodGet + "/api/v1/notifications/time-intervals":
+ eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsRead), ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead), ac.EvalPermission(ac.ActionAlertingProvisioningRead))
}
if eval != nil {
diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go
index c1f9c43b748..264c74ba7e3 100644
--- a/pkg/services/ngalert/api/authorization_test.go
+++ b/pkg/services/ngalert/api/authorization_test.go
@@ -40,7 +40,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
- require.Len(t, paths, 60)
+ require.Len(t, paths, 62)
ac := acmock.New()
api := &API{AccessControl: ac}
diff --git a/pkg/services/ngalert/api/generated_base_api_notifications.go b/pkg/services/ngalert/api/generated_base_api_notifications.go
new file mode 100644
index 00000000000..1423daf23dc
--- /dev/null
+++ b/pkg/services/ngalert/api/generated_base_api_notifications.go
@@ -0,0 +1,62 @@
+/*Package api contains base API implementation of unified alerting
+ *
+ *Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git)
+ *
+ *Do not manually edit these files, please find ngalert/api/swagger-codegen/ for commands on how to generate them.
+ */
+package api
+
+import (
+ "net/http"
+
+ "github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/api/routing"
+ "github.com/grafana/grafana/pkg/middleware"
+ "github.com/grafana/grafana/pkg/middleware/requestmeta"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+ "github.com/grafana/grafana/pkg/services/ngalert/metrics"
+ "github.com/grafana/grafana/pkg/web"
+)
+
+type NotificationsApi interface {
+ RouteNotificationsGetTimeInterval(*contextmodel.ReqContext) response.Response
+ RouteNotificationsGetTimeIntervals(*contextmodel.ReqContext) response.Response
+}
+
+func (f *NotificationsApiHandler) RouteNotificationsGetTimeInterval(ctx *contextmodel.ReqContext) response.Response {
+ // Parse Path Parameters
+ nameParam := web.Params(ctx.Req)[":name"]
+ return f.handleRouteNotificationsGetTimeInterval(ctx, nameParam)
+}
+func (f *NotificationsApiHandler) RouteNotificationsGetTimeIntervals(ctx *contextmodel.ReqContext) response.Response {
+ return f.handleRouteNotificationsGetTimeIntervals(ctx)
+}
+
+func (api *API) RegisterNotificationsApiEndpoints(srv NotificationsApi, m *metrics.API) {
+ api.RouteRegister.Group("", func(group routing.RouteRegister) {
+ group.Get(
+ toMacaronPath("/api/v1/notifications/time-intervals/{name}"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodGet, "/api/v1/notifications/time-intervals/{name}"),
+ metrics.Instrument(
+ http.MethodGet,
+ "/api/v1/notifications/time-intervals/{name}",
+ api.Hooks.Wrap(srv.RouteNotificationsGetTimeInterval),
+ m,
+ ),
+ )
+ group.Get(
+ toMacaronPath("/api/v1/notifications/time-intervals"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodGet, "/api/v1/notifications/time-intervals"),
+ metrics.Instrument(
+ http.MethodGet,
+ "/api/v1/notifications/time-intervals",
+ api.Hooks.Wrap(srv.RouteNotificationsGetTimeIntervals),
+ m,
+ ),
+ )
+ }, middleware.ReqSignedIn)
+}
diff --git a/pkg/services/ngalert/api/notifications.go b/pkg/services/ngalert/api/notifications.go
new file mode 100644
index 00000000000..5b91c9a246a
--- /dev/null
+++ b/pkg/services/ngalert/api/notifications.go
@@ -0,0 +1,34 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/grafana/grafana/pkg/api/response"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+)
+
+type NotificationsApiHandler struct {
+ muteTimingService MuteTimingService
+}
+
+func NewNotificationsApi(muteTimingService MuteTimingService) NotificationsApi {
+ return &NotificationsApiHandler{
+ muteTimingService: muteTimingService,
+ }
+}
+
+func (f *NotificationsApiHandler) handleRouteNotificationsGetTimeInterval(ctx *contextmodel.ReqContext, name string) response.Response {
+ model, err := f.muteTimingService.GetMuteTiming(ctx.Req.Context(), name, ctx.OrgID)
+ if err != nil {
+ return errorToResponse(err)
+ }
+ return response.JSON(http.StatusOK, model) // TODO convert to timing interval
+}
+
+func (f *NotificationsApiHandler) handleRouteNotificationsGetTimeIntervals(ctx *contextmodel.ReqContext) response.Response {
+ model, err := f.muteTimingService.GetMuteTimings(ctx.Req.Context(), ctx.OrgID)
+ if err != nil {
+ return errorToResponse(err)
+ }
+ return response.JSON(http.StatusOK, model) // TODO convert to timing interval
+}
diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json
index 0a9a87eba07..31333220b78 100644
--- a/pkg/services/ngalert/api/tooling/api.json
+++ b/pkg/services/ngalert/api/tooling/api.json
@@ -1256,6 +1256,15 @@
},
"typeVersion": {
"$ref": "#/definitions/FrameTypeVersion"
+ },
+ "uniqueRowIdFields": {
+ "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.",
+ "example": "TraceID in Tempo, table name + primary key in SQL",
+ "items": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "type": "array"
}
},
"title": "FrameMeta matches:",
@@ -1667,6 +1676,23 @@
],
"type": "object"
},
+ "GettableTimeIntervals": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "provenance": {
+ "$ref": "#/definitions/Provenance"
+ },
+ "time_intervals": {
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
"GettableUserConfig": {
"properties": {
"alertmanager_config": {
@@ -2816,6 +2842,20 @@
},
"type": "object"
},
+ "PostableTimeIntervals": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "time_intervals": {
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
"PostableUserConfig": {
"properties": {
"alertmanager_config": {
@@ -4166,6 +4206,55 @@
},
"type": "object"
},
+ "TimeIntervalItem": {
+ "properties": {
+ "days_of_month": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "location": {
+ "type": "string"
+ },
+ "months": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "times": {
+ "items": {
+ "$ref": "#/definitions/TimeIntervalTimeRange"
+ },
+ "type": "array"
+ },
+ "weekdays": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "years": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "TimeIntervalTimeRange": {
+ "properties": {
+ "end_time": {
+ "type": "string"
+ },
+ "start_time": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"TimeRange": {
"description": "Redefining this to avoid an import cycle",
"properties": {
@@ -4181,7 +4270,6 @@
"type": "object"
},
"URL": {
- "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -4217,7 +4305,7 @@
"$ref": "#/definitions/Userinfo"
}
},
- "title": "A URL represents a parsed URL (technically, a URI reference).",
+ "title": "URL is a custom URL type that allows validation at configuration load time.",
"type": "object"
},
"UpdateRuleGroupResponse": {
@@ -4613,6 +4701,7 @@
"type": "array"
},
"gettableSilence": {
+ "description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4661,13 +4750,13 @@
"type": "object"
},
"gettableSilences": {
- "description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array"
},
"integration": {
+ "description": "Integration integration",
"properties": {
"lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@@ -4849,7 +4938,6 @@
"type": "object"
},
"receiver": {
- "description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@@ -4969,6 +5057,61 @@
"version": "1.1.0"
},
"paths": {
+ "/v1/notifications/time-intervals": {
+ "get": {
+ "description": "Get all the time intervals",
+ "operationId": "RouteNotificationsGetTimeIntervals",
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetAllIntervalsResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ }
+ },
+ "tags": [
+ "notifications"
+ ]
+ }
+ },
+ "/v1/notifications/time-intervals/{name}": {
+ "get": {
+ "operationId": "RouteNotificationsGetTimeInterval",
+ "parameters": [
+ {
+ "description": "Time interval name",
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetIntervalsByNameResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ },
+ "404": {
+ "description": "NotFound",
+ "schema": {
+ "$ref": "#/definitions/NotFound"
+ }
+ }
+ },
+ "summary": "Get a time interval by name.",
+ "tags": [
+ "notifications"
+ ]
+ }
+ },
"/v1/provisioning/alert-rules": {
"get": {
"operationId": "RouteGetAlertRules",
@@ -6010,6 +6153,21 @@
"application/json"
],
"responses": {
+ "GetAllIntervalsResponse": {
+ "description": "",
+ "schema": {
+ "items": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ },
+ "type": "array"
+ }
+ },
+ "GetIntervalsByNameResponse": {
+ "description": "",
+ "schema": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ }
+ },
"GettableHistoricUserConfigs": {
"description": "",
"schema": {
diff --git a/pkg/services/ngalert/api/tooling/definitions/time_intervals.go b/pkg/services/ngalert/api/tooling/definitions/time_intervals.go
new file mode 100644
index 00000000000..04ac68aa845
--- /dev/null
+++ b/pkg/services/ngalert/api/tooling/definitions/time_intervals.go
@@ -0,0 +1,64 @@
+package definitions
+
+// swagger:route GET /v1/notifications/time-intervals notifications stable RouteNotificationsGetTimeIntervals
+//
+// Get all the time intervals
+//
+// Responses:
+// 200: GetAllIntervalsResponse
+// 403: ForbiddenError
+
+// swagger:route GET /v1/notifications/time-intervals/{name} notifications stable RouteNotificationsGetTimeInterval
+//
+// Get a time interval by name.
+//
+// Responses:
+// 200: GetIntervalsByNameResponse
+// 404: NotFound
+// 403: ForbiddenError
+
+// swagger:parameters stable RouteNotificationsGetTimeInterval
+type RouteTimeIntervalNameParam struct {
+ // Time interval name
+ // in:path
+ Name string `json:"name"`
+}
+
+// swagger:response GetAllIntervalsResponse
+type GetAllIntervalsResponse struct {
+ // in:body
+ Body []GettableTimeIntervals
+}
+
+// swagger:response GetIntervalsByNameResponse
+type GetIntervalsByNameResponse struct {
+ // in:body
+ Body GettableTimeIntervals
+}
+
+// swagger:model
+type PostableTimeIntervals struct {
+ Name string `json:"name" hcl:"name"`
+ TimeIntervals []TimeIntervalItem `json:"time_intervals" hcl:"intervals,block"`
+}
+
+type TimeIntervalItem struct {
+ Times []TimeIntervalTimeRange `json:"times,omitempty" hcl:"times,block"`
+ Weekdays *[]string `json:"weekdays,omitempty" hcl:"weekdays"`
+ DaysOfMonth *[]string `json:"days_of_month,omitempty" hcl:"days_of_month"`
+ Months *[]string `json:"months,omitempty" hcl:"months"`
+ Years *[]string `json:"years,omitempty" hcl:"years"`
+ Location *string `json:"location,omitempty" hcl:"location"`
+}
+
+type TimeIntervalTimeRange struct {
+ StartMinute string `json:"start_time" hcl:"start"`
+ EndMinute string `json:"end_time" hcl:"end"`
+}
+
+// swagger:model
+type GettableTimeIntervals struct {
+ Name string `json:"name" hcl:"name"`
+ TimeIntervals []TimeIntervalItem `json:"time_intervals" hcl:"intervals,block"`
+ Provenance Provenance `json:"provenance,omitempty"`
+}
diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json
index c3d72b28d26..cfb69720ed1 100644
--- a/pkg/services/ngalert/api/tooling/post.json
+++ b/pkg/services/ngalert/api/tooling/post.json
@@ -1256,6 +1256,15 @@
},
"typeVersion": {
"$ref": "#/definitions/FrameTypeVersion"
+ },
+ "uniqueRowIdFields": {
+ "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.",
+ "example": "TraceID in Tempo, table name + primary key in SQL",
+ "items": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "type": "array"
}
},
"title": "FrameMeta matches:",
@@ -1667,6 +1676,23 @@
],
"type": "object"
},
+ "GettableTimeIntervals": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "provenance": {
+ "$ref": "#/definitions/Provenance"
+ },
+ "time_intervals": {
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
"GettableUserConfig": {
"properties": {
"alertmanager_config": {
@@ -2816,6 +2842,20 @@
},
"type": "object"
},
+ "PostableTimeIntervals": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "time_intervals": {
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
"PostableUserConfig": {
"properties": {
"alertmanager_config": {
@@ -4166,6 +4206,55 @@
},
"type": "object"
},
+ "TimeIntervalItem": {
+ "properties": {
+ "days_of_month": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "location": {
+ "type": "string"
+ },
+ "months": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "times": {
+ "items": {
+ "$ref": "#/definitions/TimeIntervalTimeRange"
+ },
+ "type": "array"
+ },
+ "weekdays": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "years": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "TimeIntervalTimeRange": {
+ "properties": {
+ "end_time": {
+ "type": "string"
+ },
+ "start_time": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"TimeRange": {
"description": "Redefining this to avoid an import cycle",
"properties": {
@@ -4181,6 +4270,7 @@
"type": "object"
},
"URL": {
+ "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -4216,7 +4306,7 @@
"$ref": "#/definitions/Userinfo"
}
},
- "title": "URL is a custom URL type that allows validation at configuration load time.",
+ "title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"UpdateRuleGroupResponse": {
@@ -4422,6 +4512,7 @@
"type": "object"
},
"alertGroup": {
+ "description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@@ -4445,7 +4536,6 @@
"type": "object"
},
"alertGroups": {
- "description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@@ -4550,7 +4640,6 @@
"type": "object"
},
"gettableAlert": {
- "description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@@ -4612,6 +4701,7 @@
"type": "array"
},
"gettableSilence": {
+ "description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4660,14 +4750,12 @@
"type": "object"
},
"gettableSilences": {
- "description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array"
},
"integration": {
- "description": "Integration integration",
"properties": {
"lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@@ -4811,7 +4899,6 @@
"type": "array"
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4849,6 +4936,7 @@
"type": "object"
},
"receiver": {
+ "description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@@ -6958,6 +7046,61 @@
]
}
},
+ "/v1/notifications/time-intervals": {
+ "get": {
+ "description": "Get all the time intervals",
+ "operationId": "RouteNotificationsGetTimeIntervals",
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetAllIntervalsResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ }
+ },
+ "tags": [
+ "notifications"
+ ]
+ }
+ },
+ "/v1/notifications/time-intervals/{name}": {
+ "get": {
+ "operationId": "RouteNotificationsGetTimeInterval",
+ "parameters": [
+ {
+ "description": "Time interval name",
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetIntervalsByNameResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ },
+ "404": {
+ "description": "NotFound",
+ "schema": {
+ "$ref": "#/definitions/NotFound"
+ }
+ }
+ },
+ "summary": "Get a time interval by name.",
+ "tags": [
+ "notifications"
+ ]
+ }
+ },
"/v1/provisioning/alert-rules": {
"get": {
"operationId": "RouteGetAlertRules",
@@ -8349,6 +8492,21 @@
"application/json"
],
"responses": {
+ "GetAllIntervalsResponse": {
+ "description": "",
+ "schema": {
+ "items": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ },
+ "type": "array"
+ }
+ },
+ "GetIntervalsByNameResponse": {
+ "description": "",
+ "schema": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ }
+ },
"GettableHistoricUserConfigs": {
"description": "",
"schema": {
diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json
index a67c47a68a0..50e830b39da 100644
--- a/pkg/services/ngalert/api/tooling/spec.json
+++ b/pkg/services/ngalert/api/tooling/spec.json
@@ -2007,6 +2007,63 @@
}
}
},
+ "/v1/notifications/time-intervals": {
+ "get": {
+ "description": "Get all the time intervals",
+ "tags": [
+ "notifications",
+ "stable"
+ ],
+ "operationId": "RouteNotificationsGetTimeIntervals",
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetAllIntervalsResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ }
+ }
+ }
+ },
+ "/v1/notifications/time-intervals/{name}": {
+ "get": {
+ "tags": [
+ "notifications",
+ "stable"
+ ],
+ "summary": "Get a time interval by name.",
+ "operationId": "RouteNotificationsGetTimeInterval",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Time interval name",
+ "name": "name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetIntervalsByNameResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ },
+ "404": {
+ "description": "NotFound",
+ "schema": {
+ "$ref": "#/definitions/NotFound"
+ }
+ }
+ }
+ }
+ },
"/v1/provisioning/alert-rules": {
"get": {
"tags": [
@@ -4682,6 +4739,15 @@
},
"typeVersion": {
"$ref": "#/definitions/FrameTypeVersion"
+ },
+ "uniqueRowIdFields": {
+ "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "example": "TraceID in Tempo, table name + primary key in SQL"
}
}
},
@@ -5091,6 +5157,23 @@
}
}
},
+ "GettableTimeIntervals": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "provenance": {
+ "$ref": "#/definitions/Provenance"
+ },
+ "time_intervals": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ }
+ }
+ }
+ },
"GettableUserConfig": {
"type": "object",
"properties": {
@@ -6241,6 +6324,20 @@
}
}
},
+ "PostableTimeIntervals": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "time_intervals": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ }
+ }
+ }
+ },
"PostableUserConfig": {
"type": "object",
"properties": {
@@ -7591,6 +7688,55 @@
}
}
},
+ "TimeIntervalItem": {
+ "type": "object",
+ "properties": {
+ "days_of_month": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "location": {
+ "type": "string"
+ },
+ "months": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "times": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TimeIntervalTimeRange"
+ }
+ },
+ "weekdays": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "years": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "TimeIntervalTimeRange": {
+ "type": "object",
+ "properties": {
+ "end_time": {
+ "type": "string"
+ },
+ "start_time": {
+ "type": "string"
+ }
+ }
+ },
"TimeRange": {
"description": "Redefining this to avoid an import cycle",
"type": "object",
@@ -7606,8 +7752,9 @@
}
},
"URL": {
+ "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
- "title": "URL is a custom URL type that allows validation at configuration load time.",
+ "title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -7847,6 +7994,7 @@
}
},
"alertGroup": {
+ "description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@@ -7871,7 +8019,6 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
- "description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@@ -7977,7 +8124,6 @@
}
},
"gettableAlert": {
- "description": "GettableAlert gettable alert",
"type": "object",
"required": [
"labels",
@@ -8041,6 +8187,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
+ "description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@@ -8090,7 +8237,6 @@
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
- "description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
@@ -8098,7 +8244,6 @@
"$ref": "#/definitions/gettableSilences"
},
"integration": {
- "description": "Integration integration",
"type": "object",
"required": [
"name",
@@ -8243,7 +8388,6 @@
}
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@@ -8282,6 +8426,7 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
+ "description": "Receiver receiver",
"type": "object",
"required": [
"active",
@@ -8397,6 +8542,21 @@
}
},
"responses": {
+ "GetAllIntervalsResponse": {
+ "description": "",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ }
+ }
+ },
+ "GetIntervalsByNameResponse": {
+ "description": "",
+ "schema": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ }
+ },
"GettableHistoricUserConfigs": {
"description": "",
"schema": {
diff --git a/pkg/services/ngalert/migration/permissions_test.go b/pkg/services/ngalert/migration/permissions_test.go
index 0b33927b60d..195b0a75200 100644
--- a/pkg/services/ngalert/migration/permissions_test.go
+++ b/pkg/services/ngalert/migration/permissions_test.go
@@ -643,6 +643,7 @@ func TestDashAlertPermissionMigration(t *testing.T) {
for i := 1; i < 3; i++ {
_, err := x.Insert(user.User{
ID: int64(i),
+ UID: fmt.Sprintf("u%d", i),
OrgID: 1,
Name: fmt.Sprintf("user%v", i),
Login: fmt.Sprintf("user%v", i),
diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go
new file mode 100644
index 00000000000..048955428bc
--- /dev/null
+++ b/pkg/services/ngalert/models/receivers.go
@@ -0,0 +1,10 @@
+package models
+
+// GetReceiversQuery represents a query for receiver groups.
+type GetReceiversQuery struct {
+ OrgID int64
+ Names []string
+ Limit int
+ Offset int
+ Decrypt bool
+}
diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go
index e4e610400ab..22268ab993f 100644
--- a/pkg/services/ngalert/ngalert.go
+++ b/pkg/services/ngalert/ngalert.go
@@ -313,9 +313,11 @@ func (ng *AlertNG) init() error {
ng.stateManager = stateManager
ng.schedule = scheduler
+ receiverService := notifier.NewReceiverService(ng.accesscontrol, ng.store, ng.store, ng.SecretsService, ng.store, ng.Log)
+
// Provisioning
policyService := provisioning.NewNotificationPolicyService(ng.store, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log)
- contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, ng.Log, ng.accesscontrol)
+ contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, receiverService, ng.Log)
templateService := provisioning.NewTemplateService(ng.store, ng.store, ng.store, ng.Log)
muteTimingService := provisioning.NewMuteTimingService(ng.store, ng.store, ng.store, ng.Log)
alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.dashboardService, ng.QuotaService, ng.store,
diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go
index eb06c23ae7f..980a66905cf 100644
--- a/pkg/services/ngalert/notifier/alertmanager.go
+++ b/pkg/services/ngalert/notifier/alertmanager.go
@@ -180,7 +180,7 @@ func (am *alertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error {
}
err = am.Store.SaveAlertmanagerConfigurationWithCallback(ctx, cmd, func() error {
- _, err := am.applyConfig(cfg, []byte(am.Settings.UnifiedAlerting.DefaultConfiguration))
+ _, err := am.applyConfig(cfg)
return err
})
if err != nil {
@@ -210,7 +210,7 @@ func (am *alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.P
}
err = am.Store.SaveAlertmanagerConfigurationWithCallback(ctx, cmd, func() error {
- _, err := am.applyConfig(cfg, rawConfig)
+ _, err := am.applyConfig(cfg)
return err
})
if err != nil {
@@ -232,7 +232,7 @@ func (am *alertmanager) ApplyConfig(ctx context.Context, dbCfg *ngmodels.AlertCo
var outerErr error
am.Base.WithLock(func() {
- if err := am.applyAndMarkConfig(ctx, dbCfg.ConfigurationHash, cfg, nil); err != nil {
+ if err := am.applyAndMarkConfig(ctx, dbCfg.ConfigurationHash, cfg); err != nil {
outerErr = fmt.Errorf("unable to apply configuration: %w", err)
return
}
@@ -286,16 +286,13 @@ func (am *alertmanager) aggregateInhibitMatchers(rules []config.InhibitRule, amu
// applyConfig applies a new configuration by re-initializing all components using the configuration provided.
// It returns a boolean indicating whether the user config was changed and an error.
// It is not safe to call concurrently.
-func (am *alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig []byte) (bool, error) {
+func (am *alertmanager) applyConfig(cfg *apimodels.PostableUserConfig) (bool, error) {
// First, let's make sure this config is not already loaded
var amConfigChanged bool
- if rawConfig == nil {
- enc, err := json.Marshal(cfg.AlertmanagerConfig)
- if err != nil {
- // In theory, this should never happen.
- return false, err
- }
- rawConfig = enc
+ rawConfig, err := json.Marshal(cfg.AlertmanagerConfig)
+ if err != nil {
+ // In theory, this should never happen.
+ return false, err
}
if am.Base.ConfigHash() != md5.Sum(rawConfig) {
@@ -335,8 +332,8 @@ func (am *alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig
}
// applyAndMarkConfig applies a configuration and marks it as applied if no errors occur.
-func (am *alertmanager) applyAndMarkConfig(ctx context.Context, hash string, cfg *apimodels.PostableUserConfig, rawConfig []byte) error {
- configChanged, err := am.applyConfig(cfg, rawConfig)
+func (am *alertmanager) applyAndMarkConfig(ctx context.Context, hash string, cfg *apimodels.PostableUserConfig) error {
+ configChanged, err := am.applyConfig(cfg)
if err != nil {
return err
}
diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go
index 90d548d5e6c..cf500c86419 100644
--- a/pkg/services/ngalert/notifier/alertmanager_config.go
+++ b/pkg/services/ngalert/notifier/alertmanager_config.go
@@ -156,6 +156,13 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex
}
func (moa *MultiOrgAlertmanager) ApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig) error {
+ // We cannot add this validation to PostableUserConfig as that struct is used for both
+ // Grafana Alertmanager (where inhibition rules are not supported) and External Alertmanagers
+ // (including Mimir) where inhibition rules are supported.
+ if len(config.AlertmanagerConfig.InhibitRules) > 0 {
+ return errors.New("inhibition rules are not supported")
+ }
+
// Get the last known working configuration
_, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org)
if err != nil {
diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go
index 41bcb1cab87..1731d384416 100644
--- a/pkg/services/ngalert/notifier/compat.go
+++ b/pkg/services/ngalert/notifier/compat.go
@@ -4,8 +4,11 @@ import (
"encoding/json"
alertingNotify "github.com/grafana/alerting/notify"
+ "github.com/prometheus/alertmanager/config"
+ "github.com/grafana/grafana/pkg/components/simplejson"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
)
func PostableGrafanaReceiverToGrafanaIntegrationConfig(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaIntegrationConfig {
@@ -40,3 +43,68 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf
}
return apiReceivers
}
+
+type DecryptFn = func(value string) string
+
+func PostableToGettableGrafanaReceiver(r *apimodels.PostableGrafanaReceiver, provenance *models.Provenance, decryptFn DecryptFn) (apimodels.GettableGrafanaReceiver, error) {
+ out := apimodels.GettableGrafanaReceiver{
+ UID: r.UID,
+ Name: r.Name,
+ Type: r.Type,
+ DisableResolveMessage: r.DisableResolveMessage,
+ SecureFields: make(map[string]bool, len(r.SecureSettings)),
+ }
+
+ if provenance != nil {
+ out.Provenance = apimodels.Provenance(*provenance)
+ }
+
+ settings, err := simplejson.NewJson([]byte(r.Settings))
+ if err != nil {
+ return apimodels.GettableGrafanaReceiver{}, err
+ }
+
+ for k, v := range r.SecureSettings {
+ decryptedValue := decryptFn(v)
+ if err != nil {
+ return apimodels.GettableGrafanaReceiver{}, err
+ }
+ if decryptedValue == "" {
+ continue
+ } else {
+ settings.Set(k, decryptedValue)
+ }
+ out.SecureFields[k] = true
+ }
+
+ jsonBytes, err := settings.MarshalJSON()
+ if err != nil {
+ return apimodels.GettableGrafanaReceiver{}, err
+ }
+ out.Settings = jsonBytes
+
+ return out, nil
+}
+
+func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances map[string]models.Provenance, decryptFn DecryptFn) (apimodels.GettableApiReceiver, error) {
+ out := apimodels.GettableApiReceiver{
+ Receiver: config.Receiver{
+ Name: r.Receiver.Name,
+ },
+ }
+
+ for _, gr := range r.GrafanaManagedReceivers {
+ var prov *models.Provenance
+ if p, ok := provenances[gr.UID]; ok {
+ prov = &p
+ }
+
+ gettable, err := PostableToGettableGrafanaReceiver(gr, prov, decryptFn)
+ if err != nil {
+ return apimodels.GettableApiReceiver{}, err
+ }
+ out.GrafanaManagedReceivers = append(out.GrafanaManagedReceivers, &gettable)
+ }
+
+ return out, nil
+}
diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go
new file mode 100644
index 00000000000..cefa1ffd64a
--- /dev/null
+++ b/pkg/services/ngalert/notifier/receiver_svc.go
@@ -0,0 +1,149 @@
+package notifier
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "slices"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/auth/identity"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/secrets"
+)
+
+var (
+ // ErrPermissionDenied is returned when the user does not have permission to perform the requested action.
+ ErrPermissionDenied = errors.New("permission denied") // TODO: convert to errutil
+)
+
+// ReceiverService is the service for managing alertmanager receivers.
+type ReceiverService struct {
+ ac accesscontrol.AccessControl
+ provisioningStore provisoningStore
+ cfgStore configStore
+ encryptionService secrets.Service
+ xact transactionManager
+ log log.Logger
+}
+
+type configStore interface {
+ GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error)
+ UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error
+}
+
+type provisoningStore interface {
+ GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error)
+}
+
+type transactionManager interface {
+ InTransaction(ctx context.Context, work func(ctx context.Context) error) error
+}
+
+func NewReceiverService(
+ ac accesscontrol.AccessControl,
+ cfgStore configStore,
+ provisioningStore provisoningStore,
+ encryptionService secrets.Service,
+ xact transactionManager,
+ log log.Logger,
+) *ReceiverService {
+ return &ReceiverService{
+ ac: ac,
+ provisioningStore: provisioningStore,
+ cfgStore: cfgStore,
+ encryptionService: encryptionService,
+ xact: xact,
+ log: log,
+ }
+}
+
+func (rs *ReceiverService) canDecrypt(ctx context.Context, user identity.Requester, name string) (bool, error) {
+ receiverAccess := false // TODO: stub, check for read secrets access
+ eval := accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets)
+ provisioningAccess, err := rs.ac.Evaluate(ctx, user, eval)
+ if err != nil {
+ return false, err
+ }
+ return receiverAccess || provisioningAccess, nil
+}
+
+// GetReceivers returns a list of receivers a user has access to.
+// Receivers can be filtered by name, and secure settings are decrypted if requested and the user has access to do so.
+func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) {
+ baseCfg, err := rs.cfgStore.GetLatestAlertmanagerConfiguration(ctx, q.OrgID)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := definitions.PostableUserConfig{}
+ err = json.Unmarshal([]byte(baseCfg.AlertmanagerConfiguration), &cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, "contactPoint")
+ if err != nil {
+ return nil, err
+ }
+
+ // TODO: check for list access
+
+ var output []definitions.GettableApiReceiver
+ for i := q.Offset; i < len(cfg.AlertmanagerConfig.Receivers); i++ {
+ r := cfg.AlertmanagerConfig.Receivers[i]
+ if len(q.Names) > 0 && !slices.Contains(q.Names, r.Name) {
+ continue
+ }
+
+ // TODO: check for scoped read access and continue if not allowed
+
+ decryptAccess, err := rs.canDecrypt(ctx, user, r.Name)
+ if err != nil {
+ return nil, err
+ }
+ if q.Decrypt && !decryptAccess {
+ return nil, ErrPermissionDenied
+ }
+
+ decryptFn := rs.decryptOrRedact(ctx, decryptAccess && q.Decrypt, r.Name, "")
+
+ res, err := PostableToGettableApiReceiver(r, provenances, decryptFn)
+ if err != nil {
+ return nil, err
+ }
+
+ // TODO: redact settings if the user only has list access
+
+ output = append(output, res)
+ // stop if we have reached the limit or we have found all the requested receivers
+ if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) {
+ break
+ }
+ }
+
+ return output, nil
+}
+
+func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, name, fallback string) func(value string) string {
+ return func(value string) string {
+ if !decrypt {
+ return definitions.RedactedValue
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(value)
+ if err != nil {
+ rs.log.Warn("failed to decode secure setting", "name", name, "error", err)
+ return fallback
+ }
+ decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
+ if err != nil {
+ rs.log.Warn("failed to decrypt secure setting", "name", name, "error", err)
+ return fallback
+ }
+ return string(decrypted)
+ }
+}
diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go
new file mode 100644
index 00000000000..86426192767
--- /dev/null
+++ b/pkg/services/ngalert/notifier/receiver_svc_test.go
@@ -0,0 +1,208 @@
+package notifier
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
+ "github.com/grafana/grafana/pkg/services/secrets"
+ "github.com/grafana/grafana/pkg/services/secrets/database"
+ "github.com/grafana/grafana/pkg/services/secrets/manager"
+ "github.com/grafana/grafana/pkg/services/user"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReceiverService_GetReceivers(t *testing.T) {
+ sqlStore := db.InitTestDB(t)
+ secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
+
+ t.Run("service gets receiver groups from AM config", func(t *testing.T) {
+ sut := createReceiverServiceSut(t, secretsService)
+
+ Receivers, err := sut.GetReceivers(context.Background(), rQuery(1), nil)
+ require.NoError(t, err)
+ require.Len(t, Receivers, 2)
+ require.Equal(t, "grafana-default-email", Receivers[0].Name)
+ require.Equal(t, "slack receiver", Receivers[1].Name)
+ })
+
+ t.Run("service filters receiver groups by name", func(t *testing.T) {
+ sut := createReceiverServiceSut(t, secretsService)
+
+ Receivers, err := sut.GetReceivers(context.Background(), rQuery(1, "slack receiver"), nil)
+ require.NoError(t, err)
+ require.Len(t, Receivers, 1)
+ require.Equal(t, "slack receiver", Receivers[0].Name)
+ })
+}
+
+func TestReceiverService_DecryptRedact(t *testing.T) {
+ sqlStore := db.InitTestDB(t)
+ secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
+ ac := acimpl.ProvideAccessControl(setting.NewCfg())
+
+ t.Run("service redacts receiver groups by default", func(t *testing.T) {
+ sut := createReceiverServiceSut(t, secretsService)
+ Receivers, err := sut.GetReceivers(context.Background(), rQuery(1, "slack receiver"), nil)
+ require.NoError(t, err)
+
+ require.Len(t, Receivers, 1)
+
+ rGroup := Receivers[0]
+ require.Equal(t, "slack receiver", rGroup.Name)
+ require.Len(t, rGroup.GrafanaManagedReceivers, 1)
+
+ grafanaReceiver := rGroup.GrafanaManagedReceivers[0]
+ require.Equal(t, "UID2", grafanaReceiver.UID)
+
+ testedSettings, err := simplejson.NewJson([]byte(grafanaReceiver.Settings))
+ require.NoError(t, err)
+ require.Equal(t, definitions.RedactedValue, testedSettings.Get("url").MustString())
+ })
+
+ t.Run("service returns error when trying to decrypt with nil user", func(t *testing.T) {
+ sut := createReceiverServiceSut(t, secretsService)
+ sut.ac = ac
+
+ q := rQuery(1)
+ q.Decrypt = true
+ _, err := sut.GetReceivers(context.Background(), q, nil)
+ require.ErrorIs(t, err, ErrPermissionDenied)
+ })
+
+ t.Run("service returns error when trying to decrypt without permission", func(t *testing.T) {
+ sut := createReceiverServiceSut(t, secretsService)
+ sut.ac = ac
+
+ q := rQuery(1)
+ q.Decrypt = true
+ _, err := sut.GetReceivers(context.Background(), q, &user.SignedInUser{})
+ require.ErrorIs(t, err, ErrPermissionDenied)
+ })
+
+ t.Run("service decrypts receiver groups with permission", func(t *testing.T) {
+ sut := createReceiverServiceSut(t, secretsService)
+ sut.ac = ac
+
+ q := rQuery(1, "slack receiver")
+ q.Decrypt = true
+ Receivers, err := sut.GetReceivers(context.Background(), q, &user.SignedInUser{
+ OrgID: 1,
+ Permissions: map[int64]map[string][]string{
+ 1: {accesscontrol.ActionAlertingProvisioningReadSecrets: nil},
+ },
+ })
+ require.NoError(t, err)
+
+ require.Len(t, Receivers, 1)
+
+ rGroup := Receivers[0]
+ require.Equal(t, "slack receiver", rGroup.Name)
+ require.Len(t, rGroup.GrafanaManagedReceivers, 1)
+
+ grafanaReceiver := rGroup.GrafanaManagedReceivers[0]
+ require.Equal(t, "UID2", grafanaReceiver.UID)
+
+ settings, err := simplejson.NewJson([]byte(grafanaReceiver.Settings))
+ require.NoError(t, err)
+ require.Equal(t, "secure url", settings.Get("url").MustString())
+ })
+}
+
+func createReceiverServiceSut(t *testing.T, encryptSvc secrets.Service) *ReceiverService {
+ cfg := createEncryptedConfig(t, encryptSvc)
+ store := fakes.NewFakeAlertmanagerConfigStore(cfg)
+ xact := newNopTransactionManager()
+ provisioningStore := fakes.NewFakeProvisioningStore()
+
+ return &ReceiverService{
+ actest.FakeAccessControl{},
+ provisioningStore,
+ store,
+ encryptSvc,
+ xact,
+ log.NewNopLogger(),
+ }
+}
+
+func createEncryptedConfig(t *testing.T, secretService secrets.Service) string {
+ c := &definitions.PostableUserConfig{}
+ err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c)
+ require.NoError(t, err)
+ err = EncryptReceiverConfigs(c.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) {
+ return secretService.Encrypt(ctx, payload, secrets.WithoutScope())
+ })
+ require.NoError(t, err)
+ bytes, err := json.Marshal(c)
+ require.NoError(t, err)
+ return string(bytes)
+}
+
+func rQuery(orgID int64, names ...string) models.GetReceiversQuery {
+ return models.GetReceiversQuery{
+ OrgID: orgID,
+ Names: names,
+ }
+}
+
+const defaultAlertmanagerConfigJSON = `
+{
+ "template_files": null,
+ "alertmanager_config": {
+ "route": {
+ "receiver": "grafana-default-email",
+ "group_by": [
+ "..."
+ ],
+ "routes": [{
+ "receiver": "grafana-default-email",
+ "object_matchers": [["a", "=", "b"]]
+ }]
+ },
+ "templates": null,
+ "receivers": [{
+ "name": "grafana-default-email",
+ "grafana_managed_receiver_configs": [{
+ "uid": "UID1",
+ "name": "grafana-default-email",
+ "type": "email",
+ "disableResolveMessage": false,
+ "settings": {
+ "addresses": "\u003cexample@email.com\u003e"
+ },
+ "secureFields": {}
+ }]
+ }, {
+ "name": "slack receiver",
+ "grafana_managed_receiver_configs": [{
+ "uid": "UID2",
+ "name": "slack receiver",
+ "type": "slack",
+ "disableResolveMessage": false,
+ "settings": {},
+ "secureSettings": {"url":"secure url"}
+ }]
+ }]
+ }
+}
+`
+
+type NopTransactionManager struct{}
+
+func newNopTransactionManager() *NopTransactionManager {
+ return &NopTransactionManager{}
+}
+
+func (n *NopTransactionManager) InTransaction(ctx context.Context, work func(ctx context.Context) error) error {
+ return work(context.WithValue(ctx, NopTransactionManager{}, struct{}{}))
+}
diff --git a/pkg/services/ngalert/notifier/status.go b/pkg/services/ngalert/notifier/status.go
index bae821f6d40..e93dbffd010 100644
--- a/pkg/services/ngalert/notifier/status.go
+++ b/pkg/services/ngalert/notifier/status.go
@@ -8,15 +8,15 @@ import (
// TODO: We no longer do apimodels at this layer, move it to the API.
func (am *alertmanager) GetStatus() apimodels.GettableStatus {
- config := &apimodels.PostableUserConfig{}
- status := am.Base.GetStatus()
+ config := &apimodels.PostableApiAlertingConfig{}
+ status := am.Base.GetStatus() // TODO: This should return a GettableStatus, for now it returns PostableApiAlertingConfig.
if status == nil {
- return *apimodels.NewGettableStatus(&config.AlertmanagerConfig)
+ return *apimodels.NewGettableStatus(config)
}
if err := json.Unmarshal(status, config); err != nil {
am.logger.Error("Unable to unmarshall alertmanager config", "Err", err)
}
- return *apimodels.NewGettableStatus(&config.AlertmanagerConfig)
+ return *apimodels.NewGettableStatus(config)
}
diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/testreceivers.go
similarity index 100%
rename from pkg/services/ngalert/notifier/receivers.go
rename to pkg/services/ngalert/notifier/testreceivers.go
diff --git a/pkg/services/ngalert/notifier/receivers_test.go b/pkg/services/ngalert/notifier/testreceivers_test.go
similarity index 100%
rename from pkg/services/ngalert/notifier/receivers_test.go
rename to pkg/services/ngalert/notifier/testreceivers_test.go
diff --git a/pkg/services/ngalert/provisioning/compat.go b/pkg/services/ngalert/provisioning/compat.go
index 621ce88b4f5..0502d921d8d 100644
--- a/pkg/services/ngalert/provisioning/compat.go
+++ b/pkg/services/ngalert/provisioning/compat.go
@@ -45,3 +45,25 @@ func PostableGrafanaReceiverToEmbeddedContactPoint(contactPoint *definitions.Pos
}
return embeddedContactPoint, nil
}
+
+func GettableGrafanaReceiverToEmbeddedContactPoint(r *definitions.GettableGrafanaReceiver) (definitions.EmbeddedContactPoint, error) {
+ settingJson, err := simplejson.NewJson(r.Settings)
+ if err != nil {
+ return definitions.EmbeddedContactPoint{}, err
+ }
+
+ for k := range r.SecureFields {
+ if settingJson.Get(k).MustString() == "" {
+ settingJson.Set(k, definitions.RedactedValue)
+ }
+ }
+
+ return definitions.EmbeddedContactPoint{
+ UID: r.UID,
+ Name: r.Name,
+ Type: r.Type,
+ DisableResolveMessage: r.DisableResolveMessage,
+ Settings: settingJson,
+ Provenance: string(r.Provenance),
+ }, nil
+}
diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go
index c5dd067682a..92142fb363c 100644
--- a/pkg/services/ngalert/provisioning/contactpoints.go
+++ b/pkg/services/ngalert/provisioning/contactpoints.go
@@ -12,11 +12,12 @@ import (
"github.com/prometheus/alertmanager/config"
"github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
+ "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/util"
)
@@ -26,21 +27,25 @@ type ContactPointService struct {
encryptionService secrets.Service
provenanceStore ProvisioningStore
xact TransactionManager
+ receiverService receiverService
log log.Logger
- ac accesscontrol.AccessControl
+}
+
+type receiverService interface {
+ GetReceivers(ctx context.Context, query models.GetReceiversQuery, user identity.Requester) ([]apimodels.GettableApiReceiver, error)
}
func NewContactPointService(store AMConfigStore, encryptionService secrets.Service,
- provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger, ac accesscontrol.AccessControl) *ContactPointService {
+ provenanceStore ProvisioningStore, xact TransactionManager, receiverService receiverService, log log.Logger) *ContactPointService {
return &ContactPointService{
configStore: &alertmanagerConfigStoreImpl{
store: store,
},
+ receiverService: receiverService,
encryptionService: encryptionService,
provenanceStore: provenanceStore,
xact: xact,
log: log,
- ac: ac,
}
}
@@ -52,48 +57,38 @@ type ContactPointQuery struct {
Decrypt bool
}
-func (ecp *ContactPointService) canDecryptSecrets(ctx context.Context, u identity.Requester) bool {
- if u == nil {
- return false
- }
- permitted, err := ecp.ac.Evaluate(ctx, u, accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets))
- if err != nil {
- ecp.log.Error("Failed to evaluate user permissions", "error", err)
- permitted = false
- }
- return permitted
-}
-
// GetContactPoints returns contact points. If q.Decrypt is true and the user is an OrgAdmin, decrypted secure settings are included instead of redacted ones.
func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery, u identity.Requester) ([]apimodels.EmbeddedContactPoint, error) {
- if q.Decrypt && !ecp.canDecryptSecrets(ctx, u) {
- return nil, fmt.Errorf("%w: user requires Admin role or alert.provisioning.secrets:read permission to view decrypted secure settings", ErrPermissionDenied)
+ receiverQuery := models.GetReceiversQuery{
+ OrgID: q.OrgID,
+ Decrypt: q.Decrypt,
}
- revision, err := ecp.configStore.Get(ctx, q.OrgID)
- if err != nil {
- return nil, err
+ if q.Name != "" {
+ receiverQuery.Names = []string{q.Name}
}
- provenances, err := ecp.provenanceStore.GetProvenances(ctx, q.OrgID, "contactPoint")
- if err != nil {
- return nil, err
- }
- var contactPoints []apimodels.EmbeddedContactPoint
- for _, contactPoint := range revision.cfg.GetGrafanaReceiverMap() {
- if q.Name != "" && contactPoint.Name != q.Name {
- continue
+ res, err := ecp.receiverService.GetReceivers(ctx, receiverQuery, u)
+ if err != nil {
+ return nil, convertRecSvcErr(err)
+ }
+ grafanaReceivers := []*apimodels.GettableGrafanaReceiver{}
+ if q.Name != "" {
+ grafanaReceivers = res[0].GettableGrafanaReceivers.GrafanaManagedReceivers // we only expect one receiver group
+ } else {
+ for _, r := range res {
+ grafanaReceivers = append(grafanaReceivers, r.GettableGrafanaReceivers.GrafanaManagedReceivers...)
}
+ }
- embeddedContactPoint, err := PostableGrafanaReceiverToEmbeddedContactPoint(
- contactPoint,
- provenances[contactPoint.UID],
- ecp.decryptValueOrRedacted(q.Decrypt, contactPoint.UID),
- )
+ var contactPoints []apimodels.EmbeddedContactPoint
+ for _, gr := range grafanaReceivers {
+ contactPoint, err := GettableGrafanaReceiverToEmbeddedContactPoint(gr)
if err != nil {
return nil, err
}
- contactPoints = append(contactPoints, embeddedContactPoint)
+ contactPoints = append(contactPoints, contactPoint)
}
+
sort.SliceStable(contactPoints, func(i, j int) bool {
switch strings.Compare(contactPoints[i].Name, contactPoints[j].Name) {
case -1:
@@ -103,6 +98,7 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP
}
return contactPoints[i].UID < contactPoints[j].UID
})
+
return contactPoints, nil
}
@@ -507,3 +503,23 @@ func RemoveSecretsForContactPoint(e *apimodels.EmbeddedContactPoint) (map[string
}
return s, nil
}
+
+// handleWrappedError unwraps an error and wraps it with a new expected error type. If the error is not wrapped, it returns just the expected error.
+func handleWrappedError(err error, expected error) error {
+ err = errors.Unwrap(err)
+ if err == nil {
+ return expected
+ }
+ return fmt.Errorf("%w: %s", expected, err.Error())
+}
+
+// convertRecSvcErr converts errors from notifier.ReceiverService to errors expected from ContactPointService.
+func convertRecSvcErr(err error) error {
+ if errors.Is(err, notifier.ErrPermissionDenied) {
+ return handleWrappedError(err, ErrPermissionDenied)
+ }
+ if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
+ return ErrNoAlertmanagerConfiguration.Errorf("")
+ }
+ return err
+}
diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go
index 38f9bfa983b..6cd33217446 100644
--- a/pkg/services/ngalert/provisioning/contactpoints_test.go
+++ b/pkg/services/ngalert/provisioning/contactpoints_test.go
@@ -248,9 +248,19 @@ func TestContactPointService(t *testing.T) {
}
func TestContactPointServiceDecryptRedact(t *testing.T) {
- sqlStore := db.InitTestDB(t)
- secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
- ac := acimpl.ProvideAccessControl(setting.NewCfg())
+ secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t)))
+ receiverServiceWithAC := func(ecp *ContactPointService) *notifier.ReceiverService {
+ return notifier.NewReceiverService(
+ acimpl.ProvideAccessControl(setting.NewCfg()),
+ // Get won't use the sut's config store, so we can use a different one here.
+ fakes.NewFakeAlertmanagerConfigStore(createEncryptedConfig(t, secretsService)),
+ ecp.provenanceStore,
+ ecp.encryptionService,
+ ecp.xact,
+ log.NewNopLogger(),
+ )
+ }
+
t.Run("GetContactPoints gets redacted contact points by default", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
@@ -261,9 +271,10 @@ func TestContactPointServiceDecryptRedact(t *testing.T) {
require.Equal(t, "slack receiver", cps[1].Name)
require.Equal(t, definitions.RedactedValue, cps[1].Settings.Get("url").MustString())
})
+
t.Run("GetContactPoints errors when Decrypt = true and user does not have permissions", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
- sut.ac = ac
+ sut.receiverService = receiverServiceWithAC(sut)
q := cpsQuery(1)
q.Decrypt = true
@@ -272,7 +283,7 @@ func TestContactPointServiceDecryptRedact(t *testing.T) {
})
t.Run("GetContactPoints errors when Decrypt = true and user is nil", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
- sut.ac = ac
+ sut.receiverService = receiverServiceWithAC(sut)
q := cpsQuery(1)
q.Decrypt = true
@@ -282,7 +293,7 @@ func TestContactPointServiceDecryptRedact(t *testing.T) {
t.Run("GetContactPoints gets decrypted contact points when Decrypt = true and user has permissions", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
- sut.ac = ac
+ sut.receiverService = receiverServiceWithAC(sut)
expectedName := "slack receiver"
q := cpsQueryWithName(1, expectedName)
@@ -333,24 +344,27 @@ func TestContactPointInUse(t *testing.T) {
func createContactPointServiceSut(t *testing.T, secretService secrets.Service) *ContactPointService {
// Encrypt secure settings.
- c := &definitions.PostableUserConfig{}
- err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c)
- require.NoError(t, err)
- err = notifier.EncryptReceiverConfigs(c.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) {
- return secretService.Encrypt(ctx, payload, secrets.WithoutScope())
- })
- require.NoError(t, err)
+ cfg := createEncryptedConfig(t, secretService)
+ store := fakes.NewFakeAlertmanagerConfigStore(cfg)
+ xact := newNopTransactionManager()
+ provisioningStore := fakes.NewFakeProvisioningStore()
- raw, err := json.Marshal(c)
- require.NoError(t, err)
+ receiverService := notifier.NewReceiverService(
+ actest.FakeAccessControl{},
+ store,
+ provisioningStore,
+ secretService,
+ xact,
+ log.NewNopLogger(),
+ )
return &ContactPointService{
- configStore: &alertmanagerConfigStoreImpl{store: fakes.NewFakeAlertmanagerConfigStore(string(raw))},
- provenanceStore: fakes.NewFakeProvisioningStore(),
- xact: newNopTransactionManager(),
+ configStore: &alertmanagerConfigStoreImpl{store: store},
+ provenanceStore: provisioningStore,
+ receiverService: receiverService,
+ xact: xact,
encryptionService: secretService,
log: log.NewNopLogger(),
- ac: actest.FakeAccessControl{},
}
}
@@ -376,6 +390,19 @@ func cpsQueryWithName(orgID int64, name string) ContactPointQuery {
}
}
+func createEncryptedConfig(t *testing.T, secretService secrets.Service) string {
+ c := &definitions.PostableUserConfig{}
+ err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c)
+ require.NoError(t, err)
+ err = notifier.EncryptReceiverConfigs(c.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) {
+ return secretService.Encrypt(ctx, payload, secrets.WithoutScope())
+ })
+ require.NoError(t, err)
+ bytes, err := json.Marshal(c)
+ require.NoError(t, err)
+ return string(bytes)
+}
+
func TestStitchReceivers(t *testing.T) {
type testCase struct {
name string
diff --git a/pkg/services/ngalert/tests/fakes/config.go b/pkg/services/ngalert/tests/fakes/config.go
index 5fe396556ba..c77450f6ca5 100644
--- a/pkg/services/ngalert/tests/fakes/config.go
+++ b/pkg/services/ngalert/tests/fakes/config.go
@@ -9,7 +9,12 @@ import (
)
type FakeAlertmanagerConfigStore struct {
- Config models.AlertConfiguration
+ Config models.AlertConfiguration
+ // GetFn is an optional function that can be set to mock the GetLatestAlertmanagerConfiguration method
+ GetFn func(ctx context.Context, orgID int64) (*models.AlertConfiguration, error)
+ // UpdateFn is an optional function that can be set to mock the UpdateAlertmanagerConfiguration method
+ UpdateFn func(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error
+ // LastSaveCommand is the last command that was passed to UpdateAlertmanagerConfiguration
LastSaveCommand *models.SaveAlertmanagerConfigurationCmd
}
@@ -26,13 +31,22 @@ func NewFakeAlertmanagerConfigStore(config string) *FakeAlertmanagerConfigStore
}
func (f *FakeAlertmanagerConfigStore) GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) {
+ if f.GetFn != nil {
+ return f.GetFn(ctx, orgID)
+ }
+
result := &f.Config
result.OrgID = orgID
result.ConfigurationHash = fmt.Sprintf("%x", md5.Sum([]byte(f.Config.AlertmanagerConfiguration)))
+ result.ConfigurationVersion = f.Config.ConfigurationVersion
return result, nil
}
func (f *FakeAlertmanagerConfigStore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error {
+ if f.UpdateFn != nil {
+ return f.UpdateFn(ctx, cmd)
+ }
+
f.Config = models.AlertConfiguration{
AlertmanagerConfiguration: cmd.AlertmanagerConfiguration,
ConfigurationVersion: cmd.ConfigurationVersion,
diff --git a/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go
new file mode 100644
index 00000000000..f5c62396571
--- /dev/null
+++ b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go
@@ -0,0 +1,136 @@
+package clientmiddleware
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "path"
+
+ "github.com/google/uuid"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/services/contexthandler"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/web"
+)
+
+const GrafanaRequestID = "X-Grafana-Request-Id"
+const GrafanaSignedRequestID = "X-Grafana-Signed-Request-Id"
+const GrafanaInternalRequest = "X-Grafana-Internal-Request"
+
+// NewHostedGrafanaACHeaderMiddleware creates a new plugins.ClientMiddleware that will
+// generate a random request ID, sign it using internal key and populate X-Grafana-Request-ID with the request ID
+// and X-Grafana-Signed-Request-ID with signed request ID. We can then use this to verify that the request
+// is coming from hosted Grafana and is not an external request. This is used for IP range access control.
+func NewHostedGrafanaACHeaderMiddleware(cfg *setting.Cfg) plugins.ClientMiddleware {
+ return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client {
+ return &HostedGrafanaACHeaderMiddleware{
+ next: next,
+ log: log.New("ip_header_middleware"),
+ cfg: cfg,
+ }
+ })
+}
+
+type HostedGrafanaACHeaderMiddleware struct {
+ next plugins.Client
+ log log.Logger
+ cfg *setting.Cfg
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) applyGrafanaRequestIDHeader(ctx context.Context, pCtx backend.PluginContext, h backend.ForwardHTTPHeaders) {
+ // if request is not for a datasource, skip the middleware
+ if h == nil || pCtx.DataSourceInstanceSettings == nil {
+ return
+ }
+
+ // Check if the request is for a datasource that is allowed to have the header
+ target := pCtx.DataSourceInstanceSettings.URL
+
+ foundMatch := false
+ for _, allowedURL := range m.cfg.IPRangeACAllowedURLs {
+ if path.Clean(allowedURL) == path.Clean(target) {
+ foundMatch = true
+ break
+ }
+ }
+ if !foundMatch {
+ m.log.Debug("Data source URL not among the allow-listed URLs", "url", target)
+ return
+ }
+
+ // Generate a new Grafana request ID and sign it with the secret key
+ uid, err := uuid.NewRandom()
+ if err != nil {
+ m.log.Debug("Failed to generate Grafana request ID", "error", err)
+ return
+ }
+ grafanaRequestID := uid.String()
+
+ hmac := hmac.New(sha256.New, []byte(m.cfg.IPRangeACSecretKey))
+ if _, err := hmac.Write([]byte(grafanaRequestID)); err != nil {
+ m.log.Debug("Failed to sign IP range access control header", "error", err)
+ return
+ }
+ signedGrafanaRequestID := hex.EncodeToString(hmac.Sum(nil))
+ h.SetHTTPHeader(GrafanaSignedRequestID, signedGrafanaRequestID)
+ h.SetHTTPHeader(GrafanaRequestID, grafanaRequestID)
+
+ reqCtx := contexthandler.FromContext(ctx)
+ if reqCtx != nil && reqCtx.Req != nil {
+ remoteAddress := web.RemoteAddr(reqCtx.Req)
+ if remoteAddress != "" {
+ return
+ }
+ }
+ h.SetHTTPHeader(GrafanaInternalRequest, "true")
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
+ if req == nil {
+ return m.next.QueryData(ctx, req)
+ }
+
+ m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req)
+
+ return m.next.QueryData(ctx, req)
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+ if req == nil {
+ return m.next.CallResource(ctx, req, sender)
+ }
+
+ m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req)
+
+ return m.next.CallResource(ctx, req, sender)
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
+ if req == nil {
+ return m.next.CheckHealth(ctx, req)
+ }
+
+ m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req)
+
+ return m.next.CheckHealth(ctx, req)
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) {
+ return m.next.CollectMetrics(ctx, req)
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
+ return m.next.SubscribeStream(ctx, req)
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
+ return m.next.PublishStream(ctx, req)
+}
+
+func (m *HostedGrafanaACHeaderMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
+ return m.next.RunStream(ctx, req, sender)
+}
diff --git a/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go
new file mode 100644
index 00000000000..4ef2ef76d83
--- /dev/null
+++ b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go
@@ -0,0 +1,130 @@
+package clientmiddleware
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ "github.com/grafana/grafana/pkg/plugins/manager/client/clienttest"
+ "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+ "github.com/grafana/grafana/pkg/services/user"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/web"
+)
+
+func Test_HostedGrafanaACHeaderMiddleware(t *testing.T) {
+ t.Run("Should set Grafana request ID headers if the data source URL is in the allow list", func(t *testing.T) {
+ cfg := setting.NewCfg()
+ cfg.IPRangeACAllowedURLs = []string{"https://logs.grafana.net"}
+ cfg.IPRangeACSecretKey = "secret"
+ cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg)))
+
+ ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{
+ Context: &web.Context{Req: &http.Request{
+ Header: map[string][]string{"X-Real-Ip": {"1.2.3.4"}},
+ }},
+ SignedInUser: &user.SignedInUser{},
+ })
+
+ err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{
+ PluginContext: backend.PluginContext{
+ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
+ URL: "https://logs.grafana.net",
+ },
+ },
+ }, nopCallResourceSender)
+ require.NoError(t, err)
+
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 1)
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 1)
+
+ requestID := cdt.CallResourceReq.Headers[GrafanaRequestID][0]
+
+ instance := hmac.New(sha256.New, []byte(cfg.IPRangeACSecretKey))
+ _, err = instance.Write([]byte(requestID))
+ require.NoError(t, err)
+ computed := hex.EncodeToString(instance.Sum(nil))
+
+ require.Equal(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID][0], computed)
+
+ // Internal header should not be set
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaInternalRequest], 0)
+ })
+
+ t.Run("Should not set Grafana request ID headers if the data source URL is not in the allow list", func(t *testing.T) {
+ cfg := setting.NewCfg()
+ cfg.IPRangeACAllowedURLs = []string{"https://logs.grafana.net"}
+ cfg.IPRangeACSecretKey = "secret"
+ cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg)))
+
+ ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{
+ Context: &web.Context{Req: &http.Request{}},
+ SignedInUser: &user.SignedInUser{},
+ })
+
+ err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{
+ PluginContext: backend.PluginContext{
+ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
+ URL: "https://logs.not-grafana.net",
+ },
+ },
+ }, nopCallResourceSender)
+ require.NoError(t, err)
+
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 0)
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 0)
+ })
+
+ t.Run("Should set Grafana request ID headers if a sanitized data source URL is in the allow list", func(t *testing.T) {
+ cfg := setting.NewCfg()
+ cfg.IPRangeACAllowedURLs = []string{"https://logs.GRAFANA.net/"}
+ cfg.IPRangeACSecretKey = "secret"
+ cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg)))
+
+ ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{
+ Context: &web.Context{Req: &http.Request{}},
+ SignedInUser: &user.SignedInUser{},
+ })
+
+ err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{
+ PluginContext: backend.PluginContext{
+ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
+ URL: "https://logs.grafana.net/abc/../",
+ },
+ },
+ }, nopCallResourceSender)
+ require.NoError(t, err)
+
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 0)
+ require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 0)
+ })
+
+ t.Run("Should set Grafana internal request header if the request is internal (doesn't have X-Real-IP header set)", func(t *testing.T) {
+ cfg := setting.NewCfg()
+ cfg.IPRangeACAllowedURLs = []string{"https://logs.grafana.net"}
+ cfg.IPRangeACSecretKey = "secret"
+ cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg)))
+
+ ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{
+ Context: &web.Context{Req: &http.Request{}},
+ SignedInUser: &user.SignedInUser{},
+ })
+
+ err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{
+ PluginContext: backend.PluginContext{
+ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
+ URL: "https://logs.grafana.net",
+ },
+ },
+ }, nopCallResourceSender)
+ require.NoError(t, err)
+ require.Equal(t, cdt.CallResourceReq.Headers[GrafanaInternalRequest][0], "true")
+ })
+}
diff --git a/pkg/services/pluginsintegration/config/config.go b/pkg/services/pluginsintegration/config/config.go
index 50056ce54f3..9cd7a54b1aa 100644
--- a/pkg/services/pluginsintegration/config/config.go
+++ b/pkg/services/pluginsintegration/config/config.go
@@ -49,6 +49,7 @@ func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg, fe
grafanaCfg.DisablePlugins,
grafanaCfg.HideAngularDeprecation,
grafanaCfg.ForwardHostEnvVars,
+ grafanaCfg.ConcurrentQueryCount,
), nil
}
diff --git a/pkg/services/pluginsintegration/plugins_integration_test.go b/pkg/services/pluginsintegration/plugins_integration_test.go
index 14d3fbb835c..ad03c22c67a 100644
--- a/pkg/services/pluginsintegration/plugins_integration_test.go
+++ b/pkg/services/pluginsintegration/plugins_integration_test.go
@@ -78,7 +78,7 @@ func TestIntegrationPluginManager(t *testing.T) {
idb := influxdb.ProvideService(hcp, features)
lk := loki.ProvideService(hcp, features, tracer)
otsdb := opentsdb.ProvideService(hcp)
- pr := prometheus.ProvideService(hcp, cfg, features)
+ pr := prometheus.ProvideService(hcp, cfg)
tmpo := tempo.ProvideService(hcp)
td := testdatasource.ProvideService()
pg := postgres.ProvideService(cfg)
@@ -165,29 +165,29 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor
}
expDataSources := map[string]struct{}{
- "cloudwatch": {},
- "grafana-azure-monitor-datasource": {},
- "grafana-cloud-monitoring-datasource": {},
- "elasticsearch": {},
- "graphite": {},
- "influxdb": {},
- "loki": {},
- "opentsdb": {},
- "prometheus": {},
- "tempo": {},
- "grafana-testdata-datasource": {},
- "grafana-postgresql-datasource": {},
- "mysql": {},
- "mssql": {},
- "grafana": {},
- "alertmanager": {},
- "dashboard": {},
- "input": {},
- "jaeger": {},
- "mixed": {},
- "zipkin": {},
- "grafana-pyroscope-datasource": {},
- "parca": {},
+ "cloudwatch": {},
+ "grafana-azure-monitor-datasource": {},
+ "stackdriver": {},
+ "elasticsearch": {},
+ "graphite": {},
+ "influxdb": {},
+ "loki": {},
+ "opentsdb": {},
+ "prometheus": {},
+ "tempo": {},
+ "grafana-testdata-datasource": {},
+ "grafana-postgresql-datasource": {},
+ "mysql": {},
+ "mssql": {},
+ "grafana": {},
+ "alertmanager": {},
+ "dashboard": {},
+ "input": {},
+ "jaeger": {},
+ "mixed": {},
+ "zipkin": {},
+ "grafana-pyroscope-datasource": {},
+ "parca": {},
}
expApps := map[string]struct{}{
diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go
index 6edee03042d..0826cfade83 100644
--- a/pkg/services/pluginsintegration/pluginsintegration.go
+++ b/pkg/services/pluginsintegration/pluginsintegration.go
@@ -181,6 +181,10 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken
middlewares = append(middlewares, clientmiddleware.NewUserHeaderMiddleware())
}
+ if cfg.IPRangeACEnabled {
+ middlewares = append(middlewares, clientmiddleware.NewHostedGrafanaACHeaderMiddleware(cfg))
+ }
+
middlewares = append(middlewares, clientmiddleware.NewHTTPClientMiddleware())
if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) {
diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go
index 47e5fad7916..2f35b9d60e9 100644
--- a/pkg/services/provisioning/provisioning.go
+++ b/pkg/services/provisioning/provisioning.go
@@ -16,6 +16,7 @@ import (
datasourceservice "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/encryption"
"github.com/grafana/grafana/pkg/services/folder"
+ alertingNotifier "github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/notifications"
@@ -280,8 +281,9 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error
int64(ps.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()),
int64(ps.Cfg.UnifiedAlerting.BaseInterval.Seconds()),
ps.log)
+ receiverSvc := alertingNotifier.NewReceiverService(ps.ac, &st, st, ps.secretService, ps.SQLStore, ps.log)
contactPointService := provisioning.NewContactPointService(&st, ps.secretService,
- st, ps.SQLStore, ps.log, ps.ac)
+ st, ps.SQLStore, receiverSvc, ps.log)
notificationPolicyService := provisioning.NewNotificationPolicyService(&st,
st, ps.SQLStore, ps.Cfg.UnifiedAlerting, ps.log)
mutetimingsService := provisioning.NewMuteTimingService(&st, st, &st, ps.log)
diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go
index ef60e500799..c811260a588 100644
--- a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go
+++ b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go
@@ -46,6 +46,7 @@ var (
users = []user.User{
{
ID: 1,
+ UID: "u1",
Email: "viewer1@example.org",
Name: "viewer1",
Login: "viewer1",
@@ -55,6 +56,7 @@ var (
},
{
ID: 2,
+ UID: "u2",
Email: "viewer2@example.org",
Name: "viewer2",
Login: "viewer2",
@@ -64,6 +66,7 @@ var (
},
{
ID: 3,
+ UID: "u3",
Email: "editor1@example.org",
Name: "editor1",
Login: "editor1",
@@ -73,6 +76,7 @@ var (
},
{
ID: 4,
+ UID: "u4",
Email: "admin1@example.org",
Name: "admin1",
Login: "admin1",
@@ -82,6 +86,7 @@ var (
},
{
ID: 5,
+ UID: "u5",
Email: "editor2@example.org",
Name: "editor2",
Login: "editor2",
diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go
index 6edf5161f47..4f56666fcdb 100644
--- a/pkg/services/sqlstore/migrations/user_mig.go
+++ b/pkg/services/sqlstore/migrations/user_mig.go
@@ -140,6 +140,19 @@ func addUserMigrations(mg *Migrator) {
SQLite(migSQLITEisServiceAccountNullable).
Postgres("ALTER TABLE `user` ALTER COLUMN is_service_account DROP NOT NULL;").
Mysql("ALTER TABLE user MODIFY is_service_account BOOLEAN DEFAULT 0;"))
+
+ mg.AddMigration("Add uid column to user", NewAddColumnMigration(userV2, &Column{
+ Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
+ }))
+
+ mg.AddMigration("Update uid column values for users", NewRawSQLMigration("").
+ SQLite("UPDATE user SET uid=printf('u%09d',id) WHERE uid IS NULL;").
+ Postgres("UPDATE `user` SET uid='u' || lpad('' || id::text,9,'0') WHERE uid IS NULL;").
+ Mysql("UPDATE user SET uid=concat('u',lpad(id,9,'0')) WHERE uid IS NULL;"))
+
+ mg.AddMigration("Add unique index user_uid", NewAddIndexMigration(userV2, &Index{
+ Cols: []string{"uid"}, Type: UniqueIndex,
+ }))
}
const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0;
diff --git a/pkg/services/ssosettings/api/api.go b/pkg/services/ssosettings/api/api.go
index 66357222650..4ebc206a137 100644
--- a/pkg/services/ssosettings/api/api.go
+++ b/pkg/services/ssosettings/api/api.go
@@ -233,7 +233,11 @@ type UpdateProviderSettingsParams struct {
Provider string `json:"key"`
// in:body
// required:true
- Body models.SSOSettings `json:"body"`
+ Body struct {
+ ID string `json:"id"`
+ Provider string `json:"provider"`
+ Settings map[string]any `json:"settings"`
+ } `json:"body"`
}
// swagger:parameters removeProviderSettings
@@ -246,11 +250,21 @@ type RemoveProviderSettingsParams struct {
// swagger:response listSSOSettingsResponse
type ListSSOSettingsResponse struct {
// in: body
- Body []models.SSOSettings `json:"body"`
+ Body []struct {
+ ID string `json:"id"`
+ Provider string `json:"provider"`
+ Settings map[string]any `json:"settings"`
+ Source string `json:"source"`
+ } `json:"body"`
}
// swagger:response getSSOSettingsResponse
type GetSSOSettingsResponse struct {
// in: body
- Body models.SSOSettings `json:"body"`
+ Body struct {
+ ID string `json:"id"`
+ Provider string `json:"provider"`
+ Settings map[string]any `json:"settings"`
+ Source string `json:"source"`
+ } `json:"body"`
}
diff --git a/pkg/services/user/identity.go b/pkg/services/user/identity.go
index 49b4edc2c8c..119bc5e9baa 100644
--- a/pkg/services/user/identity.go
+++ b/pkg/services/user/identity.go
@@ -8,9 +8,14 @@ import (
"github.com/grafana/grafana/pkg/services/auth/identity"
)
+const (
+ GlobalOrgID = int64(0)
+)
+
type SignedInUser struct {
- UserID int64 `xorm:"user_id"`
- OrgID int64 `xorm:"org_id"`
+ UserID int64 `xorm:"user_id"`
+ UserUID string `xorm:"user_uid"`
+ OrgID int64 `xorm:"org_id"`
OrgName string
OrgRole roletype.RoleType
Login string
@@ -54,6 +59,7 @@ func (u *SignedInUser) NameOrFallback() string {
func (u *SignedInUser) ToUserDisplayDTO() *UserDisplayDTO {
return &UserDisplayDTO{
ID: u.UserID,
+ UID: u.UserUID,
Login: u.Login,
Name: u.Name,
// AvatarURL: dtos.GetGravatarUrl(u.GetEmail()),
@@ -159,6 +165,19 @@ func (u *SignedInUser) GetPermissions() map[string][]string {
return u.Permissions[u.GetOrgID()]
}
+// GetGlobalPermissions returns the permissions of the active entity that are available across all organizations
+func (u *SignedInUser) GetGlobalPermissions() map[string][]string {
+ if u.Permissions == nil {
+ return make(map[string][]string)
+ }
+
+ if u.Permissions[GlobalOrgID] == nil {
+ return make(map[string][]string)
+ }
+
+ return u.Permissions[GlobalOrgID]
+}
+
// DEPRECATED: GetTeams returns the teams the entity is a member of
// Retrieve the teams from the team service instead of using this method.
func (u *SignedInUser) GetTeams() []int64 {
diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go
index 81b184286ab..430f0ddccf3 100644
--- a/pkg/services/user/model.go
+++ b/pkg/services/user/model.go
@@ -20,7 +20,8 @@ const (
)
type User struct {
- ID int64 `xorm:"pk autoincr 'id'"`
+ ID int64 `xorm:"pk autoincr 'id'"`
+ UID string `json:"uid" xorm:"uid"`
Version int
Email string
Name string
@@ -44,6 +45,7 @@ type User struct {
}
type CreateUserCommand struct {
+ UID string
Email string
Login string
Name string
@@ -115,6 +117,7 @@ type SearchUserQueryResult struct {
type UserSearchHitDTO struct {
ID int64 `json:"id" xorm:"id"`
+ UID string `json:"uid" xorm:"id"`
Name string `json:"name"`
Login string `json:"login"`
Email string `json:"email"`
@@ -133,6 +136,7 @@ type GetUserProfileQuery struct {
type UserProfileDTO struct {
ID int64 `json:"id"`
+ UID string `json:"uid"`
Email string `json:"email"`
Name string `json:"name"`
Login string `json:"login"`
@@ -215,6 +219,7 @@ type ErrCaseInsensitiveLoginConflict struct {
type UserDisplayDTO struct {
ID int64 `json:"id,omitempty"`
+ UID string `json:"uid,omitempty"`
Name string `json:"name,omitempty"`
Login string `json:"login,omitempty"`
AvatarURL string `json:"avatarUrl"`
diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go
index cedd27fe3b7..8923e446348 100644
--- a/pkg/services/user/userimpl/store.go
+++ b/pkg/services/user/userimpl/store.go
@@ -63,6 +63,9 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) {
var err error
err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
sess.UseBool("is_admin")
+ if cmd.UID == "" {
+ cmd.UID = util.GenerateShortUID()
+ }
if _, err = sess.Insert(cmd); err != nil {
return err
@@ -393,6 +396,7 @@ func (ss *sqlStore) GetSignedInUser(ctx context.Context, query *user.GetSignedIn
var rawSQL = `SELECT
u.id as user_id,
+ u.uid as user_uid,
u.is_admin as is_grafana_admin,
u.email as email,
u.login as login,
@@ -466,6 +470,7 @@ func (ss *sqlStore) GetProfile(ctx context.Context, query *user.GetUserProfileQu
userProfile = user.UserProfileDTO{
ID: usr.ID,
+ UID: usr.UID,
Name: usr.Name,
Email: usr.Email,
Login: usr.Login,
diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go
index 4940bb7d15f..39d87e8d30d 100644
--- a/pkg/services/user/userimpl/store_test.go
+++ b/pkg/services/user/userimpl/store_test.go
@@ -104,6 +104,7 @@ func TestIntegrationUserGet(t *testing.T) {
} else {
require.NoError(t, err)
require.NotNil(t, usr)
+ require.NotEmpty(t, usr.UID)
}
})
}
@@ -150,6 +151,32 @@ func TestIntegrationUserDataAccess(t *testing.T) {
require.NoError(t, err)
})
+ t.Run("insert user (with known UID)", func(t *testing.T) {
+ ctx := context.Background()
+ id, err := userStore.Insert(ctx,
+ &user.User{
+ UID: "abcd",
+ Email: "next-test@email.com",
+ Name: "next-test1",
+ Login: "next-test1",
+ Created: time.Now(),
+ Updated: time.Now(),
+ },
+ )
+ require.NoError(t, err)
+
+ found, err := userStore.GetByID(ctx, id)
+ require.NoError(t, err)
+ require.Equal(t, "abcd", found.UID)
+
+ siu, err := userStore.GetSignedInUser(ctx, &user.GetSignedInUserQuery{
+ UserID: id,
+ OrgID: found.OrgID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "abcd", siu.UserUID)
+ })
+
t.Run("get user", func(t *testing.T) {
_, err := userStore.Get(context.Background(),
&user.User{
diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go
index c3e64d4a8ee..eef5a1bbfb7 100644
--- a/pkg/services/user/userimpl/user.go
+++ b/pkg/services/user/userimpl/user.go
@@ -129,6 +129,7 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use
// create user
usr := &user.User{
+ UID: cmd.UID,
Email: cmd.Email,
Name: cmd.Name,
Login: cmd.Login,
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index ba5fc64f7a8..7cb19b47cfd 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -348,18 +348,25 @@ type Cfg struct {
// Number of queries to be executed concurrently. Only for the datasource supports concurrency.
ConcurrentQueryCount int
+ // IP range access control
+ IPRangeACEnabled bool
+ IPRangeACAllowedURLs []string
+ IPRangeACSecretKey string
+
// SQL Data sources
SqlDatasourceMaxOpenConnsDefault int
SqlDatasourceMaxIdleConnsDefault int
SqlDatasourceMaxConnLifetimeDefault int
// Snapshots
- SnapshotEnabled bool
- ExternalSnapshotUrl string
- ExternalSnapshotName string
- ExternalEnabled bool
+ SnapshotEnabled bool
+ ExternalSnapshotUrl string
+ ExternalSnapshotName string
+ ExternalEnabled bool
+ // Deprecated: setting this to false adds deprecation warnings at runtime
SnapShotRemoveExpired bool
+ // Only used in https://snapshots.raintank.io/
SnapshotPublicMode bool
ErrTemplateName string
@@ -582,6 +589,7 @@ func RedactedValue(key, value string) string {
"ENCRYPTION_KEY",
"VAULT_TOKEN",
"CLIENT_SECRET",
+ "ENTERPRISE_LICENSE",
} {
if match, err := regexp.MatchString(pattern, uppercased); match && err == nil {
return RedactedPassword
@@ -1200,6 +1208,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
}
cfg.readDataSourcesSettings()
+ cfg.readDataSourceSecuritySettings()
cfg.readSqlDataSourceSettings()
cfg.Storage = readStorageSettings(iniFile)
@@ -1938,6 +1947,14 @@ func (cfg *Cfg) readDataSourcesSettings() {
cfg.ConcurrentQueryCount = datasources.Key("concurrent_query_count").MustInt(10)
}
+func (cfg *Cfg) readDataSourceSecuritySettings() {
+ datasources := cfg.Raw.Section("datasources.ip_range_security")
+ cfg.IPRangeACEnabled = datasources.Key("enabled").MustBool(false)
+ cfg.IPRangeACSecretKey = datasources.Key("secret_key").MustString("")
+ allowedURLString := datasources.Key("allow_list").MustString("")
+ cfg.IPRangeACAllowedURLs = util.SplitString(allowedURLString)
+}
+
func (cfg *Cfg) readSqlDataSourceSettings() {
sqlDatasources := cfg.Raw.Section("sql_datasources")
cfg.SqlDatasourceMaxOpenConnsDefault = sqlDatasources.Key("max_open_conns_default").MustInt(100)
diff --git a/pkg/setting/setting_grafana_javascript_agent.go b/pkg/setting/setting_grafana_javascript_agent.go
index cfc04d5136d..a623bd242ae 100644
--- a/pkg/setting/setting_grafana_javascript_agent.go
+++ b/pkg/setting/setting_grafana_javascript_agent.go
@@ -8,6 +8,7 @@ type GrafanaJavascriptAgent struct {
ErrorInstrumentalizationEnabled bool `json:"errorInstrumentalizationEnabled"`
ConsoleInstrumentalizationEnabled bool `json:"consoleInstrumentalizationEnabled"`
WebVitalsInstrumentalizationEnabled bool `json:"webVitalsInstrumentalizationEnabled"`
+ InternalLoggerLevel int `json:"internalLoggerLevel"`
ApiKey string `json:"apiKey"`
}
@@ -21,6 +22,7 @@ func (cfg *Cfg) readGrafanaJavascriptAgentConfig() {
ErrorInstrumentalizationEnabled: raw.Key("instrumentations_errors_enabled").MustBool(true),
ConsoleInstrumentalizationEnabled: raw.Key("instrumentations_console_enabled").MustBool(true),
WebVitalsInstrumentalizationEnabled: raw.Key("instrumentations_webvitals_enabled").MustBool(true),
+ InternalLoggerLevel: raw.Key("internal_logger_level").MustInt(0),
ApiKey: raw.Key("api_key").String(),
}
}
diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go
index 08a20bd0125..a139160329c 100644
--- a/pkg/setting/setting_test.go
+++ b/pkg/setting/setting_test.go
@@ -830,6 +830,12 @@ func TestRedactedValue(t *testing.T) {
value: "/path/to/key",
expected: RedactedPassword,
},
+ {
+ desc: "license key with non-empty value",
+ key: "GF_ENTERPRISE_LICENSE_TEXT",
+ value: "some_license_key_test",
+ expected: RedactedPassword,
+ },
{
desc: "sensitive key with empty value",
key: "private_key_path",
diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go
index 7ceae36e0b0..ed601c60ff2 100644
--- a/pkg/tests/api/alerting/api_admin_configuration_test.go
+++ b/pkg/tests/api/alerting/api_admin_configuration_test.go
@@ -10,11 +10,13 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/expr"
+ "github.com/prometheus/alertmanager/config"
+ "github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/services/datasources"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
@@ -352,3 +354,49 @@ func TestIntegrationAdminConfiguration_SendingToExternalAlertmanagers(t *testing
}, 16*time.Second, 8*time.Second) // the sync interval is 2s so after 8s all alertmanagers (if any) most probably are started
}
}
+
+func TestIntegrationAdminConfiguration_CannotCreateInhibitionRules(t *testing.T) {
+ testinfra.SQLiteIntegrationTest(t)
+ dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
+ DisableLegacyAlerting: true,
+ EnableUnifiedAlerting: true,
+ AppModeProduction: true,
+ })
+ grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
+ createUser(t, store, user.CreateUserCommand{
+ DefaultOrgRole: string(org.RoleAdmin),
+ Password: "admin",
+ Login: "admin",
+ })
+ client := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
+
+ cfg := apimodels.PostableUserConfig{
+ AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
+ Config: apimodels.Config{
+ Route: &apimodels.Route{
+ Receiver: "test",
+ },
+ InhibitRules: []config.InhibitRule{{
+ SourceMatchers: config.Matchers{{
+ Type: labels.MatchEqual,
+ Name: "foo",
+ Value: "bar",
+ }},
+ TargetMatchers: config.Matchers{{
+ Type: labels.MatchEqual,
+ Name: "bar",
+ Value: "baz",
+ }},
+ }},
+ },
+ Receivers: []*apimodels.PostableApiReceiver{{
+ Receiver: config.Receiver{
+ Name: "test",
+ },
+ }},
+ },
+ }
+ ok, err := client.PostConfiguration(t, cfg)
+ require.False(t, ok)
+ require.EqualError(t, err, "inhibition rules are not supported")
+}
diff --git a/pkg/tests/api/alerting/api_backtesting_test.go b/pkg/tests/api/alerting/api_backtesting_test.go
index de83add0e0f..08c60978bb7 100644
--- a/pkg/tests/api/alerting/api_backtesting_test.go
+++ b/pkg/tests/api/alerting/api_backtesting_test.go
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/require"
+ "github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/datasources"
@@ -92,9 +93,10 @@ func TestBacktesting(t *testing.T) {
t.Run("if user does not have permissions", func(t *testing.T) {
testUserId := createUser(t, env.SQLStore, user.CreateUserCommand{
- DefaultOrgRole: "",
+ DefaultOrgRole: string(roletype.RoleNone),
Password: "test",
Login: "test",
+ OrgID: 1,
})
testUserApiCli := newAlertingApiClient(grafanaListedAddr, "test", "test")
diff --git a/pkg/tests/api/alerting/api_notifications_time_interval_test.go b/pkg/tests/api/alerting/api_notifications_time_interval_test.go
new file mode 100644
index 00000000000..76b890ecdc5
--- /dev/null
+++ b/pkg/tests/api/alerting/api_notifications_time_interval_test.go
@@ -0,0 +1,204 @@
+package alerting
+
+import (
+ "net/http"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/prometheus/alertmanager/config"
+ "github.com/prometheus/alertmanager/timeinterval"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/org"
+ "github.com/grafana/grafana/pkg/services/user"
+ "github.com/grafana/grafana/pkg/tests/testinfra"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+func TestTimeInterval(t *testing.T) {
+ dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
+ DisableLegacyAlerting: true,
+ EnableUnifiedAlerting: true,
+ DisableAnonymous: true,
+ AppModeProduction: true,
+ })
+
+ grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
+
+ createUser(t, store, user.CreateUserCommand{
+ DefaultOrgRole: string(org.RoleAdmin),
+ Password: "admin",
+ Login: "admin",
+ })
+
+ apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
+
+ t.Run("default config should return empty list", func(t *testing.T) {
+ mt, status, body := apiClient.GetAllTimeIntervalsWithStatus(t)
+ requireStatusCode(t, http.StatusOK, status, body)
+ require.Empty(t, mt)
+ })
+
+ emptyTimeInterval := definitions.PostableTimeIntervals{
+ Name: "Empty Mute Timing",
+ TimeIntervals: []definitions.TimeIntervalItem{},
+ }
+
+ func() {
+ // TODO replace with Time-Interval later
+ emptyMuteTiming := definitions.MuteTimeInterval{
+ MuteTimeInterval: config.MuteTimeInterval{
+ Name: "Empty Mute Timing",
+ TimeIntervals: []timeinterval.TimeInterval{},
+ },
+ }
+
+ // TODO replace with create interval API
+ // t.Run("should create a new mute timing without any intervals", func(t *testing.T) {
+ mt, status, body := apiClient.CreateMuteTimingWithStatus(t, emptyMuteTiming)
+ requireStatusCode(t, http.StatusCreated, status, body)
+ require.Equal(t, emptyMuteTiming.MuteTimeInterval, mt.MuteTimeInterval)
+ require.EqualValues(t, models.ProvenanceAPI, mt.Provenance)
+ // })
+
+ anotherMuteTiming := definitions.MuteTimeInterval{
+ MuteTimeInterval: config.MuteTimeInterval{
+ Name: "Not Empty Mute Timing",
+ TimeIntervals: []timeinterval.TimeInterval{
+ {
+ Times: []timeinterval.TimeRange{
+ {
+ StartMinute: 10,
+ EndMinute: 45,
+ },
+ },
+ Weekdays: []timeinterval.WeekdayRange{
+ {
+ InclusiveRange: timeinterval.InclusiveRange{
+ Begin: 0,
+ End: 2,
+ },
+ },
+ {
+ InclusiveRange: timeinterval.InclusiveRange{
+ Begin: 4,
+ End: 5,
+ },
+ },
+ },
+ DaysOfMonth: []timeinterval.DayOfMonthRange{
+ {
+ InclusiveRange: timeinterval.InclusiveRange{
+ Begin: 1,
+ End: 7,
+ },
+ },
+ {
+ InclusiveRange: timeinterval.InclusiveRange{
+ Begin: 14,
+ End: 28,
+ },
+ },
+ },
+ Months: []timeinterval.MonthRange{
+ {
+ InclusiveRange: timeinterval.InclusiveRange{
+ Begin: 1,
+ End: 5,
+ },
+ },
+ },
+ Years: []timeinterval.YearRange{
+ {
+ InclusiveRange: timeinterval.InclusiveRange{
+ Begin: 2024,
+ End: 2025,
+ },
+ },
+ },
+ Location: &timeinterval.Location{
+ Location: time.UTC,
+ },
+ },
+ },
+ },
+ }
+
+ // t.Run("should create a new mute timing with some settings", func(t *testing.T) {
+ mt, status, body = apiClient.CreateMuteTimingWithStatus(t, anotherMuteTiming)
+ requireStatusCode(t, http.StatusCreated, status, body)
+ require.Equal(t, anotherMuteTiming.MuteTimeInterval, mt.MuteTimeInterval)
+ require.EqualValues(t, models.ProvenanceAPI, mt.Provenance)
+ // })
+ }()
+
+ anotherTimeInterval := definitions.PostableTimeIntervals{
+ Name: "Not Empty Mute Timing",
+ TimeIntervals: []definitions.TimeIntervalItem{
+ {
+ Times: []definitions.TimeIntervalTimeRange{
+ {
+ StartMinute: "00:10",
+ EndMinute: "00:45",
+ },
+ },
+ Weekdays: util.Pointer([]string{
+ "sunday:tuesday",
+ "thursday:friday",
+ }),
+ DaysOfMonth: util.Pointer([]string{
+ "1:7",
+ "14:28",
+ }),
+ Months: util.Pointer([]string{
+ "1:5",
+ }),
+ Years: util.Pointer([]string{
+ "2024:2025",
+ }),
+ Location: util.Pointer("UTC"),
+ },
+ },
+ }
+
+ t.Run("should return time interval by name", func(t *testing.T) {
+ ti, status, body := apiClient.GetTimeIntervalByNameWithStatus(t, emptyTimeInterval.Name)
+ requireStatusCode(t, http.StatusOK, status, body)
+ require.Equal(t, emptyTimeInterval.TimeIntervals, ti.TimeIntervals)
+ require.Equal(t, emptyTimeInterval.Name, ti.Name)
+ require.EqualValues(t, models.ProvenanceAPI, ti.Provenance)
+
+ ti, status, body = apiClient.GetTimeIntervalByNameWithStatus(t, anotherTimeInterval.Name)
+ requireStatusCode(t, http.StatusOK, status, body)
+ require.Equal(t, anotherTimeInterval.TimeIntervals, ti.TimeIntervals)
+ require.Equal(t, anotherTimeInterval.Name, ti.Name)
+ require.EqualValues(t, models.ProvenanceAPI, ti.Provenance)
+ })
+
+ t.Run("should return NotFound if time interval does not exist", func(t *testing.T) {
+ _, status, body := apiClient.GetTimeIntervalByNameWithStatus(t, "some-missing-timing")
+ requireStatusCode(t, http.StatusNotFound, status, body)
+ })
+
+ t.Run("should return all mute timings", func(t *testing.T) {
+ mt, status, body := apiClient.GetAllTimeIntervalsWithStatus(t)
+ requireStatusCode(t, http.StatusOK, status, body)
+ require.Len(t, mt, 2)
+
+ slices.SortFunc(mt, func(a, b definitions.GettableTimeIntervals) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ require.Equal(t, emptyTimeInterval.TimeIntervals, mt[0].TimeIntervals)
+ require.Equal(t, emptyTimeInterval.Name, mt[0].Name)
+ require.EqualValues(t, models.ProvenanceAPI, mt[0].Provenance)
+
+ require.Equal(t, anotherTimeInterval.TimeIntervals, mt[1].TimeIntervals)
+ require.Equal(t, anotherTimeInterval.Name, mt[1].Name)
+ require.EqualValues(t, models.ProvenanceAPI, mt[1].Provenance)
+ })
+}
diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go
index 271546ee67a..0a3ab45e00b 100644
--- a/pkg/tests/api/alerting/testing.go
+++ b/pkg/tests/api/alerting/testing.go
@@ -3,6 +3,7 @@ package alerting
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -341,6 +342,39 @@ func (a apiClient) UpdateAlertRuleOrgQuota(t *testing.T, orgID int64, limit int6
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
+func (a apiClient) PostConfiguration(t *testing.T, c apimodels.PostableUserConfig) (bool, error) {
+ t.Helper()
+
+ b, err := json.Marshal(c)
+ require.NoError(t, err)
+
+ u := fmt.Sprintf("%s/api/alertmanager/grafana/config/api/v1/alerts", a.url)
+ req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ b, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ data := struct {
+ Message string `json:"message"`
+ }{}
+ require.NoError(t, json.Unmarshal(b, &data))
+
+ if resp.StatusCode == http.StatusAccepted {
+ return true, nil
+ }
+
+ return false, errors.New(data.Message)
+}
+
func (a apiClient) PostRulesGroupWithStatus(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (apimodels.UpdateRuleGroupResponse, int, string) {
t.Helper()
buf := bytes.Buffer{}
@@ -722,6 +756,24 @@ func (a apiClient) GetRuleHistoryWithStatus(t *testing.T, ruleUID string) (data.
return sendRequest[data.Frame](t, req, http.StatusOK)
}
+func (a apiClient) GetAllTimeIntervalsWithStatus(t *testing.T) ([]apimodels.GettableTimeIntervals, int, string) {
+ t.Helper()
+
+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/notifications/time-intervals", a.url), nil)
+ require.NoError(t, err)
+
+ return sendRequest[[]apimodels.GettableTimeIntervals](t, req, http.StatusOK)
+}
+
+func (a apiClient) GetTimeIntervalByNameWithStatus(t *testing.T, name string) (apimodels.GettableTimeIntervals, int, string) {
+ t.Helper()
+
+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/notifications/time-intervals/%s", a.url, name), nil)
+ require.NoError(t, err)
+
+ return sendRequest[apimodels.GettableTimeIntervals](t, req, http.StatusOK)
+}
+
func sendRequest[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) {
client := &http.Client{}
resp, err := client.Do(req)
diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json
index ea8682fe3f4..3abaf4caf8d 100644
--- a/pkg/tests/api/plugins/data/expectedListResp.json
+++ b/pkg/tests/api/plugins/data/expectedListResp.json
@@ -626,7 +626,7 @@
{
"name": "Google Cloud Monitoring",
"type": "datasource",
- "id": "grafana-cloud-monitoring-datasource",
+ "id": "stackdriver",
"enabled": true,
"pinned": false,
"info": {
@@ -652,7 +652,7 @@
},
"latestVersion": "",
"hasUpdate": false,
- "defaultNavUrl": "/plugins/grafana-cloud-monitoring-datasource/",
+ "defaultNavUrl": "/plugins/stackdriver/",
"category": "cloud",
"state": "",
"signature": "internal",
diff --git a/pkg/tests/apis/dashboardsnapshot/snapshots_test.go b/pkg/tests/apis/dashboardsnapshot/snapshots_test.go
new file mode 100644
index 00000000000..c25c4029d0a
--- /dev/null
+++ b/pkg/tests/apis/dashboardsnapshot/snapshots_test.go
@@ -0,0 +1,80 @@
+package dashboardsnapshots
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/tests/apis"
+ "github.com/grafana/grafana/pkg/tests/testinfra"
+)
+
+func TestDashboardSnapshots(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+ helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
+ AppModeProduction: false, // required for experimental apis
+ DisableAnonymous: true,
+ EnableFeatureToggles: []string{
+ featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // required to register dashboardsnapshot.grafana.app
+ },
+ })
+
+ t.Run("Check discovery client", func(t *testing.T) {
+ disco := helper.GetGroupVersionInfoJSON("dashboardsnapshot.grafana.app")
+
+ // fmt.Printf("%s", disco)
+ require.JSONEq(t, `[
+ {
+ "freshness": "Current",
+ "resources": [
+ {
+ "resource": "dashboardsnapshot",
+ "responseKind": {
+ "group": "",
+ "kind": "DashboardSnapshot",
+ "version": ""
+ },
+ "scope": "Namespaced",
+ "singularResource": "dashsnap",
+ "subresources": [
+ {
+ "responseKind": {
+ "group": "",
+ "kind": "FullDashboardSnapshot",
+ "version": ""
+ },
+ "subresource": "body",
+ "verbs": [
+ "get"
+ ]
+ }
+ ],
+ "verbs": [
+ "delete",
+ "get",
+ "list"
+ ]
+ },
+ {
+ "resource": "options",
+ "responseKind": {
+ "group": "",
+ "kind": "SharingOptions",
+ "version": ""
+ },
+ "scope": "Namespaced",
+ "singularResource": "options",
+ "verbs": [
+ "get",
+ "list"
+ ]
+ }
+ ],
+ "version": "v0alpha1"
+ }
+ ]`, disco)
+ })
+}
diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go
index c21b1bdf309..b0449903fe3 100644
--- a/pkg/tests/apis/datasource/testdata_test.go
+++ b/pkg/tests/apis/datasource/testdata_test.go
@@ -107,7 +107,7 @@ func TestTestDatasource(t *testing.T) {
})
t.Run("Call subresources", func(t *testing.T) {
- client := helper.Org1.Admin.Client.Resource(schema.GroupVersionResource{
+ client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "testdata.datasource.grafana.app",
Version: "v0alpha1",
Resource: "connections",
diff --git a/pkg/tests/apis/example/example_test.go b/pkg/tests/apis/example/example_test.go
index 6f3f6f423f6..b84db254f2f 100644
--- a/pkg/tests/apis/example/example_test.go
+++ b/pkg/tests/apis/example/example_test.go
@@ -29,7 +29,7 @@ func TestExampleApp(t *testing.T) {
t.Run("Check runtime info resource", func(t *testing.T) {
// Resource is not namespaced!
- client := helper.Org1.Admin.Client.Resource(schema.GroupVersionResource{
+ client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "example.grafana.app",
Version: "v0alpha1",
Resource: "runtime",
@@ -139,7 +139,7 @@ func TestExampleApp(t *testing.T) {
})
t.Run("Check dummy with subresource", func(t *testing.T) {
- client := helper.Org1.Viewer.Client.Resource(schema.GroupVersionResource{
+ client := helper.Org1.Viewer.ResourceClient(t, schema.GroupVersionResource{
Group: "example.grafana.app",
Version: "v0alpha1",
Resource: "dummy",
diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go
index ee7e2c24aff..8faebfdfda7 100644
--- a/pkg/tests/apis/helper.go
+++ b/pkg/tests/apis/helper.go
@@ -25,9 +25,9 @@ import (
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/server"
+ "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/datasources"
- "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
@@ -98,10 +98,13 @@ func (c *K8sTestHelper) GetResourceClient(args ResourceClientArgs) *K8sResourceC
args.Namespace = c.namespacer(args.User.Identity.GetOrgID())
}
+ client, err := dynamic.NewForConfig(args.User.NewRestConfig())
+ require.NoError(c.t, err)
+
return &K8sResourceClient{
t: c.t,
Args: args,
- Resource: args.User.Client.Resource(args.GVR).Namespace(args.Namespace),
+ Resource: client.Resource(args.GVR).Namespace(args.Namespace),
}
}
@@ -163,8 +166,31 @@ type OrgUsers struct {
type User struct {
Identity identity.Requester
- Client *dynamic.DynamicClient
password string
+ baseURL string
+}
+
+func (c *User) NewRestConfig() *rest.Config {
+ return &rest.Config{
+ Host: c.baseURL,
+ Username: c.Identity.GetLogin(),
+ Password: c.password,
+ }
+}
+
+func (c *User) ResourceClient(t *testing.T, gvr schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
+ client, err := dynamic.NewForConfig(c.NewRestConfig())
+ require.NoError(t, err)
+ return client.Resource(gvr)
+}
+
+func (c *User) RESTClient(t *testing.T, gv *schema.GroupVersion) *rest.RESTClient {
+ cfg := dynamic.ConfigFor(c.NewRestConfig()) // adds negotiated serializers!
+ cfg.GroupVersion = gv
+ cfg.APIPath = "apis" // the plural
+ client, err := rest.RESTClientFor(cfg)
+ require.NoError(t, err)
+ return client
}
type RequestParams struct {
@@ -380,19 +406,10 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers {
require.Equal(c.t, orgId, s.OrgID)
require.Equal(c.t, role, s.OrgRole) // make sure the role was set properly
- config := &rest.Config{
- Host: baseUrl,
- Username: s.Login,
- Password: key,
- }
-
- client, err := dynamic.NewForConfig(config)
- require.NoError(c.t, err)
-
return User{
Identity: s,
- Client: client,
password: key,
+ baseURL: baseUrl,
}
}
return OrgUsers{
diff --git a/pkg/tests/apis/query/query_test.go b/pkg/tests/apis/query/query_test.go
new file mode 100644
index 00000000000..8a70de87986
--- /dev/null
+++ b/pkg/tests/apis/query/query_test.go
@@ -0,0 +1,98 @@
+package dashboards
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+
+ query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/tests/apis"
+ "github.com/grafana/grafana/pkg/tests/testinfra"
+)
+
+func TestSimpleQuery(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+ helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
+ AppModeProduction: false, // dev mode required for datasource connections
+ EnableFeatureToggles: []string{
+ featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
+ },
+ })
+
+ // Create a single datasource
+ ds := helper.CreateDS(&datasources.AddDataSourceCommand{
+ Name: "test",
+ Type: datasources.DS_TESTDATA,
+ UID: "test",
+ OrgID: int64(1),
+ })
+ require.Equal(t, "test", ds.UID)
+
+ t.Run("Call query", func(t *testing.T) {
+ client := helper.Org1.Admin.RESTClient(t, &schema.GroupVersion{
+ Group: "query.grafana.app",
+ Version: "v0alpha1",
+ })
+
+ q := query.GenericDataQuery{
+ Datasource: &query.DataSourceRef{
+ Type: "grafana-testdata-datasource",
+ UID: ds.UID,
+ },
+ }
+ q.AdditionalProperties()["csvContent"] = "a,b,c\n1,hello,true"
+ q.AdditionalProperties()["scenarioId"] = "csv_content"
+ body, err := json.Marshal(&query.GenericQueryRequest{Queries: []query.GenericDataQuery{q}})
+ require.NoError(t, err)
+
+ result := client.Post().
+ Namespace("default").
+ Suffix("query").
+ SetHeader("Content-type", "application/json").
+ Body(body).
+ Do(context.Background())
+
+ require.NoError(t, result.Error())
+
+ body, err = result.Raw()
+ require.NoError(t, err)
+ fmt.Printf("OUT: %s", string(body))
+
+ rsp := &backend.QueryDataResponse{}
+ err = json.Unmarshal(body, rsp)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(rsp.Responses))
+
+ frame := rsp.Responses["A"].Frames[0]
+ disp, err := frame.StringTable(100, 10)
+ require.NoError(t, err)
+ fmt.Printf("%s\n", disp)
+
+ type expect struct {
+ idx int
+ name string
+ val any
+ }
+ for _, check := range []expect{
+ {0, "a", int64(1)},
+ {1, "b", "hello"},
+ {2, "c", true},
+ } {
+ field := frame.Fields[check.idx]
+ require.Equal(t, check.name, field.Name)
+
+ v, _ := field.ConcreteAt(0)
+ require.Equal(t, check.val, v)
+ }
+ })
+}
diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go
index 2590efa252c..911b9fe5e9d 100644
--- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go
+++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go
@@ -57,7 +57,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return dsInfo.QueryData(ctx, req)
}
-func newPostgres(cfg *setting.Cfg, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger) (*sql.DB, *sqleng.DataSourceHandler, error) {
+func newPostgres(cfg *setting.Cfg, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, *sqleng.DataSourceHandler, error) {
connector, err := pq.NewConnector(cnnstr)
if err != nil {
logger.Error("postgres connector creation failed", "error", err)
@@ -65,7 +65,7 @@ func newPostgres(cfg *setting.Cfg, dsInfo sqleng.DataSourceInfo, cnnstr string,
}
// use the proxy-dialer if the secure socks proxy is enabled
- proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo)
+ proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo, settings.Name, settings.Type)
if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() {
dialer, err := newPostgresProxyDialer(proxyOpts)
if err != nil {
@@ -140,7 +140,7 @@ func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFacto
return nil, err
}
- _, handler, err := newPostgres(cfg, dsInfo, cnnstr, logger)
+ _, handler, err := newPostgres(cfg, dsInfo, cnnstr, logger, settings)
if err != nil {
logger.Error("Failed connecting to Postgres", "err", err)
diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go
index 426e462165a..10ddf67cae1 100644
--- a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go
+++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go
@@ -164,7 +164,7 @@ func TestIntegrationPostgresSnapshots(t *testing.T) {
cnnstr := getCnnStr()
- db, handler, err := newPostgres(cfg, dsInfo, cnnstr, logger)
+ db, handler, err := newPostgres(cfg, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{})
t.Cleanup((func() {
_, err := db.Exec("DROP TABLE tbl")
diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go
index 7a37b839150..cd7a4c3c687 100644
--- a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go
+++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go
@@ -212,7 +212,7 @@ func TestIntegrationPostgres(t *testing.T) {
cnnstr := postgresTestDBConnString()
- db, exe, err := newPostgres(cfg, dsInfo, cnnstr, logger)
+ db, exe, err := newPostgres(cfg, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{})
require.NoError(t, err)
@@ -1268,7 +1268,7 @@ func TestIntegrationPostgres(t *testing.T) {
dsInfo := sqleng.DataSourceInfo{}
conf := setting.NewCfg()
conf.DataProxyRowLimit = 1
- _, handler, err := newPostgres(conf, dsInfo, cnnstr, logger)
+ _, handler, err := newPostgres(conf, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{})
require.NoError(t, err)
diff --git a/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go b/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go
index 9ab05e31f31..3d12be8da1c 100644
--- a/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go
+++ b/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go
@@ -22,7 +22,7 @@ func TestPostgresProxyDriver(t *testing.T) {
ProxyAddress: settings.ProxyAddress,
ServerName: settings.ServerName,
}
- opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}})
+ opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}}, "pg", "postgres")
dbURL := "localhost:5432"
cnnstr := fmt.Sprintf("postgres://auser:password@%s/db?sslmode=disable", dbURL)
diff --git a/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go
index 2475dff45e8..3013282e006 100644
--- a/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go
+++ b/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go
@@ -11,9 +11,10 @@ package dataquery
// Defines values for NodesQueryType.
const (
- NodesQueryTypeRandom NodesQueryType = "random"
- NodesQueryTypeRandomEdges NodesQueryType = "random edges"
- NodesQueryTypeResponse NodesQueryType = "response"
+ NodesQueryTypeRandom NodesQueryType = "random"
+ NodesQueryTypeRandomEdges NodesQueryType = "random edges"
+ NodesQueryTypeResponseMedium NodesQueryType = "response_medium"
+ NodesQueryTypeResponseSmall NodesQueryType = "response_small"
)
// Defines values for StreamingQueryType.
@@ -100,6 +101,7 @@ type DataQuery struct {
// NodesQuery defines model for NodesQuery.
type NodesQuery struct {
Count *int64 `json:"count,omitempty"`
+ Seed *int64 `json:"seed,omitempty"`
Type *NodesQueryType `json:"type,omitempty"`
}
diff --git a/pkg/tsdb/influxdb/influxql/influxql.go b/pkg/tsdb/influxdb/influxql/influxql.go
index f6cdf46aef5..88a7ac7b1bd 100644
--- a/pkg/tsdb/influxdb/influxql/influxql.go
+++ b/pkg/tsdb/influxdb/influxql/influxql.go
@@ -3,11 +3,14 @@ package influxql
import (
"context"
"errors"
+ "fmt"
"net/http"
"net/url"
"path"
"strings"
+ "sync"
+ "github.com/grafana/dskit/concurrency"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"go.opentelemetry.io/otel/trace"
@@ -30,40 +33,91 @@ var (
func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceInfo, req *backend.QueryDataRequest, features featuremgmt.FeatureToggles) (*backend.QueryDataResponse, error) {
logger := glog.FromContext(ctx)
response := backend.NewQueryDataResponse()
+ var err error
- for _, reqQuery := range req.Queries {
- query, err := models.QueryParse(reqQuery)
+ // We are testing running of queries in parallel behind feature flag
+ if features.IsEnabled(ctx, featuremgmt.FlagInfluxdbRunQueriesInParallel) {
+ concurrentQueryCount, err := req.PluginContext.GrafanaConfig.ConcurrentQueryCount()
if err != nil {
- return &backend.QueryDataResponse{}, err
+ logger.Debug(fmt.Sprintf("Concurrent Query Count read/parse error: %v", err), featuremgmt.FlagInfluxdbRunQueriesInParallel)
+ concurrentQueryCount = 10
}
- rawQuery, err := query.Build(req)
- if err != nil {
- return &backend.QueryDataResponse{}, err
- }
+ responseLock := sync.Mutex{}
+ err = concurrency.ForEachJob(ctx, len(req.Queries), concurrentQueryCount, func(ctx context.Context, idx int) error {
+ reqQuery := req.Queries[idx]
+ query, err := models.QueryParse(reqQuery)
+ if err != nil {
+ return err
+ }
- query.RefID = reqQuery.RefID
- query.RawQuery = rawQuery
+ rawQuery, err := query.Build(req)
+ if err != nil {
+ return err
+ }
- if setting.Env == setting.Dev {
- logger.Debug("Influxdb query", "raw query", rawQuery)
- }
+ query.RefID = reqQuery.RefID
+ query.RawQuery = rawQuery
- request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy)
- if err != nil {
- return &backend.QueryDataResponse{}, err
- }
+ if setting.Env == setting.Dev {
+ logger.Debug("Influxdb query", "raw query", rawQuery)
+ }
- resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser))
+ request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy)
+ if err != nil {
+ return err
+ }
+
+ resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser))
+
+ responseLock.Lock()
+ defer responseLock.Unlock()
+ if err != nil {
+ response.Responses[query.RefID] = backend.DataResponse{Error: err}
+ } else {
+ response.Responses[query.RefID] = resp
+ }
+ return nil // errors are saved per-query,always return nil
+ })
if err != nil {
- response.Responses[query.RefID] = backend.DataResponse{Error: err}
- } else {
- response.Responses[query.RefID] = resp
+ logger.Debug("Influxdb concurrent query error", "concurrent query", err)
+ }
+ } else {
+ for _, reqQuery := range req.Queries {
+ query, err := models.QueryParse(reqQuery)
+ if err != nil {
+ return &backend.QueryDataResponse{}, err
+ }
+
+ rawQuery, err := query.Build(req)
+ if err != nil {
+ return &backend.QueryDataResponse{}, err
+ }
+
+ query.RefID = reqQuery.RefID
+ query.RawQuery = rawQuery
+
+ if setting.Env == setting.Dev {
+ logger.Debug("Influxdb query", "raw query", rawQuery)
+ }
+
+ request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy)
+ if err != nil {
+ return &backend.QueryDataResponse{}, err
+ }
+
+ resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser))
+
+ if err != nil {
+ response.Responses[query.RefID] = backend.DataResponse{Error: err}
+ } else {
+ response.Responses[query.RefID] = resp
+ }
}
}
- return response, nil
+ return response, err
}
func createRequest(ctx context.Context, logger log.Logger, dsInfo *models.DatasourceInfo, queryStr string, retentionPolicy string) (*http.Request, error) {
diff --git a/pkg/tsdb/influxdb/models/query.go b/pkg/tsdb/influxdb/models/query.go
index e8e6837aae7..64f3331a37f 100644
--- a/pkg/tsdb/influxdb/models/query.go
+++ b/pkg/tsdb/influxdb/models/query.go
@@ -13,8 +13,11 @@ import (
)
var (
- regexpOperatorPattern = regexp.MustCompile(`^\/.*\/$`)
- regexpMeasurementPattern = regexp.MustCompile(`^\/.*\/$`)
+ regexpOperatorPattern = regexp.MustCompile(`^\/.*\/$`)
+ regexpMeasurementPattern = regexp.MustCompile(`^\/.*\/$`)
+ regexMatcherSimple = regexp.MustCompile(`^/(.*)/$`)
+ regexMatcherWithStartEndPattern = regexp.MustCompile(`^/\^(.*)\$/$`)
+ mustEscapeCharsMatcher = regexp.MustCompile(`[\\^$*+?.()|[\]{}\/]`)
)
func (query *Query) Build(queryContext *backend.QueryDataRequest) (string, error) {
@@ -103,20 +106,20 @@ func (query *Query) renderTags() []string {
textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`))
}
- return textValue, operator
+ return removeRegexWrappers(textValue, `'`), operator
}
// quote value unless regex or number
var textValue string
switch tag.Operator {
- case "=~", "!~":
- textValue = tag.Value
+ case "=~", "!~", "":
+ textValue = escape(tag.Value)
case "<", ">", ">=", "<=":
- textValue = tag.Value
+ textValue = removeRegexWrappers(tag.Value, `'`)
case "Is", "Is Not":
textValue, tag.Operator = isOperatorTypeHandler(tag)
default:
- textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`))
+ textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(removeRegexWrappers(tag.Value, ""), `\`, `\\`))
}
escapedKey := fmt.Sprintf(`"%s"`, tag.Key)
@@ -244,3 +247,56 @@ func epochMStoInfluxTime(tr *backend.TimeRange) (string, string) {
return fmt.Sprintf("%dms", from), fmt.Sprintf("%dms", to)
}
+
+func removeRegexWrappers(wrappedValue string, wrapper string) string {
+ value := wrappedValue
+ // get the value only in between /^...$/
+ matches := regexMatcherWithStartEndPattern.FindStringSubmatch(wrappedValue)
+ if len(matches) > 1 {
+ // full match. the value is like /^value$/
+ value = wrapper + matches[1] + wrapper
+ }
+
+ return value
+}
+
+func escape(unescapedValue string) string {
+ pipe := `|`
+ beginning := `/^`
+ ending := `$/`
+ value := unescapedValue
+ substitute := `\$0`
+ fullMatch := false
+
+ // get the value only in between /^...$/
+ matches := regexMatcherWithStartEndPattern.FindStringSubmatch(unescapedValue)
+ if len(matches) > 1 {
+ // full match. the value is like /^value$/
+ value = matches[1]
+ fullMatch = true
+ }
+
+ if !fullMatch {
+ // get the value only in between /.../
+ matches = regexMatcherSimple.FindStringSubmatch(unescapedValue)
+ if len(matches) > 1 {
+ value = matches[1]
+ beginning = `/`
+ ending = `/`
+ }
+ }
+
+ // split them with pipe |
+ parts := strings.Split(value, pipe)
+ for i, v := range parts {
+ // escape each item
+ parts[i] = mustEscapeCharsMatcher.ReplaceAllString(v, substitute)
+ }
+
+ // stitch them to each other
+ escaped := make([]byte, 0, 64)
+ escaped = append(escaped, beginning...)
+ escaped = append(escaped, strings.Join(parts, pipe)...)
+ escaped = append(escaped, ending...)
+ return string(escaped)
+}
diff --git a/pkg/tsdb/influxdb/models/query_test.go b/pkg/tsdb/influxdb/models/query_test.go
index c590e829d16..35c8bbd9f22 100644
--- a/pkg/tsdb/influxdb/models/query_test.go
+++ b/pkg/tsdb/influxdb/models/query_test.go
@@ -303,5 +303,53 @@ func TestInfluxdbQueryBuilder(t *testing.T) {
require.Equal(t, query.renderMeasurement(), ` FROM "policy"./apa/`)
})
+
+ t.Run("can render regexp tags", func(t *testing.T) {
+ query := &Query{Tags: []*Tag{{Operator: "=~", Value: `/etc/hosts|/etc/hostname`, Key: "key"}}}
+
+ require.Equal(t, `"key" =~ /^\/etc\/hosts|\/etc\/hostname$/`, strings.Join(query.renderTags(), ""))
+ })
+
+ t.Run("can render regexp tags 2", func(t *testing.T) {
+ query := &Query{Tags: []*Tag{{Operator: "=~", Value: `/^/etc/hosts$/`, Key: "key"}}}
+
+ require.Equal(t, `"key" =~ /^\/etc\/hosts$/`, strings.Join(query.renderTags(), ""))
+ })
+
+ t.Run("can render regexp tags 3", func(t *testing.T) {
+ query := &Query{Tags: []*Tag{{Operator: "=~", Value: `/etc/hosts`, Key: "key"}}}
+
+ require.Equal(t, `"key" =~ /^\/etc\/hosts$/`, strings.Join(query.renderTags(), ""))
+ })
+
+ t.Run("can render regexp tags with dots in values", func(t *testing.T) {
+ query := &Query{Tags: []*Tag{{Operator: "=~", Value: `/etc/resolv.conf`, Key: "key"}}}
+
+ require.Equal(t, `"key" =~ /^\/etc\/resolv\.conf$/`, strings.Join(query.renderTags(), ""))
+ })
+
+ t.Run("can render single quoted tag value when regexed value has been sent", func(t *testing.T) {
+ query := &Query{Tags: []*Tag{{Operator: ">", Value: `/^12.2$/`, Key: "key"}}}
+
+ require.Equal(t, `"key" > '12.2'`, strings.Join(query.renderTags(), ""))
+ })
+ })
+}
+
+func TestRemoveRegexWrappers(t *testing.T) {
+ t.Run("remove regex wrappers", func(t *testing.T) {
+ wrappedText := `/^someValue$/`
+ expected := `'someValue'`
+ result := removeRegexWrappers(wrappedText, `'`)
+
+ require.Equal(t, expected, result)
+ })
+
+ t.Run("return same value if the value is not wrapped by regex wrappers", func(t *testing.T) {
+ wrappedText := `someValue`
+ expected := `someValue`
+ result := removeRegexWrappers(wrappedText, "")
+
+ require.Equal(t, expected, result)
})
}
diff --git a/pkg/tsdb/legacydata/conversions.go b/pkg/tsdb/legacydata/conversions.go
new file mode 100644
index 00000000000..37eeaffd53f
--- /dev/null
+++ b/pkg/tsdb/legacydata/conversions.go
@@ -0,0 +1,85 @@
+package legacydata
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+
+ "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
+)
+
+// ToDataSourceQueries returns queries that should be sent to a single datasource
+// This will throw an error if the queries reference multiple instances
+func ToDataSourceQueries(req v0alpha1.GenericQueryRequest) ([]backend.DataQuery, *v0alpha1.DataSourceRef, error) {
+ var dsRef *v0alpha1.DataSourceRef
+ var tr *backend.TimeRange
+ if req.From != "" {
+ val := NewDataTimeRange(req.From, req.To)
+ tr = &backend.TimeRange{
+ From: val.GetFromAsTimeUTC(),
+ To: val.GetToAsTimeUTC(),
+ }
+ }
+
+ queries := []backend.DataQuery{}
+ if len(req.Queries) > 0 {
+ dsRef := req.Queries[0].Datasource
+ for _, generic := range req.Queries {
+ if generic.Datasource != nil && dsRef != nil {
+ if dsRef.Type != generic.Datasource.Type {
+ return queries, dsRef, fmt.Errorf("expect same datasource types")
+ }
+ if dsRef.UID != generic.Datasource.UID {
+ return queries, dsRef, fmt.Errorf("expect same datasource UID")
+ }
+ }
+ q, err := toBackendDataQuery(generic, tr)
+ if err != nil {
+ return queries, dsRef, err
+ }
+ queries = append(queries, q)
+ }
+ return queries, dsRef, nil
+ }
+ return queries, dsRef, nil
+}
+
+// Converts a generic query to a backend one
+func toBackendDataQuery(q v0alpha1.GenericDataQuery, defaultTimeRange *backend.TimeRange) (backend.DataQuery, error) {
+ var err error
+ bq := backend.DataQuery{
+ RefID: q.RefID,
+ QueryType: q.QueryType,
+ MaxDataPoints: q.MaxDataPoints,
+ }
+
+ // Set an explicit time range for the query
+ if q.TimeRange != nil {
+ tr := NewDataTimeRange(q.TimeRange.From, q.TimeRange.To)
+ bq.TimeRange = backend.TimeRange{
+ From: tr.GetFromAsTimeUTC(),
+ To: tr.GetToAsTimeUTC(),
+ }
+ } else if defaultTimeRange != nil {
+ bq.TimeRange = *defaultTimeRange
+ }
+
+ bq.JSON, err = json.Marshal(q)
+ if err != nil {
+ return bq, err
+ }
+ if bq.RefID == "" {
+ bq.RefID = "A"
+ }
+ if bq.MaxDataPoints == 0 {
+ bq.MaxDataPoints = 100
+ }
+ if q.IntervalMS > 0 {
+ bq.Interval = time.Millisecond * time.Duration(q.IntervalMS)
+ } else {
+ bq.Interval = time.Second
+ }
+ return bq, nil
+}
diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go
index 1c2509d9b7d..14fa101147b 100644
--- a/pkg/tsdb/mssql/mssql.go
+++ b/pkg/tsdb/mssql/mssql.go
@@ -114,7 +114,7 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
}
// register a new proxy driver if the secure socks proxy is enabled
- proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo)
+ proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo, settings.Name, settings.Type)
if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() {
URL, err := ParseURL(dsInfo.URL, logger)
if err != nil {
diff --git a/pkg/tsdb/mssql/proxy_test.go b/pkg/tsdb/mssql/proxy_test.go
index bf30e21f4a1..9777e00e255 100644
--- a/pkg/tsdb/mssql/proxy_test.go
+++ b/pkg/tsdb/mssql/proxy_test.go
@@ -22,7 +22,7 @@ func TestMSSQLProxyDriver(t *testing.T) {
ProxyAddress: settings.ProxyAddress,
ServerName: settings.ServerName,
}
- opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}})
+ opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}}, "mssql", "mssql")
cnnstr := "server=127.0.0.1;port=1433;user id=sa;password=yourStrong(!)Password;database=db"
driverName, err := createMSSQLProxyDriver(cnnstr, "127.0.0.1", opts)
require.NoError(t, err)
diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go
index 7eca96859f2..6d146845fb2 100644
--- a/pkg/tsdb/mysql/mysql.go
+++ b/pkg/tsdb/mysql/mysql.go
@@ -90,7 +90,7 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
}
// register the secure socks proxy dialer context, if enabled
- proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo)
+ proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo, settings.Name, settings.Type)
if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() {
// UID is only unique per org, the only way to ensure uniqueness is to do it by connection information
uniqueIdentifier := dsInfo.User + dsInfo.DecryptedSecureJSONData["password"] + dsInfo.URL + dsInfo.Database
diff --git a/pkg/tsdb/mysql/proxy_test.go b/pkg/tsdb/mysql/proxy_test.go
index 3abffe93ebb..87ed82aa4f2 100644
--- a/pkg/tsdb/mysql/proxy_test.go
+++ b/pkg/tsdb/mysql/proxy_test.go
@@ -23,7 +23,7 @@ func TestMySQLProxyDialer(t *testing.T) {
ServerName: settings.ServerName,
}
protocol := "tcp"
- opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}})
+ opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}}, "mysql", "mysql")
dbURL := "localhost:5432"
network, err := registerProxyDialerContext(protocol, dbURL, opts)
require.NoError(t, err)
diff --git a/pkg/tsdb/prometheus/client/client_test.go b/pkg/tsdb/prometheus/client/client_test.go
index 920baf81dfb..1f957571601 100644
--- a/pkg/tsdb/prometheus/client/client_test.go
+++ b/pkg/tsdb/prometheus/client/client_test.go
@@ -2,6 +2,7 @@ package client
import (
"context"
+ "fmt"
"io"
"net/http"
"testing"
@@ -10,7 +11,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/tsdb/prometheus/models"
)
@@ -44,7 +44,7 @@ func TestClient(t *testing.T) {
defer func() {
if res != nil && res.Body != nil {
if err := res.Body.Close(); err != nil {
- logger.Warn("Error", "err", err)
+ fmt.Println("Error", "err", err)
}
}
}()
@@ -68,7 +68,7 @@ func TestClient(t *testing.T) {
defer func() {
if res != nil && res.Body != nil {
if err := res.Body.Close(); err != nil {
- logger.Warn("Error", "err", err)
+ fmt.Println("Error", "err", err)
}
}
}()
@@ -98,7 +98,7 @@ func TestClient(t *testing.T) {
defer func() {
if res != nil && res.Body != nil {
if err := res.Body.Close(); err != nil {
- logger.Warn("Error", "err", err)
+ fmt.Println("Error", "err", err)
}
}
}()
@@ -125,7 +125,7 @@ func TestClient(t *testing.T) {
defer func() {
if res != nil && res.Body != nil {
if err := res.Body.Close(); err != nil {
- logger.Warn("Error", "err", err)
+ fmt.Println("Error", "err", err)
}
}
}()
diff --git a/pkg/tsdb/prometheus/client/transport.go b/pkg/tsdb/prometheus/client/transport.go
index 1776e5ef370..ce7db24e81b 100644
--- a/pkg/tsdb/prometheus/client/transport.go
+++ b/pkg/tsdb/prometheus/client/transport.go
@@ -5,15 +5,16 @@ import (
"fmt"
"strings"
+ "github.com/grafana/grafana-azure-sdk-go/util/maputil"
"github.com/grafana/grafana-plugin-sdk-go/backend"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
+
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/prometheus/azureauth"
"github.com/grafana/grafana/pkg/tsdb/prometheus/middleware"
"github.com/grafana/grafana/pkg/tsdb/prometheus/utils"
- "github.com/grafana/grafana/pkg/util/maputil"
)
// CreateTransportOptions creates options for the http client. Probably should be shared and should not live in the
diff --git a/pkg/tsdb/prometheus/healthcheck.go b/pkg/tsdb/prometheus/healthcheck.go
index 873b8ac06fd..4041d9c08e0 100644
--- a/pkg/tsdb/prometheus/healthcheck.go
+++ b/pkg/tsdb/prometheus/healthcheck.go
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
+
"github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery"
"github.com/grafana/grafana/pkg/tsdb/prometheus/models"
)
diff --git a/pkg/tsdb/prometheus/healthcheck_test.go b/pkg/tsdb/prometheus/healthcheck_test.go
index eaf38c317e7..88d55c25ca3 100644
--- a/pkg/tsdb/prometheus/healthcheck_test.go
+++ b/pkg/tsdb/prometheus/healthcheck_test.go
@@ -11,9 +11,9 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
+
+ "github.com/grafana/grafana/pkg/setting"
)
type healthCheckProvider[T http.RoundTripper] struct {
@@ -84,7 +84,7 @@ func Test_healthcheck(t *testing.T) {
t.Run("should do a successful health check", func(t *testing.T) {
httpProvider := getMockProvider[*healthCheckSuccessRoundTripper]()
s := &Service{
- im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))),
+ im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, backend.NewLoggerWith("logger", "test"))),
}
req := &backend.CheckHealthRequest{
@@ -100,7 +100,7 @@ func Test_healthcheck(t *testing.T) {
t.Run("should return an error for an unsuccessful health check", func(t *testing.T) {
httpProvider := getMockProvider[*healthCheckFailRoundTripper]()
s := &Service{
- im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))),
+ im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, backend.NewLoggerWith("logger", "test"))),
}
req := &backend.CheckHealthRequest{
diff --git a/pkg/tsdb/prometheus/heuristics_test.go b/pkg/tsdb/prometheus/heuristics_test.go
index 70de2738dfc..89f1d5b9a88 100644
--- a/pkg/tsdb/prometheus/heuristics_test.go
+++ b/pkg/tsdb/prometheus/heuristics_test.go
@@ -14,7 +14,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
+
"github.com/grafana/grafana/pkg/setting"
)
@@ -52,7 +52,7 @@ func Test_GetHeuristics(t *testing.T) {
//httpProvider := getHeuristicsMockProvider(&rt)
httpProvider := newHeuristicsSDKProvider(rt)
s := &Service{
- im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))),
+ im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, backend.NewLoggerWith("logger", "test"))),
}
req := HeuristicsRequest{
@@ -72,7 +72,7 @@ func Test_GetHeuristics(t *testing.T) {
}
httpProvider := newHeuristicsSDKProvider(rt)
s := &Service{
- im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))),
+ im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, backend.NewLoggerWith("logger", "test"))),
}
req := HeuristicsRequest{
diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go
index 9c1a0db794a..ab91aaf7394 100644
--- a/pkg/tsdb/prometheus/prometheus.go
+++ b/pkg/tsdb/prometheus/prometheus.go
@@ -15,7 +15,7 @@ import (
apiv1 "github.com/prometheus/client_golang/api/prometheus/v1"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
+
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/prometheus/client"
"github.com/grafana/grafana/pkg/tsdb/prometheus/instrumentation"
@@ -24,9 +24,8 @@ import (
)
type Service struct {
- im instancemgmt.InstanceManager
- features featuremgmt.FeatureToggles
- logger log.Logger
+ im instancemgmt.InstanceManager
+ logger log.Logger
}
type instance struct {
@@ -35,17 +34,16 @@ type instance struct {
versionCache *cache.Cache
}
-func ProvideService(httpClientProvider *httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Service {
+func ProvideService(httpClientProvider *httpclient.Provider, cfg *setting.Cfg) *Service {
plog := backend.NewLoggerWith("logger", "tsdb.prometheus")
plog.Debug("Initializing")
return &Service{
- im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider, cfg, features, plog)),
- features: features,
- logger: plog,
+ im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider, cfg, plog)),
+ logger: plog,
}
}
-func newInstanceSettings(httpClientProvider *httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles, log log.Logger) datasource.InstanceFactoryFunc {
+func newInstanceSettings(httpClientProvider *httpclient.Provider, cfg *setting.Cfg, log log.Logger) datasource.InstanceFactoryFunc {
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
// Creates a http roundTripper.
opts, err := client.CreateTransportOptions(ctx, settings, cfg, log)
@@ -58,7 +56,7 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider, cfg *setting.C
}
// New version using custom client and better response parsing
- qd, err := querydata.New(httpClient, features, settings, log)
+ qd, err := querydata.New(httpClient, settings, log)
if err != nil {
return nil, err
}
diff --git a/pkg/tsdb/prometheus/prometheus_test.go b/pkg/tsdb/prometheus/prometheus_test.go
index 735ed25e38c..02307493664 100644
--- a/pkg/tsdb/prometheus/prometheus_test.go
+++ b/pkg/tsdb/prometheus/prometheus_test.go
@@ -10,9 +10,9 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/setting"
)
type fakeSender struct{}
@@ -69,7 +69,7 @@ func TestService(t *testing.T) {
f := &fakeHTTPClientProvider{}
httpProvider := getMockPromTestSDKProvider(f)
service := &Service{
- im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))),
+ im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, backend.NewLoggerWith("logger", "test"))),
}
req := &backend.CallResourceRequest{
diff --git a/pkg/tsdb/prometheus/querydata/framing_test.go b/pkg/tsdb/prometheus/querydata/framing_test.go
index feadc406619..0bf8ed7284c 100644
--- a/pkg/tsdb/prometheus/querydata/framing_test.go
+++ b/pkg/tsdb/prometheus/querydata/framing_test.go
@@ -14,9 +14,10 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/experimental"
- "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery"
"github.com/stretchr/testify/require"
+ "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery"
+
"github.com/grafana/grafana/pkg/tsdb/prometheus/models"
)
diff --git a/pkg/tsdb/prometheus/querydata/request.go b/pkg/tsdb/prometheus/querydata/request.go
index c35bede0815..41106ba85b3 100644
--- a/pkg/tsdb/prometheus/querydata/request.go
+++ b/pkg/tsdb/prometheus/querydata/request.go
@@ -7,6 +7,7 @@ import (
"regexp"
"time"
+ "github.com/grafana/grafana-azure-sdk-go/util/maputil"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"go.opentelemetry.io/otel/attribute"
@@ -15,13 +16,11 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tsdb/intervalv2"
"github.com/grafana/grafana/pkg/tsdb/prometheus/client"
"github.com/grafana/grafana/pkg/tsdb/prometheus/models"
"github.com/grafana/grafana/pkg/tsdb/prometheus/querydata/exemplar"
"github.com/grafana/grafana/pkg/tsdb/prometheus/utils"
- "github.com/grafana/grafana/pkg/util/maputil"
)
const legendFormatAuto = "__auto"
@@ -44,14 +43,11 @@ type QueryData struct {
ID int64
URL string
TimeInterval string
- enableDataplane bool
- enableScope bool
exemplarSampler func() exemplar.Sampler
}
func New(
httpClient *http.Client,
- features featuremgmt.FeatureToggles,
settings backend.DataSourceInstanceSettings,
plog log.Logger,
) (*QueryData, error) {
@@ -84,8 +80,6 @@ func New(
ID: settings.ID,
URL: settings.URL,
exemplarSampler: exemplarSampler,
- enableDataplane: features.IsEnabledGlobally(featuremgmt.FlagPrometheusDataplane),
- enableScope: features.IsEnabledGlobally(featuremgmt.FlagPromQLScope),
}, nil
}
@@ -95,13 +89,17 @@ func (s *QueryData) Execute(ctx context.Context, req *backend.QueryDataRequest)
Responses: backend.Responses{},
}
+ cfg := backend.GrafanaConfigFromContext(ctx)
+ hasPromQLScopeFeatureFlag := cfg.FeatureToggles().IsEnabled("promQLScope")
+ hasPrometheusDataplaneFeatureFlag := cfg.FeatureToggles().IsEnabled("prometheusDataplane")
+
for _, q := range req.Queries {
- query, err := models.Parse(q, s.TimeInterval, s.intervalCalculator, fromAlert, s.enableScope)
+ query, err := models.Parse(q, s.TimeInterval, s.intervalCalculator, fromAlert, hasPromQLScopeFeatureFlag)
if err != nil {
return &result, err
}
- r := s.fetch(ctx, s.client, query, req.Headers)
+ r := s.fetch(ctx, s.client, query, hasPrometheusDataplaneFeatureFlag)
if r == nil {
s.log.FromContext(ctx).Debug("Received nil response from runQuery", "query", query.Expr)
continue
@@ -112,7 +110,7 @@ func (s *QueryData) Execute(ctx context.Context, req *backend.QueryDataRequest)
return &result, nil
}
-func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.Query, headers map[string]string) *backend.DataResponse {
+func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.Query, enablePrometheusDataplane bool) *backend.DataResponse {
traceCtx, end := s.trace(ctx, q)
defer end()
@@ -125,14 +123,14 @@ func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.
}
if q.InstantQuery {
- res := s.instantQuery(traceCtx, client, q, headers)
+ res := s.instantQuery(traceCtx, client, q, enablePrometheusDataplane)
dr.Error = res.Error
dr.Frames = res.Frames
dr.Status = res.Status
}
if q.RangeQuery {
- res := s.rangeQuery(traceCtx, client, q, headers)
+ res := s.rangeQuery(traceCtx, client, q, enablePrometheusDataplane)
if res.Error != nil {
if dr.Error == nil {
dr.Error = res.Error
@@ -147,7 +145,7 @@ func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.
}
if q.ExemplarQuery {
- res := s.exemplarQuery(traceCtx, client, q, headers)
+ res := s.exemplarQuery(traceCtx, client, q, enablePrometheusDataplane)
if res.Error != nil {
// If exemplar query returns error, we want to only log it and
// continue with other results processing
@@ -159,7 +157,7 @@ func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.
return dr
}
-func (s *QueryData) rangeQuery(ctx context.Context, c *client.Client, q *models.Query, headers map[string]string) backend.DataResponse {
+func (s *QueryData) rangeQuery(ctx context.Context, c *client.Client, q *models.Query, enablePrometheusDataplaneFlag bool) backend.DataResponse {
res, err := c.QueryRange(ctx, q)
if err != nil {
return backend.DataResponse{
@@ -175,10 +173,10 @@ func (s *QueryData) rangeQuery(ctx context.Context, c *client.Client, q *models.
}
}()
- return s.parseResponse(ctx, q, res)
+ return s.parseResponse(ctx, q, res, enablePrometheusDataplaneFlag)
}
-func (s *QueryData) instantQuery(ctx context.Context, c *client.Client, q *models.Query, headers map[string]string) backend.DataResponse {
+func (s *QueryData) instantQuery(ctx context.Context, c *client.Client, q *models.Query, enablePrometheusDataplaneFlag bool) backend.DataResponse {
res, err := c.QueryInstant(ctx, q)
if err != nil {
return backend.DataResponse{
@@ -201,10 +199,10 @@ func (s *QueryData) instantQuery(ctx context.Context, c *client.Client, q *model
}
}()
- return s.parseResponse(ctx, q, res)
+ return s.parseResponse(ctx, q, res, enablePrometheusDataplaneFlag)
}
-func (s *QueryData) exemplarQuery(ctx context.Context, c *client.Client, q *models.Query, headers map[string]string) backend.DataResponse {
+func (s *QueryData) exemplarQuery(ctx context.Context, c *client.Client, q *models.Query, enablePrometheusDataplaneFlag bool) backend.DataResponse {
res, err := c.QueryExemplars(ctx, q)
if err != nil {
return backend.DataResponse{
@@ -218,7 +216,7 @@ func (s *QueryData) exemplarQuery(ctx context.Context, c *client.Client, q *mode
s.log.Warn("Failed to close response body", "error", err)
}
}()
- return s.parseResponse(ctx, q, res)
+ return s.parseResponse(ctx, q, res, enablePrometheusDataplaneFlag)
}
func (s *QueryData) trace(ctx context.Context, q *models.Query) (context.Context, func()) {
diff --git a/pkg/tsdb/prometheus/querydata/request_test.go b/pkg/tsdb/prometheus/querydata/request_test.go
index 40d3d783b76..488d676f25e 100644
--- a/pkg/tsdb/prometheus/querydata/request_test.go
+++ b/pkg/tsdb/prometheus/querydata/request_test.go
@@ -15,7 +15,6 @@ import (
p "github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery"
"github.com/grafana/kindsys"
@@ -442,7 +441,6 @@ func setup() (*testContext, error) {
JSONData: json.RawMessage(`{"timeInterval": "15s"}`),
}
- features := featuremgmt.WithFeatures()
opts, err := client.CreateTransportOptions(context.Background(), settings, &setting.Cfg{}, log.New())
if err != nil {
return nil, err
@@ -453,7 +451,7 @@ func setup() (*testContext, error) {
return nil, err
}
- queryData, _ := querydata.New(httpClient, features, settings, log.New())
+ queryData, _ := querydata.New(httpClient, settings, log.New())
return &testContext{
httpProvider: httpProvider,
diff --git a/pkg/tsdb/prometheus/querydata/response.go b/pkg/tsdb/prometheus/querydata/response.go
index be09ff3e575..dd0e5f3f31b 100644
--- a/pkg/tsdb/prometheus/querydata/response.go
+++ b/pkg/tsdb/prometheus/querydata/response.go
@@ -18,7 +18,7 @@ import (
"github.com/grafana/grafana/pkg/util/converter"
)
-func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *http.Response) backend.DataResponse {
+func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *http.Response, enablePrometheusDataplaneFlag bool) backend.DataResponse {
defer func() {
if err := res.Body.Close(); err != nil {
s.log.FromContext(ctx).Error("Failed to close response body", "err", err)
@@ -30,7 +30,7 @@ func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *htt
iter := jsoniter.Parse(jsoniter.ConfigDefault, res.Body, 1024)
r := converter.ReadPrometheusStyleResult(iter, converter.Options{
- Dataplane: s.enableDataplane,
+ Dataplane: enablePrometheusDataplaneFlag,
})
r.Status = backend.Status(res.StatusCode)
@@ -41,7 +41,7 @@ func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *htt
// The ExecutedQueryString can be viewed in QueryInspector in UI
for i, frame := range r.Frames {
- addMetadataToMultiFrame(q, frame, s.enableDataplane)
+ addMetadataToMultiFrame(q, frame, enablePrometheusDataplaneFlag)
if i == 0 {
frame.Meta.ExecutedQueryString = executedQueryString(q)
}
diff --git a/pkg/tsdb/prometheus/querydata/response_test.go b/pkg/tsdb/prometheus/querydata/response_test.go
index e449f3610d8..62d27222d8e 100644
--- a/pkg/tsdb/prometheus/querydata/response_test.go
+++ b/pkg/tsdb/prometheus/querydata/response_test.go
@@ -19,7 +19,7 @@ func TestQueryData_parseResponse(t *testing.T) {
t.Run("resultType is before result the field must parsed normally", func(t *testing.T) {
resBody := `{"data":{"resultType":"vector", "result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}`
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
- result := qd.parseResponse(context.Background(), &models.Query{}, res)
+ result := qd.parseResponse(context.Background(), &models.Query{}, res, false)
assert.Nil(t, result.Error)
assert.Len(t, result.Frames, 1)
})
@@ -27,7 +27,7 @@ func TestQueryData_parseResponse(t *testing.T) {
t.Run("resultType is after the result field must parsed normally", func(t *testing.T) {
resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}],"resultType":"vector"},"status":"success"}`
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
- result := qd.parseResponse(context.Background(), &models.Query{}, res)
+ result := qd.parseResponse(context.Background(), &models.Query{}, res, false)
assert.Nil(t, result.Error)
assert.Len(t, result.Frames, 1)
})
@@ -35,7 +35,7 @@ func TestQueryData_parseResponse(t *testing.T) {
t.Run("no resultType is existed in the data", func(t *testing.T) {
resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}`
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
- result := qd.parseResponse(context.Background(), &models.Query{}, res)
+ result := qd.parseResponse(context.Background(), &models.Query{}, res, false)
assert.Error(t, result.Error)
assert.Equal(t, result.Error.Error(), "no resultType found")
})
@@ -43,7 +43,7 @@ func TestQueryData_parseResponse(t *testing.T) {
t.Run("resultType is set as empty string before result", func(t *testing.T) {
resBody := `{"data":{"resultType":"", "result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}`
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
- result := qd.parseResponse(context.Background(), &models.Query{}, res)
+ result := qd.parseResponse(context.Background(), &models.Query{}, res, false)
assert.Error(t, result.Error)
assert.Equal(t, result.Error.Error(), "unknown result type: ")
})
@@ -51,7 +51,7 @@ func TestQueryData_parseResponse(t *testing.T) {
t.Run("resultType is set as empty string after result", func(t *testing.T) {
resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}],"resultType":""},"status":"success"}`
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
- result := qd.parseResponse(context.Background(), &models.Query{}, res)
+ result := qd.parseResponse(context.Background(), &models.Query{}, res, false)
assert.Error(t, result.Error)
assert.Equal(t, result.Error.Error(), "unknown result type: ")
})
diff --git a/pkg/tsdb/prometheus/resource/resource.go b/pkg/tsdb/prometheus/resource/resource.go
index 1c5223c3617..aeda2303ea2 100644
--- a/pkg/tsdb/prometheus/resource/resource.go
+++ b/pkg/tsdb/prometheus/resource/resource.go
@@ -6,11 +6,12 @@ import (
"fmt"
"net/http"
+ "github.com/grafana/grafana-azure-sdk-go/util/maputil"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
+
"github.com/grafana/grafana/pkg/tsdb/prometheus/client"
"github.com/grafana/grafana/pkg/tsdb/prometheus/utils"
- "github.com/grafana/grafana/pkg/util/maputil"
)
type Resource struct {
diff --git a/pkg/tsdb/sqleng/proxyutil/proxy_util.go b/pkg/tsdb/sqleng/proxyutil/proxy_util.go
index 9f5d41e0c78..e1b60d82990 100644
--- a/pkg/tsdb/sqleng/proxyutil/proxy_util.go
+++ b/pkg/tsdb/sqleng/proxyutil/proxy_util.go
@@ -6,9 +6,11 @@ import (
"github.com/grafana/grafana/pkg/tsdb/sqleng"
)
-func GetSQLProxyOptions(cfg setting.SecureSocksDSProxySettings, dsInfo sqleng.DataSourceInfo) *sdkproxy.Options {
+func GetSQLProxyOptions(cfg setting.SecureSocksDSProxySettings, dsInfo sqleng.DataSourceInfo, datasource, datasourceType string) *sdkproxy.Options {
opts := &sdkproxy.Options{
- Enabled: dsInfo.JsonData.SecureDSProxy && cfg.Enabled,
+ Enabled: dsInfo.JsonData.SecureDSProxy && cfg.Enabled,
+ DatasourceName: datasource,
+ DatasourceType: datasourceType,
Auth: &sdkproxy.AuthOptions{
Username: dsInfo.UID,
},
diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json
index 3a27c7aa42e..1fce6c5f1c1 100644
--- a/public/api-enterprise-spec.json
+++ b/public/api-enterprise-spec.json
@@ -3258,8 +3258,12 @@
"dashboard"
],
"properties": {
+ "apiVersion": {
+ "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
+ "type": "string"
+ },
"dashboard": {
- "$ref": "#/definitions/Json"
+ "$ref": "#/definitions/Unstructured"
},
"deleteKey": {
"description": "Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.",
@@ -3280,6 +3284,10 @@
"description": "Define the unique key. Required if `external` is `true`.",
"type": "string"
},
+ "kind": {
+ "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
+ "type": "string"
+ },
"name": {
"description": "Snapshot name",
"type": "string"
@@ -3618,6 +3626,41 @@
}
}
},
+ "DashboardCreateCommand": {
+ "description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object",
+ "type": "object",
+ "required": [
+ "dashboard"
+ ],
+ "properties": {
+ "apiVersion": {
+ "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
+ "type": "string"
+ },
+ "dashboard": {
+ "$ref": "#/definitions/Unstructured"
+ },
+ "expires": {
+ "description": "When the snapshot should expire in seconds in seconds. Default is never to expire.",
+ "type": "integer",
+ "format": "int64",
+ "default": 0
+ },
+ "external": {
+ "description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.",
+ "type": "boolean",
+ "default": false
+ },
+ "kind": {
+ "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
+ "type": "string"
+ },
+ "name": {
+ "description": "Snapshot name",
+ "type": "string"
+ }
+ }
+ },
"DashboardFullWithMeta": {
"type": "object",
"properties": {
@@ -6537,24 +6580,6 @@
}
}
},
- "SSOSettings": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string"
- },
- "provider": {
- "type": "string"
- },
- "settings": {
- "type": "object",
- "additionalProperties": {}
- },
- "source": {
- "$ref": "#/definitions/SettingsSource"
- }
- }
- },
"SaveDashboardCommand": {
"type": "object",
"properties": {
@@ -6945,10 +6970,6 @@
}
}
},
- "SettingsSource": {
- "type": "integer",
- "format": "int64"
- },
"ShareType": {
"type": "string"
},
@@ -7402,6 +7423,21 @@
"Type": {
"type": "string"
},
+ "TypeMeta": {
+ "description": "+k8s:deepcopy-gen=false",
+ "type": "object",
+ "title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.",
+ "properties": {
+ "apiVersion": {
+ "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
+ "type": "string"
+ },
+ "kind": {
+ "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
+ "type": "string"
+ }
+ }
+ },
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
@@ -7442,6 +7478,17 @@
}
}
},
+ "Unstructured": {
+ "description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.",
+ "type": "object",
+ "properties": {
+ "Object": {
+ "description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.",
+ "type": "object",
+ "additionalProperties": {}
+ }
+ }
+ },
"UpdateAlertNotificationCommand": {
"type": "object",
"properties": {
@@ -7973,6 +8020,9 @@
"theme": {
"type": "string"
},
+ "uid": {
+ "type": "string"
+ },
"updatedAt": {
"type": "string",
"format": "date-time"
@@ -8016,6 +8066,9 @@
},
"name": {
"type": "string"
+ },
+ "uid": {
+ "type": "string"
}
}
},
@@ -9006,7 +9059,22 @@
"getSSOSettingsResponse": {
"description": "",
"schema": {
- "$ref": "#/definitions/SSOSettings"
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "source": {
+ "type": "string"
+ }
+ }
}
},
"getSharingOptionsResponse": {
@@ -9192,7 +9260,22 @@
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/SSOSettings"
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "source": {
+ "type": "string"
+ }
+ }
}
}
},
diff --git a/public/api-merged.json b/public/api-merged.json
index e30d02d361b..0595858477e 100644
--- a/public/api-merged.json
+++ b/public/api-merged.json
@@ -10362,6 +10362,61 @@
}
}
},
+ "/v1/notifications/time-intervals": {
+ "get": {
+ "description": "Get all the time intervals",
+ "tags": [
+ "notifications"
+ ],
+ "operationId": "RouteNotificationsGetTimeIntervals",
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetAllIntervalsResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ }
+ }
+ }
+ },
+ "/v1/notifications/time-intervals/{name}": {
+ "get": {
+ "tags": [
+ "notifications"
+ ],
+ "summary": "Get a time interval by name.",
+ "operationId": "RouteNotificationsGetTimeInterval",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Time interval name",
+ "name": "name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GetIntervalsByNameResponse"
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "schema": {
+ "$ref": "#/definitions/ForbiddenError"
+ }
+ },
+ "404": {
+ "description": "NotFound",
+ "schema": {
+ "$ref": "#/definitions/NotFound"
+ }
+ }
+ }
+ }
+ },
"/v1/provisioning/alert-rules": {
"get": {
"tags": [
@@ -11475,7 +11530,19 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/SSOSettings"
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
}
}
],
@@ -13511,8 +13578,12 @@
"dashboard"
],
"properties": {
+ "apiVersion": {
+ "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
+ "type": "string"
+ },
"dashboard": {
- "$ref": "#/definitions/Json"
+ "$ref": "#/definitions/Unstructured"
},
"deleteKey": {
"description": "Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.",
@@ -13533,6 +13604,10 @@
"description": "Define the unique key. Required if `external` is `true`.",
"type": "string"
},
+ "kind": {
+ "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
+ "type": "string"
+ },
"name": {
"description": "Snapshot name",
"type": "string"
@@ -13871,6 +13946,41 @@
}
}
},
+ "DashboardCreateCommand": {
+ "description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object",
+ "type": "object",
+ "required": [
+ "dashboard"
+ ],
+ "properties": {
+ "apiVersion": {
+ "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
+ "type": "string"
+ },
+ "dashboard": {
+ "$ref": "#/definitions/Unstructured"
+ },
+ "expires": {
+ "description": "When the snapshot should expire in seconds in seconds. Default is never to expire.",
+ "type": "integer",
+ "format": "int64",
+ "default": 0
+ },
+ "external": {
+ "description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.",
+ "type": "boolean",
+ "default": false
+ },
+ "kind": {
+ "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
+ "type": "string"
+ },
+ "name": {
+ "description": "Snapshot name",
+ "type": "string"
+ }
+ }
+ },
"DashboardFullWithMeta": {
"type": "object",
"properties": {
@@ -15521,6 +15631,23 @@
}
}
},
+ "GettableTimeIntervals": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "provenance": {
+ "$ref": "#/definitions/Provenance"
+ },
+ "time_intervals": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ }
+ }
+ }
+ },
"GettableUserConfig": {
"type": "object",
"properties": {
@@ -17769,6 +17896,20 @@
}
}
},
+ "PostableTimeIntervals": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "time_intervals": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TimeIntervalItem"
+ }
+ }
+ }
+ },
"PostableUserConfig": {
"type": "object",
"properties": {
@@ -19268,24 +19409,6 @@
}
}
},
- "SSOSettings": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string"
- },
- "provider": {
- "type": "string"
- },
- "settings": {
- "type": "object",
- "additionalProperties": false
- },
- "source": {
- "$ref": "#/definitions/SettingsSource"
- }
- }
- },
"Sample": {
"type": "object",
"title": "Sample is a single sample belonging to a metric.",
@@ -19704,10 +19827,6 @@
}
}
},
- "SettingsSource": {
- "type": "integer",
- "format": "int64"
- },
"ShareType": {
"type": "string"
},
@@ -20458,6 +20577,55 @@
}
}
},
+ "TimeIntervalItem": {
+ "type": "object",
+ "properties": {
+ "days_of_month": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "location": {
+ "type": "string"
+ },
+ "months": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "times": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TimeIntervalTimeRange"
+ }
+ },
+ "weekdays": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "years": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "TimeIntervalTimeRange": {
+ "type": "object",
+ "properties": {
+ "end_time": {
+ "type": "string"
+ },
+ "start_time": {
+ "type": "string"
+ }
+ }
+ },
"TimeRange": {
"description": "Redefining this to avoid an import cycle",
"type": "object",
@@ -20639,6 +20807,21 @@
"Type": {
"type": "string"
},
+ "TypeMeta": {
+ "description": "+k8s:deepcopy-gen=false",
+ "type": "object",
+ "title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.",
+ "properties": {
+ "apiVersion": {
+ "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
+ "type": "string"
+ },
+ "kind": {
+ "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
+ "type": "string"
+ }
+ }
+ },
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
@@ -20679,6 +20862,17 @@
}
}
},
+ "Unstructured": {
+ "description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.",
+ "type": "object",
+ "properties": {
+ "Object": {
+ "description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.",
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ },
"UpdateAlertNotificationCommand": {
"type": "object",
"properties": {
@@ -21236,6 +21430,9 @@
"theme": {
"type": "string"
},
+ "uid": {
+ "type": "string"
+ },
"updatedAt": {
"type": "string",
"format": "date-time"
@@ -21279,6 +21476,9 @@
},
"name": {
"type": "string"
+ },
+ "uid": {
+ "type": "string"
}
}
},
@@ -21729,6 +21929,7 @@
}
},
"gettableSilence": {
+ "description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@@ -21777,13 +21978,13 @@
}
},
"gettableSilences": {
- "description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
}
},
"integration": {
+ "description": "Integration integration",
"type": "object",
"required": [
"name",
@@ -21993,7 +22194,6 @@
}
},
"receiver": {
- "description": "Receiver receiver",
"type": "object",
"required": [
"active",
@@ -22180,6 +22380,21 @@
}
},
"responses": {
+ "GetAllIntervalsResponse": {
+ "description": "(empty)",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ }
+ }
+ },
+ "GetIntervalsByNameResponse": {
+ "description": "(empty)",
+ "schema": {
+ "$ref": "#/definitions/GettableTimeIntervals"
+ }
+ },
"GettableHistoricUserConfigs": {
"description": "(empty)",
"schema": {
@@ -22991,7 +23206,22 @@
"getSSOSettingsResponse": {
"description": "(empty)",
"schema": {
- "$ref": "#/definitions/SSOSettings"
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object",
+ "additionalProperties": false
+ },
+ "source": {
+ "type": "string"
+ }
+ }
}
},
"getSharingOptionsResponse": {
@@ -23177,7 +23407,22 @@
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/SSOSettings"
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object",
+ "additionalProperties": false
+ },
+ "source": {
+ "type": "string"
+ }
+ }
}
}
},
diff --git a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx
index 15777eb8580..3b25134f5ff 100644
--- a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx
+++ b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx
@@ -45,6 +45,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
zIndex: theme.zIndex.portal,
position: 'fixed',
bottom: theme.spacing.x4,
+ boxShadow: theme.shadows.z3,
}),
});
diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
index 819608c4fb1..6bdfc8c89c6 100644
--- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx
+++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
@@ -9,7 +9,6 @@ import {
Button,
Field,
FieldSet,
- Form,
Label,
Select,
stylesFactory,
@@ -87,7 +86,8 @@ export class SharedPreferences extends PureComponent {
});
}
- onSubmitForm = async () => {
+ onSubmitForm = async (event: React.FormEvent) => {
+ event.preventDefault();
const confirmationResult = this.props.onConfirm ? await this.props.onConfirm() : true;
if (confirmationResult) {
@@ -137,94 +137,84 @@ export class SharedPreferences extends PureComponent {
const currentThemeOption = this.themeOptions.find((x) => x.value === theme) ?? this.themeOptions[0];
return (
- } disabled={disabled}>
-
-
-
+
+
+
+ Language
+
+
+
+ }
+ data-testid="User preferences language drop down"
+ >
+
+
+
+
);
}
}
@@ -233,9 +223,13 @@ export default SharedPreferences;
const getStyles = stylesFactory(() => {
return {
- labelText: css`
- margin-right: 6px;
- `,
+ labelText: css({
+ marginRight: '6px',
+ }),
+ form: css({
+ width: '100%',
+ maxWidth: '600px',
+ }),
};
});
diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts
index 9ab94dbdf07..f2c436062bd 100644
--- a/public/app/core/reducers/root.ts
+++ b/public/app/core/reducers/root.ts
@@ -2,7 +2,6 @@ import { ReducersMapObject } from '@reduxjs/toolkit';
import { AnyAction, combineReducers } from 'redux';
import sharedReducers from 'app/core/reducers';
-import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI';
import ldapReducers from 'app/features/admin/state/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
import apiKeysReducers from 'app/features/api-keys/state/reducers';
@@ -56,7 +55,6 @@ const rootReducers = {
[alertingApi.reducerPath]: alertingApi.reducer,
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
- [togglesApi.reducerPath]: togglesApi.reducer,
};
const addedReducers = {};
diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts
index 6b49da8f97e..d305d2a4fbc 100644
--- a/public/app/core/services/context_srv.ts
+++ b/public/app/core/services/context_srv.ts
@@ -15,6 +15,7 @@ export const AutoRefreshInterval = 'auto';
export class User implements Omit {
isSignedIn: boolean;
id: number;
+ uid: string;
login: string;
email: string;
name: string;
@@ -39,6 +40,7 @@ export class User implements Omit {
constructor() {
this.id = 0;
+ this.uid = '';
this.isGrafanaAdmin = false;
this.isSignedIn = false;
this.orgRole = '';
diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts
index f454486f5be..c20b8ec34f1 100644
--- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts
+++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts
@@ -1,5 +1,5 @@
import { BuildInfo } from '@grafana/data';
-import { BaseTransport } from '@grafana/faro-core';
+import { BaseTransport, defaultInternalLoggerLevel } from '@grafana/faro-core';
import {
initializeFaro,
BrowserConfig,
@@ -74,6 +74,7 @@ export class GrafanaJavascriptAgentBackend
batching: {
sendTimeout: 1000,
},
+ internalLoggerLevel: options.internalLoggerLevel || defaultInternalLoggerLevel,
};
this.faroInstance = initializeFaro(grafanaJavaScriptAgentOptions);
diff --git a/public/app/features/admin/AdminEditOrgPage.tsx b/public/app/features/admin/AdminEditOrgPage.tsx
index e1cab49a294..910bc855251 100644
--- a/public/app/features/admin/AdminEditOrgPage.tsx
+++ b/public/app/features/admin/AdminEditOrgPage.tsx
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
import { useAsyncFn } from 'react-use';
import { NavModelItem } from '@grafana/data';
-import { Form, Field, Input, Button, Legend, Alert } from '@grafana/ui';
+import { Field, Input, Button, Legend, Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@@ -27,6 +28,11 @@ const AdminEditOrgPage = ({ match }: Props) => {
const [totalPages, setTotalPages] = useState(1);
const [orgState, fetchOrg] = useAsyncFn(() => getOrg(orgId), []);
+ const {
+ handleSubmit,
+ register,
+ formState: { errors },
+ } = useForm();
const [, fetchOrgUsers] = useAsyncFn(async (page) => {
const result = await getOrgUsers(orgId, page);
@@ -45,8 +51,8 @@ const AdminEditOrgPage = ({ match }: Props) => {
fetchOrgUsers(page);
}, [fetchOrg, fetchOrgUsers, page]);
- const onUpdateOrgName = async (name: string) => {
- await updateOrgName(name, orgId);
+ const onUpdateOrgName = async ({ orgName }: OrgNameDTO) => {
+ await updateOrgName(orgName, orgId);
};
const renderMissingPermissionMessage = () => (
@@ -82,21 +88,18 @@ const AdminEditOrgPage = ({ match }: Props) => {
<>
{orgState.value && (
-
+
)}
diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.test.ts b/public/app/features/admin/AdminFeatureTogglesAPI.test.ts
new file mode 100644
index 00000000000..ca6c25be525
--- /dev/null
+++ b/public/app/features/admin/AdminFeatureTogglesAPI.test.ts
@@ -0,0 +1,102 @@
+import { BackendSrvRequest, config } from '@grafana/runtime';
+
+import { getTogglesAPI } from './AdminFeatureTogglesAPI';
+
+// implements @grafana/runtime/BackendSrv
+class MockSrv {
+ constructor() {
+ this.apiCalls = [];
+ }
+
+ apiCalls: Array<{
+ url: string;
+ method: string;
+ }>;
+
+ async get(
+ url: string,
+ params?: BackendSrvRequest['params'],
+ requestId?: BackendSrvRequest['requestId'],
+ options?: Partial
+ ) {
+ this.apiCalls.push({
+ url: url,
+ method: 'get',
+ });
+ if (config.featureToggles.kubernetesFeatureToggles && url.indexOf('current') > -1) {
+ return await { toggles: [] };
+ }
+
+ return await {};
+ }
+
+ async post(url: string, data?: unknown, options?: Partial) {
+ this.apiCalls.push({
+ url: url,
+ method: 'post',
+ });
+ return await {};
+ }
+
+ async patch(url: string, data: unknown, options?: Partial) {
+ this.apiCalls.push({
+ url: url,
+ method: 'patch',
+ });
+ return await {};
+ }
+
+ // these aren't needed for this test
+ async put(url: string, data: unknown, options?: Partial) {
+ return await {};
+ }
+ async delete(url: string, data?: unknown, options?: Partial) {
+ return await {};
+ }
+}
+
+const testBackendSrv = new MockSrv();
+
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ getBackendSrv: () => testBackendSrv,
+ config: {
+ featureToggles: {
+ kubernetesFeatureToggles: false,
+ grafanaAPIServerWithExperimentalAPIs: false,
+ },
+ },
+}));
+
+describe('AdminFeatureTogglesApi', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ testBackendSrv.apiCalls.length = 0;
+ });
+
+ const originalToggles = { ...config.featureToggles };
+
+ afterAll(() => {
+ config.featureToggles = originalToggles;
+ });
+
+ it('uses the k8s api when the k8s toggles are on', async () => {
+ config.featureToggles.kubernetesFeatureToggles = true;
+ config.featureToggles.grafanaAPIServerWithExperimentalAPIs = true;
+
+ const togglesApi = getTogglesAPI();
+ await togglesApi.getFeatureToggles();
+ await togglesApi.updateFeatureToggles([]);
+ const expected = [
+ {
+ method: 'get',
+ url: '/apis/featuretoggle.grafana.app/v0alpha1/current',
+ },
+ {
+ method: 'patch',
+ url: '/apis/featuretoggle.grafana.app/v0alpha1/current',
+ },
+ ];
+ expect(testBackendSrv.apiCalls).toEqual(expect.arrayContaining(expected));
+ });
+});
diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.ts b/public/app/features/admin/AdminFeatureTogglesAPI.ts
index 889f6f0759e..62f0c8b5f26 100644
--- a/public/app/features/admin/AdminFeatureTogglesAPI.ts
+++ b/public/app/features/admin/AdminFeatureTogglesAPI.ts
@@ -1,62 +1,74 @@
-import { createApi, BaseQueryFn } from '@reduxjs/toolkit/query/react';
-import { lastValueFrom } from 'rxjs';
-
import { getBackendSrv } from '@grafana/runtime';
-type QueryArgs = {
- url: string;
- method?: string;
- body?: { featureToggles: FeatureToggle[] };
-};
-
-const backendSrvBaseQuery =
- ({ baseUrl }: { baseUrl: string }): BaseQueryFn =>
- async ({ url, method = 'GET', body }) => {
- try {
- const { data } = await lastValueFrom(
- getBackendSrv().fetch({
- url: baseUrl + url,
- method,
- data: body,
- })
- );
- return { data };
- } catch (error) {
- return { error };
- }
- };
-
-export const togglesApi = createApi({
- reducerPath: 'togglesApi',
- baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }),
- endpoints: (builder) => ({
- getManagerState: builder.query({
- query: () => ({ url: '/featuremgmt/state' }),
- }),
- getFeatureToggles: builder.query({
- query: () => ({ url: '/featuremgmt' }),
- }),
- updateFeatureToggles: builder.mutation({
- query: (updatedToggles) => ({
- url: '/featuremgmt',
- method: 'POST',
- body: { featureToggles: updatedToggles },
- }),
- }),
- }),
-});
-
-type FeatureToggle = {
+export type FeatureToggle = {
name: string;
description?: string;
enabled: boolean;
readOnly?: boolean;
+ hidden?: boolean;
};
-type FeatureMgmtState = {
+export type CurrentTogglesState = {
restartRequired: boolean;
allowEditing: boolean;
+ toggles: FeatureToggle[];
};
-export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi;
-export type { FeatureToggle, FeatureMgmtState };
+interface ResolvedToggleState {
+ kind: 'ResolvedToggleState';
+ restartRequired?: boolean;
+ allowEditing?: boolean;
+ toggles?: K8sToggleSpec[]; // not used in patch
+ enabled: { [key: string]: boolean };
+}
+
+interface K8sToggleSpec {
+ name: string;
+ description: string;
+ enabled: boolean;
+ writeable: boolean;
+ source: K8sToggleSource;
+}
+
+interface K8sToggleSource {
+ namespace: string;
+ name: string;
+}
+
+interface FeatureTogglesAPI {
+ getFeatureToggles(): Promise;
+ updateFeatureToggles(toggles: FeatureToggle[]): Promise;
+}
+
+class K8sAPI implements FeatureTogglesAPI {
+ baseURL = '/apis/featuretoggle.grafana.app/v0alpha1';
+
+ async getFeatureToggles(): Promise {
+ const current = await getBackendSrv().get(this.baseURL + '/current');
+ return {
+ restartRequired: Boolean(current.restartRequired),
+ allowEditing: Boolean(current.allowEditing),
+ toggles: current.toggles!.map((t) => ({
+ name: t.name,
+ description: t.description!,
+ enabled: t.enabled,
+ readOnly: !Boolean(t.writeable),
+ hidden: false, // only return visible things
+ })),
+ };
+ }
+ updateFeatureToggles(toggles: FeatureToggle[]): Promise {
+ const patchBody: ResolvedToggleState = {
+ kind: 'ResolvedToggleState',
+ enabled: {},
+ };
+ toggles.forEach((t) => {
+ patchBody.enabled[t.name] = t.enabled;
+ });
+ return getBackendSrv().patch(this.baseURL + '/current', patchBody);
+ }
+}
+
+export const getTogglesAPI = (): FeatureTogglesAPI => {
+ return new K8sAPI();
+};
diff --git a/public/app/features/admin/AdminFeatureTogglesPage.tsx b/public/app/features/admin/AdminFeatureTogglesPage.tsx
index 7c7fde06c5f..4d231b644fd 100644
--- a/public/app/features/admin/AdminFeatureTogglesPage.tsx
+++ b/public/app/features/admin/AdminFeatureTogglesPage.tsx
@@ -1,26 +1,24 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
+import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Icon } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
-import { useGetFeatureTogglesQuery, useGetManagerStateQuery } from './AdminFeatureTogglesAPI';
+import { getTogglesAPI } from './AdminFeatureTogglesAPI';
import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable';
export default function AdminFeatureTogglesPage() {
- const { data: featureToggles, isLoading, isError } = useGetFeatureTogglesQuery();
- const { data: featureMgmtState } = useGetManagerStateQuery();
+ const [reload] = useState(1);
+ const togglesApi = getTogglesAPI();
+ const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]);
const [updateSuccessful, setUpdateSuccessful] = useState(false);
-
const styles = useStyles2(getStyles);
- const getErrorMessage = () => {
- return 'Error fetching feature toggles';
- };
-
const handleUpdateSuccess = () => {
setUpdateSuccessful(true);
+ // setReload(reload+1); << would trigger updating the server state!
};
const EditingAlert = () => {
@@ -30,7 +28,7 @@ export default function AdminFeatureTogglesPage() {
- {featureMgmtState?.restartRequired || updateSuccessful
+ {featureState.value?.restartRequired || updateSuccessful
? 'A restart is pending for your Grafana instance to apply the latest feature toggle changes'
: 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'}
@@ -54,15 +52,16 @@ export default function AdminFeatureTogglesPage() {
return (
-
+
<>
- {isError && getErrorMessage()}
- {isLoading && 'Fetching feature toggles'}
- {featureMgmtState?.allowEditing && }
- {featureToggles && (
+ {featureState.error}
+ {featureState.loading && 'Fetching feature toggles'}
+
+ {featureState.value?.restartRequired && }
+ {featureState.value && (
)}
diff --git a/public/app/features/admin/AdminFeatureTogglesTable.tsx b/public/app/features/admin/AdminFeatureTogglesTable.tsx
index d67a60c71eb..67697699af7 100644
--- a/public/app/features/admin/AdminFeatureTogglesTable.tsx
+++ b/public/app/features/admin/AdminFeatureTogglesTable.tsx
@@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react';
import { Switch, InteractiveTable, Tooltip, type CellProps, Button, type SortByFn } from '@grafana/ui';
-import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI';
+import { FeatureToggle, getTogglesAPI } from './AdminFeatureTogglesAPI';
interface Props {
featureToggles: FeatureToggle[];
@@ -30,10 +30,12 @@ const sortByEnabled: SortByFn = (a, b) => {
};
export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdateSuccess }: Props) {
+ // sort manually, doesn't look like it can be automatically done in the table
+ featureToggles.sort((a, b) => a.name.localeCompare(b.name));
const serverToggles = useRef(featureToggles);
const [localToggles, setLocalToggles] = useState(featureToggles);
- const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
const [isSaving, setIsSaving] = useState(false);
+ const togglesApi = getTogglesAPI();
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
const updatedToggle = { ...toggle, enabled: newValue };
@@ -47,12 +49,10 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat
setIsSaving(true);
try {
const modifiedToggles = getModifiedToggles();
- const resp = await updateFeatureToggles(modifiedToggles);
- if (!('error' in resp)) {
- // server toggles successfully updated
- serverToggles.current = [...localToggles];
- onUpdateSuccess();
- }
+ await togglesApi.updateFeatureToggles(modifiedToggles);
+ // Pretend the values came from a new request
+ serverToggles.current = [...localToggles];
+ onUpdateSuccess(); // should trigger a new get
} finally {
setIsSaving(false);
}
diff --git a/public/app/features/admin/UserCreatePage.tsx b/public/app/features/admin/UserCreatePage.tsx
index da72bd30d90..34bdee39089 100644
--- a/public/app/features/admin/UserCreatePage.tsx
+++ b/public/app/features/admin/UserCreatePage.tsx
@@ -1,9 +1,10 @@
import React, { useCallback } from 'react';
+import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router-dom';
import { NavModelItem } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
-import { Form, Button, Input, Field } from '@grafana/ui';
+import { Button, Input, Field } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
interface UserDTO {
@@ -24,6 +25,11 @@ const pageNav: NavModelItem = {
const UserCreatePage = () => {
const history = useHistory();
+ const {
+ handleSubmit,
+ register,
+ formState: { errors },
+ } = useForm({ mode: 'onBlur' });
const onSubmit = useCallback(
async (data: UserDTO) => {
@@ -37,45 +43,34 @@ const UserCreatePage = () => {
return (
-
+
+
+
+
+ value.trim() !== '' && value.length >= 4,
+ })}
+ type="password"
+ />
+
+
+
);
diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx
index d61c7711ca1..d7bf34f6b87 100644
--- a/public/app/features/admin/ldap/LdapPage.tsx
+++ b/public/app/features/admin/ldap/LdapPage.tsx
@@ -1,9 +1,10 @@
-import React, { PureComponent } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
import { connect, ConnectedProps } from 'react-redux';
import { NavModelItem } from '@grafana/data';
import { featureEnabled } from '@grafana/runtime';
-import { Alert, Button, Field, Form, Input, Stack } from '@grafana/ui';
+import { Alert, Button, Field, Input, Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@@ -37,10 +38,6 @@ interface OwnProps extends GrafanaRouteComponentProps<{}, { username?: string }>
userError?: LdapError;
}
-interface State {
- isLoading: boolean;
-}
-
interface FormModel {
username: string;
}
@@ -52,101 +49,105 @@ const pageNav: NavModelItem = {
id: 'LDAP',
};
-export class LdapPage extends PureComponent {
- state = {
- isLoading: true,
- };
+export const LdapPage = ({
+ clearUserMappingInfo,
+ queryParams,
+ loadLdapState,
+ loadLdapSyncStatus,
+ loadUserMapping,
+ clearUserError,
+ ldapUser,
+ userError,
+ ldapError,
+ ldapSyncInfo,
+ ldapConnectionInfo,
+}: Props) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const { register, handleSubmit } = useForm();
- async componentDidMount() {
- const { clearUserMappingInfo, queryParams } = this.props;
- await clearUserMappingInfo();
- await this.fetchLDAPStatus();
+ const fetchUserMapping = useCallback(
+ async (username: string) => {
+ return loadUserMapping(username);
+ },
+ [loadUserMapping]
+ );
- if (queryParams.username) {
- await this.fetchUserMapping(queryParams.username);
+ useEffect(() => {
+ const fetchLDAPStatus = async () => {
+ return Promise.all([loadLdapState(), loadLdapSyncStatus()]);
+ };
+
+ async function init() {
+ await clearUserMappingInfo();
+ await fetchLDAPStatus();
+
+ if (queryParams.username) {
+ await fetchUserMapping(queryParams.username);
+ }
+
+ setIsLoading(false);
}
- this.setState({ isLoading: false });
- }
+ init();
+ }, [clearUserMappingInfo, fetchUserMapping, loadLdapState, loadLdapSyncStatus, queryParams]);
- async fetchLDAPStatus() {
- const { loadLdapState, loadLdapSyncStatus } = this.props;
- return Promise.all([loadLdapState(), loadLdapSyncStatus()]);
- }
-
- async fetchUserMapping(username: string) {
- const { loadUserMapping } = this.props;
- return await loadUserMapping(username);
- }
-
- search = (username: string) => {
+ const search = ({ username }: FormModel) => {
if (username) {
- this.fetchUserMapping(username);
+ fetchUserMapping(username);
}
};
- onClearUserError = () => {
- this.props.clearUserError();
+ const onClearUserError = () => {
+ clearUserError();
};
- render() {
- const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, queryParams } = this.props;
- const { isLoading } = this.state;
- const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead);
+ const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead);
+ return (
+
+
+
+ {ldapError && ldapError.title && (
+
+ {ldapError.body}
+
+ )}
- return (
-
-
-
- {ldapError && ldapError.title && (
-
- {ldapError.body}
-
- )}
+
-
+ {featureEnabled('ldapsync') && ldapSyncInfo && }
- {featureEnabled('ldapsync') && ldapSyncInfo && }
-
- {canReadLDAPUser && (
-
- Test user mapping
-
- {userError && userError.title && (
-
- {userError.body}
-
- )}
- {ldapUser && }
-
- )}
-
-
-
- );
- }
-}
+ {canReadLDAPUser && (
+
+ Test user mapping
+
+ {userError && userError.title && (
+
+ {userError.body}
+
+ )}
+ {ldapUser && }
+
+ )}
+
+
+
+ );
+};
const mapStateToProps = (state: StoreState) => ({
ldapConnectionInfo: state.ldap.connectionInfo,
diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts
index 2cc69d578dd..012bbd988e0 100644
--- a/public/app/features/alerting/unified/Analytics.ts
+++ b/public/app/features/alerting/unified/Analytics.ts
@@ -1,6 +1,5 @@
import { dateTime } from '@grafana/data';
-import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk';
-import { getBackendSrv, logError } from '@grafana/runtime';
+import { createMonitoringLogger, getBackendSrv } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime/src';
import { contextSrv } from 'app/core/core';
@@ -22,18 +21,14 @@ export const LogMessages = {
unknownMessageFromError: 'unknown messageFromError',
};
-// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent correctly
-export function logInfo(message: string, context: Record = {}) {
- if (config.grafanaJavascriptAgent.enabled) {
- faro.api.pushLog([message], {
- level: GrafanaLogLevel.INFO,
- context: { ...context, module: 'Alerting' },
- });
- }
+const alertingLogger = createMonitoringLogger('features.alerting', { module: 'Alerting' });
+
+export function logInfo(message: string, context?: Record) {
+ alertingLogger.logInfo(message, context);
}
-export function logAlertingError(error: Error, context: Record = {}) {
- logError(error, { ...context, module: 'Alerting' });
+export function logError(error: Error, context?: Record) {
+ alertingLogger.logError(error, context);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts
index 2ec7fc1d5c6..d4f9118395e 100644
--- a/public/app/features/alerting/unified/api/alertingApi.ts
+++ b/public/app/features/alerting/unified/api/alertingApi.ts
@@ -27,6 +27,12 @@ export const backendSrvBaseQuery = (): BaseQueryFn => async (
export const alertingApi = createApi({
reducerPath: 'alertingApi',
baseQuery: backendSrvBaseQuery(),
- tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration', 'OnCallIntegrations', 'OrgMigrationState'],
+ tagTypes: [
+ 'AlertmanagerChoice',
+ 'AlertmanagerConfiguration',
+ 'OnCallIntegrations',
+ 'OrgMigrationState',
+ 'DataSourceSettings',
+ ],
endpoints: () => ({}),
});
diff --git a/public/app/features/alerting/unified/api/dataSourcesApi.ts b/public/app/features/alerting/unified/api/dataSourcesApi.ts
new file mode 100644
index 00000000000..94bb2de0937
--- /dev/null
+++ b/public/app/features/alerting/unified/api/dataSourcesApi.ts
@@ -0,0 +1,15 @@
+import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
+
+import { alertingApi } from './alertingApi';
+
+export const dataSourcesApi = alertingApi.injectEndpoints({
+ endpoints: (build) => ({
+ getAllDataSourceSettings: build.query>, void>({
+ query: () => ({ url: 'api/datasources' }),
+ // we'll create individual cache entries for each datasource UID
+ providesTags: (result) => {
+ return result ? result.map(({ uid }) => ({ type: 'DataSourceSettings', id: uid })) : ['DataSourceSettings'];
+ },
+ }),
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx
index d84f930d3cc..757346b7976 100644
--- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx
+++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx
@@ -5,11 +5,11 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, CallToActionCard, Card, Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
-import { ExternalDataSourceAM } from '../../hooks/useExternalAmSelector';
+import { ExternalAlertmanagerDataSourceWithStatus } from '../../hooks/useExternalAmSelector';
import { makeDataSourceLink } from '../../utils/misc';
export interface ExternalAlertManagerDataSourcesProps {
- alertmanagers: ExternalDataSourceAM[];
+ alertmanagers: ExternalAlertmanagerDataSourceWithStatus[];
inactive: boolean;
}
@@ -39,7 +39,7 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext
{alertmanagers.length > 0 && (
{alertmanagers.map((am) => (
-
+
))}
)}
@@ -48,20 +48,20 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext
}
interface ExternalAMdataSourceCardProps {
- alertmanager: ExternalDataSourceAM;
+ alertmanager: ExternalAlertmanagerDataSourceWithStatus;
inactive: boolean;
}
export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMdataSourceCardProps) {
const styles = useStyles2(getStyles);
- const { dataSource, status, statusInconclusive, url } = alertmanager;
+ const { dataSourceSettings, status } = alertmanager;
return (
- {dataSource.name}{' '}
- {statusInconclusive && (
+ {dataSourceSettings.name}{' '}
+ {status === 'inconclusive' && (
@@ -90,9 +90,9 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd
/>
)}
- {url}
+ {dataSourceSettings.url}
-
+
Go to datasource
diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
index 1bfb98fe018..ddcf5b9176d 100644
--- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
+++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
@@ -23,6 +23,9 @@ export const ExternalAlertmanagers = () => {
const dispatch = useDispatch();
const externalDsAlertManagers = useExternalDataSourceAlertmanagers();
+ const gmaHandlingAlertmanagers = externalDsAlertManagers.filter(
+ (settings) => settings.dataSourceSettings.jsonData.handleGrafanaManagedAlerts === true
+ );
const {
useSaveExternalAlertmanagersConfigMutation,
@@ -71,7 +74,7 @@ export const ExternalAlertmanagers = () => {
diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts
index 79a71733fb7..38a69fd3b72 100644
--- a/public/app/features/alerting/unified/components/contact-points/utils.ts
+++ b/public/app/features/alerting/unified/components/contact-points/utils.ts
@@ -1,10 +1,12 @@
import { countBy, split, trim, upperFirst } from 'lodash';
import { ReactNode } from 'react';
+import { config } from '@grafana/runtime';
import {
AlertManagerCortexConfig,
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
+ MatcherOperator,
Route,
} from 'app/plugins/datasource/alertmanager/types';
import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/types';
@@ -17,6 +19,8 @@ import { getOnCallMetadata, ReceiverPluginMetadata } from '../receivers/grafanaA
import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY, RECEIVER_STATUS_KEY } from './useContactPoints';
+const AUTOGENERATED_RECEIVER_POLICY_MATCHER_KEY = '__grafana_receiver__';
+
export function isProvisioned(contactPoint: GrafanaManagedContactPoint) {
// for some reason the provenance is on the receiver and not the entire contact point
const provenance = contactPoint.grafana_managed_receiver_configs?.find((receiver) => receiver.provenance)?.provenance;
@@ -126,10 +130,27 @@ export function enhanceContactPointsWithMetadata(
};
});
}
+export function isAutoGeneratedPolicy(route: Route) {
+ const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
+ if (!simplifiedRoutingToggleEnabled) {
+ return false;
+ }
+ if (!route.object_matchers) {
+ return false;
+ }
+ return (
+ route.object_matchers.some((objectMatcher) => {
+ return (
+ objectMatcher[0] === AUTOGENERATED_RECEIVER_POLICY_MATCHER_KEY && objectMatcher[1] === MatcherOperator.equal
+ );
+ }) ?? false
+ );
+}
export function getUsedContactPoints(route: Route): string[] {
const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? [];
- if (route.receiver) {
+ // we don't want to count the autogenerated policy for receiver level, for checking if a contact point is used
+ if (route.receiver && !isAutoGeneratedPolicy(route)) {
return [route.receiver, ...childrenContactPoints];
}
diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx
index 1266590af80..b6ba61d28c5 100644
--- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx
+++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx
@@ -34,7 +34,7 @@ import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility }
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
-import { InhertitableProperties, getInheritedProperties } from '../../utils/notification-policies';
+import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
import { Authorize } from '../Authorize';
import { HoverCard } from '../HoverCard';
import { Label } from '../Label';
@@ -53,7 +53,7 @@ interface PolicyComponentProps {
contactPointsState?: ReceiversState;
readOnly?: boolean;
provisioned?: boolean;
- inheritedProperties?: Partial;
+ inheritedProperties?: Partial;
routesMatchingFilters?: RouteWithID[];
// routeAlertGroupsMap?: Map;
@@ -239,7 +239,7 @@ const Policy = (props: PolicyComponentProps) => {
{
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
// This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy.
const isThisChildAutoGenerated = isAutoGeneratedRootAndSimplifiedEnabled(child) || isAutoGenerated;
- /* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable,
+ /* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable,
then the child policy should not be editable either */
const isThisChildReadOnly = readOnly || provisioned || isAutoGenerated;
@@ -319,7 +319,7 @@ interface MetadataRowProps {
groupBy?: string[];
muteTimings?: string[];
timingOptions?: TimingOptions;
- inheritedProperties?: Partial;
+ inheritedProperties?: Partial;
alertManagerSourceName: string;
receivers: Receiver[];
matchingAlertGroups?: AlertmanagerGroup[];
@@ -571,19 +571,25 @@ function AutogeneratedRootIndicator() {
return Auto-generated policies;
}
-const InheritedProperties: FC<{ properties: InhertitableProperties }> = ({ properties }) => (
+const InheritedProperties: FC<{ properties: InheritableProperties }> = ({ properties }) => (
- {Object.entries(properties).map(([key, value]) => (
-
@@ -248,7 +234,6 @@ interface VariableEditorSettingsEditViewProps {
dashboard: DashboardScene;
onTypeChange: (variableType: EditableVariableType) => void;
onGoBack: () => void;
- onDiscardChanges: () => void;
}
function VariableEditorSettingsView({
@@ -258,7 +243,6 @@ function VariableEditorSettingsView({
dashboard,
onTypeChange,
onGoBack,
- onDiscardChanges,
}: VariableEditorSettingsEditViewProps) {
const parentTab = pageNav.children!.find((p) => p.active)!;
parentTab.parentItem = pageNav;
@@ -271,12 +255,7 @@ function VariableEditorSettingsView({
return (
-
+
);
}
diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx
index 14cecad19ae..3038cb6fe04 100644
--- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx
@@ -22,10 +22,9 @@ interface VariableEditorFormProps {
variable: SceneVariable;
onTypeChange: (type: EditableVariableType) => void;
onGoBack: () => void;
- onDiscardChanges: () => void;
}
-export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDiscardChanges }: VariableEditorFormProps) {
+export function VariableEditorForm({ variable, onTypeChange, onGoBack }: VariableEditorFormProps) {
const { name, type, label, description, hide } = variable.useState();
const EditorToRender = isEditableVariableType(type) ? getVariableEditor(type) : undefined;
const [runQueryState, onRunQuery] = useAsyncFn(async () => {
@@ -106,13 +105,6 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDiscard
{runQueryState.loading ? : `Run query`}
)}
-
diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx
index 8cbe42568ef..27d16634ae8 100644
--- a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx
@@ -5,7 +5,7 @@ import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
-import { useStyles2, Stack } from '@grafana/ui';
+import { useStyles2, Stack, Button } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { VariableEditorListRow } from './VariableEditorListRow';
@@ -80,6 +80,15 @@ export function VariableEditorList({
+
+
+
)}
@@ -94,7 +103,6 @@ function EmptyVariablesList({ onAdd }: { onAdd: () => void }): ReactElement {
title="There are no variables yet"
buttonIcon="calculator-alt"
buttonTitle="Add variable"
- buttonDisabled
infoBox={{
__html: `
Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server
diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx
new file mode 100644
index 00000000000..18436c85c2b
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+
+import { DataSourceApi, LoadingState, TimeRange } from '@grafana/data';
+import { getTemplateSrv } from '@grafana/runtime';
+import { QueryVariable } from '@grafana/scenes';
+import { Text, Box } from '@grafana/ui';
+import { isLegacyQueryEditor, isQueryEditor } from 'app/features/variables/guard';
+import { VariableQueryEditorType } from 'app/features/variables/types';
+
+type VariableQueryType = QueryVariable['state']['query'];
+
+interface QueryEditorProps {
+ query: VariableQueryType;
+ datasource: DataSourceApi;
+ VariableQueryEditor: VariableQueryEditorType;
+ timeRange: TimeRange;
+ onLegacyQueryChange: (query: VariableQueryType, definition: string) => void;
+ onQueryChange: (query: VariableQueryType) => void;
+}
+
+export function QueryEditor({
+ query,
+ datasource,
+ VariableQueryEditor,
+ onLegacyQueryChange,
+ onQueryChange,
+ timeRange,
+}: QueryEditorProps) {
+ let queryWithDefaults;
+ if (typeof query === 'string') {
+ queryWithDefaults = query || (datasource.variables?.getDefaultQuery?.() ?? '');
+ } else {
+ queryWithDefaults = {
+ ...datasource.variables?.getDefaultQuery?.(),
+ ...query,
+ };
+ }
+
+ if (VariableQueryEditor && isLegacyQueryEditor(VariableQueryEditor, datasource)) {
+ return (
+
+ Query
+
+
+
+
+ );
+ }
+
+ if (VariableQueryEditor && isQueryEditor(VariableQueryEditor, datasource)) {
+ return (
+
+ Query
+
+ {}}
+ data={{ series: [], state: LoadingState.Done, timeRange }}
+ range={timeRange}
+ onBlur={() => {}}
+ history={[]}
+ />
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
new file mode 100644
index 00000000000..8e37364ccc7
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
@@ -0,0 +1,269 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React, { FormEvent } from 'react';
+import { of } from 'rxjs';
+import { MockDataSourceApi } from 'test/mocks/datasource_srv';
+
+import {
+ LoadingState,
+ PanelData,
+ getDefaultTimeRange,
+ toDataFrame,
+ FieldType,
+ VariableSupportType,
+} from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
+import { setRunRequest } from '@grafana/runtime';
+import { VariableRefresh, VariableSort } from '@grafana/schema';
+import { mockDataSource } from 'app/features/alerting/unified/mocks';
+import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
+
+import { QueryVariableEditorForm } from './QueryVariableForm';
+
+const defaultDatasource = mockDataSource({
+ name: 'Default Test Data Source',
+ type: 'test',
+});
+
+const promDatasource = mockDataSource({
+ name: 'Prometheus',
+ type: 'prometheus',
+});
+
+jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
+ ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
+ getDataSourceSrv: () => ({
+ get: async () => ({
+ ...defaultDatasource,
+ variables: {
+ getType: () => VariableSupportType.Custom,
+ query: jest.fn(),
+ editor: jest.fn().mockImplementation(LegacyVariableQueryEditor),
+ },
+ }),
+ getList: () => [defaultDatasource, promDatasource],
+ getInstanceSettings: () => ({ ...defaultDatasource }),
+ }),
+}));
+
+const runRequestMock = jest.fn().mockReturnValue(
+ of({
+ state: LoadingState.Done,
+ series: [
+ toDataFrame({
+ fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }],
+ }),
+ ],
+ timeRange: getDefaultTimeRange(),
+ })
+);
+
+setRunRequest(runRequestMock);
+
+describe('QueryVariableEditorForm', () => {
+ const mockOnDataSourceChange = jest.fn();
+ const mockOnQueryChange = jest.fn();
+ const mockOnLegacyQueryChange = jest.fn();
+ const mockOnRegExChange = jest.fn();
+ const mockOnSortChange = jest.fn();
+ const mockOnRefreshChange = jest.fn();
+ const mockOnMultiChange = jest.fn();
+ const mockOnIncludeAllChange = jest.fn();
+ const mockOnAllValueChange = jest.fn();
+
+ const defaultProps = {
+ datasource: new MockDataSourceApi(promDatasource.name, undefined, promDatasource.meta),
+ onDataSourceChange: mockOnDataSourceChange,
+ query: 'my-query',
+ onQueryChange: mockOnQueryChange,
+ onLegacyQueryChange: mockOnLegacyQueryChange,
+ timeRange: getDefaultTimeRange(),
+ VariableQueryEditor: LegacyVariableQueryEditor,
+ regex: '.*',
+ onRegExChange: mockOnRegExChange,
+ sort: VariableSort.alphabeticalAsc,
+ onSortChange: mockOnSortChange,
+ refresh: VariableRefresh.onDashboardLoad,
+ onRefreshChange: mockOnRefreshChange,
+ isMulti: true,
+ onMultiChange: mockOnMultiChange,
+ includeAll: true,
+ onIncludeAllChange: mockOnIncludeAllChange,
+ allValue: 'custom all value',
+ onAllValueChange: mockOnAllValueChange,
+ };
+
+ function setup(props?: React.ComponentProps) {
+ return {
+ renderer: render(),
+ user: userEvent.setup(),
+ };
+ }
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render the component with initializing the components correctly', () => {
+ const {
+ renderer: { getByTestId, getByRole },
+ } = setup();
+ const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.container);
+ //const queryEditor = getByTestId('query-editor');
+ const regexInput = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2
+ );
+ const sortSelect = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2
+ );
+ const refreshSelect = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2
+ );
+
+ const multiSwitch = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
+ );
+ const includeAllSwitch = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
+ );
+ const allValueInput = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
+ );
+
+ expect(dataSourcePicker).toBeInTheDocument();
+ expect(dataSourcePicker).toHaveTextContent('Default Test Data Source');
+ expect(regexInput).toBeInTheDocument();
+ expect(regexInput).toHaveValue('.*');
+ expect(sortSelect).toBeInTheDocument();
+ expect(sortSelect).toHaveTextContent('Alphabetical (asc)');
+ expect(refreshSelect).toBeInTheDocument();
+ expect(getByRole('radio', { name: 'On dashboard load' })).toBeChecked();
+ expect(multiSwitch).toBeInTheDocument();
+ expect(multiSwitch).toBeChecked();
+ expect(includeAllSwitch).toBeInTheDocument();
+ expect(includeAllSwitch).toBeChecked();
+ expect(allValueInput).toBeInTheDocument();
+ expect(allValueInput).toHaveValue('custom all value');
+ });
+
+ it('should call onDataSourceChange when changing the datasource', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2);
+ await waitFor(async () => {
+ await userEvent.click(dataSourcePicker); // open the select
+ await userEvent.tab();
+ });
+ expect(mockOnDataSourceChange).toHaveBeenCalledTimes(1);
+ expect(mockOnDataSourceChange).toHaveBeenCalledWith(defaultDatasource);
+ });
+
+ it('should call onQueryChange when changing the query', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const queryEditor = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput
+ );
+
+ await waitFor(async () => {
+ await userEvent.type(queryEditor, '-new');
+ await userEvent.tab();
+ });
+
+ expect(mockOnLegacyQueryChange).toHaveBeenCalledTimes(1);
+ expect(mockOnLegacyQueryChange).toHaveBeenCalledWith('my-query-new', expect.anything());
+ });
+
+ it('should call onRegExChange when changing the regex', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const regexInput = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2
+ );
+ await userEvent.type(regexInput, '{backspace}?');
+ await userEvent.tab();
+ expect(mockOnRegExChange).toHaveBeenCalledTimes(1);
+ expect(
+ ((mockOnRegExChange.mock.calls[0][0] as FormEvent).target as HTMLTextAreaElement).value
+ ).toBe('.?');
+ });
+
+ it('should call onSortChange when changing the sort', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const sortSelect = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2
+ );
+ await userEvent.click(sortSelect); // open the select
+ const anotherOption = await screen.getByText('Alphabetical (desc)');
+ await userEvent.click(anotherOption);
+
+ expect(mockOnSortChange).toHaveBeenCalledTimes(1);
+ expect(mockOnSortChange).toHaveBeenCalledWith(
+ expect.objectContaining({ value: VariableSort.alphabeticalDesc }),
+ expect.anything()
+ );
+ });
+
+ it('should call onRefreshChange when changing the refresh', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const refreshSelect = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2
+ );
+ await userEvent.click(refreshSelect); // open the select
+ const anotherOption = await screen.getByText('On time range change');
+ await userEvent.click(anotherOption);
+
+ expect(mockOnRefreshChange).toHaveBeenCalledTimes(1);
+ expect(mockOnRefreshChange).toHaveBeenCalledWith(VariableRefresh.onTimeRangeChanged);
+ });
+
+ it('should call onMultiChange when changing the multi switch', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const multiSwitch = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
+ );
+ await userEvent.click(multiSwitch);
+ expect(mockOnMultiChange).toHaveBeenCalledTimes(1);
+ expect(
+ (mockOnMultiChange.mock.calls[0][0] as FormEvent).target as HTMLInputElement
+ ).toBeChecked();
+ });
+
+ it('should call onIncludeAllChange when changing the include all switch', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const includeAllSwitch = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
+ );
+ await userEvent.click(includeAllSwitch);
+ expect(mockOnIncludeAllChange).toHaveBeenCalledTimes(1);
+ expect(
+ (mockOnIncludeAllChange.mock.calls[0][0] as FormEvent).target as HTMLInputElement
+ ).toBeChecked();
+ });
+
+ it('should call onAllValueChange when changing the all value', async () => {
+ const {
+ renderer: { getByTestId },
+ } = setup();
+ const allValueInput = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
+ );
+ await userEvent.type(allValueInput, ' and another value');
+ await userEvent.tab();
+ expect(mockOnAllValueChange).toHaveBeenCalledTimes(1);
+ expect(
+ ((mockOnAllValueChange.mock.calls[0][0] as FormEvent).target as HTMLInputElement).value
+ ).toBe('custom all value and another value');
+ });
+});
diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx
new file mode 100644
index 00000000000..0e7d86740cc
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx
@@ -0,0 +1,128 @@
+import React, { FormEvent } from 'react';
+
+import { DataSourceApi, DataSourceInstanceSettings, SelectableValue, TimeRange } from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
+import { QueryVariable } from '@grafana/scenes';
+import { VariableRefresh, VariableSort } from '@grafana/schema';
+import { Field } from '@grafana/ui';
+import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
+import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm';
+import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
+import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
+import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
+import { VariableQueryEditorType } from 'app/features/variables/types';
+
+import { VariableLegend } from './VariableLegend';
+import { VariableTextAreaField } from './VariableTextAreaField';
+
+type VariableQueryType = QueryVariable['state']['query'];
+
+interface QueryVariableEditorFormProps {
+ datasource: DataSourceApi | undefined;
+ onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void;
+ query: VariableQueryType;
+ onQueryChange: (query: VariableQueryType) => void;
+ onLegacyQueryChange: (query: VariableQueryType, definition: string) => void;
+ VariableQueryEditor: VariableQueryEditorType | undefined;
+ timeRange: TimeRange;
+ regex: string | null;
+ onRegExChange: (event: FormEvent) => void;
+ sort: VariableSort;
+ onSortChange: (option: SelectableValue) => void;
+ refresh: VariableRefresh;
+ onRefreshChange: (option: VariableRefresh) => void;
+ isMulti: boolean;
+ onMultiChange: (event: FormEvent) => void;
+ includeAll: boolean;
+ onIncludeAllChange: (event: FormEvent) => void;
+ allValue: string;
+ onAllValueChange: (event: FormEvent) => void;
+}
+
+export function QueryVariableEditorForm({
+ datasource,
+ onDataSourceChange,
+ query,
+ onQueryChange,
+ onLegacyQueryChange,
+ VariableQueryEditor,
+ timeRange,
+ regex,
+ onRegExChange,
+ sort,
+ onSortChange,
+ refresh,
+ onRefreshChange,
+ isMulti,
+ onMultiChange,
+ includeAll,
+ onIncludeAllChange,
+ allValue,
+ onAllValueChange,
+}: QueryVariableEditorFormProps) {
+ return (
+ <>
+ Query options
+
+
+
+
+ {datasource && VariableQueryEditor && (
+
+ )}
+
+
+ Optional, if you want to extract part of a series name or metric node segment.
+
+ Named capture groups can be used to separate the display text and value (
+
+ see examples
+
+ ).
+
+ }
+ placeholder="/.*-(?.*)-(?.*)-.*/"
+ onBlur={onRegExChange}
+ testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
+ width={52}
+ />
+
+
+
+
+
+ Selection options
+
+ >
+ );
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx
index 17f273dab46..9f0024a7ab1 100644
--- a/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
-import React, { PropsWithChildren, ReactElement, useId } from 'react';
+import React, { PropsWithChildren, useId } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Field, Select, useStyles2 } from '@grafana/ui';
@@ -22,7 +22,7 @@ export function VariableSelectField({
onChange,
testId,
width,
-}: PropsWithChildren>): ReactElement {
+}: PropsWithChildren>) {
const styles = useStyles2(getStyles);
const uniqueId = useId();
const inputId = `variable-select-input-${name}-${uniqueId}`;
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
new file mode 100644
index 00000000000..867ecb3e836
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
@@ -0,0 +1,288 @@
+import { getByRole, render, screen, act, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { lastValueFrom, of } from 'rxjs';
+
+import {
+ VariableSupportType,
+ PanelData,
+ LoadingState,
+ toDataFrame,
+ getDefaultTimeRange,
+ FieldType,
+} from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
+import { setRunRequest } from '@grafana/runtime';
+import { QueryVariable } from '@grafana/scenes';
+import { VariableRefresh, VariableSort } from '@grafana/schema';
+import { mockDataSource } from 'app/features/alerting/unified/mocks';
+import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
+
+import { QueryVariableEditor } from './QueryVariableEditor';
+
+const defaultDatasource = mockDataSource({
+ name: 'Default Test Data Source',
+ type: 'test',
+});
+
+const promDatasource = mockDataSource({
+ name: 'Prometheus',
+ type: 'prometheus',
+});
+
+jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
+ ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
+ getDataSourceSrv: () => ({
+ get: async () => ({
+ ...defaultDatasource,
+ variables: {
+ getType: () => VariableSupportType.Custom,
+ query: jest.fn(),
+ editor: jest.fn().mockImplementation(LegacyVariableQueryEditor),
+ },
+ }),
+ getList: () => [defaultDatasource, promDatasource],
+ getInstanceSettings: () => ({ ...defaultDatasource }),
+ }),
+}));
+
+const runRequestMock = jest.fn().mockReturnValue(
+ of({
+ state: LoadingState.Done,
+ series: [
+ toDataFrame({
+ fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }],
+ }),
+ ],
+ timeRange: getDefaultTimeRange(),
+ })
+);
+
+setRunRequest(runRequestMock);
+
+describe('QueryVariableEditor', () => {
+ const onRunQueryMock = jest.fn();
+
+ async function setup(props?: React.ComponentProps) {
+ const variable = new QueryVariable({
+ datasource: {
+ uid: defaultDatasource.uid,
+ type: defaultDatasource.type,
+ },
+ query: 'my-query',
+ regex: '.*',
+ sort: VariableSort.alphabeticalAsc,
+ refresh: VariableRefresh.onDashboardLoad,
+ isMulti: true,
+ includeAll: true,
+ allValue: 'custom all value',
+ });
+
+ return {
+ renderer: await act(() => {
+ return render();
+ }),
+ variable,
+ user: userEvent.setup(),
+ };
+ }
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render the component with initializing the components correctly', async () => {
+ const { renderer } = await setup();
+ const dataSourcePicker = renderer.getByTestId(selectors.components.DataSourcePicker.container);
+ const queryEditor = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput
+ );
+ const regexInput = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2
+ );
+ const sortSelect = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2
+ );
+ const refreshSelect = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2
+ );
+
+ const multiSwitch = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
+ );
+ const includeAllSwitch = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
+ );
+ const allValueInput = renderer.getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
+ );
+
+ expect(dataSourcePicker).toBeInTheDocument();
+ expect(dataSourcePicker).toHaveTextContent('Default Test Data Source');
+ expect(queryEditor).toBeInTheDocument();
+ expect(queryEditor).toHaveValue('my-query');
+ expect(regexInput).toBeInTheDocument();
+ expect(regexInput).toHaveValue('.*');
+ expect(sortSelect).toBeInTheDocument();
+ expect(sortSelect).toHaveTextContent('Alphabetical (asc)');
+ expect(refreshSelect).toBeInTheDocument();
+ expect(getByRole(refreshSelect, 'radio', { name: 'On dashboard load' })).toBeChecked();
+ expect(multiSwitch).toBeInTheDocument();
+ expect(multiSwitch).toBeChecked();
+ expect(includeAllSwitch).toBeInTheDocument();
+ expect(includeAllSwitch).toBeChecked();
+ expect(allValueInput).toBeInTheDocument();
+ expect(allValueInput).toHaveValue('custom all value');
+ });
+
+ it('should update variable state when changing the datasource', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.container).getElementsByTagName('input');
+
+ await waitFor(async () => {
+ await user.type(dataSourcePicker[0], 'm');
+ await user.tab();
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.datasource).toEqual({ uid: 'mock-ds-2', type: 'test' });
+ });
+
+ it('should update the variable state when changing the query', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const queryEditor = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput
+ );
+
+ await waitFor(async () => {
+ await user.type(queryEditor, '-new');
+ await user.tab();
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.query).toEqual('my-query-new');
+ expect(onRunQueryMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should update the variable state when changing the regex', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const regexInput = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2
+ );
+
+ await waitFor(async () => {
+ await user.type(regexInput, '{backspace}?');
+ await user.tab();
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.regex).toBe('.?');
+ });
+
+ it('should update the variable state when changing the sort', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const sortSelect = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2
+ );
+
+ await waitFor(async () => {
+ await user.click(sortSelect);
+ const anotherOption = await screen.getByText('Alphabetical (desc)');
+ await user.click(anotherOption);
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.sort).toBe(VariableSort.alphabeticalDesc);
+ });
+
+ it('should update the variable state when changing the refresh', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const refreshSelect = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2
+ );
+
+ await waitFor(async () => {
+ await user.click(refreshSelect);
+ const anotherOption = await screen.getByText('On time range change');
+ await user.click(anotherOption);
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.refresh).toBe(VariableRefresh.onTimeRangeChanged);
+ });
+
+ it('should update the variable state when changing the multi switch', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const multiSwitch = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
+ );
+
+ await waitFor(async () => {
+ await user.click(multiSwitch);
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.isMulti).toBe(false);
+ });
+
+ it('should update the variable state when changing the include all switch', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const includeAllSwitch = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
+ );
+
+ await waitFor(async () => {
+ await user.click(includeAllSwitch);
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.includeAll).toBe(false);
+ });
+
+ it('should update the variable state when changing the all value', async () => {
+ const {
+ variable,
+ renderer: { getByTestId },
+ user,
+ } = await setup();
+ const allValueInput = getByTestId(
+ selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
+ );
+
+ await waitFor(async () => {
+ await user.type(allValueInput, ' and another value');
+ await user.tab();
+ await lastValueFrom(variable.validateAndUpdate());
+ });
+
+ expect(variable.state.allValue).toBe('custom all value and another value');
+ });
+});
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
index 57cf0b39ef4..0c577d74b6b 100644
--- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
@@ -1,12 +1,80 @@
-import React from 'react';
+import React, { FormEvent } from 'react';
+import { useAsync } from 'react-use';
-import { QueryVariable } from '@grafana/scenes';
+import { SelectableValue, DataSourceInstanceSettings } from '@grafana/data';
+import { getDataSourceSrv } from '@grafana/runtime';
+import { QueryVariable, sceneGraph } from '@grafana/scenes';
+import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
+import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor';
+
+import { QueryVariableEditorForm } from '../components/QueryVariableForm';
interface QueryVariableEditorProps {
variable: QueryVariable;
- onChange: (variable: QueryVariable) => void;
+ onRunQuery: () => void;
}
+type VariableQueryType = QueryVariable['state']['query'];
-export function QueryVariableEditor(props: QueryVariableEditorProps) {
- return QueryVariableEditor
;
+export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEditorProps) {
+ const { datasource: datasourceRef, regex, sort, refresh, isMulti, includeAll, allValue, query } = variable.useState();
+ const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
+
+ const { value: dsConfig } = useAsync(async () => {
+ const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
+ const VariableQueryEditor = await getVariableQueryEditor(datasource);
+
+ return { datasource, VariableQueryEditor };
+ }, [datasourceRef]);
+ const { datasource, VariableQueryEditor } = dsConfig ?? {};
+
+ const onRegExChange = (event: React.FormEvent) => {
+ variable.setState({ regex: event.currentTarget.value });
+ };
+ const onSortChange = (sort: SelectableValue) => {
+ variable.setState({ sort: sort.value });
+ };
+ const onRefreshChange = (refresh: VariableRefresh) => {
+ variable.setState({ refresh: refresh });
+ };
+ const onMultiChange = (event: FormEvent) => {
+ variable.setState({ isMulti: event.currentTarget.checked });
+ };
+ const onIncludeAllChange = (event: FormEvent) => {
+ variable.setState({ includeAll: event.currentTarget.checked });
+ };
+ const onAllValueChange = (event: FormEvent) => {
+ variable.setState({ allValue: event.currentTarget.value });
+ };
+ const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings) => {
+ const datasource: DataSourceRef = { uid: dsInstanceSettings.uid, type: dsInstanceSettings.type };
+ variable.setState({ datasource });
+ };
+ const onQueryChange = (query: VariableQueryType) => {
+ variable.setState({ query });
+ onRunQuery();
+ };
+
+ return (
+
+ );
}
diff --git a/public/app/features/dashboard-scene/settings/variables/utils.test.ts b/public/app/features/dashboard-scene/settings/variables/utils.test.ts
index 6d43b34891a..9725ee5fe5c 100644
--- a/public/app/features/dashboard-scene/settings/variables/utils.test.ts
+++ b/public/app/features/dashboard-scene/settings/variables/utils.test.ts
@@ -8,6 +8,7 @@ import {
DataSourceVariable,
AdHocFiltersVariable,
TextBoxVariable,
+ SceneVariableSet,
} from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, VariableType } from '@grafana/schema';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
@@ -31,6 +32,8 @@ import {
EditableVariableType,
getDefinition,
getOptionDataSourceTypes,
+ getNextAvailableId,
+ getVariableDefault,
} from './utils';
const templateSrv = {
@@ -115,7 +118,7 @@ describe('getVariableEditor', () => {
});
it.each(Object.keys(EDITABLE_VARIABLES) as EditableVariableType[])(
- 'should define an editor for every variable type',
+ 'should define an editor for variable type "%s"',
(type) => {
const editor = getVariableEditor(type);
expect(editor).toBeDefined();
@@ -130,7 +133,7 @@ describe('getVariableEditor', () => {
['datasource', DataSourceVariableEditor],
['adhoc', AdHocFiltersVariableEditor],
['textbox', TextBoxVariableEditor],
- ])('should return the correct editor for each variable type', (type, ExpectedVariableEditor) => {
+ ])('should return the correct editor for variable type "%s"', (type, ExpectedVariableEditor) => {
expect(getVariableEditor(type as EditableVariableType)).toBe(ExpectedVariableEditor);
});
});
@@ -249,3 +252,47 @@ describe('getOptionDataSourceTypes', () => {
expect(optionTypes[1].label).toBe('ds1');
});
});
+
+describe('getNextAvailableId', () => {
+ it('should return the initial ID for an empty array', () => {
+ const sceneVariables = new SceneVariableSet({
+ variables: [],
+ });
+
+ expect(getNextAvailableId('query', sceneVariables.state.variables)).toBe('query0');
+ });
+
+ it('should return a non-conflicting ID for a non-empty array', () => {
+ const variable = new QueryVariable({
+ name: 'query0',
+ label: 'test-label',
+ description: 'test-desc',
+ value: ['selected-value'],
+ text: ['selected-value-text'],
+ datasource: { uid: 'fake-std', type: 'fake-std' },
+ query: 'query',
+ includeAll: true,
+ allValue: 'test-all',
+ isMulti: true,
+ });
+
+ const sceneVariables = new SceneVariableSet({
+ variables: [variable],
+ });
+
+ expect(getNextAvailableId('query', sceneVariables.state.variables)).toBe('query1');
+ });
+});
+
+describe('getVariableDefault', () => {
+ it('should return a QueryVariable instance with the correct name', () => {
+ const sceneVariables = new SceneVariableSet({
+ variables: [],
+ });
+
+ const defaultVariable = getVariableDefault(sceneVariables.state.variables);
+
+ expect(defaultVariable).toBeInstanceOf(QueryVariable);
+ expect(defaultVariable.state.name).toBe('query0');
+ });
+});
diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts
index 4b1514a336e..8b6480b6c9e 100644
--- a/public/app/features/dashboard-scene/settings/variables/utils.ts
+++ b/public/app/features/dashboard-scene/settings/variables/utils.ts
@@ -12,6 +12,7 @@ import {
AdHocFilterSet,
SceneVariable,
MultiValueVariable,
+ SceneVariableState,
} from '@grafana/scenes';
import { VariableType } from '@grafana/schema';
@@ -122,6 +123,25 @@ export function getVariableScene(type: EditableVariableType, initialState: Commo
}
}
+export function getVariableDefault(variables: Array>) {
+ const defaultVariableType = 'query';
+ const nextVariableIdName = getNextAvailableId(defaultVariableType, variables);
+ return new QueryVariable({
+ name: nextVariableIdName,
+ });
+}
+
+export function getNextAvailableId(type: VariableType, variables: Array>): string {
+ let counter = 0;
+ let nextId = `${type}${counter}`;
+
+ while (variables.find((variable) => variable.state.name === nextId)) {
+ nextId = `${type}${++counter}`;
+ }
+
+ return nextId;
+}
+
export function hasVariableOptions(variable: SceneVariable): variable is MultiValueVariable {
// variable options can be defined by state.options or state.intervals in case of interval variable
return 'options' in variable.state || 'intervals' in variable.state;
diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx
index c65584657f9..2e9503c3a11 100644
--- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx
@@ -15,8 +15,6 @@ import { DashboardInteractions } from '../utils/interactions';
import { SceneShareTabState } from './types';
-const SNAPSHOTS_API_ENDPOINT = '/api/snapshots';
-
const getExpireOptions = () => {
const DEFAULT_EXPIRE_OPTION: SelectableValue = {
label: t('share-modal.snapshot.expire-never', `Never`),
@@ -121,8 +119,7 @@ export class ShareSnapshotTab extends SceneObjectBase {
};
try {
- const results: { deleteUrl: string; url: string } = await getBackendSrv().post(SNAPSHOTS_API_ENDPOINT, cmdData);
- return results;
+ return await getDashboardSnapshotSrv().create(cmdData);
} finally {
if (external) {
DashboardInteractions.publishSnapshotClicked({ expires: cmdData.expires });
diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts
index 44f045df7a8..8b4c65ab284 100644
--- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts
+++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts
@@ -11,8 +11,10 @@ import {
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
+import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { dashboardSceneGraph } from './dashboardSceneGraph';
+import { findVizPanelByKey } from './utils';
describe('dashboardSceneGraph', () => {
describe('getTimePicker', () => {
@@ -75,6 +77,20 @@ describe('dashboardSceneGraph', () => {
expect(dashboardControls).not.toBeNull();
});
});
+
+ describe('getPanelLinks', () => {
+ it('should throw if no links object defined', () => {
+ const scene = buildTestScene();
+ const panelWithNoLinks = findVizPanelByKey(scene, 'panel-1')!;
+ expect(() => dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toThrow();
+ });
+
+ it('should resolve VizPanelLinks object', () => {
+ const scene = buildTestScene();
+ const panelWithNoLinks = findVizPanelByKey(scene, 'panel-with-links')!;
+ expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeInstanceOf(VizPanelLinks);
+ });
+ });
});
function buildTestScene(overrides?: Partial) {
@@ -121,6 +137,15 @@ function buildTestScene(overrides?: Partial) {
$data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }),
}),
}),
+ new SceneGridItem({
+ body: new VizPanel({
+ title: 'Panel B',
+ key: 'panel-with-links',
+ pluginId: 'table',
+ $data: new SceneQueryRunner({ key: 'data-query-runner3', queries: [{ refId: 'A' }] }),
+ titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
+ }),
+ }),
],
}),
...overrides,
diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts
index 666b764b001..641b21c7f3b 100644
--- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts
+++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts
@@ -1,7 +1,8 @@
-import { SceneTimePicker, SceneRefreshPicker } from '@grafana/scenes';
+import { SceneTimePicker, SceneRefreshPicker, VizPanel } from '@grafana/scenes';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene } from '../scene/DashboardScene';
+import { VizPanelLinks } from '../scene/PanelLinks';
function getTimePicker(scene: DashboardScene) {
const dashboardControls = getDashboardControls(scene);
@@ -36,8 +37,21 @@ function getDashboardControls(scene: DashboardScene) {
return null;
}
+function getPanelLinks(panel: VizPanel) {
+ if (
+ panel.state.titleItems &&
+ Array.isArray(panel.state.titleItems) &&
+ panel.state.titleItems[0] instanceof VizPanelLinks
+ ) {
+ return panel.state.titleItems[0];
+ }
+
+ throw new Error('VizPanelLinks links not found');
+}
+
export const dashboardSceneGraph = {
getTimePicker,
getRefreshPicker,
getDashboardControls,
+ getPanelLinks,
};
diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts
index aa8a8afe29f..9e6b18d0f97 100644
--- a/public/app/features/dashboard-scene/utils/test-utils.ts
+++ b/public/app/features/dashboard-scene/utils/test-utils.ts
@@ -15,6 +15,7 @@ import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboar
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { DashboardDTO } from 'app/types';
+import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
@@ -120,7 +121,11 @@ export function buildPanelRepeaterScene(options: SceneOptions) {
y: 0,
width: 10,
height: 10,
- body: new VizPanel({ title: 'Panel $server', pluginId: 'timeseries' }),
+ body: new VizPanel({
+ title: 'Panel $server',
+ pluginId: 'timeseries',
+ titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
+ }),
});
const rowChildren = defaults.usePanelRepeater ? repeater : gridItem;
diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx
index 52ced1ac177..ffd013ebcf8 100644
--- a/public/app/features/dashboard/components/DashNav/DashNav.tsx
+++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx
@@ -188,7 +188,13 @@ export const DashNav = React.memo((props) => {
if (dashboard.meta.publicDashboardEnabled) {
// TODO: This will be replaced with the new badge component. Color is required but gets override by css
buttons.push(
-
+
);
}
diff --git a/public/app/features/dashboard/components/DashNav/ShareButton.tsx b/public/app/features/dashboard/components/DashNav/ShareButton.tsx
index b1f3ca69dd7..2ff6ac53448 100644
--- a/public/app/features/dashboard/components/DashNav/ShareButton.tsx
+++ b/public/app/features/dashboard/components/DashNav/ShareButton.tsx
@@ -1,31 +1,13 @@
-import React, { useContext, useEffect } from 'react';
+import React from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
-import { ModalsContext, Button } from '@grafana/ui';
-import { useQueryParams } from 'app/core/hooks/useQueryParams';
+import { locationService } from '@grafana/runtime';
+import { Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { DashboardModel } from 'app/features/dashboard/state';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
-import { ShareModal } from '../ShareModal';
-
export const ShareButton = ({ dashboard }: { dashboard: DashboardModel }) => {
- const [queryParams] = useQueryParams();
- const { showModal, hideModal } = useContext(ModalsContext);
-
- useEffect(() => {
- if (!!queryParams.shareView) {
- showModal(ShareModal, {
- dashboard,
- onDismiss: hideModal,
- activeTab: String(queryParams.shareView),
- });
- }
- return () => {
- hideModal();
- };
- }, [showModal, hideModal, dashboard, queryParams.shareView]);
-
return (