diff --git a/.betterer.results b/.betterer.results index 436113045cf..74eec4a6c9a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1640,15 +1640,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], - "public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx:5381": [ - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "4"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "5"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "6"] - ], "public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/.drone.yml b/.drone.yml index b6bc050aad3..298efd04173 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4343,7 +4343,8 @@ steps: - apk add docker - export GITHUB_TOKEN=$(cat /github-app/token) - dagger run --silent go run ./pkg/build/cmd artifacts -a $${ARTIFACTS} --grafana-ref=$${GRAFANA_REF} - --enterprise-ref=$${ENTERPRISE_REF} --grafana-repo=$${GRAFANA_REPO} --version=$${VERSION} + --enterprise-ref=$${ENTERPRISE_REF} --grafana-repo=$${GRAFANA_REPO} --build-id=$${DRONE_BUILD_NUMBER} + --version=$${VERSION} depends_on: - github-app-generate-token environment: @@ -5205,6 +5206,6 @@ kind: secret name: gcr_credentials --- kind: signature -hmac: 1da5c9da34dc5dbffef8bef4ef84282997cb2363f929fdf4b897d2ee87a33703 +hmac: e430c51190ba46dbd426d13f47c13c275f4d05b6b1a26d1d9f67b5a8f09ecb41 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4d1f7cd679d..55285548803 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -177,6 +177,7 @@ /pkg/tests/apis/query @grafana/grafana-datasources-core-services /pkg/tests/apis/alerting @grafana/grafana-app-platform-squad @grafana/alerting-backend /pkg/tests/api/correlations/ @grafana/datapro +/pkg/tests/apis/secret/ @grafana/grafana-operator-experience-squad /pkg/tsdb/grafanads/ @grafana/grafana-backend-group /pkg/tsdb/opentsdb/ @grafana/partner-datasources /pkg/util/ @grafana/grafana-backend-group @@ -485,7 +486,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/core/components/TimelineChart/ @grafana/dataviz-squad /public/app/core/components/Form/ @grafana/grafana-frontend-platform /public/app/core/components/OptionsUI/ @grafana/dashboards-squad @grafana/dataviz-squad -/public/app/mock-api-utils.ts @grafana/grafana-frontend-platform +/public/app/dev-utils.ts @grafana/grafana-frontend-platform /public/app/core/history/ @grafana/observability-traces-and-profiling /public/app/features/admin/ @grafana/identity-access-team diff --git a/.github/commands.json b/.github/commands.json index 861c9a86707..da2c16a918d 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -160,7 +160,7 @@ "name": "datasource/Elasticsearch", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/97" + "url": "https://github.com/orgs/grafana/projects/190" } }, { diff --git a/.github/workflows/create-next-release-branch.yml b/.github/workflows/create-next-release-branch.yml index 6a48327dc04..5f537a5418a 100644 --- a/.github/workflows/create-next-release-branch.yml +++ b/.github/workflows/create-next-release-branch.yml @@ -42,10 +42,12 @@ jobs: GRAFANA_DELIVERY_BOT_APP_PEM=delivery-bot-app:PRIVATE_KEY - name: "Generate token" id: generate_token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a with: app_id: ${{ vars.DELIVERY_BOT_APP_ID }} private_key: ${{ env.GRAFANA_DELIVERY_BOT_APP_PEM }} + repositories: "[\"grafana\", \"grafana-enterprise\"]" + permissions: "{\"contents\": \"write\", \"pull_requests\": \"write\", \"workflows\":\"write\"}" - name: Create release branch id: branch uses: grafana/grafana-github-actions-go/bump-release@main # zizmor: ignore[unpinned-uses] diff --git a/.github/workflows/i18n-crowdin-create-tasks.yml b/.github/workflows/i18n-crowdin-create-tasks.yml index ade6fd32028..1a5847d83da 100644 --- a/.github/workflows/i18n-crowdin-create-tasks.yml +++ b/.github/workflows/i18n-crowdin-create-tasks.yml @@ -3,8 +3,9 @@ name: Crowdin automatic task management on: workflow_dispatch: # once a week on Sunday at midnight - schedule: - - cron: "0 0 * * 0" + # TODO enable once we're ready to create tasks automatically + # schedule: + # - cron: "0 0 * * 0" jobs: create-tasks-in-crowdin: diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index e0bd93d3b7d..5208b3a4606 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -44,13 +44,6 @@ jobs: VERSION: ${{ inputs.version }} runs-on: ubuntu-latest steps: - - name: "Get vault secrets" - id: vault-secrets - uses: grafana/shared-workflows/actions/get-vault-secrets@main - with: - # Secrets placed in the ci/data/repo/grafana/grafana/delivery-bot-app path in Vault - repo_secrets: | - GRAFANA_DELIVERY_BOT_APP_PEM=delivery-bot-app:PRIVATE_KEY - if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') }} run: | { @@ -73,14 +66,14 @@ jobs: create_next_release_branch_grafana: name: Create next release branch (Grafana) needs: setup - uses: ./.github/workflows/create-next-release-branch.yml + uses: grafana/grafana/.github/workflows/create-next-release-branch.yml@main with: ownerRepo: 'grafana/grafana' source: ${{ needs.setup.outputs.release_branch }} create_next_release_branch_enterprise: name: Create next release branch (Grafana Enterprise) needs: setup - uses: ./.github/workflows/create-next-release-branch.yml + uses: grafana/grafana/.github/workflows/create-next-release-branch.yml@main with: ownerRepo: 'grafana/grafana-enterprise' source: ${{ needs.setup.outputs.release_branch }} @@ -88,7 +81,7 @@ jobs: needs: - setup - create_next_release_branch_grafana - uses: ./.github/workflows/migrate-prs.yml + uses: grafana/grafana/.github/workflows/migrate-prs.yml@main with: ownerRepo: 'grafana/grafana' from: ${{ needs.setup.outputs.release_branch }} @@ -97,14 +90,14 @@ jobs: needs: - setup - create_next_release_branch_enterprise - uses: ./.github/workflows/migrate-prs.yml + uses: grafana/grafana/.github/workflows/migrate-prs.yml@main with: ownerRepo: 'grafana/grafana-enterprise' from: ${{ needs.setup.outputs.release_branch }} to: ${{ needs.create_next_release_branch_enterprise.outputs.branch }} post_changelog_on_forum: needs: setup - uses: ./.github/workflows/community-release.yml + uses: grafana/grafana/.github/workflows/community-release.yml@main with: version: ${{ needs.setup.outputs.version }} dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} @@ -113,7 +106,7 @@ jobs: # The github-release action retrieves the changelog using the /repos/grafana/grafana/contents/CHANGELOG.md API # endpoint. needs: setup - uses: ./.github/workflows/github-release.yml + uses: grafana/grafana/.github/workflows/github-release.yml@main with: version: ${{ needs.setup.outputs.version }} dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 02f283e5d0b..fb6a9f2058a 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -16,10 +16,6 @@ on: required: true type: string description: The version of Grafana that is being released (without the `v` prefix)` - target: - required: true - type: string - description: The release branch pattern (eg v9.5.x) that these changes are being merged into changelog: required: false type: boolean @@ -73,19 +69,28 @@ jobs: id: vault-secrets uses: grafana/shared-workflows/actions/get-vault-secrets@main with: - # Secrets placed in the ci/data/repo/grafana/grafana/delivery-bot-app path in Vault repo_secrets: | GRAFANA_DELIVERY_BOT_APP_PEM=delivery-bot-app:PRIVATE_KEY + - name: Generate token + id: generate_changelog_token + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a + with: + app_id: ${{ vars.DELIVERY_BOT_APP_ID }} + private_key: ${{ env.GRAFANA_DELIVERY_BOT_APP_PEM }} + repositories: "[\"grafana\", \"grafana-enterprise\"]" + permissions: "{\"contents\": \"write\", \"pull_requests\": \"write\", \"workflows\":\"write\"}" - run: echo "RELEASE_BRANCH=release-${VERSION}" >> "$GITHUB_ENV" - name: Checkout Grafana uses: actions/checkout@v4 with: + token: ${{ steps.generate_changelog_token.outputs.token }} ref: ${{ env.RELEASE_BRANCH }} fetch-tags: true fetch-depth: 0 - name: Checkout Grafana (main) uses: actions/checkout@v4 with: + token: ${{ steps.generate_changelog_token.outputs.token }} ref: main fetch-depth: '0' path: .grafana-main @@ -94,21 +99,17 @@ jobs: uses: actions/setup-node@v4 with: node-version-file: .nvmrc + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod - name: Configure git user run: | - git config --local user.name "github-actions[bot]" - git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "grafana-delivery-bot[bot]" + git config --local user.email "grafana-delivery-bot[bot]@users.noreply.github.com" git config --local --add --bool push.autoSetupRemote true - name: Create branch run: git checkout -b "release/${{ github.run_number }}/$VERSION" - - name: Generate changelog token - if: ${{ inputs.changelog == true || inputs.changelog == 'true' }} - id: generate_changelog_token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 - with: - app_id: ${{ vars.DELIVERY_BOT_APP_ID }} - private_key: ${{ env.GRAFANA_DELIVERY_BOT_APP_PEM }} - name: Generate changelog id: changelog if: ${{ inputs.changelog == true || inputs.changelog == 'true' }} @@ -156,13 +157,16 @@ jobs: - name: Commit CHANGELOG.md changes if: ${{ inputs.changelog == true || inputs.changelog == 'true' }} run: git add CHANGELOG.md && git commit --allow-empty -m "Update changelog" CHANGELOG.md - - - name: Update package.json versions + - name: Bump versions if: ${{ inputs.bump == true || inputs.bump == 'true' }} - uses: ./.grafana-main/pkg/build/actions/bump-version + uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e with: - version: 'patch' + verb: run + args: go run -C .grafana-main ./pkg/build/actions/bump-version -version="patch" + - name: make gen-cue + shell: bash + run: make gen-cue - name: Add package.json changes if: ${{ inputs.bump == true || inputs.bump == 'true' }} run: | @@ -171,14 +175,13 @@ jobs: git commit -m "Update version to $VERSION" - name: Git push - if: inputs.dry_run != true run: git push - name: Create PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: ${{ steps.branch.outputs.branch }} + DRY_RUN: ${{ inputs.dry_run }} run: | - LATEST_FLAG="" + LATEST_FLAG=() if [ "$LATEST" = "true" ]; then LATEST_FLAG=(-l "release/latest") fi @@ -186,6 +189,6 @@ jobs: "${LATEST_FLAG[@]}" \ -l "no-changelog" \ --dry-run="$DRY_RUN" \ - -B "$BRANCH" \ + -B "${RELEASE_BRANCH}" \ --title "Release: $VERSION" \ --body "These code changes must be merged after a release is complete" diff --git a/.gitignore b/.gitignore index 29fe8a788bd..9ffe2cf1e61 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,6 @@ __debug_bin* # This is the new place of the block, but I leave the previous here for a while /devenv/docker/blocks/auth/saml-enterprise /devenv/docker/blocks/auth/signer -/devenv/docker/blocks/spanner_tests -/devenv/docker/blocks/spanner_tests_multi /devenv/docker/blocks/mt-db /tmp diff --git a/.ignore b/.ignore index 01e1bb8d6e2..e2253d1f69d 100644 --- a/.ignore +++ b/.ignore @@ -19,4 +19,3 @@ # This is the new place of the block, but I leave the previous here for a while !/devenv/docker/blocks/auth/saml-enterprise !/devenv/docker/blocks/auth/signer -!/devenv/docker/blocks/spanner_tests \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index aed72ee265b..e0a269eb40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,182 @@ + + +# 12.0.1 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105101](https://github.com/grafana/grafana/pull/105101), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/openfga/openfga from v1.8.6 to v1.8.12 [#105368](https://github.com/grafana/grafana/pull/105368), [@macabu](https://github.com/macabu) +- **Dependencies:** Unpin and bump github.com/getkin/kin-openapi from v0.126.0 to v0.132.0 [#105249](https://github.com/grafana/grafana/pull/105249), [@macabu](https://github.com/macabu) +- **K8s:** Dashboards: Add fine grained access control checks to /apis [#104419](https://github.com/grafana/grafana/pull/104419), [@stephaniehingtgen](https://github.com/stephaniehingtgen) + +### Bug fixes + +- **Dashboard:** Fix Panel Explore link subpath duplication (#104952) [#105056](https://github.com/grafana/grafana/pull/105056), [@axelavargas](https://github.com/axelavargas) +- **Dashboard:** Fixes issue with row repeats and first row [#104469](https://github.com/grafana/grafana/pull/104469), [@torkelo](https://github.com/torkelo) +- **Graphite:** Ensure template variables are interpolated correctly [#105389](https://github.com/grafana/grafana/pull/105389), [@aangelisc](https://github.com/aangelisc) +- **Graphite:** Fix Graphite series interpolation [#104516](https://github.com/grafana/grafana/pull/104516), [@aangelisc](https://github.com/aangelisc) +- **InfluxDB:** Fix nested variable interpolation [#104176](https://github.com/grafana/grafana/pull/104176), [@aangelisc](https://github.com/aangelisc) +- **MetricsDrilldown:** Restore link to Metrics Drilldown from Explore [#104073](https://github.com/grafana/grafana/pull/104073), [@NWRichmond](https://github.com/NWRichmond) +- **NestedFolderPicker:** Fix scroll jumps back to top [#105769](https://github.com/grafana/grafana/pull/105769), [@samsch](https://github.com/samsch) +- **Preferences:** Disable the save button whilst saving preferences [#105612](https://github.com/grafana/grafana/pull/105612), [@ashharrison90](https://github.com/ashharrison90) +- **Prometheus:** Fix semver import path [#104945](https://github.com/grafana/grafana/pull/104945), [@jackw](https://github.com/jackw) +- **Themes:** Prevent duplicated API call in drawer [#105611](https://github.com/grafana/grafana/pull/105611), [@ashharrison90](https://github.com/ashharrison90) +- **XYChart:** Coerce threshold steps to numbers [#104492](https://github.com/grafana/grafana/pull/104492), [@leeoniya](https://github.com/leeoniya) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 11.6.2 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105103](https://github.com/grafana/grafana/pull/105103), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/blevesearch/bleve/v2 from v2.4.4-git to v2.5.0 [#105443](https://github.com/grafana/grafana/pull/105443), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/openfga/openfga from v1.8.6 to v1.8.12 [#105369](https://github.com/grafana/grafana/pull/105369), [@macabu](https://github.com/macabu) +- **Dependencies:** Unpin and bump github.com/getkin/kin-openapi from v0.126.0 to v0.132.0 [#105251](https://github.com/grafana/grafana/pull/105251), [@macabu](https://github.com/macabu) + +### Bug fixes + +- **Dashboard:** Fixes issue with row repeats and first row [#104467](https://github.com/grafana/grafana/pull/104467), [@torkelo](https://github.com/torkelo) +- **Graphite:** Ensure template variables are interpolated correctly [#105388](https://github.com/grafana/grafana/pull/105388), [@aangelisc](https://github.com/aangelisc) +- **Graphite:** Fix Graphite series interpolation [#104568](https://github.com/grafana/grafana/pull/104568), [@aangelisc](https://github.com/aangelisc) +- **Prometheus:** Fix semver import path [#104943](https://github.com/grafana/grafana/pull/104943), [@jackw](https://github.com/jackw) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 11.5.5 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105109](https://github.com/grafana/grafana/pull/105109), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/blevesearch/bleve/v2 from v2.4.3 to v2.5.0 [#105441](https://github.com/grafana/grafana/pull/105441), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/openfga/openfga from v1.8.5 to v1.8.12 [#105373](https://github.com/grafana/grafana/pull/105373), [@macabu](https://github.com/macabu) +- **Dependencies:** Unpin and bump github.com/getkin/kin-openapi from v0.126.0 to v0.132.0 [#105252](https://github.com/grafana/grafana/pull/105252), [@macabu](https://github.com/macabu) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 11.4.5 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105110](https://github.com/grafana/grafana/pull/105110), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/blevesearch/bleve/v2 from v2.4.2 to v2.5.0 [#105445](https://github.com/grafana/grafana/pull/105445), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/openfga/openfga from v1.8.5 to v1.8.12 [#105375](https://github.com/grafana/grafana/pull/105375), [@macabu](https://github.com/macabu) +- **Dependencies:** Unpin and bump github.com/getkin/kin-openapi from v0.125.0 to v0.132.0 [#105253](https://github.com/grafana/grafana/pull/105253), [@macabu](https://github.com/macabu) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 11.3.7 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105112](https://github.com/grafana/grafana/pull/105112), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/blevesearch/bleve/v2 from v2.4.2 to v2.5.0 [#105447](https://github.com/grafana/grafana/pull/105447), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/openfga/openfga from v1.8.5 to v1.8.12 [#105376](https://github.com/grafana/grafana/pull/105376), [@macabu](https://github.com/macabu) +- **Dependencies:** Unpin and bump github.com/getkin/kin-openapi from v0.125.0 to v0.132.0 [#105254](https://github.com/grafana/grafana/pull/105254), [@macabu](https://github.com/macabu) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 11.2.10 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105113](https://github.com/grafana/grafana/pull/105113), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump github.com/openfga/openfga from v1.8.5 to v1.8.12 [#105378](https://github.com/grafana/grafana/pull/105378), [@macabu](https://github.com/macabu) +- **Dependencies:** Unpin and bump github.com/getkin/kin-openapi from v0.125.0 to v0.132.0 [#105255](https://github.com/grafana/grafana/pull/105255), [@macabu](https://github.com/macabu) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 10.4.19 (2025-05-22) + +### Features and enhancements + +- **Chore:** Bump Go version to 1.24.3 [#105115](https://github.com/grafana/grafana/pull/105115), [@macabu](https://github.com/macabu) +- **Dependencies:** Bump golang.org/x/net from v0.37.0 to v0.40.0 [#105449](https://github.com/grafana/grafana/pull/105449), [@macabu](https://github.com/macabu) +- **Security:** Fix CVE-2025-4123 +- **Security:** Fix CVE-2025-3580 + + + + +# 12.0.0+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + + + +# 11.6.1+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + + + +# 11.5.4+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + + + +# 11.4.4+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + + + +# 11.3.6+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + + + +# 11.2.9+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + + + +# 10.4.18+security-01 (2025-05-21) + +### Bug fixes + +- **Security:** Fix CVE-2025-4123 + + # 12.0.0 (2025-05-05) diff --git a/apps/advisor/pkg/app/checks/authchecks/check.go b/apps/advisor/pkg/app/checks/authchecks/check.go index e1f9f0c204b..74f6a07e7f6 100644 --- a/apps/advisor/pkg/app/checks/authchecks/check.go +++ b/apps/advisor/pkg/app/checks/authchecks/check.go @@ -30,7 +30,7 @@ func (c *check) ID() string { } func (c *check) Name() string { - return "SSO Setting" + return "SSO setting" } func (c *check) Init(ctx context.Context) error { diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index 1cc2c6e2d9d..e5bdc4c4e2e 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -88,7 +88,7 @@ func (c *check) ID() string { } func (c *check) Name() string { - return "Data Source" + return "data source" } func (c *check) Init(ctx context.Context) error { diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index 08458df6e50..30264100bd0 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -46,7 +46,7 @@ func (c *check) ID() string { } func (c *check) Name() string { - return "Plugin" + return "plugin" } func (c *check) Items(ctx context.Context) ([]any, error) { @@ -149,7 +149,7 @@ func (s *deprecationStep) Run(ctx context.Context, log logging.Logger, _ *adviso p.ID, []advisor.CheckErrorLink{ { - Message: "Admin", + Message: "View plugin", Url: fmt.Sprintf("/plugins/%s", p.ID), }, }, diff --git a/apps/advisor/pkg/app/checks/plugincheck/check_test.go b/apps/advisor/pkg/app/checks/plugincheck/check_test.go index 09a566f03ca..ad3feaf43b5 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check_test.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check_test.go @@ -47,7 +47,7 @@ func TestRun(t *testing.T) { Links: []advisor.CheckErrorLink{ { Url: "/plugins/plugin1", - Message: "Admin", + Message: "View plugin", }, }, }, diff --git a/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go b/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go index f154a7afb51..5979ed2b2b4 100644 --- a/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go +++ b/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go @@ -72,9 +72,12 @@ func (r *Runner) createOrUpdate(ctx context.Context, log logging.Logger, obj res return err } currentAnnotations := current.GetAnnotations() + if currentAnnotations == nil { + currentAnnotations = make(map[string]string) + } annotations := obj.GetAnnotations() - maps.Copy(annotations, currentAnnotations) - obj.SetAnnotations(annotations) + maps.Copy(currentAnnotations, annotations) + obj.SetAnnotations(currentAnnotations) // This will update the annotations in the object _, err = r.client.Update(ctx, id, obj, resource.UpdateOptions{}) if err != nil { // Ignore the error, it's probably due to a race condition diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 6bedd25edb7..04273371f01 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -874,6 +874,7 @@ CustomVariableKind: { GroupByVariableSpec: { name: string | *"" datasource?: DataSourceRef + defaultValue?: VariableOption current: VariableOption | *{ text: "" value: "" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index c6eb849f6bc..38ec4bf46ce 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -878,6 +878,7 @@ CustomVariableKind: { GroupByVariableSpec: { name: string | *"" datasource?: DataSourceRef + defaultValue?: VariableOption current: VariableOption | *{ text: "" value: "" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 19ccd04527d..45db16e7490 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1601,15 +1601,16 @@ func NewDashboardGroupByVariableKind() *DashboardGroupByVariableKind { // GroupBy variable specification // +k8s:openapi-gen=true type DashboardGroupByVariableSpec struct { - Name string `json:"name"` - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` - Current DashboardVariableOption `json:"current"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` + Name string `json:"name"` + Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` + DefaultValue *DashboardVariableOption `json:"defaultValue,omitempty"` + Current DashboardVariableOption `json:"current"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` } // NewDashboardGroupByVariableSpec creates a new DashboardGroupByVariableSpec object. diff --git a/docs/sources/developers/http_api/licensing.md b/docs/sources/developers/http_api/licensing.md index 0d09183750c..c17eef55e1d 100644 --- a/docs/sources/developers/http_api/licensing.md +++ b/docs/sources/developers/http_api/licensing.md @@ -112,22 +112,36 @@ Content-Type: application/json Content-Length: 357 { - "jti":"2", - "iss":"https://grafana.com", - "sub":"https://play.grafana.org/" - "lid":"1", - "included_users":15, - "lic_exp_warn_days":30, - "tok_exp_warn_days":2, - "update_days":1, - "prod":["grafana-enterprise"], - "company":"Grafana Labs" +"status":0, +"jti":"", +"iss":"", +"sub":"", +"iat":0, +"exp":0, +"nbf":0, +"lexp":0, +"lid":"", +"limit_by":"", +"included_users":0, +"lic_exp_warn_days":0, +"tok_exp_warn_days":0, +"update_days":0, +"prod":null, +"company":"", +"account":"", +"slug":"", +"usage_billing":false, +"max_concurrent_user_sessions":0, +"details_url":"", +"trial":false, +"trial_exp":0, +"anonymousRatio":0 } ``` -The response is a JSON blob available for debugging purposes. The -available fields may change at any time without any prior notice. +The response is a JSON blob with specific values intentionally not shown. The +available fields may change at any time without any prior notice. Refer to [Check license availability](#check-license-availability) for information on using the API to check the status of your license. Status Codes: diff --git a/docs/sources/developers/http_api/team_sync.md b/docs/sources/developers/http_api/team_sync.md index c4c0e607a2d..e3061524684 100644 --- a/docs/sources/developers/http_api/team_sync.md +++ b/docs/sources/developers/http_api/team_sync.md @@ -147,3 +147,54 @@ Status Codes: - **401** - Unauthorized - **403** - Permission denied - **404** - Team not found/Group not found + +## Search Team Groups + +`GET /api/teams/:teamId/groups/search` + +Search for team groups with pagination support. + +**Required permissions** + +| Action | Scope | +| ---------------------- | -------- | +| teams.permissions:read | teams:\* | + +**Example Request**: + +```http +GET /api/teams/1/groups/search?name=editors&query=group&page=1&perpage=10 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "totalCount": 1, + "teamGroups": [ + { + "orgId": 1, + "teamId": 1, + "groupId": "cn=editors,ou=groups,dc=grafana,dc=org" + } + ], + "page": 1, + "perPage": 10 + } +] +``` + +Status Codes: + +- **200** - Ok +- **400** - Bad Request (invalid team ID format or missing query parameter) +- **401** - Unauthorized +- **403** - Permission denied +- **500** - Internal Server Error diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index a2c7d1af36f..d87e542bd05 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -172,7 +172,7 @@ "grafanaDependency": { "type": "string", "description": "Required Grafana version for this plugin. Validated using https://github.com/npm/node-semver.", - "pattern": "^(<=|>=|<|>|=|~|\\^)?([0-9]+)(\\.[0-9x\\*]+)?(\\.[0-9x\\*]+)?(\\s(<=|>=|<|=>)?([0-9]+)(\\.[0-9x\\*]+)?(\\.[0-9x\\*]+)?)?$" + "pattern": "^(<=|>=|<|>|=|~|\\^)?([0-9]+)(\\.[0-9x\\*]+)?(\\.[0-9x\\*]+)?(-[0-9A-Za-z-.]+)?(\\s(<=|>=|<|=>)?([0-9]+)(\\.[0-9x\\*]+)?(\\.[0-9x\\*]+)?(-[0-9A-Za-z-.]+)?)?$" }, "plugins": { "type": "array", diff --git a/docs/sources/observability-as-code/provision-resources/_index.md b/docs/sources/observability-as-code/provision-resources/_index.md index d8b06048917..f6eb97af7be 100644 --- a/docs/sources/observability-as-code/provision-resources/_index.md +++ b/docs/sources/observability-as-code/provision-resources/_index.md @@ -18,7 +18,7 @@ weight: 300 # Provision resources and sync dashboards {{< admonition type="caution" >}} -Provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. This feature is not publicly available in Grafana Cloud yet. +Provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). {{< /admonition >}} diff --git a/docs/sources/observability-as-code/provision-resources/file-path-setup.md b/docs/sources/observability-as-code/provision-resources/file-path-setup.md index 830728270d3..b5e922456b6 100644 --- a/docs/sources/observability-as-code/provision-resources/file-path-setup.md +++ b/docs/sources/observability-as-code/provision-resources/file-path-setup.md @@ -16,7 +16,7 @@ weight: 200 # Set up file provisioning {{< admonition type="caution" >}} -Local file provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. +Local file provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). diff --git a/docs/sources/observability-as-code/provision-resources/git-sync-setup.md b/docs/sources/observability-as-code/provision-resources/git-sync-setup.md index 213d8da2961..4a81b71a197 100644 --- a/docs/sources/observability-as-code/provision-resources/git-sync-setup.md +++ b/docs/sources/observability-as-code/provision-resources/git-sync-setup.md @@ -16,7 +16,7 @@ weight: 100 # Set up Git Sync {{< admonition type="caution" >}} -Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. +Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). diff --git a/docs/sources/observability-as-code/provision-resources/intro-git-sync.md b/docs/sources/observability-as-code/provision-resources/intro-git-sync.md index 0a953c9c5d9..3ebcd0bd2e8 100644 --- a/docs/sources/observability-as-code/provision-resources/intro-git-sync.md +++ b/docs/sources/observability-as-code/provision-resources/intro-git-sync.md @@ -16,7 +16,7 @@ weight: 100 # Git Sync {{< admonition type="caution" >}} -Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. +Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). diff --git a/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md b/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md index 6ae40c456a4..8ab3afa9c03 100644 --- a/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md +++ b/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md @@ -16,7 +16,7 @@ weight: 300 # Work with provisioned dashboards {{< admonition type="caution" >}} -Git Sync and File path provisioning an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana. These features aren't available publicly in Grafana Cloud yet. +Git Sync and File path provisioning an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana. These features aren't available publicly in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). diff --git a/docs/sources/observability-as-code/provision-resources/use-git-sync.md b/docs/sources/observability-as-code/provision-resources/use-git-sync.md index acb9b77dad6..f54d43347dd 100644 --- a/docs/sources/observability-as-code/provision-resources/use-git-sync.md +++ b/docs/sources/observability-as-code/provision-resources/use-git-sync.md @@ -19,7 +19,7 @@ weight: 400 # Manage provisioned repositories with Git Sync {{< admonition type="caution" >}} -Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. +Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). diff --git a/docs/sources/observability-as-code/schema-v2/_index.md b/docs/sources/observability-as-code/schema-v2/_index.md index 207b85dc891..c86fd130818 100644 --- a/docs/sources/observability-as-code/schema-v2/_index.md +++ b/docs/sources/observability-as-code/schema-v2/_index.md @@ -148,31 +148,31 @@ The table includes default and other fields: ### `annotations` The configuration for the list of annotations that are associated with the dashboard. -For the JSON and field usage notes, refer to the [annotations schema documentation](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/annotations-schema/). +For the JSON and field usage notes, refer to the [annotations schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/annotations-schema/). ### `elements` Dashboards can contain the following elements: -- [PanelKind](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/panel-schema/) -- [LibraryPanelKind](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/librarypanel-schema/) +- [PanelKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) +- [LibraryPanelKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/librarypanel-schema/) ### `layout` Dashboards can have four layout options: -- [GridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/layout-schema/#gridlayoutkind) -- [AutoGridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/layout-schema/#autogridlayoutkind) -- [RowsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/layout-schema/#rowslayoutkind) -- [TabsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/layout-schema/#tabslayoutkind) +- [GridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#gridlayoutkind) +- [AutoGridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#autogridlayoutkind) +- [RowsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#rowslayoutkind) +- [TabsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#tabslayoutkind) -For the JSON and field usage notes about each of these, refer to the [layout schema documentation](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/layout-schema/). +For the JSON and field usage notes about each of these, refer to the [layout schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/). ### `links` The configuration for links with references to other dashboards or external websites. -For the JSON and field usage notes, refer to the [links schema documentation](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/links-schema/). +For the JSON and field usage notes, refer to the [links schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/links-schema/). ### `tags` @@ -183,7 +183,7 @@ The tags associated with the dashboard: ### `timesettings` The `TimeSettingsSpec` defines the default time configuration for the time picker and the refresh picker for the specific dashboard. -For the JSON and field usage notes about the `TimeSettingsSpec`, refer to the [timesettings schema documentation](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/timesettings-schema/). +For the JSON and field usage notes about the `TimeSettingsSpec`, refer to the [timesettings schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/timesettings-schema/). ### `variables` @@ -200,7 +200,7 @@ There are eight variables types: - GroupByVariableKind - AdhocVariableKind -For the JSON and field usage notes about the `variables` spec, refer to the [variables schema documentation](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/variables-schema/). +For the JSON and field usage notes about the `variables` spec, refer to the [variables schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/variables-schema/). ## Notes and limitations diff --git a/docs/sources/observability-as-code/schema-v2/layout-schema.md b/docs/sources/observability-as-code/schema-v2/layout-schema.md index f9654286929..71bd7e30399 100644 --- a/docs/sources/observability-as-code/schema-v2/layout-schema.md +++ b/docs/sources/observability-as-code/schema-v2/layout-schema.md @@ -87,14 +87,14 @@ Following is the JSON for a default grid layout, a grid layout item, and a grid The following table explains the usage of the grid layout item JSON fields: -| Name | Usage | -| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| x | integer. Position of the item x-axis. | -| y | integer. Position of the item y-axis. | -| width | Width of the item in pixels. | -| height | Height of the item in pixels. | -| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | -| repeat? | [RepeatOptions](#repeatoptions). Configured repeat options, if any | +| Name | Usage | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| x | integer. Position of the item x-axis. | +| y | integer. Position of the item y-axis. | +| width | Width of the item in pixels. | +| height | Height of the item in pixels. | +| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | +| repeat? | [RepeatOptions](#repeatoptions). Configured repeat options, if any | #### `RepeatOptions` @@ -188,7 +188,7 @@ The following table explains the usage of the auto grid layout item JSON fields: | Name | Usage | | ---- | ----- | -| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/json-models/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | +| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | | repeat? | [AutoGridRepeatOptions](#autogridrepeatoptions). Configured repeat options, if any. | | conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing panels, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| diff --git a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md index 2748f83d97d..ad67cc099c8 100644 --- a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @@ -201,6 +201,7 @@ Use this transformation to add a new field calculated from two other fields. Eac - **All number fields** - Set the left side of a **Binary operation** to apply the calculation to all number fields. - **As percentile** - If you select **Row index** mode, then the **As percentile** switch appears. This switch allows you to transform the row index as a percentage of the total number of rows. - **Alias** - (Optional) Enter the name of your new field. If you leave this blank, then the field will be named to match the calculation. + > **Note:** If a variable will be used in this transformation, the default alias will be interpolated with the value of the variable. Please explicitly define an alias if you would like the alias to not be affected by variable changes. - **Replace all fields** - (Optional) Select this option if you want to hide all other fields and display only your calculated field in the visualization. In the example below, we added two fields together and named them Sum. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index 7867cb4767b..17305e59a3f 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -209,6 +209,10 @@ You can use the `org_attribute_path` and `org_mapping` configuration options to To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads. +{{< admonition type="note" >}} +When using `org_attribute_path`, the value returned by the JMESPath expression must be an array, not a string. +{{< /admonition >}} + #### Role mapping examples This section includes examples of JMESPath expressions used for role mapping. diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md index d00e99cface..99deb0331b5 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md @@ -85,7 +85,7 @@ The table below describes all SCIM configuration options. Like any other Grafana - SCIM group sync (`group_sync_enabled = true`) and Team Sync cannot be enabled simultaneously - You can use SCIM user sync (`user_sync_enabled = true`) alongside Team Sync -- For more details about migration and compatibility, see [SCIM vs Team Sync](./manage-users-teams/_index.md#scim-vs-team-sync) +- For more details about migration and compatibility, see [SCIM vs Team Sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/#scim-vs-team-sync) {{< /admonition >}} ### Example SCIM configuration @@ -134,5 +134,5 @@ The following table compares SCIM with other synchronization methods to help you ## Next steps - [Manage users and teams with SCIM provisioning](manage-users-teams/) -- [Configure SCIM with Azure AD](azuread/) -- [Configure SCIM with Okta](okta/) +- [Configure SCIM with Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/) +- [Configure SCIM with Okta](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/) diff --git a/docs/sources/setup-grafana/start-restart-grafana.md b/docs/sources/setup-grafana/start-restart-grafana.md index 0cba9899590..f0c4e570b87 100644 --- a/docs/sources/setup-grafana/start-restart-grafana.md +++ b/docs/sources/setup-grafana/start-restart-grafana.md @@ -128,7 +128,7 @@ services: restart: unless-stopped environment: - TERM=linux - - GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-polystat-panel + - GF_PLUGINS_PREINSTALL=grafana-clock-panel,grafana-polystat-panel ports: - '3000:3000' volumes: diff --git a/e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts b/e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts new file mode 100644 index 00000000000..43ac0576f47 --- /dev/null +++ b/e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts @@ -0,0 +1,30 @@ +import { e2e } from '../utils'; + +import { flows } from './dashboard-edit-flows'; + +describe('Dashboard panels', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('can duplicate a panel', () => { + e2e.flows.scenes.importV2Dashboard({ title: 'Paste tab' }); + + e2e.flows.scenes.toggleEditMode(); + const panelTitle = 'Unique'; + flows.changePanelTitle('New panel', panelTitle); + + e2e.components.Panels.Panel.title(panelTitle).should('have.length', 1); + + e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); + e2e.components.Panels.Panel.menuItems('More...').trigger('mouseover'); + e2e.components.Panels.Panel.menuItems('Duplicate').click(); + + e2e.components.Panels.Panel.title(panelTitle).should('have.length', 2); + + // Save, reload, and ensure duplicate has persisted + e2e.flows.scenes.saveDashboard(); + cy.reload(); + e2e.components.Panels.Panel.title(panelTitle).should('have.length', 2); + }); +}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts index fdb2d177fd7..40b1d1c7261 100644 --- a/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts +++ b/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts @@ -34,6 +34,10 @@ describe('Dashboard edit - variables', () => { e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); const values = variable.value.split(','); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(values[0]).should('be.visible'); + + // check that variable deletion works + e2e.components.EditPaneHeader.deleteButton().click(); + e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('not.exist'); }); it('can add a new constant variable', () => { diff --git a/go.mod b/go.mod index a4dc55e3fe0..fdb41c952ad 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( buf.build/gen/go/parca-dev/parca/connectrpc/go v1.17.0-20240902100956-02fd72488966.1 // @grafana/observability-traces-and-profiling buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.34.2-20240902100956-02fd72488966.2 // @grafana/observability-traces-and-profiling cloud.google.com/go/kms v1.20.5 // @grafana/grafana-backend-group - cloud.google.com/go/spanner v1.75.0 // @grafana/grafana-search-and-storage cloud.google.com/go/storage v1.50.0 // @grafana/grafana-backend-group connectrpc.com/connect v1.17.0 // @grafana/observability-traces-and-profiling cuelang.org/go v0.11.1 // @grafana/grafana-as-code @@ -75,7 +74,6 @@ require ( github.com/google/uuid v1.6.0 // @grafana/grafana-backend-group github.com/google/wire v0.6.0 // @grafana/grafana-backend-group github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group - github.com/googleapis/go-sql-spanner v1.11.1 // @grafana/grafana-search-and-storage github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad github.com/grafana/alerting v0.0.0-20250521131632-6e476b0b04c3 // @grafana/alerting-backend @@ -193,7 +191,7 @@ require ( gopkg.in/mail.v2 v2.3.1 // @grafana/grafana-backend-group gopkg.in/yaml.v2 v2.4.0 // @grafana/alerting-backend gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-backend - k8s.io/api v0.32.3 // indirect; @grafana/grafana-app-platform-squad + k8s.io/api v0.32.3 // @grafana/grafana-app-platform-squad k8s.io/apimachinery v0.32.3 // @grafana/grafana-app-platform-squad k8s.io/apiserver v0.32.3 // @grafana/grafana-app-platform-squad k8s.io/client-go v0.32.3 // @grafana/grafana-app-platform-squad @@ -202,6 +200,7 @@ require ( k8s.io/kube-aggregator v0.32.0 // @grafana/grafana-app-platform-squad k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // @grafana/grafana-app-platform-squad k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // @grafana/partner-datasources + sigs.k8s.io/randfill v1.0.0 // @grafana/grafana-app-platform-squad sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // @grafana-app-platform-squad xorm.io/builder v0.3.6 // @grafana/grafana-backend-group ) @@ -252,7 +251,6 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect github.com/FZambia/eagle v0.2.0 // indirect - github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect @@ -569,7 +567,6 @@ require ( modernc.org/sqlite v1.37.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index b73237ae465..b0fe824a133 100644 --- a/go.sum +++ b/go.sum @@ -187,7 +187,6 @@ cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvj cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= -cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= @@ -541,8 +540,6 @@ cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+ cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos= cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= cloud.google.com/go/spanner v1.45.0/go.mod h1:FIws5LowYz8YAE1J8fOS7DJup8ff7xJeetWEo5REA2M= -cloud.google.com/go/spanner v1.75.0 h1:2zrltTJv/4P3pCgpYgde4Eb1vN8Cgy1fNy7pbTnOovg= -cloud.google.com/go/spanner v1.75.0/go.mod h1:TLFZBvPQmx3We7sGh12eTk9lLsRLczzZaiweqfMpR80= cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0= @@ -718,8 +715,6 @@ github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/FZambia/eagle v0.2.0 h1:1kQaZpJvbkvAXFRE/9K2ucBMuVqo+E29EMLYB74hIis= github.com/FZambia/eagle v0.2.0/go.mod h1:LKMYBwGYhao5sJI0TppvQ4SvvldFj9gITxrl8NvGwG0= -github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 h1:DBjmt6/otSdULyJdVg2BlG0qGZO5tKL4VzOs0jpvw5Q= -github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2/go.mod h1:dppbR7CwXD4pgtV9t3wD1812RaLDcBjtblcDF5f1vI0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 h1:f2Qw/Ehhimh5uO1fayV0QIW7DShEQqhtUfhYc+cBPlw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0/go.mod h1:2bIszWvQRlJVmJLiuLhukLImRjKPcYdzzsx6darK02A= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 h1:o90wcURuxekmXrtxmYWTyNla0+ZEHhud6DI1ZTxd1vI= @@ -1019,7 +1014,6 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -1133,7 +1127,6 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI= -github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= @@ -1144,7 +1137,6 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= @@ -1549,8 +1541,6 @@ github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEP github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.3.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= -github.com/googleapis/go-sql-spanner v1.11.1 h1:z3ThtKV5HFvaNv9UGc26+ggS+lS0dsCAkaFduKL7vws= -github.com/googleapis/go-sql-spanner v1.11.1/go.mod h1:fuA5q4yMS3SZiVfRr5bvksPNk7zUn/irbQW62H/ffZw= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gophercloud/gophercloud v0.3.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= @@ -2392,7 +2382,6 @@ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -3398,11 +3387,9 @@ google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCD google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= -google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= -google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= diff --git a/go.work.sum b/go.work.sum index 84895460656..99c60fe0c96 100644 --- a/go.work.sum +++ b/go.work.sum @@ -442,6 +442,7 @@ cloud.google.com/go/shell v1.8.1/go.mod h1:jaU7OHeldDhTwgs3+clM0KYEDYnBAPevUI6wN cloud.google.com/go/shell v1.8.3 h1:mjYgUsOtV3jl9xvDmcvlRRmA64deEPf52zOfuc68b/g= cloud.google.com/go/shell v1.8.3/go.mod h1:OYcrgWF6JSp/uk76sNTtYFlMD0ho2+Cdzc7U3P/bF54= cloud.google.com/go/spanner v1.70.0/go.mod h1:X5T0XftydYp0K1adeJQDJtdWpbrOeJ7wHecM4tK6FiE= +cloud.google.com/go/spanner v1.73.0 h1:0bab8QDn6MNj9lNK6XyGAVFhMlhMU2waePPa6GZNoi8= cloud.google.com/go/spanner v1.73.0/go.mod h1:mw98ua5ggQXVWwp83yjwggqEmW9t8rjs9Po1ohcUGW4= cloud.google.com/go/speech v1.25.1 h1:iGZJS3wrdkje/Vqiacx1+r+zVwUZoXVMdklYIVsvfNw= cloud.google.com/go/speech v1.25.1/go.mod h1:WgQghvghkZ1htG6BhYn98mP7Tg0mti8dBFDLMVXH/vM= @@ -1047,8 +1048,6 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= diff --git a/package.json b/package.json index 94f2da51f20..824191f9b0d 100644 --- a/package.json +++ b/package.json @@ -259,7 +259,7 @@ "yargs": "^17.5.1" }, "dependencies": { - "@bsull/augurs": "^0.9.0", + "@bsull/augurs": "^0.10.0", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@fingerprintjs/fingerprintjs": "^3.4.2", @@ -267,7 +267,7 @@ "@formatjs/intl-durationformat": "^0.7.0", "@glideapps/glide-data-grid": "^6.0.0", "@grafana/alerting": "workspace:*", - "@grafana/aws-sdk": "0.6.0", + "@grafana/aws-sdk": "0.7.0", "@grafana/azure-sdk": "0.0.7", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", @@ -278,7 +278,7 @@ "@grafana/google-sdk": "0.1.2", "@grafana/i18n": "workspace:*", "@grafana/lezer-logql": "0.2.7", - "@grafana/llm": "0.13.2", + "@grafana/llm": "0.19.2", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/plugin-ui": "0.10.6", @@ -302,7 +302,7 @@ "@msagl/parser": "^1.1.19", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-collector": "0.25.0", - "@opentelemetry/semantic-conventions": "1.28.0", + "@opentelemetry/semantic-conventions": "1.34.0", "@popperjs/core": "2.11.8", "@react-aria/dialog": "3.5.25", "@react-aria/focus": "3.20.3", @@ -390,7 +390,7 @@ "react-router": "5.3.4", "react-router-dom": "5.3.4", "react-router-dom-v5-compat": "^6.26.1", - "react-select": "5.10.0", + "react-select": "5.10.1", "react-split-pane": "0.1.92", "react-table": "7.8.0", "react-transition-group": "4.4.5", diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 97362791486..6b29551e1c0 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -1,6 +1,6 @@ import { isNumber, set, unset, get, cloneDeep } from 'lodash'; import { useMemo, useRef } from 'react'; -import usePrevious from 'react-use/lib/usePrevious'; +import { usePrevious } from 'react-use'; import { ThresholdsMode, VariableFormatID } from '@grafana/schema'; diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 631b4374ab6..2a190301d1d 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -814,7 +814,11 @@ export { export { type Action, type ActionModel, + type ActionVariable, + type ActionVariableInput, + ActionType, HttpRequestMethod, + ActionVariableType, defaultActionConfig, contentTypeOptions, httpMethodOptions, diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.ts b/packages/grafana-data/src/transformations/transformers/calculateField.ts index 8b290f3cbc9..1e9baf3f872 100644 --- a/packages/grafana-data/src/transformations/transformers/calculateField.ts +++ b/packages/grafana-data/src/transformations/transformers/calculateField.ts @@ -695,9 +695,9 @@ export function getNameFromOptions(options: CalculateFieldTransformerOptions) { const { binary } = options; const alias = `${binary?.left?.matcher?.options ?? binary?.left?.fixed ?? ''} ${binary?.operator ?? ''} ${binary?.right?.matcher?.options ?? binary?.right?.fixed ?? ''}`; - //Remove $ signs as they will be interpolated and cause issues. Variables can still be used - //in alias but shouldn't in the autogenerated name - return alias.replace(/\$/g, ''); + // binary calculations with variables will be interpolated on the visualization but we don't want to do that here, so just give a blank placeholder + const variableFound = /\$/g.test(alias); + return variableFound ? '' : alias; } case CalculateFieldMode.ReduceRow: { diff --git a/packages/grafana-data/src/transformations/transformers/transpose.test.ts b/packages/grafana-data/src/transformations/transformers/transpose.test.ts index e522b93e885..c14026a8920 100644 --- a/packages/grafana-data/src/transformations/transformers/transpose.test.ts +++ b/packages/grafana-data/src/transformations/transformers/transpose.test.ts @@ -86,9 +86,17 @@ describe('Transpose transformer', () => { { name: 'env', type: FieldType.string, values: ['dev', 'prod', 'staging', 'release', 'beta'] }, { name: 'january', type: FieldType.number, values: [11, 12, 13, 14, 15] }, { name: 'february', type: FieldType.number, values: [6, 7, 8, 9, 10] }, - { name: 'type', type: FieldType.string, values: ['metricA', 'metricB', 'metricC', 'metricD', 'metricE'] }, + { + name: 'metricName', + type: FieldType.string, + values: ['metricA', 'metricB', 'metricC', 'metricD', 'metricE'], + config: { + displayName: 'type', + }, + }, ], }); + await expect(transformDataFrame([cfgB], [seriesB])).toEmitValuesWith((received) => { const result = received[0]; expect(result[0].fields).toEqual([ diff --git a/packages/grafana-data/src/transformations/transformers/transpose.ts b/packages/grafana-data/src/transformations/transformers/transpose.ts index ae97e8f4ceb..651254a736d 100644 --- a/packages/grafana-data/src/transformations/transformers/transpose.ts +++ b/packages/grafana-data/src/transformations/transformers/transpose.ts @@ -1,5 +1,6 @@ import { map } from 'rxjs/operators'; +import { cacheFieldDisplayNames } from '../../field/fieldState'; import { DataFrame, Field, FieldType } from '../../types/dataFrame'; import { DataTransformerInfo } from '../../types/transformations'; @@ -28,6 +29,8 @@ export const transposeTransformer: DataTransformerInfo { const firstField = frame.fields[0]; const firstName = !options.firstFieldName ? 'Field' : options.firstFieldName; @@ -38,8 +41,12 @@ function transposeDataFrame(options: TransposeTransformerOptions, data: DataFram ? [firstName, ...fieldValuesAsStrings(firstField, firstField.values)] : [firstName, ...firstField.values.map((_, i) => restName)]; const rows = useFirstFieldAsHeaders - ? frame.fields.map((field) => field.name).slice(1) - : frame.fields.map((field) => field.name); + ? frame.fields + .map((field) => { + return field.state?.displayName ?? field.name; + }) + .slice(1) + : frame.fields.map((field) => field.state?.displayName ?? field.name); const fieldType = determineFieldType( useFirstFieldAsHeaders ? frame.fields.map((field) => field.type).slice(1) diff --git a/packages/grafana-data/src/types/action.ts b/packages/grafana-data/src/types/action.ts index 06a61e53ecd..3a637ffa071 100644 --- a/packages/grafana-data/src/types/action.ts +++ b/packages/grafana-data/src/types/action.ts @@ -1,4 +1,4 @@ -import { CSSProperties } from 'react'; +import { CSSProperties, ReactNode } from 'react'; import { SelectableValue } from './select'; @@ -18,6 +18,7 @@ export interface Action { [ActionType.Fetch]: FetchOptions; confirmation?: string; oneClick?: boolean; + variables?: ActionVariable[]; style?: ActionButtonCssProperties; } @@ -26,10 +27,21 @@ export interface Action { */ export interface ActionModel { title: string; - onClick: (event: any, origin?: any) => void; - confirmation?: string; + onClick: (event: any, origin?: any, actionVars?: ActionVariableInput) => void; + confirmation: (actionVars?: ActionVariableInput) => ReactNode; oneClick?: boolean; style: ActionButtonCssProperties; + variables?: ActionVariable[]; +} + +export type ActionVariable = { + key: string; + name: string; + type: ActionVariableType; +}; + +export enum ActionVariableType { + String = 'string', } interface FetchOptions { @@ -70,3 +82,5 @@ export const defaultActionConfig: Action = { headers: [['Content-Type', 'application/json']], }, }; + +export type ActionVariableInput = { [key: string]: string }; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 69175808369..e2a79ca824f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -679,10 +679,6 @@ export interface FeatureToggles { */ exploreLogsLimitedTimeRange?: boolean; /** - * Used in Home for users who want to return to the onboarding flow or quickly find popular config pages - */ - homeSetupGuide?: boolean; - /** * Enables the gRPC client to authenticate with the App Platform by using ID & access tokens */ appPlatformGrpcClientAuth?: boolean; @@ -845,6 +841,10 @@ export interface FeatureToggles { */ teamHttpHeadersMimir?: boolean; /** + * Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams + */ + teamHttpHeadersTempo?: boolean; + /** * Test feature toggle to see how cohorts could be set up AB testing * @default false */ @@ -1028,4 +1028,9 @@ export interface FeatureToggles { * Use proxy-based read-only objects for plugin extensions instead of deep cloning */ extensionsReadOnlyProxy?: boolean; + /** + * Enables restore deleted dashboards feature + * @default false + */ + restoreDashboards?: boolean; } diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 56b391a92b8..60a5c6a6026 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -78,6 +78,7 @@ export interface GroupByVariableModel extends VariableWithOptions { datasource: DataSourceRef | null; multi: true; allowCustomValue?: boolean; + defaultValue?: VariableOption; } export interface VariableOption { diff --git a/packages/grafana-i18n/src/i18n.tsx b/packages/grafana-i18n/src/i18n.tsx index 4afa54bb0f5..5435dc3168a 100644 --- a/packages/grafana-i18n/src/i18n.tsx +++ b/packages/grafana-i18n/src/i18n.tsx @@ -1,4 +1,4 @@ -import i18n, { InitOptions, ReactOptions, TFunction } from 'i18next'; +import i18n, { InitOptions, ReactOptions, TFunction as I18NextTFunction } from 'i18next'; import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetector'; // eslint-disable-next-line no-restricted-imports import { initReactI18next, setDefaults, setI18n, Trans as I18NextTrans, getI18n } from 'react-i18next'; @@ -6,9 +6,9 @@ import { initReactI18next, setDefaults, setI18n, Trans as I18NextTrans, getI18n import { DEFAULT_LANGUAGE, PSEUDO_LOCALE } from './constants'; import { initRegionalFormat } from './dates'; import { LANGUAGES } from './languages'; -import { TransProps, TransType } from './types'; +import { TFunction, TransProps, TransType } from './types'; -let tFunc: TFunction | undefined; +let tFunc: I18NextTFunction | undefined; let transComponent: TransType; export async function initPluginTranslations(id: string) { @@ -140,7 +140,7 @@ export function addResourceBundle(language: string, namespace: string, resource: getI18nInstance().addResourceBundle(language, namespace, resource, undefined, true); } -export function t(id: string, defaultMessage: string, values?: Record) { +export const t: TFunction = (id: string, defaultMessage: string, values?: Record) => { if (!tFunc) { if (process.env.NODE_ENV !== 'test') { console.warn( @@ -156,7 +156,7 @@ export function t(id: string, defaultMessage: string, values?: Record) => string; + +export type { UseTranslateHook, TransProps, TransType, TFunction }; diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index 894cf4c919a..0cdcec2db12 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -24,7 +24,7 @@ "@grafana/runtime": "12.1.0-pre", "@grafana/schema": "12.1.0-pre", "@grafana/ui": "12.1.0-pre", - "react-select": "5.10.0", + "react-select": "5.10.1", "react-use": "17.6.0", "rxjs": "7.8.2", "tslib": "2.8.1" diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 9c405e0a136..1f58f7d9b8b 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -51,7 +51,7 @@ "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", - "@prometheus-io/lezer-promql": "0.301.0", + "@prometheus-io/lezer-promql": "0.304.0", "@reduxjs/toolkit": "2.5.1", "@types/debounce-promise": "3.1.9", "@types/lodash": "4.17.15", diff --git a/packages/grafana-prometheus/src/utf8_support.ts b/packages/grafana-prometheus/src/utf8_support.ts index a9ce776364b..a6c19ff2750 100644 --- a/packages/grafana-prometheus/src/utf8_support.ts +++ b/packages/grafana-prometheus/src/utf8_support.ts @@ -1,3 +1,17 @@ +/** + * Ensures a string is compatible with Prometheus' UTF-8 handling rules. + * + * Prometheus has specific rules for handling UTF-8 strings in metric names and label values: + * - Legacy names (matching pattern [a-zA-Z_:][a-zA-Z0-9_:]*) are used as-is + * - Non-legacy names containing UTF-8 characters must be wrapped in double quotes + * + * @param value - The string to make UTF-8 compatible + * @returns The original string if it's empty or a valid legacy name, otherwise the string wrapped in double quotes + * + * @example + * utf8Support('metric_name') // returns 'metric_name' + * utf8Support('metric-📈') // returns '"metric-📈"' + */ export const utf8Support = (value: string) => { if (value === '') { return value; @@ -9,6 +23,22 @@ export const utf8Support = (value: string) => { return `"${value}"`; }; +/** + * Escapes a string to make it compatible with Prometheus UTF-8 support. + * + * This function converts non-legacy name characters to an escaped format: + * - Underscores are doubled as '__' + * - Valid legacy runes are preserved as-is + * - Invalid code points are replaced with '_FFFD_' + * - Other characters are converted to '_HEX_' format where HEX is the hexadecimal code point + * + * @param value - The string to escape + * @returns An escaped string prefixed with 'U__' that is compatible with Prometheus + * + * @example + * escapeForUtf8Support("my lovely_http.status:sum") // returns U__my_20_lovely__http_2e_status:sum + * escapeForUtf8Support("label with 😱") // returns U__label_20_with_20__1f631_ + */ export const escapeForUtf8Support = (value: string) => { const isLegacyLabel = isValidLegacyName(value); if (isLegacyLabel) { @@ -42,6 +72,16 @@ export const escapeForUtf8Support = (value: string) => { return escaped; }; +/** + * Checks if a string is a valid legacy (the standard) Prometheus metric or label name. + * + * Valid legacy (the standard) names match the pattern [a-zA-Z_:][a-zA-Z0-9_:]* which means: + * - First character must be a letter, underscore, or colon + * - Remaining characters can only be letters, numbers, underscores, or colons + * + * @param name - The string to check + * @returns true if the string is a valid legacy (the standard) name, false otherwise + */ export const isValidLegacyName = (name: string): boolean => { if (name.length === 0) { return false; @@ -57,9 +97,17 @@ export const isValidLegacyName = (name: string): boolean => { return true; }; -// const labelNamePriorToUtf8Support = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/; -// instead of regex we use rune check (converted from prometheus code) -// https://github.com/prometheus/common/blob/main/model/metric.go#L426-L428 +/** + * Checks if a character is valid for a legacy (the standard) Prometheus metric or label name. + * + * This is an implementation of the Prometheus model rune validation logic, which + * determines if a character is allowed in a legacy metric or label name. + * https://github.com/prometheus/common/blob/v0.64.0/model/metric.go#L430-L432 + * + * @param char - The character to check + * @param index - The position of the character in the string + * @returns true if the character is valid at the given position, false otherwise + */ const isValidLegacyRune = (char: string, index: number): boolean => { const codePoint = char.codePointAt(0); if (codePoint === undefined) { @@ -75,11 +123,26 @@ const isValidLegacyRune = (char: string, index: number): boolean => { ); }; +/** + * Validates if a Unicode code point is valid for UTF-8 encoding. + * + * @param codePoint - The Unicode code point to validate + * @returns true if the code point is valid (between 0 and 0x10FFFF), false otherwise + */ const isValidCodePoint = (codePoint: number): boolean => { - // Validate the code point for UTF-8 compliance if needed. return codePoint >= 0 && codePoint <= 0x10ffff; }; +/** + * Wraps each key in a Prometheus filter string with UTF-8 support. + * + * This function processes a filter string (e.g. 'metric="value",name=~"pattern"') + * and applies UTF-8 support to each key while preserving the operators and values. + * It handles quoted values and comma separators correctly. + * + * @param filterStr - The filter string to process + * @returns A new filter string with UTF-8 support applied to the keys + */ export const wrapUtf8Filters = (filterStr: string): string => { const resultArray: string[] = []; const operatorRegex = /(=~|!=|!~|=)/; // NOTE: the order of the operators is important here diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index 009026fa970..33e7f2ed0b8 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -1290,6 +1290,7 @@ export const defaultGroupByVariableKind = (): GroupByVariableKind => ({ export interface GroupByVariableSpec { name: string; datasource?: DataSourceRef; + defaultValue?: VariableOption; current: VariableOption; options: VariableOption[]; multi: boolean; diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 02301c0ae08..5af3c3fa16f 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -25,7 +25,7 @@ "lodash": "4.17.21", "react": "18.3.1", "react-dom": "18.3.1", - "react-select": "5.10.0", + "react-select": "5.10.1", "react-use": "17.6.0", "react-virtualized-auto-sizer": "1.0.25", "rxjs": "7.8.2", diff --git a/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx index 5ababb2f236..0f86f8aeb50 100644 --- a/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx +++ b/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx @@ -1,7 +1,6 @@ import { css } from '@emotion/css'; import { useState, useMemo, useEffect } from 'react'; -import { useAsyncFn } from 'react-use'; -import useDebounce from 'react-use/lib/useDebounce'; +import { useAsyncFn, useDebounce } from 'react-use'; import { formattedValueToString, getValueFormat, TimeRange } from '@grafana/data'; import { Icon, Spinner, useTheme2 } from '@grafana/ui'; diff --git a/packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx index 95ac8676511..8a77663398f 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx @@ -1,4 +1,4 @@ -import useAsync from 'react-use/lib/useAsync'; +import { useAsync } from 'react-use'; import { SelectableValue, TypedVariableModel } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 37cb20ead94..a7baee88fad 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -73,7 +73,7 @@ "@grafana/schema": "12.1.0-pre", "@hello-pangea/dnd": "17.0.0", "@leeoniya/ufuzzy": "1.0.18", - "@monaco-editor/react": "4.6.0", + "@monaco-editor/react": "4.7.0", "@popperjs/core": "2.11.8", "@react-aria/dialog": "3.5.25", "@react-aria/focus": "3.20.3", @@ -117,7 +117,7 @@ "react-loading-skeleton": "3.5.0", "react-router-dom": "5.3.4", "react-router-dom-v5-compat": "^6.26.1", - "react-select": "5.10.0", + "react-select": "5.10.1", "react-table": "7.8.0", "react-transition-group": "4.4.5", "react-use": "17.6.0", diff --git a/packages/grafana-ui/src/components/Actions/ActionButton.tsx b/packages/grafana-ui/src/components/Actions/ActionButton.tsx index 9d2987dc47d..55b8d3baf32 100644 --- a/packages/grafana-ui/src/components/Actions/ActionButton.tsx +++ b/packages/grafana-ui/src/components/Actions/ActionButton.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; -import { ActionModel, Field } from '@grafana/data'; +import { ActionModel, Field, ActionVariableInput } from '@grafana/data'; import { useTheme2 } from '../../themes'; import { t } from '../../utils/i18n'; import { Button, ButtonProps } from '../Button'; import { ConfirmModal } from '../ConfirmModal/ConfirmModal'; +import { VariablesInputModal } from './VariablesInputModal'; + type ActionButtonProps = ButtonProps & { action: ActionModel; }; @@ -21,27 +23,52 @@ export function ActionButton({ action, ...buttonProps }: ActionButtonProps) { const [showConfirm, setShowConfirm] = useState(false); + // Action variables + const [showVarsModal, setShowVarsModal] = useState(false); + const [actionVars, setActionVars] = useState({}); + + const actionHasVariables = action.variables && action.variables.length > 0; + + const onClick = () => { + if (actionHasVariables) { + setShowVarsModal(true); + } else { + setShowConfirm(true); + } + }; + return ( <> + + {actionHasVariables && showVarsModal && ( + setShowVarsModal(false)} + action={action} + onShowConfirm={() => setShowConfirm(true)} + variables={actionVars} + setVariables={setActionVars} + /> + )} + {showConfirm && ( { setShowConfirm(false); - action.onClick(new MouseEvent('click')); + action.onClick(new MouseEvent('click'), null, actionVars); }} onDismiss={() => { setShowConfirm(false); diff --git a/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx b/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx new file mode 100644 index 00000000000..73be7916e72 --- /dev/null +++ b/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx @@ -0,0 +1,72 @@ +import { css } from '@emotion/css'; + +import { ActionModel, ActionVariableInput } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { t } from '../../utils/i18n'; +import { Button } from '../Button/Button'; +import { Field } from '../Forms/Field'; +import { FieldSet } from '../Forms/FieldSet'; +import { Input } from '../Input/Input'; +import { Modal } from '../Modal/Modal'; + +interface Props { + action: ActionModel; + onDismiss: () => void; + onShowConfirm: () => void; + variables: ActionVariableInput; + setVariables: (vars: ActionVariableInput) => void; +} + +/** + * @internal + */ +export function VariablesInputModal({ action, onDismiss, onShowConfirm, variables, setVariables }: Props) { + const styles = useStyles2(getStyles); + + const onModalContinue = () => { + onDismiss(); + onShowConfirm(); + }; + + return ( + +
+ {action.variables!.map((variable) => ( + + { + setVariables({ ...variables, [variable.key]: e.currentTarget.value }); + }} + placeholder={t('grafana-ui.action-editor.button.variable-value-placeholder', 'Value')} + width={20} + /> + + ))} +
+ + + + +
+ ); +} + +const getStyles = () => { + return { + variablesModal: css({ + zIndex: 10000, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Button/Button.story.tsx b/packages/grafana-ui/src/components/Button/Button.story.tsx index 708facda0a3..76fec86e1e2 100644 --- a/packages/grafana-ui/src/components/Button/Button.story.tsx +++ b/packages/grafana-ui/src/components/Button/Button.story.tsx @@ -100,7 +100,7 @@ export const Examples: StoryFn = () => { @@ -37,7 +37,7 @@ describe('Card', () => { expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled(); rerender( - + Test Heading @@ -54,7 +54,7 @@ describe('Card', () => { it('Children should be independently enabled or disabled if explicitly set', () => { const { rerender } = render( - + Test Heading @@ -69,7 +69,7 @@ describe('Card', () => { expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); rerender( - + Test Heading @@ -87,7 +87,7 @@ describe('Card', () => { it('Children should be conditional', () => { const shouldNotRender = false; render( - + Test Heading @@ -105,7 +105,7 @@ describe('Card', () => { it('Should allow selectable cards', () => { const { rerender } = render( - + My Option ); @@ -114,7 +114,7 @@ describe('Card', () => { expect(screen.getByRole('radio')).toBeChecked(); rerender( - + My Option ); @@ -123,7 +123,7 @@ describe('Card', () => { expect(screen.getByRole('radio')).not.toBeChecked(); rerender( - + My Option ); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index be8cfad5398..6f9fd93db93 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -11,6 +11,7 @@ import { Input, Props as InputProps } from '../Input/Input'; import { Portal } from '../Portal/Portal'; import { ComboboxList } from './ComboboxList'; +import { SuffixIcon } from './SuffixIcon'; import { itemToString } from './filter'; import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles'; import { ComboboxOption } from './types'; @@ -129,7 +130,6 @@ export const Combobox = (props: ComboboxProps) => autoFocus, onBlur, disabled, - loading, invalid, } = props; @@ -330,12 +330,7 @@ export const Combobox = (props: ComboboxProps) => const InputComponent = isAutoSize ? AutoSizeInput : Input; const placeholder = (isOpen ? itemToString(selectedItem) : null) || placeholderProp; - const suffixIcon = asyncLoading - ? 'spinner' - : // If it's loading, show loading icon. Otherwise, icon indicating menu state - isOpen - ? 'search' - : 'angle-down'; + const loading = props.loading || asyncLoading; const inputSuffix = ( <> @@ -357,7 +352,7 @@ export const Combobox = (props: ComboboxProps) => /> )} - + ); @@ -369,7 +364,6 @@ export const Combobox = (props: ComboboxProps) => autoFocus={autoFocus} onBlur={onBlur} disabled={disabled} - loading={loading} invalid={invalid} className={styles.input} suffix={inputSuffix} diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 5f91741bfd4..6b808b44542 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -3,7 +3,7 @@ import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react import Prism, { Grammar, LanguageMap } from 'prismjs'; import { memo, useEffect, useRef, useState } from 'react'; import * as React from 'react'; -import usePrevious from 'react-use/lib/usePrevious'; +import { usePrevious } from 'react-use'; import { Value } from 'slate'; import Plain from 'slate-plain-serializer'; import { Editor } from 'slate-react'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx index 9d943c4e0c3..1308657df35 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { groupBy, capitalize } from 'lodash'; import { useRef, useMemo } from 'react'; import * as React from 'react'; -import useClickAway from 'react-use/lib/useClickAway'; +import { useClickAway } from 'react-use'; import { VariableSuggestion, GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 563adfa9772..f812b863840 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { forwardRef, HTMLProps, ReactNode, useContext } from 'react'; -import useMeasure from 'react-use/lib/useMeasure'; +import { useMeasure } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx b/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx index df06086c430..26460bf62c6 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryFn } from '@storybook/react'; import { useState } from 'react'; import * as React from 'react'; -import { AsyncState } from 'react-use/lib/useAsync'; +import { type AsyncState } from 'react-use/lib/useAsync'; import { SelectableValue } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx b/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx index b0f9907554b..9819507db01 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx @@ -3,7 +3,7 @@ import { isObject } from 'lodash'; import { HTMLProps } from 'react'; import * as React from 'react'; import { useAsyncFn } from 'react-use'; -import { AsyncState } from 'react-use/lib/useAsync'; +import { type AsyncState } from 'react-use/lib/useAsync'; import { SelectableValue } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Segment/SegmentInput.tsx b/packages/grafana-ui/src/components/Segment/SegmentInput.tsx index 24ab7e8af1e..45ac598203b 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentInput.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentInput.tsx @@ -1,7 +1,7 @@ import { cx, css } from '@emotion/css'; import { HTMLProps, useRef, useState } from 'react'; import * as React from 'react'; -import useClickAway from 'react-use/lib/useClickAway'; +import { useClickAway } from 'react-use'; import { useStyles2 } from '../../themes'; import { measureText } from '../../utils/measureText'; diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx index 53bb472f54b..e6918901f2b 100644 --- a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx @@ -19,6 +19,7 @@ describe('DataLinksActionsTooltip', () => { const mockAction: ActionModel = { title: 'Action1', onClick: jest.fn(), + confirmation: jest.fn(), style: { backgroundColor: '#ff0000' }, }; diff --git a/packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.tsx b/packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.tsx index cc243bee735..ab36eabca9e 100644 --- a/packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.tsx +++ b/packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.tsx @@ -226,7 +226,7 @@ export const ThemeDemo = () => { Disabled - + Button inside card {allButtonVariants.map((variant) => ( @@ -259,8 +259,8 @@ export function VizHuesDemo({ theme, color }: VizHuesDemoProps) { return ( {color.name} - {color.shades.map((shade) => ( - + {color.shades.map((shade, index) => ( +
{ + // this tooltip is Portaled, but actions inside it create forms in Modals + const isModalOrPortaled = '[role="dialog"], #grafana-portal-container'; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - if (!domRef.current!.contains(e.target as Node)) { + if ((e.target as HTMLElement).closest(isModalOrPortaled) == null) { dismiss(); } }; diff --git a/packaging/docker/run.sh b/packaging/docker/run.sh index ac1f9ea45a6..a4c91b49379 100755 --- a/packaging/docker/run.sh +++ b/packaging/docker/run.sh @@ -63,18 +63,21 @@ done export HOME="$GF_PATHS_HOME" if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then - OLDIFS=$IFS - IFS=',' - for plugin in ${GF_INSTALL_PLUGINS}; do - IFS=$OLDIFS - if [[ $plugin =~ .*\;.* ]]; then - pluginUrl=$(echo "$plugin" | cut -d';' -f 1) - pluginInstallFolder=$(echo "$plugin" | cut -d';' -f 2) - grafana cli --pluginUrl ${pluginUrl} --pluginsDir "${GF_PATHS_PLUGINS}" plugins install "${pluginInstallFolder}" - else - grafana cli --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${plugin} - fi - done + >&2 echo "\033[0;33mWARN\033[0m: GF_INSTALL_PLUGINS is deprecated. Use GF_PLUGINS_PREINSTALL or GF_PLUGINS_PREINSTALL_SYNC instead. Checkout the documentation for more info." + if [ "${GF_INSTALL_PLUGINS_FORCE}" = "true" ]; then + OLDIFS=$IFS + IFS=',' + for plugin in ${GF_INSTALL_PLUGINS}; do + IFS=$OLDIFS + if [[ $plugin =~ .*\;.* ]]; then + pluginUrl=$(echo "$plugin" | cut -d';' -f 1) + pluginInstallFolder=$(echo "$plugin" | cut -d';' -f 2) + grafana cli --pluginUrl ${pluginUrl} --pluginsDir "${GF_PATHS_PLUGINS}" plugins install "${pluginInstallFolder}" + else + grafana cli --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${plugin} + fi + done + fi fi exec grafana server \ diff --git a/pkg/build/actions/bump-version/action.yml b/pkg/build/actions/bump-version/action.yml index 24ae182daf2..d7d6fdf5c50 100644 --- a/pkg/build/actions/bump-version/action.yml +++ b/pkg/build/actions/bump-version/action.yml @@ -12,9 +12,12 @@ runs: go-version-file: go.mod - name: Bump versions uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e + env: + GO_MOD_DIR: ${{ inputs.go-mod-dir }} + VERSION: ${{ inputs.version }} with: verb: run - args: go run ./pkg/build/actions/bump-version -version=${{ inputs.version }} + args: go run ./pkg/build/actions/bump-version -version=${VERSION} - name: make gen-cue shell: bash run: make gen-cue diff --git a/pkg/build/daggerbuild/scripts/drone_build_main.sh b/pkg/build/daggerbuild/scripts/drone_build_main.sh index ae1d356152f..4c584cdc3a1 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_main.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_main.sh @@ -24,7 +24,6 @@ dagger run --silent go run ./pkg/build/cmd \ -a docker:grafana:linux/arm/v7 \ --yarn-cache=${YARN_CACHE_FOLDER} \ --checksum \ - --verify \ --build-id=${DRONE_BUILD_NUMBER} \ --grafana-dir=${GRAFANA_DIR} \ --github-token=${GITHUB_TOKEN} \ @@ -36,4 +35,4 @@ echo "Final list of artifacts:" cat assets.txt # Move the tar.gz packages to their expected locations -cat assets.txt | IS_MAIN=true go run ./scripts/move_packages.go ./dist/main +cat assets.txt | IS_MAIN=true go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/main diff --git a/pkg/build/daggerbuild/scripts/drone_build_main_enterprise.sh b/pkg/build/daggerbuild/scripts/drone_build_main_enterprise.sh index ee87d4842c0..e224ce9443b 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_main_enterprise.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_main_enterprise.sh @@ -19,13 +19,11 @@ dagger run --silent go run ./pkg/build/cmd \ -a docker:enterprise:linux/arm64 \ --yarn-cache=${YARN_CACHE_FOLDER} \ --checksum \ - --verify \ --build-id=${DRONE_BUILD_NUMBER} \ --grafana-ref=${SOURCE_COMMIT} \ --grafana-repo="https://github.com/grafana/grafana.git" \ --enterprise-ref=${DRONE_COMMIT} \ --github-token=${GITHUB_TOKEN} \ - --go-version=${GO_VERSION} \ --ubuntu-base=${UBUNTU_BASE} \ --alpine-base=${ALPINE_BASE} \ --patches-repo=${PATCHES_REPO} \ @@ -35,4 +33,4 @@ dagger run --silent go run ./pkg/build/cmd \ cat assets.txt # Move the tar.gz packages to their expected locations -cat assets.txt | DESTINATION=gs://grafana-downloads IS_MAIN=true go run ./scripts/move_packages.go ./dist/main +cat assets.txt | DESTINATION=gs://grafana-downloads IS_MAIN=true go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/main diff --git a/pkg/build/daggerbuild/scripts/drone_build_main_pro.sh b/pkg/build/daggerbuild/scripts/drone_build_main_pro.sh index fe276c98872..932c6420d08 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_main_pro.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_main_pro.sh @@ -15,13 +15,11 @@ dagger run --silent go run ./pkg/build/cmd \ -a frontend:enterprise \ --yarn-cache=${YARN_CACHE_FOLDER} \ --checksum \ - --verify \ --build-id=${DRONE_BUILD_NUMBER} \ --grafana-ref=${SOURCE_COMMIT} \ --grafana-repo="https://github.com/grafana/grafana.git" \ --enterprise-ref=${DRONE_COMMIT} \ --github-token=${GITHUB_TOKEN} \ - --go-version=${GO_VERSION} \ --ubuntu-base=${UBUNTU_BASE} \ --alpine-base=${ALPINE_BASE} \ --patches-repo=${PATCHES_REPO} \ @@ -30,5 +28,5 @@ dagger run --silent go run ./pkg/build/cmd \ echo "Final list of artifacts:" # Move the tar.gz packages to their expected locations -cat assets.txt | grep -v "public" | DESTINATION=gs://grafana-downloads-enterprise2 IS_MAIN=true go run ./scripts/move_packages.go ./dist/main -cat assets.txt | grep "public" | DESTINATION=gs://grafana-static-assets IS_MAIN=true go run ./scripts/move_packages.go ./dist/cdn +cat assets.txt | grep -v "public" | DESTINATION=gs://grafana-downloads-enterprise2 IS_MAIN=true go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/main +cat assets.txt | grep "public" | DESTINATION=gs://grafana-static-assets IS_MAIN=true go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/cdn diff --git a/pkg/build/daggerbuild/scripts/drone_build_nightly_enterprise.sh b/pkg/build/daggerbuild/scripts/drone_build_nightly_enterprise.sh index dd1ce54153e..f93eb94d604 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_nightly_enterprise.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_nightly_enterprise.sh @@ -12,7 +12,7 @@ docker run --privileged --rm tonistiigi/binfmt:qemu-v7.0.0-28 --install all # -a deb:enterprise:linux/arm/v7:nightly \ # -a docker:enterprise:linux/arm/v7 \ # -a docker:enterprise:linux/arm/v7:ubuntu \ - + dagger run --silent go run ./pkg/build/cmd \ artifacts \ -a targz:enterprise:linux/amd64 \ @@ -46,7 +46,6 @@ dagger run --silent go run ./pkg/build/cmd \ --github-token=${GITHUB_TOKEN} \ --destination=${local_dst} \ --yarn-cache=${YARN_CACHE_FOLDER} \ - --go-version=${GO_VERSION} \ --ubuntu-base="${UBUNTU_BASE}" \ --alpine-base="${ALPINE_BASE}" > assets.txt diff --git a/pkg/build/daggerbuild/scripts/drone_build_nightly_grafana.sh b/pkg/build/daggerbuild/scripts/drone_build_nightly_grafana.sh index 4b4c4d3a1a4..11c071e6ed8 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_nightly_grafana.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_nightly_grafana.sh @@ -37,7 +37,6 @@ dagger run --silent go run ./pkg/build/cmd \ --github-token=${GITHUB_TOKEN} \ --destination=${local_dst} \ --yarn-cache=${YARN_CACHE_FOLDER} \ - --go-version=${GO_VERSION} \ --ubuntu-base="${UBUNTU_BASE}" \ --alpine-base="${ALPINE_BASE}" > assets.txt diff --git a/pkg/build/daggerbuild/scripts/drone_build_tag_enterprise.sh b/pkg/build/daggerbuild/scripts/drone_build_tag_enterprise.sh index c1598149934..ac31860b56a 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_tag_enterprise.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_tag_enterprise.sh @@ -42,11 +42,10 @@ dagger run --silent go run ./pkg/build/cmd \ --grafana-ref=${DRONE_TAG} \ --grafana-repo=https://github.com/grafana/grafana-security-mirror.git \ --github-token=${GITHUB_TOKEN} \ - --go-version=${GO_VERSION} \ --ubuntu-base="${UBUNTU_BASE}" \ --alpine-base="${ALPINE_BASE}" \ --version=${DRONE_TAG} \ --destination=${local_dst} > assets.txt # Move the tar.gz packages to their expected locations -cat assets.txt | go run ./scripts/move_packages.go ./dist/prerelease +cat assets.txt | go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/prerelease diff --git a/pkg/build/daggerbuild/scripts/drone_build_tag_grafana.sh b/pkg/build/daggerbuild/scripts/drone_build_tag_grafana.sh index 766b3e47e7a..e21a705b380 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_tag_grafana.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_tag_grafana.sh @@ -39,10 +39,9 @@ dagger run --silent go run ./pkg/build/cmd \ --build-id=${DRONE_BUILD_NUMBER} \ --grafana-dir=${GRAFANA_DIR} \ --github-token=${GITHUB_TOKEN} \ - --go-version=${GO_VERSION} \ --ubuntu-base="${UBUNTU_BASE}" \ --alpine-base="${ALPINE_BASE}" \ --version=${DRONE_TAG} \ --destination=${local_dst} > assets.txt -cat assets.txt | go run ./scripts/move_packages.go ./dist/prerelease +cat assets.txt | go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/prerelease diff --git a/pkg/build/daggerbuild/scripts/drone_build_tag_pro.sh b/pkg/build/daggerbuild/scripts/drone_build_tag_pro.sh index 0578f2e96c4..1bd7c4e7cc8 100755 --- a/pkg/build/daggerbuild/scripts/drone_build_tag_pro.sh +++ b/pkg/build/daggerbuild/scripts/drone_build_tag_pro.sh @@ -33,10 +33,9 @@ dagger run --silent go run ./pkg/build/cmd \ --grafana-repo=https://github.com/grafana/grafana-security-mirror.git \ --github-token=${GITHUB_TOKEN} \ --version=${DRONE_TAG} \ - --go-version=${GO_VERSION} \ --ubuntu-base="${UBUNTU_BASE}" \ --alpine-base="${ALPINE_BASE}" \ --destination=${local_dst} > assets.txt # Move the tar.gz packages to their expected locations -cat assets.txt | go run ./scripts/move_packages.go ./dist/prerelease +cat assets.txt | go run ./pkg/build/daggerbuild/scripts/move_packages.go ./dist/prerelease diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index f54f5d134c5..faab1f25efc 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -6,7 +6,6 @@ package extensions import ( _ "cloud.google.com/go/kms/apiv1" _ "cloud.google.com/go/kms/apiv1/kmspb" - _ "cloud.google.com/go/spanner" _ "github.com/Azure/azure-sdk-for-go/sdk/azidentity" _ "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys" _ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault" @@ -30,6 +29,11 @@ import ( _ "github.com/spf13/cobra" // used by the standalone apiserver cli _ "github.com/stretchr/testify/require" _ "golang.org/x/time/rate" + _ "k8s.io/api" + _ "k8s.io/kube-aggregator/pkg/apiserver/scheme" + _ "k8s.io/kube-aggregator/pkg/generated/openapi" + _ "k8s.io/kube-aggregator/pkg/registry/apiservice/rest" + _ "sigs.k8s.io/randfill" _ "xorm.io/builder" _ "github.com/grafana/authlib/authn" @@ -42,8 +46,4 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - - _ "k8s.io/kube-aggregator/pkg/apiserver/scheme" - _ "k8s.io/kube-aggregator/pkg/generated/openapi" - _ "k8s.io/kube-aggregator/pkg/registry/apiservice/rest" ) diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index f3621b0a3ac..ede3d64677c 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -54,7 +54,6 @@ type InitTestDBOpt = sqlstore.InitTestDBOpt var SetupTestDB = sqlstore.SetupTestDB var CleanupTestDB = sqlstore.CleanupTestDB var ProvideService = sqlstore.ProvideService -var SkipTestsOnSpanner = sqlstore.SkipTestsOnSpanner func InitTestDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.SQLStore { db, _ := InitTestDBWithCfg(t, opts...) @@ -96,11 +95,3 @@ func IsTestDBMSSQL() bool { return false } - -func IsTestDBSpanner() bool { - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - return db == migrator.Spanner - } - - return false -} diff --git a/pkg/infra/serverlock/serverlock.go b/pkg/infra/serverlock/serverlock.go index ea66851c34a..768854f9b06 100644 --- a/pkg/infra/serverlock/serverlock.go +++ b/pkg/infra/serverlock/serverlock.go @@ -374,10 +374,6 @@ func (sl *ServerLockService) createLock(ctx context.Context, } lockRow.Id = id } else { - if sl.SQLStore.GetDBType() == migrator.Spanner { - rawSQL += " THEN RETURN id" // Required for successful LastInsertId call. - } - res, err := dbSession.Exec( rawSQL, lockRow.OperationUID, lockRow.LastExecution, 0) diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 5238b7ffd83..555395fbee4 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -186,11 +186,6 @@ func (r *rowsWrapper) ContinueToken() string { return r.row.token.String() } -// ContinueTokenWithCurrentRV implements resource.ListIterator. -func (r *rowsWrapper) ContinueTokenWithCurrentRV() string { - return r.row.token.String() -} - // Error implements resource.ListIterator. func (r *rowsWrapper) Error() error { return r.err diff --git a/pkg/registry/apis/dashboard/legacy/storage.go b/pkg/registry/apis/dashboard/legacy/storage.go index 5c9e392a862..5c47a5f6cfc 100644 --- a/pkg/registry/apis/dashboard/legacy/storage.go +++ b/pkg/registry/apis/dashboard/legacy/storage.go @@ -229,7 +229,12 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.R return rsp } -// List implements AppendingStore. +// ListHistory implements StorageBackend. +func (a *dashboardSqlAccess) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) { + return a.ListIterator(ctx, req, cb) +} + +// List implements StorageBackend. func (a *dashboardSqlAccess) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) { if req.ResourceVersion != 0 { return 0, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend") diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index 014ee911db6..667d208cc06 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -304,9 +304,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso return nil, err } - hits := formatQueryResult(res) - - for _, dashboard := range hits { + for _, dashboard := range res { tags, err := json.Marshal(dashboard.Tags) if err != nil { return nil, err @@ -324,7 +322,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso } list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{ - Key: getResourceKey(dashboard, req.Options.Key.Namespace), + Key: getResourceKey(&dashboard, req.Options.Key.Namespace), Cells: cells, }) } @@ -352,35 +350,6 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string } } -func formatQueryResult(res []dashboards.DashboardSearchProjection) []*dashboards.DashboardSearchProjection { - hitList := make([]*dashboards.DashboardSearchProjection, 0) - hits := make(map[string]*dashboards.DashboardSearchProjection) - - for _, item := range res { - key := fmt.Sprintf("%s-%d", item.UID, item.OrgID) - hit, exists := hits[key] - if !exists { - hit = &dashboards.DashboardSearchProjection{ - ID: item.ID, - UID: item.UID, - Title: item.Title, - FolderUID: item.FolderUID, - Tags: []string{}, - IsFolder: item.IsFolder, - SortMeta: item.SortMeta, - } - hitList = append(hitList, hit) - hits[key] = hit - } - - if len(item.Term) > 0 { - hit.Tags = append(hit.Tags, item.Term) - } - } - - return hitList -} - func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest, _ ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) { info, err := claims.ParseNamespace(req.Namespace) if err != nil { diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go index 9161e16a68e..eeb0a18c469 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go @@ -44,8 +44,8 @@ func TestDashboardSearchClient_Search(t *testing.T) { Type: "dash-db", // should set type based off of key Sort: sorter, }).Return([]dashboards.DashboardSearchProjection{ - {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Term: "term"}, - {ID: 2, UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2"}, + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Tags: []string{"term"}}, + {ID: 2, UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2", Tags: []string{}}, }, nil).Once() req := &resourcepb.ResourceSearchRequest{ @@ -121,7 +121,7 @@ func TestDashboardSearchClient_Search(t *testing.T) { Type: "dash-db", Sort: sortOptionAsc, }).Return([]dashboards.DashboardSearchProjection{ - {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(50)}, + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(50), Tags: []string{}}, }, nil).Once() req := &resourcepb.ResourceSearchRequest{ @@ -186,7 +186,7 @@ func TestDashboardSearchClient_Search(t *testing.T) { Type: "dash-db", Sort: sortOptionAsc, }).Return([]dashboards.DashboardSearchProjection{ - {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(2)}, + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(2), Tags: []string{}}, }, nil).Once() req := &resourcepb.ResourceSearchRequest{ diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index 9c01c9266a7..ded201ec0f4 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -254,7 +254,7 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d return &backend.QueryDataResponse{}, nil } - client, err := b.client.GetDataSourceClient( + client, err := b.clientSupplier.GetDataSourceClient( ctx, v0alpha1.DataSourceRef{ Type: req.PluginId, diff --git a/pkg/registry/apis/query/query_test.go b/pkg/registry/apis/query/query_test.go index fbe1a2766e7..ea6ad1e1bd0 100644 --- a/pkg/registry/apis/query/query_test.go +++ b/pkg/registry/apis/query/query_test.go @@ -24,7 +24,7 @@ import ( func TestQueryRestConnectHandler(t *testing.T) { b := &QueryAPIBuilder{ - client: mockClient{ + clientSupplier: mockClient{ lastCalledWithHeaders: &map[string]string{}, }, tracer: tracing.InitializeTracerForTest(), @@ -80,7 +80,7 @@ func TestQueryRestConnectHandler(t *testing.T) { "X-Rule-Type": "type-1", "X-Rule-Version": "version-1", "X-Grafana-Org-Id": "1", - }, *b.client.(mockClient).lastCalledWithHeaders) + }, *b.clientSupplier.(mockClient).lastCalledWithHeaders) } func TestInstantQueryFromAlerting(t *testing.T) { diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index 9692c21d2c7..bd034976727 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -43,17 +43,17 @@ type QueryAPIBuilder struct { authorizer authorizer.Authorizer - tracer tracing.Tracer - metrics *metrics.ExprMetrics - parser *queryParser - client clientapi.DataSourceClientSupplier - registry query.DataSourceApiServerRegistry - converter *expr.ResultConverter - queryTypes *query.QueryTypeDefinitionList + tracer tracing.Tracer + metrics *metrics.ExprMetrics + parser *queryParser + clientSupplier clientapi.DataSourceClientSupplier + registry query.DataSourceApiServerRegistry + converter *expr.ResultConverter + queryTypes *query.QueryTypeDefinitionList } func NewQueryAPIBuilder(features featuremgmt.FeatureToggles, - client clientapi.DataSourceClientSupplier, + clientSupplier clientapi.DataSourceClientSupplier, ar authorizer.Authorizer, registry query.DataSourceApiServerRegistry, legacy service.LegacyDataSourceLookup, @@ -80,7 +80,7 @@ func NewQueryAPIBuilder(features featuremgmt.FeatureToggles, return &QueryAPIBuilder{ concurrentQueryLimit: 4, log: log.New("query_apiserver"), - client: client, + clientSupplier: clientSupplier, authorizer: ar, registry: registry, parser: newQueryParser(reader, legacy, tracer, log.New("query_parser")), diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index bcee5ba7412..28ee61f4afb 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -203,10 +203,6 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { t.Skip("skipping integration test") } - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } - orgID := int64(1) permissions := []accesscontrol.Permission{ { diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index 711be12e68c..317f9915566 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -334,7 +334,6 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer } if len(tags) > 0 { - // "at" is a keyword in Spanner and needs to be quoted. tagsSubQuery := fmt.Sprintf(` SELECT SUM(1) FROM annotation_tag `+r.db.Quote("at")+` INNER JOIN tag on tag.id = `+r.db.Quote("at")+`.tag_id diff --git a/pkg/services/anonymous/anonimpl/anonstore/database.go b/pkg/services/anonymous/anonimpl/anonstore/database.go index fe53ee66dee..6fa8766ba46 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database.go @@ -146,14 +146,11 @@ func (s *AnonDBStore) CreateOrUpdateDevice(ctx context.Context, device *Device) } // If CreatedAt time is not set (i.e. it's zero), and we end up creating the device, use current time as creation time. - // Spanner converts zero time to NULL, but CreatedAt is not nullable, so this helps to fix that problem too. + // If database converts zero time to NULL, but CreatedAt is not nullable, this helps to fix that problem too. created := device.CreatedAt if created.IsZero() { created = time.Now() } - if s.sqlStore.GetDBType() == migrator.Spanner { - return s.insertIntoSpanner(ctx, device, created) - } args := []any{device.DeviceID, device.ClientIP, device.UserAgent, created.UTC(), device.UpdatedAt.UTC()} switch s.sqlStore.GetDBType() { @@ -192,30 +189,6 @@ func (s *AnonDBStore) CreateOrUpdateDevice(ctx context.Context, device *Device) return err } -// In Spanner INSERT OR UPDATE only works when conflict is on primary key. However here we expect conflict on non-PK -// column "device_id", so we need to use a transaction instead. -func (s *AnonDBStore) insertIntoSpanner(ctx context.Context, dev *Device, created time.Time) error { - return s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { - prev := &Device{DeviceID: dev.DeviceID} - ok, err := dbSession.Table(tableName).Get(prev) - if err != nil { - return err - } - if !ok { - // Don't call Insert(dev) directly, because successful Insert modifies dev.DeviceID, - // but we don't do the same for other databases. That's why we create a copy. - devCopy := *dev - devCopy.CreatedAt = created - _, err = dbSession.Table(tableName).Insert(devCopy) - return err - } - - // Include all columns in the update, even when empty (it's what inserts for other databases do too). - _, err = dbSession.Table(tableName).Where("device_id=?", dev.DeviceID).MustCols("client_ip", "user_agent", "created_at", "updated_at").Update(dev) - return err - }) -} - func (s *AnonDBStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { var count int64 err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 24d6c0e7d07..4830b564543 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -922,7 +922,39 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F return nil, err } - return res, nil + if len(res) <= 1 { + return res, nil + } + + // the search query above will return one row per dashboard tag, and dashboards + // can have multiple tags. we only want to return one row per dashboard, so dedup + // the results by id. + // note: we must preserve the order of the results as we dedup, as the query can be sorted + seen := make(map[int64]int) + uniqueRes := make([]dashboards.DashboardSearchProjection, 0, len(res)) + + for _, item := range res { + if idx, exists := seen[item.ID]; exists { + if item.Term != "" { + if uniqueRes[idx].Tags == nil { + uniqueRes[idx].Tags = make([]string, 0) + } + uniqueRes[idx].Tags = append(uniqueRes[idx].Tags, item.Term) + } + continue + } + + if item.Tags == nil { + item.Tags = make([]string, 0) + } + if item.Term != "" { + item.Tags = append(item.Tags, item.Term) + } + seen[item.ID] = len(uniqueRes) + uniqueRes = append(uniqueRes, item) + } + + return uniqueRes, nil } func (d *dashboardStore) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) { diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 4a743b5840b..f41ffe9a11f 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -234,10 +234,6 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } - // the maximux nested folder hierarchy starting from parent down to subfolders nestedFolders := make([]*folder.Folder, 0, folder.MaxNestedFolderDepth+1) diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 91fa92392e8..ff148d1e0a2 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -841,7 +841,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { require.NoError(t, err) orgID := int64(1) - insertTestDashboard(t, dashboardStore, "dashboard under general", orgID, 0, "", false) + insertTestDashboard(t, dashboardStore, "dashboard under general", orgID, 0, "", false, []string{"tag1", "tag2"}) ac := acimpl.ProvideAccessControl(features) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) @@ -869,7 +869,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { }) require.NoError(t, err) - insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, 0, f0.UID, false) + insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, 0, f0.UID, false, []string{"tag3"}) subfolder, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ OrgID: orgID, @@ -1244,43 +1244,35 @@ func testSearchDashboards(d dashboards.Store, query *dashboards.FindPersistedDas func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) model.HitList { hitList := make([]*model.Hit, 0) - hits := make(map[int64]*model.Hit) for _, item := range res { - hit, exists := hits[item.ID] - if !exists { - hitType := model.DashHitDB - if item.IsFolder { - hitType = model.DashHitFolder - } - - hit = &model.Hit{ - ID: item.ID, - UID: item.UID, - Title: item.Title, - URI: "db/" + item.Slug, - URL: dashboards.GetDashboardFolderURL(item.IsFolder, item.UID, item.Slug), - Type: hitType, - FolderUID: item.FolderUID, - FolderTitle: item.FolderTitle, - Tags: []string{}, - } - - if item.FolderUID != "" { - hit.FolderURL = dashboards.GetFolderURL(item.FolderUID, item.FolderSlug) - } - - if query.Sort.MetaName != "" { - hit.SortMeta = item.SortMeta - hit.SortMetaName = query.Sort.MetaName - } - - hitList = append(hitList, hit) - hits[item.ID] = hit + hitType := model.DashHitDB + if item.IsFolder { + hitType = model.DashHitFolder } - if len(item.Term) > 0 { - hit.Tags = append(hit.Tags, item.Term) + + hit := &model.Hit{ + ID: item.ID, + UID: item.UID, + Title: item.Title, + URI: "db/" + item.Slug, + URL: dashboards.GetDashboardFolderURL(item.IsFolder, item.UID, item.Slug), + Type: hitType, + FolderUID: item.FolderUID, + FolderTitle: item.FolderTitle, + Tags: item.Tags, } + + if item.FolderUID != "" { + hit.FolderURL = dashboards.GetFolderURL(item.FolderUID, item.FolderSlug) + } + + if query.Sort.MetaName != "" { + hit.SortMeta = item.SortMeta + hit.SortMetaName = query.Sort.MetaName + } + + hitList = append(hitList, hit) } return hitList } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 430b3174ef0..c5333e6c0bc 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -1650,55 +1650,44 @@ func getHitType(item dashboards.DashboardSearchProjection) model.HitType { func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) model.HitList { hitList := make([]*model.Hit, 0) - hits := make(map[string]*model.Hit) for _, item := range res { - key := fmt.Sprintf("%s-%d", item.UID, item.OrgID) - hit, exists := hits[key] - if !exists { - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - hit = &model.Hit{ - ID: item.ID, - UID: item.UID, - OrgID: item.OrgID, - Title: item.Title, - URI: "db/" + item.Slug, - URL: dashboards.GetDashboardFolderURL(item.IsFolder, item.UID, item.Slug), - Type: getHitType(item), - FolderID: item.FolderID, // nolint:staticcheck - FolderUID: item.FolderUID, - FolderTitle: item.FolderTitle, - Tags: []string{}, - } - - // when searching through unified storage, the dashboard will come as one - // item, when searching through legacy, the dashboard will come multiple times - // per tag. So we need to add the array here for unified, and the term below for legacy. - if item.Tags != nil { - hit.Tags = item.Tags - } - - // nolint:staticcheck - if item.FolderID > 0 || item.FolderUID != "" { - hit.FolderURL = dashboards.GetFolderURL(item.FolderUID, item.FolderSlug) - } - - if query.Sort.MetaName != "" { - hit.SortMeta = item.SortMeta - hit.SortMetaName = query.Sort.MetaName - } - - hitList = append(hitList, hit) - hits[key] = hit + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() + hit := &model.Hit{ + ID: item.ID, + UID: item.UID, + OrgID: item.OrgID, + Title: item.Title, + URI: "db/" + item.Slug, + URL: dashboards.GetDashboardFolderURL(item.IsFolder, item.UID, item.Slug), + Type: getHitType(item), + FolderID: item.FolderID, // nolint:staticcheck + FolderUID: item.FolderUID, + FolderTitle: item.FolderTitle, + Tags: []string{}, } - if len(item.Term) > 0 { - hit.Tags = append(hit.Tags, item.Term) + + if item.Tags != nil { + hit.Tags = item.Tags } + + // nolint:staticcheck + if item.FolderID > 0 || item.FolderUID != "" { + hit.FolderURL = dashboards.GetFolderURL(item.FolderUID, item.FolderSlug) + } + + if query.Sort.MetaName != "" { + hit.SortMeta = item.SortMeta + hit.SortMetaName = query.Sort.MetaName + } + if item.Deleted != nil { deletedDate := (*item.Deleted).Add(daysInTrash) hit.IsDeleted = true hit.PermanentlyDeleteDate = &deletedDate } + + hitList = append(hitList, hit) } return hitList } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7437565e76d..1bdb9bb20d3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1163,13 +1163,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, - { - Name: "homeSetupGuide", - Description: "Used in Home for users who want to return to the onboarding flow or quickly find popular config pages", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: growthAndOnboarding, - }, { Name: "appPlatformGrpcClientAuth", Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens", @@ -1437,6 +1430,13 @@ var ( AllowSelfServe: true, Owner: identityAccessTeam, }, + { + Name: "teamHttpHeadersTempo", + Description: "Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: identityAccessTeam, + }, { Name: "ABTestFeatureToggleA", Description: "Test feature toggle to see how cohorts could be set up AB testing", @@ -1767,6 +1767,14 @@ var ( HideFromDocs: true, FrontendOnly: true, }, + { + Name: "restoreDashboards", + Description: "Enables restore deleted dashboards feature", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + HideFromAdminPage: true, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 5b5dd86153f..5123d14872a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -151,7 +151,6 @@ alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false, exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true -homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true @@ -189,6 +188,7 @@ k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,preview,@grafana/identity-access-team,false,false,false teamHttpHeadersMimir,preview,@grafana/identity-access-team,false,false,false +teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false ABTestFeatureToggleA,experimental,@grafana/sharing-squad,false,false,false templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true ABTestFeatureToggleB,experimental,@grafana/sharing-squad,false,false,false @@ -231,3 +231,4 @@ alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,fal alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true extensionsReadOnlyProxy,experimental,@grafana/plugins-platform-backend,false,false,true +restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7157f7fa236..83f95f37f1e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -615,10 +615,6 @@ const ( // Used in Logs Drilldown to limit the time range FlagExploreLogsLimitedTimeRange = "exploreLogsLimitedTimeRange" - // FlagHomeSetupGuide - // Used in Home for users who want to return to the onboarding flow or quickly find popular config pages - FlagHomeSetupGuide = "homeSetupGuide" - // FlagAppPlatformGrpcClientAuth // Enables the gRPC client to authenticate with the App Platform by using ID & access tokens FlagAppPlatformGrpcClientAuth = "appPlatformGrpcClientAuth" @@ -767,6 +763,10 @@ const ( // Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams FlagTeamHttpHeadersMimir = "teamHttpHeadersMimir" + // FlagTeamHttpHeadersTempo + // Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams + FlagTeamHttpHeadersTempo = "teamHttpHeadersTempo" + // FlagABTestFeatureToggleA // Test feature toggle to see how cohorts could be set up AB testing FlagABTestFeatureToggleA = "ABTestFeatureToggleA" @@ -934,4 +934,8 @@ const ( // FlagExtensionsReadOnlyProxy // Use proxy-based read-only objects for plugin extensions instead of deep cloning FlagExtensionsReadOnlyProxy = "extensionsReadOnlyProxy" + + // FlagRestoreDashboards + // Enables restore deleted dashboards feature + FlagRestoreDashboards = "restoreDashboards" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a99defd2459..4702b756557 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -777,6 +777,21 @@ "frontend": true } }, + { + "metadata": { + "name": "dashboardRestore", + "resourceVersion": "1747744953711", + "creationTimestamp": "2024-05-16T17:36:26Z", + "deletionTimestamp": "2025-04-03T07:52:54Z" + }, + "spec": { + "description": "Enables deleted dashboard restore feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "hideFromAdminPage": true, + "expression": "false" + } + }, { "metadata": { "name": "dashboardScene", @@ -1517,7 +1532,8 @@ "metadata": { "name": "homeSetupGuide", "resourceVersion": "1743693517832", - "creationTimestamp": "2024-09-25T17:20:04Z" + "creationTimestamp": "2024-09-25T17:20:04Z", + "deletionTimestamp": "2025-05-20T16:08:36Z" }, "spec": { "description": "Used in Home for users who want to return to the onboarding flow or quickly find popular config pages", @@ -2896,6 +2912,20 @@ "expression": "true" } }, + { + "metadata": { + "name": "restoreDashboards", + "resourceVersion": "1748002635285", + "creationTimestamp": "2025-05-23T12:17:15Z" + }, + "spec": { + "description": "Enables restore deleted dashboards feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "hideFromAdminPage": true, + "expression": "false" + } + }, { "metadata": { "name": "rolePickerDrawer", @@ -3108,6 +3138,21 @@ "allowSelfServe": true } }, + { + "metadata": { + "name": "teamHttpHeadersTempo", + "resourceVersion": "1747930192816", + "creationTimestamp": "2025-05-20T14:50:32Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-05-22 16:09:52.816517 +0000 UTC" + } + }, + "spec": { + "description": "Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, { "metadata": { "name": "templateVariablesUsesCombobox", diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 7dc6ff099ab..68e570cdb6d 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -168,10 +168,6 @@ func (s *Service) DBMigration(db db.DB) { SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = true ON CONFLICT(uid, org_id) DO UPDATE SET title=excluded.title, updated=excluded.updated `) - } else if db.GetDialect().DriverName() == migrator.Spanner { - // We may eventually make this migration work with Spanner, but for now don't do anything. - // We intend to store dashboards and folders only in unified storage when using spanner. - deleteOldFolders = false } else { // covered by UQE_folder_org_id_uid _, err = sess.Exec(` diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 580ce43755d..65cd5623ec4 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -620,7 +620,7 @@ func (ss *FolderStoreImpl) GetDescendants(ctx context.Context, orgID int64, ance func getFullpathSQL(dialect migrator.Dialect) string { escaped := `\/` - if dialect.DriverName() == migrator.MySQL || dialect.DriverName() == migrator.Spanner { + if dialect.DriverName() == migrator.MySQL { escaped = `\\/` } concatCols := make([]string, 0, folder.MaxNestedFolderDepth) diff --git a/pkg/services/login/authinfoimpl/store_test.go b/pkg/services/login/authinfoimpl/store_test.go index 6dcfbf7910d..db85443fc4f 100644 --- a/pkg/services/login/authinfoimpl/store_test.go +++ b/pkg/services/login/authinfoimpl/store_test.go @@ -52,7 +52,7 @@ func TestIntegrationAuthInfoStore(t *testing.T) { // There is no guarantee that user with user_id=1 gets "oauth_azuread" or "ldap". // Both are valid results for the query (basically SELECT * FROM `user_auth` WHERE `user_id` IN (1,2) ORDER BY created), - // Spanner emulator will randomize its output, so test cannot rely on the ordering (other than "Created" column, which is equal here). + // Some databases may randomize its output, so test cannot rely on the ordering (other than "Created" column, which is equal here). require.True(t, labels[1] == login.AzureADAuthModule || labels[1] == login.LDAPAuthModule) require.Equal(t, login.GoogleAuthModule, labels[2]) }) diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 8a31154d8fb..327f74ea34c 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -225,13 +225,13 @@ func (s *ServiceImpl) getHomeNode(c *contextmodel.ReqContext, prefs *pref.Prefer SortWeight: navtree.WeightHome, } ctx := c.Req.Context() - if s.features.IsEnabled(ctx, featuremgmt.FlagHomeSetupGuide) { + if _, exists := s.pluginStore.Plugin(ctx, "grafana-setupguide-app"); exists { var children []*navtree.NavLink // setup guide (a submenu item under Home) children = append(children, &navtree.NavLink{ Id: "home-setup-guide", - Text: "Setup guide", - Url: homeUrl + "/setup-guide", + Text: "Getting started guide", + Url: "/a/grafana-setupguide-app/getting-started", SortWeight: navtree.WeightHome, }) homeNode.Children = children @@ -399,6 +399,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt Icon: "library-panel", }) } + + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagRestoreDashboards) && (c.GetOrgRole() == org.RoleAdmin || c.IsGrafanaAdmin) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Recently deleted", + SubTitle: "Any items listed here for more than 30 days will be automatically deleted.", + Id: "dashboards/recently-deleted", + Url: s.cfg.AppSubURL + "/dashboard/recently-deleted", + }) + } } if hasAccess(ac.EvalPermission(dashboards.ActionDashboardsCreate)) { diff --git a/pkg/services/ngalert/sender/sender.go b/pkg/services/ngalert/sender/sender.go index 1df7315ffd2..e4bb96146ff 100644 --- a/pkg/services/ngalert/sender/sender.go +++ b/pkg/services/ngalert/sender/sender.go @@ -182,14 +182,14 @@ func (s *ExternalAlertmanager) SendAlerts(alerts apimodels.PostableAlerts) { na := s.alertToNotifierAlert(a) as = append(as, na) - s.logger.Debug("msg", + s.logger.Debug( "Sending alert", "alert", - a, + na.String(), "starts_at", - a.StartsAt, + na.StartsAt, "ends_at", - a.EndsAt) + na.EndsAt) } s.manager.Send(as...) diff --git a/pkg/services/ngalert/store/alertmanager_test.go b/pkg/services/ngalert/store/alertmanager_test.go index 4ea0144f967..43932247831 100644 --- a/pkg/services/ngalert/store/alertmanager_test.go +++ b/pkg/services/ngalert/store/alertmanager_test.go @@ -17,7 +17,7 @@ import ( ) func TestMain(m *testing.M) { - testsuite.RunButSkipOnSpanner(m) + testsuite.Run(m) } func TestIntegrationAlertmanagerStore(t *testing.T) { diff --git a/pkg/services/sqlstore/database_config.go b/pkg/services/sqlstore/database_config.go index e3cc626066a..171ff4972b9 100644 --- a/pkg/services/sqlstore/database_config.go +++ b/pkg/services/sqlstore/database_config.go @@ -203,8 +203,6 @@ func (dbCfg *DatabaseConfig) buildConnectionString(cfg *setting.Cfg, features fe } cnnstr += buildExtraConnectionString('&', dbCfg.UrlQueryParams) - case migrator.Spanner: - cnnstr = dbCfg.Name default: return fmt.Errorf("unknown database type: %s", dbCfg.Type) } diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 5db6600e96a..a67f989a83d 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -77,8 +77,8 @@ func TestIntegrationMigrationLock(t *testing.T) { } dbType := sqlutil.GetTestDBType() - // skip for SQLite and Spanner since there is no database locking (only migrator locking) - if dbType == SQLite || dbType == Spanner { + // skip for SQLite since there is no database locking (only migrator locking) + if dbType == SQLite { t.Skip() } @@ -235,8 +235,8 @@ func TestMigratorLocking(t *testing.T) { func TestDatabaseLocking(t *testing.T) { dbType := sqlutil.GetTestDBType() - // skip for SQLite and Spanner since there is no database locking (only migrator locking) - if dbType == SQLite || dbType == Spanner { + // skip for SQLite since there is no database locking (only migrator locking) + if dbType == SQLite { t.Skip() } diff --git a/pkg/services/sqlstore/migrations/star_mig.go b/pkg/services/sqlstore/migrations/star_mig.go index 14cf9a5a093..4521acd7f2b 100644 --- a/pkg/services/sqlstore/migrations/star_mig.go +++ b/pkg/services/sqlstore/migrations/star_mig.go @@ -82,15 +82,6 @@ func RunStarMigrations(sess *xorm.Session, driverName string) error { star.org_id = dashboard.org_id, star.updated = NOW() WHERE star.dashboard_uid IS NULL OR star.org_id IS NULL;` - case Spanner: - sql = `UPDATE star - SET - dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = star.dashboard_id), - org_id = (SELECT org_id FROM dashboard WHERE dashboard.id = star.dashboard_id), - updated = CURRENT_TIMESTAMP() - WHERE - (dashboard_uid IS NULL OR org_id IS NULL) - AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = star.dashboard_id)` } if _, err := sess.Exec(sql); err != nil { diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index 0d3ced3fbb8..af9f0d235cd 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -154,8 +154,7 @@ func addUserMigrations(mg *Migrator) { mg.AddMigration("Make sure users uid are set", NewRawSQLMigration(""). SQLite("UPDATE user SET uid=printf('u%09d',id) WHERE uid is NULL OR uid = '';"). Postgres("UPDATE `user` SET uid='u' || lpad('' || id::text,9,'0') WHERE uid is NULL OR uid = '';"). - Mysql("UPDATE user SET uid=concat('u',lpad(id,9,'0')) WHERE uid is NULL OR uid = '';"). - Spanner("UPDATE user SET uid=concat('u',lpad(CAST(id AS STRING),9,'0')) WHERE uid IS NULL OR uid = '';")) + Mysql("UPDATE user SET uid=concat('u',lpad(id,9,'0')) WHERE uid is NULL OR uid = '';")) mg.AddMigration("Add unique index user_uid", NewAddIndexMigration(userV2, &Index{ Cols: []string{"uid"}, Type: UniqueIndex, diff --git a/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go b/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go index bf06e698339..a690c6d459d 100644 --- a/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go +++ b/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go @@ -72,19 +72,6 @@ func (p *ServiceAccountsSameLoginCrossOrgs) Exec(sess *xorm.Session, mg *migrato AND is_service_account = 1 AND login NOT LIKE 'sa-' || CAST(org_id AS TEXT) || '-%'; `) - case migrator.Spanner: - _, err = p.sess.Exec(` - UPDATE user - SET login = CONCAT('sa-', CAST(org_id AS STRING), '-', - CASE - WHEN login LIKE 'sa-%' THEN SUBSTRING(login, 4) - ELSE login - END - ) - WHERE login IS NOT NULL - AND is_service_account - AND login NOT LIKE CONCAT('sa-', CAST(org_id AS STRING), '-%') - `) default: return fmt.Errorf("dialect not supported: %s", p.dialect) @@ -142,19 +129,6 @@ func (p *ServiceAccountsDeduplicateOrgInLogin) Exec(sess *xorm.Session, mg *migr WHERE u2.login = 'sa-' || CAST(u.org_id AS TEXT) || SUBSTRING(u.login, LENGTH('sa-'||CAST(u.org_id AS TEXT)||'-'||CAST(u.org_id AS TEXT))+1) );; `) - case migrator.Spanner: - _, err = sess.Exec(` - UPDATE ` + dialect.Quote("user") + ` AS u - SET login = 'sa-' || CAST(u.org_id AS STRING) || SUBSTRING(u.login, LENGTH('sa-'||CAST(u.org_id AS STRING)||'-'||CAST(u.org_id AS STRING))+1) - WHERE u.login IS NOT NULL - AND u.is_service_account - AND u.login LIKE 'sa-'||CAST(u.org_id AS STRING)||'-'||CAST(u.org_id AS STRING)||'-%' - AND NOT EXISTS ( - SELECT 1 - FROM ` + dialect.Quote("user") + `AS u2 - WHERE u2.login = 'sa-' || CAST(u.org_id AS STRING) || SUBSTRING(u.login, LENGTH('sa-'||CAST(u.org_id AS STRING)||'-'||CAST(u.org_id AS STRING))+1) - );; - `) default: return fmt.Errorf("dialect not supported: %s", dialect) } diff --git a/pkg/services/sqlstore/migrator/migrations.go b/pkg/services/sqlstore/migrator/migrations.go index 24b266e57d2..b785fd867d5 100644 --- a/pkg/services/sqlstore/migrator/migrations.go +++ b/pkg/services/sqlstore/migrator/migrations.go @@ -85,10 +85,6 @@ func (m *RawSQLMigration) Mssql(sql string) *RawSQLMigration { return m.Set(MSSQL, sql) } -func (m *RawSQLMigration) Spanner(sql string) *RawSQLMigration { - return m.Set(Spanner, sql) -} - type AddColumnMigration struct { MigrationBase tableName string diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index aded736a49d..d8e55c4ee71 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -417,11 +417,6 @@ func (mg *Migrator) InTransaction(callback dbTransactionFunc) error { sess := mg.DBEngine.NewSession() defer sess.Close() - // XXX: Spanner cannot execute DDL statements in transactions - if mg.Dialect.DriverName() == Spanner { - return callback(sess) - } - if err := sess.Begin(); err != nil { return err } diff --git a/pkg/services/sqlstore/migrator/spanner_dialect.go b/pkg/services/sqlstore/migrator/spanner_dialect.go deleted file mode 100644 index 9d490f8aff2..00000000000 --- a/pkg/services/sqlstore/migrator/spanner_dialect.go +++ /dev/null @@ -1,355 +0,0 @@ -//go:build enterprise || pro - -package migrator - -import ( - "context" - _ "embed" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "cloud.google.com/go/spanner" - database "cloud.google.com/go/spanner/admin/database/apiv1" - "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" - "github.com/googleapis/gax-go/v2" - spannerdriver "github.com/googleapis/go-sql-spanner" - "github.com/grafana/grafana/pkg/util/xorm" - "google.golang.org/grpc/codes" - - "github.com/grafana/dskit/concurrency" - utilspanner "github.com/grafana/grafana/pkg/util/spanner" - "github.com/grafana/grafana/pkg/util/xorm/core" -) - -type SpannerDialect struct { - BaseDialect - d core.Dialect -} - -func init() { - supportedDialects[Spanner] = NewSpannerDialect -} - -func NewSpannerDialect() Dialect { - d := SpannerDialect{d: core.QueryDialect(Spanner)} - d.dialect = &d - d.driverName = Spanner - return &d -} - -func (s *SpannerDialect) AutoIncrStr() string { return s.d.AutoIncrStr() } -func (s *SpannerDialect) Quote(name string) string { return s.d.Quote(name) } -func (s *SpannerDialect) SupportEngine() bool { return s.d.SupportEngine() } - -func (s *SpannerDialect) LikeOperator(column string, wildcardBefore bool, pattern string, wildcardAfter bool) (string, string) { - param := strings.ToLower(pattern) - if wildcardBefore { - param = "%" + param - } - if wildcardAfter { - param = param + "%" - } - return fmt.Sprintf("LOWER(%s) LIKE ?", column), param -} - -func (s *SpannerDialect) IndexCheckSQL(tableName, indexName string) (string, []any) { - return s.d.IndexCheckSql(tableName, indexName) -} -func (s *SpannerDialect) SQLType(col *Column) string { - c := core.NewColumn(col.Name, "", core.SQLType{Name: col.Type}, col.Length, col.Length2, col.Nullable) - return s.d.SqlType(c) -} - -func (s *SpannerDialect) BatchSize() int { return 1000 } - -func (s *SpannerDialect) BooleanValue(b bool) any { - return b -} - -func (s *SpannerDialect) BooleanStr(b bool) string { - if b { - return "true" - } - return "false" -} -func (s *SpannerDialect) ErrorMessage(err error) string { - return spanner.ErrDesc(spanner.ToSpannerError(err)) -} -func (s *SpannerDialect) IsDeadlock(err error) bool { - return spanner.ErrCode(spanner.ToSpannerError(err)) == codes.Aborted -} -func (s *SpannerDialect) IsUniqueConstraintViolation(err error) bool { - return spanner.ErrCode(spanner.ToSpannerError(err)) == codes.AlreadyExists -} - -func (s *SpannerDialect) CreateTableSQL(table *Table) string { - t := core.NewEmptyTable() - t.Name = table.Name - t.PrimaryKeys = table.PrimaryKeys - for _, c := range table.Columns { - col := core.NewColumn(c.Name, c.Name, core.SQLType{Name: c.Type}, c.Length, c.Length2, c.Nullable) - col.IsAutoIncrement = c.IsAutoIncrement - col.Default = c.Default - t.AddColumn(col) - } - if len(t.PrimaryKeys) == 0 { - for _, ix := range table.Indices { - if ix.Name == "PRIMARY_KEY" { - t.PrimaryKeys = append(t.PrimaryKeys, ix.Cols...) - } - } - } - return s.d.CreateTableSql(t, t.Name, "", "") -} - -func (s *SpannerDialect) CreateIndexSQL(tableName string, index *Index) string { - idx := core.NewIndex(index.Name, index.Type) - idx.Cols = index.Cols - return s.d.CreateIndexSql(tableName, idx) -} - -func (s *SpannerDialect) UpsertMultipleSQL(tableName string, keyCols, updateCols []string, count int) (string, error) { - return "", errors.New("not supported") -} - -func (s *SpannerDialect) DropIndexSQL(tableName string, index *Index) string { - return fmt.Sprintf("DROP INDEX %v", s.Quote(index.XName(tableName))) -} - -func (s *SpannerDialect) DropTable(tableName string) string { - return fmt.Sprintf("DROP TABLE %s", s.Quote(tableName)) -} - -func (s *SpannerDialect) ColStringNoPk(col *Column) string { - sql := s.dialect.Quote(col.Name) + " " - - sql += s.dialect.SQLType(col) + " " - - if s.dialect.ShowCreateNull() && !col.Nullable { - sql += "NOT NULL " - } - - if col.Default != "" { - // Default value must be in parentheses. - sql += "DEFAULT (" + s.dialect.Default(col) + ") " - } - - return sql -} - -func (s *SpannerDialect) TruncateDBTables(engine *xorm.Engine) error { - // Get tables names only, no columns or indexes. - tables, err := engine.Dialect().GetTables() - if err != nil { - return err - } - sess := engine.NewSession() - defer sess.Close() - - var statements []string - - for _, table := range tables { - switch table.Name { - case "": - continue - case "autoincrement_sequences": - // Don't delete sequence number for migration_log.id column. - statements = append(statements, fmt.Sprintf("DELETE FROM %v WHERE name <> 'migration_log:id'", s.Quote(table.Name))) - case "migration_log": - continue - case "dashboard_acl": - // keep default dashboard permissions - statements = append(statements, fmt.Sprintf("DELETE FROM %v WHERE dashboard_id != -1 AND org_id != -1;", s.Quote(table.Name))) - default: - statements = append(statements, fmt.Sprintf("DELETE FROM %v WHERE TRUE;", s.Quote(table.Name))) - } - } - - // Run statements concurrently. - return concurrency.ForEachJob(context.Background(), len(statements), 10, func(ctx context.Context, idx int) error { - _, err := sess.Exec(statements[idx]) - return err - }) -} - -// CleanDB drops all existing tables and their indexes. -func (s *SpannerDialect) CleanDB(engine *xorm.Engine) error { - tables, err := engine.DBMetas() - if err != nil { - return err - } - - // Collect all DROP statements. - changeStreams, err := s.findChangeStreams(engine) - if err != nil { - return err - } - statements := make([]string, 0, len(tables)+len(changeStreams)) - for _, cs := range changeStreams { - statements = append(statements, fmt.Sprintf("DROP CHANGE STREAM `%s`", cs)) - } - - for _, table := range tables { - // Indexes must be dropped first, otherwise dropping tables fails. - for _, index := range table.Indexes { - if !index.IsRegular { - // Don't drop primary key. - continue - } - sql := fmt.Sprintf("DROP INDEX %s", s.Quote(index.XName(table.Name))) - statements = append(statements, sql) - } - - sql := fmt.Sprintf("DROP TABLE %s", s.Quote(table.Name)) - statements = append(statements, sql) - } - - if len(statements) == 0 { - return nil - } - - return s.executeDDLStatements(context.Background(), engine, statements) -} - -//go:embed snapshot/spanner-ddl.json -var snapshotDDL string - -//go:embed snapshot/spanner-log.json -var snapshotMigrations string - -func (s *SpannerDialect) CreateDatabaseFromSnapshot(ctx context.Context, engine *xorm.Engine, tableName string) error { - var statements, migrationIDs []string - err := json.Unmarshal([]byte(snapshotDDL), &statements) - if err != nil { - return err - } - err = json.Unmarshal([]byte(snapshotMigrations), &migrationIDs) - if err != nil { - return err - } - - err = s.executeDDLStatements(ctx, engine, statements) - if err != nil { - return err - } - - return s.recordMigrationsToMigrationLog(engine, migrationIDs, tableName) -} - -func (s *SpannerDialect) recordMigrationsToMigrationLog(engine *xorm.Engine, migrationIDs []string, tableName string) error { - now := time.Now() - makeRecord := func(id string) MigrationLog { - return MigrationLog{ - MigrationID: id, - SQL: "", - Success: true, - Timestamp: now, - } - } - - sess := engine.NewSession() - defer sess.Close() - - // Insert records in batches to avoid many roundtrips to database. - // Inserting all records at once fails due to "Number of parameters in query exceeds the maximum - // allowed limit of 950." error, so we use smaller batches. - const batchSize = 100 - - if err := sess.Begin(); err != nil { - return err - } - - records := make([]MigrationLog, 0, len(migrationIDs)) - for _, mid := range migrationIDs { - records = append(records, makeRecord(mid)) - - if len(records) >= batchSize { - if _, err := sess.Table(tableName).InsertMulti(records); err != nil { - err2 := sess.Rollback() - return errors.Join(fmt.Errorf("failed to insert migration logs: %w", err), err2) - } - records = records[:0] - } - } - - // Insert remaining records. - if len(records) > 0 { - if _, err := sess.Table(tableName).InsertMulti(records); err != nil { - err2 := sess.Rollback() - return errors.Join(fmt.Errorf("failed to insert migration logs: %w", err), err2) - } - } - - if err := sess.Commit(); err != nil { - return err - } - - return nil -} - -// Spanner can be very slow at executing single DDL statements (it can take up to a minute), but when -// many DDL statements are batched together, Spanner is *much* faster (total time to execute all statements -// is often in tens of seconds). We can't execute batch of DDL statements using sql wrapper, we use "database admin client" -// from Spanner library instead. -func (s *SpannerDialect) executeDDLStatements(ctx context.Context, engine *xorm.Engine, statements []string) error { - // Datasource name contains string used for sql.Open. - dsn := engine.Dialect().DataSourceName() - cfg, err := spannerdriver.ExtractConnectorConfig(dsn) - if err != nil { - return err - } - - opts := utilspanner.ConnectorConfigToClientOptions(cfg) - - databaseAdminClient, err := database.NewDatabaseAdminClient(ctx, opts...) - if err != nil { - return fmt.Errorf("failed to create database admin client: %v", err) - } - //nolint:errcheck // If the databaseAdminClient.Close fails, we simply don't care. - defer databaseAdminClient.Close() - - databaseName := fmt.Sprintf("projects/%s/instances/%s/databases/%s", cfg.Project, cfg.Instance, cfg.Database) - - op, err := databaseAdminClient.UpdateDatabaseDdl(ctx, &databasepb.UpdateDatabaseDdlRequest{ - Database: databaseName, - Statements: statements, - }, gax.WithTimeout(0)) /* disable default timeout */ - if err != nil { - return fmt.Errorf("failed to start database DDL update: %v", err) - } - - err = op.Wait(ctx, gax.WithTimeout(0)) /* disable default timeout */ - if err != nil { - return fmt.Errorf("failed to apply database DDL update: %v", err) - } - return nil -} - -func (s *SpannerDialect) UnionDistinct() string { - return "UNION DISTINCT" -} - -func (s *SpannerDialect) findChangeStreams(engine *xorm.Engine) ([]string, error) { - var result []string - query := `SELECT c.CHANGE_STREAM_NAME - FROM INFORMATION_SCHEMA.CHANGE_STREAMS AS C - WHERE C.CHANGE_STREAM_CATALOG='' - AND C.CHANGE_STREAM_SCHEMA=''` - rows, err := engine.DB().Query(query) - if err != nil { - return nil, err - } - //nolint:errcheck // If the rows.Close fails, we simply don't care. - defer rows.Close() - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, err - } - result = append(result, name) - } - return result, nil -} diff --git a/pkg/services/sqlstore/migrator/types.go b/pkg/services/sqlstore/migrator/types.go index 38ea5fabca5..15be7996dcc 100644 --- a/pkg/services/sqlstore/migrator/types.go +++ b/pkg/services/sqlstore/migrator/types.go @@ -12,7 +12,6 @@ const ( SQLite = "sqlite3" MySQL = "mysql" MSSQL = "mssql" - Spanner = "spanner" ) type Migration interface { diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index d6fcf89797e..7dc57810a2a 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -444,9 +444,6 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) { expectedResult: []string{"parent"}, }, } - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } var orgID int64 = 1 @@ -554,9 +551,6 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission expectedResult: []string{"parent"}, }, } - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } var orgID int64 = 1 @@ -665,10 +659,6 @@ func TestIntegration_DashboardNestedPermissionFilter_WithActionSets(t *testing.T }, } - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } - var orgID int64 = 1 for _, tc := range testCases { @@ -757,9 +747,6 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access // Insert dashboards in batches batchSize := 500 - if db.IsTestDBSpanner() { - batchSize = 30 // spanner has a limit of 950 parameters per query - } for i := 0; i < len(dashes); i += batchSize { end := i + batchSize if end > len(dashes) { diff --git a/pkg/services/sqlstore/session.go b/pkg/services/sqlstore/session.go index 155e63ab517..1ded27a3559 100644 --- a/pkg/services/sqlstore/session.go +++ b/pkg/services/sqlstore/session.go @@ -146,11 +146,6 @@ func (sess *DBSession) WithReturningID(driverName string, query string, args []a return id, err } } else { - if driverName == migrator.Spanner { - // Only works with INSERT statements. - query = fmt.Sprintf("%s THEN RETURN id", query) - } - sqlOrArgs := append([]any{query}, args...) res, err := sess.Exec(sqlOrArgs...) if err != nil { diff --git a/pkg/services/sqlstore/session/session.go b/pkg/services/sqlstore/session/session.go index d1f79d6f294..92bd29ff25e 100644 --- a/pkg/services/sqlstore/session/session.go +++ b/pkg/services/sqlstore/session/session.go @@ -110,11 +110,6 @@ func execWithReturningId(ctx context.Context, driverName string, query string, s } return id, nil } else { - if driverName == "spanner" { - // LastInsertId requires THEN RETURN directive. - query = fmt.Sprintf("%s THEN RETURN id", query) - } - res, err := sess.Exec(ctx, query, args...) if err != nil { return id, err diff --git a/pkg/services/sqlstore/session_test.go b/pkg/services/sqlstore/session_test.go index 3624b8f1905..3986874d48b 100644 --- a/pkg/services/sqlstore/session_test.go +++ b/pkg/services/sqlstore/session_test.go @@ -8,8 +8,6 @@ import ( "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - grpcstatus "google.golang.org/grpc/status" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -141,12 +139,10 @@ func getRetryErrors(t *testing.T, store *SQLStore) []error { switch store.GetDialect().DriverName() { case migrator.SQLite: retryErrors = []error{sqlite3.Error{Code: sqlite3.ErrBusy}, sqlite3.Error{Code: sqlite3.ErrLocked}} - case migrator.Spanner: - retryErrors = []error{grpcstatus.Error(codes.Aborted, "aborted transaction")} } if len(retryErrors) == 0 { - t.Skip("This test only works with sqlite or spanner") + t.Skip("This test only works with sqlite") } return retryErrors } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 306a031562f..31065d93ec5 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -373,11 +373,6 @@ func (ss *SQLStore) RecursiveQueriesAreSupported() (bool, error) { return *ss.recursiveQueriesAreSupported, nil } recursiveQueriesAreSupported := func() (bool, error) { - if ss.GetDBType() == migrator.Spanner { - // no need to try... - return false, nil - } - var result []int if err := ss.WithDbSession(context.Background(), func(sess *DBSession) error { recQry := `WITH RECURSIVE cte (n) AS @@ -414,7 +409,6 @@ var testSQLStoreSetup = false var testSQLStore *SQLStore var testSQLStoreMutex sync.Mutex var testSQLStoreCleanup []func() -var testSQLStoreSkipTestsOnBackend string // When not empty and matches DB type, test is skipped. // InitTestDBOpt contains options for InitTestDB. type InitTestDBOpt struct { @@ -459,12 +453,6 @@ func SetupTestDB() { testSQLStoreSetup = true } -func SkipTestsOnSpanner() { - testSQLStoreMutex.Lock() - defer testSQLStoreMutex.Unlock() - testSQLStoreSkipTestsOnBackend = "spanner" -} - func CleanupTestDB() { testSQLStoreMutex.Lock() defer testSQLStoreMutex.Unlock() @@ -549,10 +537,6 @@ func TestMain(m *testing.M) { if testSQLStore == nil { dbType := sqlutil.GetTestDBType() - if testSQLStoreSkipTestsOnBackend != "" && testSQLStoreSkipTestsOnBackend == dbType { - t.Skipf("test skipped when using DB type %s", testSQLStoreSkipTestsOnBackend) - } - // set test db config cfg := setting.NewCfg() // nolint:staticcheck diff --git a/pkg/services/sqlstore/sqlstore_testinfra.go b/pkg/services/sqlstore/sqlstore_testinfra.go index 3ed8da0462e..194cf843c2f 100644 --- a/pkg/services/sqlstore/sqlstore_testinfra.go +++ b/pkg/services/sqlstore/sqlstore_testinfra.go @@ -9,18 +9,9 @@ import ( "encoding/hex" "fmt" "os" - "strconv" "testing" "time" - database "cloud.google.com/go/spanner/admin/database/apiv1" - "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" - "cloud.google.com/go/spanner/spannertest" - spannerdriver "github.com/googleapis/go-sql-spanner" - "google.golang.org/api/option" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry" @@ -269,9 +260,6 @@ func createTemporaryDatabase(tb TestingTB) (*testDB, error) { // SQLite doesn't have a concept of a database server, so we always create a new file with no connections required. return newSQLite3DB(tb) } - if dbType == "spanner" { - return newSpannerDB(tb) - } // On the remaining databases, we first connect to the configured credentials, create a new database, then return this new database's info as a connection string. // We use databases rather than schemas as MySQL has no concept of schemas, so this aligns them more closely. @@ -326,121 +314,7 @@ func createTemporaryDatabase(tb TestingTB) (*testDB, error) { func generateDatabaseName() string { // The database name has to be unique amongst all tests. It is highly unlikely we will have a collision here. // The database name has to be <= 64 chars long on MySQL, and <= 31 chars on Postgres. - // Database ID length on Spanner must be between 2 and 30 characters. (https://cloud.google.com/spanner/quotas#database-limits) - return "grafana_test_" + randomLowerHex(17) -} - -func newSpannerDB(tb TestingTB) (*testDB, error) { - // See https://github.com/googleapis/go-sql-spanner/blob/main/driver.go#L56-L81 for connection string options. - spannerDB := env("SPANNER_DB", "emulator") - if spannerDB == "spannertest" { - // Start new in-memory spannertest instance. This is mostly useless for our tests - // (spannertest doesn't support many things that we use), but added for completion. - // Each spannertest instance is a separate db. - srv, err := spannertest.NewServer("localhost:0") - if err != nil { - return nil, err - } - tb.Cleanup(srv.Close) - - return &testDB{ - Driver: "spanner", - Conn: fmt.Sprintf("%s/projects/grafanatest/instances/grafanatest/databases/grafanatest;usePlainText=true", srv.Addr), - }, nil - } - - conn := spannerDB - if spannerDB == "emulator" { - host := env("SPANNER_EMULATOR_HOST", "localhost:9010") - conn = fmt.Sprintf("%s/projects/grafanatest/instances/grafanatest/databases/grafanatest;usePlainText=true", host) - } - - cfg, err := spannerdriver.ExtractConnectorConfig(conn) - if err != nil { - return nil, err - } - - clientOptions := spannerConnectorConfigToClientOptions(cfg) - - dbname := generateDatabaseName() - fullDbName := fmt.Sprintf("projects/%s/instances/%s/databases/%s", cfg.Project, cfg.Instance, dbname) - dbCreated := false - - databaseAdminClient, err := database.NewDatabaseAdminClient(tb.Context(), clientOptions...) - if err != nil { - return nil, fmt.Errorf("failed to create database admin client: %v", err) - } - tb.Cleanup(func() { - if dbCreated { - // Drop database in the cleanup. - // Can't use tb.Context() here, since that is canceled before calling Cleanup functions. - err := databaseAdminClient.DropDatabase(context.Background(), &databasepb.DropDatabaseRequest{ - Database: fullDbName, - }) - if err != nil { - tb.Logf("Failed to drop Spanner database %s due to error %v", fullDbName, err) - } else { - tb.Logf("Dropped temporary Spanner database %s", fullDbName) - } - } - - _ = databaseAdminClient.Close() - }) - - op, err := databaseAdminClient.CreateDatabase(tb.Context(), &databasepb.CreateDatabaseRequest{ - Parent: fmt.Sprintf("projects/%s/instances/%s", cfg.Project, cfg.Instance), - CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", dbname), - DatabaseDialect: databasepb.DatabaseDialect_GOOGLE_STANDARD_SQL, - }) - if err != nil { - return nil, fmt.Errorf("failed to create database: %v", err) - } - _, err = op.Wait(tb.Context()) - if err != nil { - return nil, fmt.Errorf("failed to create database: %v", err) - } - tb.Logf("Created temporary Spanner database %s", fullDbName) - - dbCreated = true - - // Rebuild connection string, but change database to ID of just-created database. - // Example: `localhost:9010/projects/test-project/instances/test-instance/databases/test-database;usePlainText=true;disableRouteToLeader=true;enableEndToEndTracing=true` - connString := "" - if cfg.Host != "" { - connString = fmt.Sprintf("%s/", cfg.Host) - } - // Use new DB name instead of cfg.Database. - connString = connString + fmt.Sprintf("projects/%s/instances/%s/databases/%s", cfg.Project, cfg.Instance, dbname) - for k, v := range cfg.Params { - connString = connString + fmt.Sprintf(";%s=%s", k, v) - } - - return &testDB{ - Driver: "spanner", - Conn: connString, - }, nil -} - -// This is same code as xorm.SpannerConnectorConfigToClientOptions, but we cannot use that because it's under "enterprise" build tag. -func spannerConnectorConfigToClientOptions(connectorConfig spannerdriver.ConnectorConfig) []option.ClientOption { - var opts []option.ClientOption - if connectorConfig.Host != "" { - opts = append(opts, option.WithEndpoint(connectorConfig.Host)) - } - if strval, ok := connectorConfig.Params["credentials"]; ok { - opts = append(opts, option.WithCredentialsFile(strval)) - } - if strval, ok := connectorConfig.Params["credentialsjson"]; ok { - opts = append(opts, option.WithCredentialsJSON([]byte(strval))) - } - if strval, ok := connectorConfig.Params["useplaintext"]; ok { - if val, err := strconv.ParseBool(strval); err == nil && val { - opts = append(opts, - option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), - option.WithoutAuthentication()) - } - } - return opts + return "grafana_test_" + randomLowerHex(18) } func env(name, fallback string) string { diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 3b6e449ff4b..8506acdab79 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -6,8 +6,6 @@ import ( "io/fs" "os" "path/filepath" - - "cloud.google.com/go/spanner/spannertest" ) // ITestDB is an interface of arguments for testing db @@ -45,8 +43,6 @@ func GetTestDB(dbType string) (*TestDB, error) { return postgresTestDB() case "sqlite3": return sqLite3TestDB() - case "spanner": - return spannerTestDB() } return nil, fmt.Errorf("unknown test db type: %s", dbType) @@ -156,49 +152,3 @@ func postgresTestDB() (*TestDB, error) { Cleanup: func() {}, }, nil } - -func spannerTestDB() (*TestDB, error) { - // See https://github.com/googleapis/go-sql-spanner/blob/main/driver.go#L56-L81 for connection string options. - - spannerDB := os.Getenv("SPANNER_DB") - if spannerDB == "" { - spannerDB = "emulator" - } - - if spannerDB == "spannertest" { - // Start in-memory spannertest instance. - srv, err := spannertest.NewServer("localhost:0") - if err != nil { - return nil, err - } - - return &TestDB{ - DriverName: "spanner", - ConnStr: fmt.Sprintf("%s/projects/grafanatest/instances/grafanatest/databases/grafanatest;usePlainText=true", srv.Addr), - Cleanup: srv.Close, - }, nil - } - - if spannerDB == "emulator" { - host := os.Getenv("SPANNER_EMULATOR_HOST") - if host == "" { - host = "localhost:9010" - } - - // To create instance and database manually, run: - // - // $ curl "localhost:9020/v1/projects/grafanatest/instances" --data '{"instanceId": "'grafanatest'"}' - // $ curl "localhost:9020/v1/projects/grafanatest/instances/grafanatest/databases" --data '{"createStatement": "CREATE DATABASE `grafanatest`"}' - return &TestDB{ - DriverName: "spanner", - ConnStr: fmt.Sprintf("%s/projects/grafanatest/instances/grafanatest/databases/grafanatest;usePlainText=true;inMemSequenceGenerator=true", host), - Cleanup: func() {}, - }, nil - } - - return &TestDB{ - DriverName: "spanner", - ConnStr: spannerDB, - Cleanup: func() {}, - }, nil -} diff --git a/pkg/services/sqlstore/transactions.go b/pkg/services/sqlstore/transactions.go index c93f1516986..8df2cc6c0d0 100644 --- a/pkg/services/sqlstore/transactions.go +++ b/pkg/services/sqlstore/transactions.go @@ -61,7 +61,7 @@ func (ss *SQLStore) inTransactionWithRetryCtx(ctx context.Context, engine *xorm. return err } - // special handling of database locked errors for sqlite and spanner, then we can retry 5 times + // special handling of database locked errors for sqlite, then we can retry 5 times if r, ok := engine.Dialect().(xorm.DialectWithRetryableErrors); ok { if retry < ss.dbCfg.TransactionRetries && r.RetryOnError(err) { if rollErr := sess.Rollback(); rollErr != nil { diff --git a/pkg/services/ssosettings/database/database.go b/pkg/services/ssosettings/database/database.go index 6d9a97c3dc7..2d7097e690c 100644 --- a/pkg/services/ssosettings/database/database.go +++ b/pkg/services/ssosettings/database/database.go @@ -144,7 +144,7 @@ func (s *SSOSettingsStore) Delete(ctx context.Context, provider string) error { existing.Updated = time.Now().UTC() existing.IsDeleted = true - // We must explicitly omit ID column from updates, because some databases (e.g. Spanner) don't allow updating + // We must explicitly omit ID column from updates, because some databases don't allow updating // primary key. Xorm ignores autoincrement columns during updates, but since ID column here is a string, // it's not ignored by default. _, err = sess.ID(existing.ID).Omit(idColumn).MustCols(updatedColumn, isDeletedColumn).Update(existing) diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 3fb980c38da..005950e9611 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -1,6 +1,8 @@ package setting import ( + "os" + "regexp" "strings" "gopkg.in/ini.v1" @@ -34,13 +36,62 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings { var ( defaultPreinstallPlugins = map[string]InstallPlugin{ // Default preinstalled plugins - "grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""}, - "grafana-pyroscope-app": {"grafana-pyroscope-app", "", ""}, - "grafana-exploretraces-app": {"grafana-exploretraces-app", "", ""}, - "grafana-metricsdrilldown-app": {"grafana-metricsdrilldown-app", "", ""}, + "grafana-lokiexplore-app": {ID: "grafana-lokiexplore-app"}, + "grafana-pyroscope-app": {ID: "grafana-pyroscope-app"}, + "grafana-exploretraces-app": {ID: "grafana-exploretraces-app"}, + "grafana-metricsdrilldown-app": {ID: "grafana-metricsdrilldown-app"}, } ) +func (cfg *Cfg) migrateInstallPluginsToPreinstallPluginsSync(rawInstallPlugins, installPluginsForce string, preinstallPluginsSync map[string]InstallPlugin) { + if strings.ToLower(installPluginsForce) == "true" || rawInstallPlugins == "" { + cfg.Logger.Debug("GF_INSTALL_PLUGINS_FORCE is set to true, skipping migration of GF_INSTALL_PLUGINS to GF_PLUGINS_PREINSTALL_SYNC") + return + } + installPluginsEntries := strings.Split(rawInstallPlugins, ",") + + // Format 1: ID only (e.g., "grafana-clock-panel") + // Format 2: ID with version (e.g., "grafana-clock-panel 1.0.1") + // Format 3: URL with folder (e.g., "https://grafana.com/api/plugins/grafana-clock-panel/versions/latest/download;grafana-clock-panel") + pluginRegex := regexp.MustCompile(`(?:([^;]+);)?([^;\s]+)(?:\s+(.+))?`) + for _, entry := range installPluginsEntries { + trimmedEntry := strings.TrimSpace(entry) + if trimmedEntry == "" { + continue + } + + matches := pluginRegex.FindStringSubmatch(trimmedEntry) + + if matches == nil { + cfg.Logger.Debug("No match found for entry: ", trimmedEntry) + continue + } + + url := "" + if len(matches) > 1 { + url = strings.TrimSpace(matches[1]) + } + + id := "" + if len(matches) > 2 { + id = strings.TrimSpace(matches[2]) + } + if _, exists := preinstallPluginsSync[id]; exists { + continue + } + + version := "" + if len(matches) > 3 { + version = strings.TrimSpace(matches[3]) + } + if id != "" { + preinstallPluginsSync[id] = InstallPlugin{ID: id, Version: version, URL: url} + } else { + cfg.Logger.Debug("No ID found for entry: ", trimmedEntry, "matches: ", matches) + } + } +} + func (cfg *Cfg) processPreinstallPlugins(rawInstallPlugins []string, preinstallPlugins map[string]InstallPlugin) { // Add the plugins defined in the configuration for _, plugin := range rawInstallPlugins { @@ -88,6 +139,7 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { rawInstallPluginsSync := util.SplitString(pluginsSection.Key("preinstall_sync").MustString("")) preinstallPluginsSync := make(map[string]InstallPlugin) cfg.processPreinstallPlugins(rawInstallPluginsSync, preinstallPluginsSync) + cfg.migrateInstallPluginsToPreinstallPluginsSync(os.Getenv("GF_INSTALL_PLUGINS"), os.Getenv("GF_INSTALL_PLUGINS_FORCE"), preinstallPluginsSync) // Remove from the list the plugins that have been disabled for _, disabledPlugin := range cfg.DisablePlugins { delete(preinstallPluginsAsync, disabledPlugin) diff --git a/pkg/setting/setting_plugins_test.go b/pkg/setting/setting_plugins_test.go index 1af9c95270f..74425c95a0e 100644 --- a/pkg/setting/setting_plugins_test.go +++ b/pkg/setting/setting_plugins_test.go @@ -172,12 +172,12 @@ func Test_readPluginSettings(t *testing.T) { { name: "should add the default preinstalled plugin and the one defined", rawInput: "plugin1", - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", ""}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{ID: "plugin1", Version: "", URL: ""}), }, { name: "should add the default preinstalled plugin and the one defined with version", rawInput: "plugin1@1.0.0", - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.0", ""}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{ID: "plugin1", Version: "1.0.0", URL: ""}), }, { name: "it should remove the disabled plugin", @@ -207,12 +207,12 @@ func Test_readPluginSettings(t *testing.T) { { name: "should parse a plugin with version and URL", rawInput: "plugin1@1.0.1@https://example.com/plugin1.tar.gz", - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.1", "https://example.com/plugin1.tar.gz"}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{ID: "plugin1", Version: "1.0.1", URL: "https://example.com/plugin1.tar.gz"}), }, { name: "should parse a plugin with URL", rawInput: "plugin1@@https://example.com/plugin1.tar.gz", - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", "https://example.com/plugin1.tar.gz"}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{ID: "plugin1", Version: "", URL: "https://example.com/plugin1.tar.gz"}), }, { name: "when preinstall_async is false, should add all plugins to preinstall_sync", @@ -272,3 +272,140 @@ func Test_readPluginSettings(t *testing.T) { } }) } + +func Test_migrateInstallPluginsToPreinstallPluginsSync(t *testing.T) { + tests := []struct { + name string + installPluginsVal string + installPluginsForce string + preinstallPlugins map[string]InstallPlugin + expectedPlugins map[string]InstallPlugin + }{ + { + name: "should return empty map when GF_INSTALL_PLUGINS is not set", + installPluginsVal: "", + preinstallPlugins: map[string]InstallPlugin{}, + expectedPlugins: map[string]InstallPlugin{}, + }, + { + name: "should parse URL with folder format", + installPluginsVal: "https://grafana.com/grafana/plugins/grafana-piechart-panel/;grafana-piechart-panel", + preinstallPlugins: map[string]InstallPlugin{}, + expectedPlugins: map[string]InstallPlugin{ + "grafana-piechart-panel": { + ID: "grafana-piechart-panel", + Version: "", + URL: "https://grafana.com/grafana/plugins/grafana-piechart-panel/", + }, + }, + }, + { + name: "should parse mixed formats", + installPluginsVal: "https://github.com/VolkovLabs/business-links/releases/download/v1.2.1/volkovlabs-links-panel-1.2.1.zip;volkovlabs-links-panel,marcusolsson-static-datasource,volkovlabs-variable-panel", + preinstallPlugins: map[string]InstallPlugin{}, + expectedPlugins: map[string]InstallPlugin{ + "volkovlabs-links-panel": { + ID: "volkovlabs-links-panel", + Version: "", + URL: "https://github.com/VolkovLabs/business-links/releases/download/v1.2.1/volkovlabs-links-panel-1.2.1.zip", + }, + "marcusolsson-static-datasource": { + ID: "marcusolsson-static-datasource", + Version: "", + URL: "", + }, + "volkovlabs-variable-panel": { + ID: "volkovlabs-variable-panel", + Version: "", + URL: "", + }, + }, + }, + { + name: "should parse ID with version format", + installPluginsVal: "volkovlabs-links-panel 1.2.1,marcusolsson-static-datasource 1.0.0,volkovlabs-variable-panel", + preinstallPlugins: map[string]InstallPlugin{}, + expectedPlugins: map[string]InstallPlugin{ + "volkovlabs-links-panel": { + ID: "volkovlabs-links-panel", + Version: "1.2.1", + URL: "", + }, + "marcusolsson-static-datasource": { + ID: "marcusolsson-static-datasource", + Version: "1.0.0", + URL: "", + }, + "volkovlabs-variable-panel": { + ID: "volkovlabs-variable-panel", + Version: "", + URL: "", + }, + }, + }, + { + name: "should return empty map when GF_INSTALL_PLUGINS_FORCE is true", + installPluginsVal: "grafana-piechart-panel", + installPluginsForce: "true", + preinstallPlugins: map[string]InstallPlugin{}, + expectedPlugins: map[string]InstallPlugin{}, + }, + { + name: "should skip plugins that are already configured", + installPluginsVal: "plugin1 1.0.0,plugin2,plugin3", + preinstallPlugins: map[string]InstallPlugin{ + "plugin1": {ID: "plugin1", Version: "1.0.1"}, + "plugin3": {ID: "plugin3"}, + }, + expectedPlugins: map[string]InstallPlugin{ + "plugin2": { + ID: "plugin2", + }, + "plugin3": { + ID: "plugin3", + }, + "plugin1": { + ID: "plugin1", + Version: "1.0.1", + }, + }, + }, + { + name: "should trim the space in the input", + installPluginsVal: " plugin1 1.0.0, plugin2, plugin3 ", + preinstallPlugins: map[string]InstallPlugin{}, + expectedPlugins: map[string]InstallPlugin{ + "plugin2": { + ID: "plugin2", + }, + "plugin3": { + ID: "plugin3", + }, + "plugin1": { + ID: "plugin1", + Version: "1.0.0", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := NewCfg() + + cfg.migrateInstallPluginsToPreinstallPluginsSync(tc.installPluginsVal, tc.installPluginsForce, tc.preinstallPlugins) + assert.Equal(t, len(tc.expectedPlugins), len(tc.preinstallPlugins), "Number of plugins doesn't match") + + // Check each expected plugin exists with correct values + for id, expectedPlugin := range tc.expectedPlugins { + actualPlugin, exists := tc.preinstallPlugins[id] + assert.True(t, exists, "Expected plugin %s not found", id) + if exists { + assert.Equal(t, expectedPlugin.ID, actualPlugin.ID, "Plugin ID mismatch for %s", id) + assert.Equal(t, expectedPlugin.Version, actualPlugin.Version, "Plugin version mismatch for %s", id) + assert.Equal(t, expectedPlugin.URL, actualPlugin.URL, "Plugin URL mismatch for %s", id) + } + } + }) + } +} diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 9a434a7e2ff..c40f761cad4 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -78,6 +78,18 @@ func TestLoadingSettings(t *testing.T) { require.Equal(t, filepath.Join(cfg.DataPath, "log"), cfg.LogsPath) }) + t.Run("Should be able to override via plugins.preinstall with GF_INSTALL_PLUGINS env var when GF_PLUGINS_PREINSTALL and cfg.plugins.preinstall are not set", func(t *testing.T) { + t.Setenv("GF_INSTALL_PLUGINS", "https://grafana.com/grafana/plugins/grafana-piechart-panel/;grafana-piechart-panel") + + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + + require.Equal(t, filepath.Join(cfg.HomePath, "data"), cfg.DataPath) + require.Equal(t, filepath.Join(cfg.DataPath, "log"), cfg.LogsPath) + require.Equal(t, cfg.PreinstallPluginsSync, []InstallPlugin{{ID: "grafana-piechart-panel", Version: "", URL: "https://grafana.com/grafana/plugins/grafana-piechart-panel/"}}) + }) + t.Run("Should be able to expand parameter from environment variables", func(t *testing.T) { t.Setenv("DEFAULT_IDP_URL", "grafana.com") t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTH_URL", "${DEFAULT_IDP_URL}/auth") diff --git a/pkg/storage/secret/metadata/data/keeper_create.sql b/pkg/storage/secret/metadata/data/keeper_create.sql new file mode 100644 index 00000000000..48319df8249 --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_create.sql @@ -0,0 +1,27 @@ +INSERT INTO {{ .Ident "secret_keeper" }} ( + {{ .Ident "guid" }}, + {{ .Ident "name" }}, + {{ .Ident "namespace" }}, + {{ .Ident "annotations" }}, + {{ .Ident "labels" }}, + {{ .Ident "created" }}, + {{ .Ident "created_by" }}, + {{ .Ident "updated" }}, + {{ .Ident "updated_by" }}, + {{ .Ident "description" }}, + {{ .Ident "type" }}, + {{ .Ident "payload" }} +) VALUES ( + {{ .Arg .Row.GUID }}, + {{ .Arg .Row.Name }}, + {{ .Arg .Row.Namespace }}, + {{ .Arg .Row.Annotations }}, + {{ .Arg .Row.Labels }}, + {{ .Arg .Row.Created }}, + {{ .Arg .Row.CreatedBy }}, + {{ .Arg .Row.Updated }}, + {{ .Arg .Row.UpdatedBy }}, + {{ .Arg .Row.Description }}, + {{ .Arg .Row.Type }}, + {{ .Arg .Row.Payload }} +); diff --git a/pkg/storage/secret/metadata/data/keeper_delete.sql b/pkg/storage/secret/metadata/data/keeper_delete.sql new file mode 100644 index 00000000000..d0f3305eadf --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_delete.sql @@ -0,0 +1,4 @@ +DELETE FROM {{ .Ident "secret_keeper" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "name" }} = {{ .Arg .Name }} +; diff --git a/pkg/storage/secret/metadata/data/keeper_list.sql b/pkg/storage/secret/metadata/data/keeper_list.sql new file mode 100644 index 00000000000..f21b8228faa --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_list.sql @@ -0,0 +1,18 @@ +SELECT + {{ .Ident "guid" }}, + {{ .Ident "name" }}, + {{ .Ident "namespace" }}, + {{ .Ident "annotations" }}, + {{ .Ident "labels" }}, + {{ .Ident "created" }}, + {{ .Ident "created_by" }}, + {{ .Ident "updated" }}, + {{ .Ident "updated_by" }}, + {{ .Ident "description" }}, + {{ .Ident "type" }}, + {{ .Ident "payload" }} +FROM + {{ .Ident "secret_keeper" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +ORDER BY {{ .Ident "updated" }} DESC +; diff --git a/pkg/storage/secret/metadata/data/keeper_listByName.sql b/pkg/storage/secret/metadata/data/keeper_listByName.sql new file mode 100644 index 00000000000..d7c2b6cb666 --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_listByName.sql @@ -0,0 +1,10 @@ +{{/* this query is used to validate the keeper update or creation */}} + +SELECT + {{ .Ident "name" }} +FROM + {{ .Ident "secret_keeper" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "name" }} IN ({{ .ArgList .KeeperNames }}) +{{ .SelectFor "UPDATE" }} +; diff --git a/pkg/storage/secret/metadata/data/keeper_read.sql b/pkg/storage/secret/metadata/data/keeper_read.sql new file mode 100644 index 00000000000..2a944a0f7e3 --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_read.sql @@ -0,0 +1,21 @@ +SELECT + {{ .Ident "guid" }}, + {{ .Ident "name" }}, + {{ .Ident "namespace" }}, + {{ .Ident "annotations" }}, + {{ .Ident "labels" }}, + {{ .Ident "created" }}, + {{ .Ident "created_by" }}, + {{ .Ident "updated" }}, + {{ .Ident "updated_by" }}, + {{ .Ident "description" }}, + {{ .Ident "type" }}, + {{ .Ident "payload" }} +FROM + {{ .Ident "secret_keeper" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "name" }} = {{ .Arg .Name }} +{{ if .IsForUpdate }} +{{ .SelectFor "UPDATE" }} +{{ end }} +; diff --git a/pkg/storage/secret/metadata/data/keeper_update.sql b/pkg/storage/secret/metadata/data/keeper_update.sql new file mode 100644 index 00000000000..6d494c6c2ff --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_update.sql @@ -0,0 +1,18 @@ +UPDATE + {{ .Ident "secret_keeper" }} +SET + {{ .Ident "guid" }} = {{ .Arg .Row.GUID }}, + {{ .Ident "name" }} = {{ .Arg .Row.Name }}, + {{ .Ident "namespace" }} = {{ .Arg .Row.Namespace }}, + {{ .Ident "annotations" }} = {{ .Arg .Row.Annotations }}, + {{ .Ident "labels" }} = {{ .Arg .Row.Labels }}, + {{ .Ident "created" }} = {{ .Arg .Row.Created }}, + {{ .Ident "created_by" }} = {{ .Arg .Row.CreatedBy }}, + {{ .Ident "updated" }} = {{ .Arg .Row.Updated }}, + {{ .Ident "updated_by" }} = {{ .Arg .Row.UpdatedBy }}, + {{ .Ident "description" }} = {{ .Arg .Row.Description }}, + {{ .Ident "type" }} = {{ .Arg .Row.Type }}, + {{ .Ident "payload" }} = {{ .Arg .Row.Payload }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Row.Namespace }} AND + {{ .Ident "name" }} = {{ .Arg .Row.Name }} +; diff --git a/pkg/storage/secret/metadata/keeper_model.go b/pkg/storage/secret/metadata/keeper_model.go new file mode 100644 index 00000000000..53843e7bf4a --- /dev/null +++ b/pkg/storage/secret/metadata/keeper_model.go @@ -0,0 +1,248 @@ +package metadata + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/apimachinery/utils" + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/grafana/grafana/pkg/storage/secret/migrator" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +type keeperDB struct { + // Kubernetes Metadata + GUID string + Name string + Namespace string + Annotations string // map[string]string + Labels string // map[string]string + Created int64 + CreatedBy string + Updated int64 + UpdatedBy string + + // Spec + Description string + Type string + Payload string +} + +func (*keeperDB) TableName() string { + return migrator.TableNameKeeper +} + +// toKubernetes maps a DB row into a Kubernetes resource (metadata + spec). +func (kp *keeperDB) toKubernetes() (*secretv0alpha1.Keeper, error) { + annotations := make(map[string]string, 0) + if kp.Annotations != "" { + if err := json.Unmarshal([]byte(kp.Annotations), &annotations); err != nil { + return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) + } + } + + labels := make(map[string]string, 0) + if kp.Labels != "" { + if err := json.Unmarshal([]byte(kp.Labels), &labels); err != nil { + return nil, fmt.Errorf("failed to unmarshal labels: %w", err) + } + } + + resource := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: kp.Description, + }, + } + + // Obtain provider configs + provider := toProvider(secretv0alpha1.KeeperType(kp.Type), kp.Payload) + switch v := provider.(type) { + case *secretv0alpha1.AWSKeeperConfig: + resource.Spec.AWS = v + case *secretv0alpha1.AzureKeeperConfig: + resource.Spec.Azure = v + case *secretv0alpha1.GCPKeeperConfig: + resource.Spec.GCP = v + case *secretv0alpha1.HashiCorpKeeperConfig: + resource.Spec.HashiCorp = v + } + + // Set all meta fields here for consistency. + meta, err := utils.MetaAccessor(resource) + if err != nil { + return nil, fmt.Errorf("failed to get meta accessor: %w", err) + } + + updated := time.Unix(kp.Updated, 0).UTC() + + meta.SetUID(types.UID(kp.GUID)) + meta.SetName(kp.Name) + meta.SetNamespace(kp.Namespace) + meta.SetAnnotations(annotations) + meta.SetLabels(labels) + meta.SetCreatedBy(kp.CreatedBy) + meta.SetCreationTimestamp(metav1.NewTime(time.Unix(kp.Created, 0).UTC())) + meta.SetUpdatedBy(kp.UpdatedBy) + meta.SetUpdatedTimestamp(&updated) + meta.SetResourceVersionInt64(kp.Updated) + + return resource, nil +} + +// toKeeperCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted. +func toKeeperCreateRow(kp *secretv0alpha1.Keeper, actorUID string) (*keeperDB, error) { + row, err := toKeeperRow(kp) + if err != nil { + return nil, fmt.Errorf("failed to map to row: %w", err) + } + + now := time.Now().UTC().Unix() + + row.GUID = uuid.New().String() + row.Created = now + row.CreatedBy = actorUID + row.Updated = now + row.UpdatedBy = actorUID + + return row, nil +} + +// toKeeperUpdateRow maps a Kubernetes resource into a DB row for existing resources being updated. +func toKeeperUpdateRow(currentRow *keeperDB, newKeeper *secretv0alpha1.Keeper, actorUID string) (*keeperDB, error) { + row, err := toKeeperRow(newKeeper) + if err != nil { + return nil, fmt.Errorf("failed to map to row: %w", err) + } + + now := time.Now().UTC().Unix() + + row.GUID = currentRow.GUID + row.Created = currentRow.Created + row.CreatedBy = currentRow.CreatedBy + row.Updated = now + row.UpdatedBy = actorUID + + return row, nil +} + +// toKeeperRow maps a Kubernetes Keeper resource into a Keeper DB row. +func toKeeperRow(kp *secretv0alpha1.Keeper) (*keeperDB, error) { + var annotations string + if len(kp.Annotations) > 0 { + cleanedAnnotations := xkube.CleanAnnotations(kp.Annotations) + if len(cleanedAnnotations) > 0 { + kp.Annotations = make(map[string]string) // Safety: reset to prohibit use of kp.Annotations further. + + encodedAnnotations, err := json.Marshal(cleanedAnnotations) + if err != nil { + return nil, fmt.Errorf("failed to encode annotations: %w", err) + } + + annotations = string(encodedAnnotations) + } + } + + var labels string + if len(kp.Labels) > 0 { + encodedLabels, err := json.Marshal(kp.Labels) + if err != nil { + return nil, fmt.Errorf("failed to encode labels: %w", err) + } + + labels = string(encodedLabels) + } + + meta, err := utils.MetaAccessor(kp) + if err != nil { + return nil, fmt.Errorf("failed to get meta accessor: %w", err) + } + + if meta.GetFolder() != "" { + return nil, fmt.Errorf("folders are not supported") + } + + updatedTimestamp, err := meta.GetResourceVersionInt64() + if err != nil { + return nil, fmt.Errorf("failed to get resource version: %w", err) + } + + keeperType, keeperPayload, err := toTypeAndPayload(kp) + if err != nil { + return nil, fmt.Errorf("failed to obtain type and payload: %w", err) + } + + return &keeperDB{ + // Kubernetes Metadata + GUID: string(kp.UID), + Name: kp.Name, + Namespace: kp.Namespace, + Annotations: annotations, + Labels: labels, + Created: meta.GetCreationTimestamp().Unix(), + CreatedBy: meta.GetCreatedBy(), + Updated: updatedTimestamp, + UpdatedBy: meta.GetUpdatedBy(), + + // Spec + Description: kp.Spec.Description, + Type: keeperType.String(), + Payload: keeperPayload, + }, nil +} + +// toTypeAndPayload obtain keeper type and payload from a Kubernetes Keeper resource. +// TODO: Move as method of KeeperSpec +func toTypeAndPayload(kp *secretv0alpha1.Keeper) (secretv0alpha1.KeeperType, string, error) { + if kp.Spec.AWS != nil { + payload, err := json.Marshal(kp.Spec.AWS.AWSCredentials) + return secretv0alpha1.AWSKeeperType, string(payload), err + } else if kp.Spec.Azure != nil { + payload, err := json.Marshal(kp.Spec.Azure) + return secretv0alpha1.AzureKeeperType, string(payload), err + } else if kp.Spec.GCP != nil { + payload, err := json.Marshal(kp.Spec.GCP) + return secretv0alpha1.GCPKeeperType, string(payload), err + } else if kp.Spec.HashiCorp != nil { + payload, err := json.Marshal(kp.Spec.HashiCorp) + return secretv0alpha1.HashiCorpKeeperType, string(payload), err + } + + return "", "", fmt.Errorf("no keeper type found") +} + +// toProvider maps a KeeperType and payload into a provider config struct. +// TODO: Move as method of KeeperType +func toProvider(keeperType secretv0alpha1.KeeperType, payload string) secretv0alpha1.KeeperConfig { + switch keeperType { + case secretv0alpha1.AWSKeeperType: + aws := &secretv0alpha1.AWSKeeperConfig{} + if err := json.Unmarshal([]byte(payload), aws); err != nil { + return nil + } + return aws + case secretv0alpha1.AzureKeeperType: + azure := &secretv0alpha1.AzureKeeperConfig{} + if err := json.Unmarshal([]byte(payload), azure); err != nil { + return nil + } + return azure + case secretv0alpha1.GCPKeeperType: + gcp := &secretv0alpha1.GCPKeeperConfig{} + if err := json.Unmarshal([]byte(payload), gcp); err != nil { + return nil + } + return gcp + case secretv0alpha1.HashiCorpKeeperType: + hashicorp := &secretv0alpha1.HashiCorpKeeperConfig{} + if err := json.Unmarshal([]byte(payload), hashicorp); err != nil { + return nil + } + return hashicorp + default: + return nil + } +} diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go index ed7f68c6fc8..4fa1572a594 100644 --- a/pkg/storage/secret/metadata/keeper_store.go +++ b/pkg/storage/secret/metadata/keeper_store.go @@ -2,50 +2,276 @@ package metadata import ( "context" + "fmt" - claims "github.com/grafana/authlib/types" secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) -func ProvideKeeperMetadataStorage(db db.DB, features featuremgmt.FeatureToggles, accessClient claims.AccessClient) (contracts.KeeperMetadataStorage, error) { +// keeperMetadataStorage is the actual implementation of the keeper metadata storage. +type keeperMetadataStorage struct { + db contracts.Database + dialect sqltemplate.Dialect +} + +var _ contracts.KeeperMetadataStorage = (*keeperMetadataStorage)(nil) + +func ProvideKeeperMetadataStorage(db contracts.Database, features featuremgmt.FeatureToggles) (contracts.KeeperMetadataStorage, error) { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) || !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) { return &keeperMetadataStorage{}, nil } - return &keeperMetadataStorage{db: db, accessClient: accessClient}, nil -} - -// keeperMetadataStorage is the actual implementation of the keeper metadata storage. -type keeperMetadataStorage struct { - db db.DB - accessClient claims.AccessClient + return &keeperMetadataStorage{ + db: db, + dialect: sqltemplate.DialectForDriver(db.DriverName()), + }, nil } func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv0alpha1.Keeper, actorUID string) (*secretv0alpha1.Keeper, error) { - return nil, nil + row, err := toKeeperCreateRow(keeper, actorUID) + if err != nil { + return nil, fmt.Errorf("failed to create row: %w", err) + } + + req := createKeeper{ + SQLTemplate: sqltemplate.New(s.dialect), + Row: row, + } + + query, err := sqltemplate.Execute(sqlKeeperCreate, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlKeeperCreate.Name(), err) + } + + err = s.db.Transaction(ctx, func(ctx context.Context) error { + result, err := s.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("inserting row: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + + if rowsAffected != 1 { + return fmt.Errorf("expected 1 row affected, got %d for %s on %s", rowsAffected, keeper.Name, keeper.Namespace) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("db failure: %w", err) + } + + createdKeeper, err := row.toKubernetes() + if err != nil { + return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) + } + + return createdKeeper, nil } func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string, opts contracts.ReadOpts) (*secretv0alpha1.Keeper, error) { - return nil, nil + keeperDB, err := s.read(ctx, namespace.String(), name, opts) + if err != nil { + return nil, err + } + + keeper, err := keeperDB.toKubernetes() + if err != nil { + return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) + } + + return keeper, nil +} + +func (s *keeperMetadataStorage) read(ctx context.Context, namespace, name string, opts contracts.ReadOpts) (*keeperDB, error) { + req := &readKeeper{ + SQLTemplate: sqltemplate.New(s.dialect), + Namespace: namespace, + Name: name, + IsForUpdate: opts.ForUpdate, + } + + query, err := sqltemplate.Execute(sqlKeeperRead, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlKeeperRead.Name(), err) + } + + res, err := s.db.QueryContext(ctx, query, req.GetArgs()...) + if err != nil { + return nil, fmt.Errorf("getting row for %s in namespace %s: %w", name, namespace, err) + } + defer func() { _ = res.Close() }() + + if !res.Next() { + return nil, contracts.ErrKeeperNotFound + } + + var keeper keeperDB + err = res.Scan( + &keeper.GUID, &keeper.Name, &keeper.Namespace, &keeper.Annotations, &keeper.Labels, &keeper.Created, + &keeper.CreatedBy, &keeper.Updated, &keeper.UpdatedBy, &keeper.Description, &keeper.Type, &keeper.Payload, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan keeper row: %w", err) + } + if err := res.Err(); err != nil { + return nil, fmt.Errorf("read rows error: %w", err) + } + + return &keeper, nil } func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv0alpha1.Keeper, actorUID string) (*secretv0alpha1.Keeper, error) { - return nil, nil + var newRow *keeperDB + + err := s.db.Transaction(ctx, func(ctx context.Context) error { + // Read old value first. + oldKeeperRow, err := s.read(ctx, newKeeper.Namespace, newKeeper.Name, contracts.ReadOpts{ForUpdate: true}) + if err != nil { + return err + } + + // Generate an update row model. + var updateErr error + newRow, updateErr = toKeeperUpdateRow(oldKeeperRow, newKeeper, actorUID) + if updateErr != nil { + return fmt.Errorf("failed to map into update row: %w", updateErr) + } + + // Update query with new model. + req := &updateKeeper{ + SQLTemplate: sqltemplate.New(s.dialect), + Row: newRow, + } + + query, err := sqltemplate.Execute(sqlKeeperUpdate, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlKeeperUpdate.Name(), err) + } + + result, err := s.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("updating row: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + + if rowsAffected != 1 { + return fmt.Errorf("expected 1 row affected, got %d for %s on %s", rowsAffected, newKeeper.Name, newKeeper.Namespace) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("db failure: %w", err) + } + + keeper, err := newRow.toKubernetes() + if err != nil { + return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) + } + + return keeper, nil } func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) error { + req := deleteKeeper{ + SQLTemplate: sqltemplate.New(s.dialect), + Namespace: namespace.String(), + Name: name, + } + + query, err := sqltemplate.Execute(sqlKeeperDelete, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlKeeperDelete.Name(), err) + } + + result, err := s.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("deleting row: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + if rowsAffected == 0 { + return contracts.ErrKeeperNotFound + } else if rowsAffected != 1 { + return fmt.Errorf("expected 1 row affected, got %d for %s on %s", rowsAffected, name, namespace) + } + return nil } func (s *keeperMetadataStorage) List(ctx context.Context, namespace xkube.Namespace) ([]secretv0alpha1.Keeper, error) { - return nil, nil + req := listKeeper{ + SQLTemplate: sqltemplate.New(s.dialect), + Namespace: namespace.String(), + } + + query, err := sqltemplate.Execute(sqlKeeperList, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlKeeperList.Name(), err) + } + + rows, err := s.db.QueryContext(ctx, query, req.GetArgs()...) + if err != nil { + return nil, fmt.Errorf("listing keepers %q: %w", sqlKeeperList.Name(), err) + } + defer func() { _ = rows.Close() }() + + keepers := make([]secretv0alpha1.Keeper, 0) + + for rows.Next() { + var row keeperDB + err = rows.Scan( + &row.GUID, &row.Name, &row.Namespace, &row.Annotations, &row.Labels, &row.Created, + &row.CreatedBy, &row.Updated, &row.UpdatedBy, &row.Description, &row.Type, &row.Payload, + ) + if err != nil { + return nil, fmt.Errorf("error reading keeper row: %w", err) + } + + keeper, err := row.toKubernetes() + if err != nil { + return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) + } + + keepers = append(keepers, *keeper) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read rows error: %w", err) + } + + return keepers, nil } func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace string, name *string, opts contracts.ReadOpts) (secretv0alpha1.KeeperConfig, error) { - return nil, nil + // Check if keeper is the systemwide one. + if name == nil { + return nil, nil + } + + // Load keeper config from metadata store, or TODO: keeper cache. + kp, err := s.read(ctx, namespace, *name, opts) + if err != nil { + return nil, err + } + + keeperConfig := toProvider(secretv0alpha1.KeeperType(kp.Type), kp.Payload) + + // TODO: this would be a good place to check if credentials are secure values and load them. + return keeperConfig, nil } diff --git a/pkg/storage/secret/metadata/keeper_store_test.go b/pkg/storage/secret/metadata/keeper_store_test.go new file mode 100644 index 00000000000..698697cdbe9 --- /dev/null +++ b/pkg/storage/secret/metadata/keeper_store_test.go @@ -0,0 +1,344 @@ +package metadata + +import ( + "context" + "testing" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/storage/secret/database" + "github.com/grafana/grafana/pkg/storage/secret/migrator" + "github.com/stretchr/testify/require" +) + +func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { + t.Parallel() + + defaultKeeperName := "kp-test" + defaultKeeperNS := "default" + + testKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "description", + AWS: &secretv0alpha1.AWSKeeperConfig{}, + }, + } + + testKeeper.Name = defaultKeeperName + testKeeper.Namespace = defaultKeeperNS + + t.Run("get the system keeper config", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + // get system keeper config + keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, nil, contracts.ReadOpts{}) + require.NoError(t, err) + require.Nil(t, keeperConfig) + }) + + t.Run("get test keeper config", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + // + _, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") + require.NoError(t, err) + + keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, &defaultKeeperName, contracts.ReadOpts{}) + require.NoError(t, err) + require.NotNil(t, keeperConfig) + require.NotEmpty(t, keeperConfig.Type()) + }) + + t.Run("get test keeper config when listing", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + // + _, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") + require.NoError(t, err) + + keeperList, err := keeperMetadataStorage.List(ctx, xkube.Namespace(defaultKeeperNS)) + require.NoError(t, err) + require.NotEmpty(t, keeperList) + + require.Len(t, keeperList, 1) + keeper := keeperList[0] + require.Equal(t, "kp-test", keeper.Name) + require.Equal(t, "default", keeper.Namespace) + require.Equal(t, "description", keeper.Spec.Description) + }) + + t.Run("create a keeper and then delete it", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + keeperTest := "kp-test2" + keeperNamespaceTest := "ns" + + testKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "another description", + AWS: &secretv0alpha1.AWSKeeperConfig{}, + }, + } + testKeeper.Name = keeperTest + testKeeper.Namespace = keeperNamespaceTest + + // create the keeper + _, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") + require.NoError(t, err) + + // we are able to get it + keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.NotNil(t, keeperConfig) + require.NotEmpty(t, keeperConfig.Type()) + + // now we delete it + delErr := keeperMetadataStorage.Delete(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest) + require.NoError(t, delErr) + + // and we shouldn't be able to get it again + _, getErr := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) + require.Errorf(t, getErr, "keeper not found") + }) + + t.Run("create, update and validate keeper", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + keeperTest := "kp-test3" + keeperNamespaceTest := "ns" + + // Create initial keeper + initialKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "initial description", + AWS: &secretv0alpha1.AWSKeeperConfig{}, + }, + } + initialKeeper.Name = keeperTest + initialKeeper.Namespace = keeperNamespaceTest + + // Create the keeper + _, err := keeperMetadataStorage.Create(ctx, initialKeeper, "testuser") + require.NoError(t, err) + + // Validate that the description was set + keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.Equal(t, "initial description", keeper.Spec.Description) + + // Update the keeper with new values + updatedKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "updated description", + AWS: &secretv0alpha1.AWSKeeperConfig{}, + }, + } + updatedKeeper.Name = keeperTest + updatedKeeper.Namespace = keeperNamespaceTest + + // Perform the update + _, err = keeperMetadataStorage.Update(ctx, updatedKeeper, "testuser") + require.NoError(t, err) + + // Validate updated values + updatedConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.NotNil(t, updatedConfig) + require.NotEmpty(t, updatedConfig.Type()) + + // Validate that the description was updated + updatedKeeper, err = keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.Equal(t, "updated description", updatedKeeper.Spec.Description) + }) + + t.Run("update keeper with different AWS configuration", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + keeperTest := "kp-test4" + keeperNamespaceTest := "ns" + + // Create initial keeper with first AWS config + initialKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "initial description", + AWS: &secretv0alpha1.AWSKeeperConfig{ + AWSCredentials: secretv0alpha1.AWSCredentials{ + AccessKeyID: secretv0alpha1.CredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID_1", + }, + SecretAccessKey: secretv0alpha1.CredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY_1", + }, + KMSKeyID: "kms-key-id-1", + }, + }, + }, + } + initialKeeper.Name = keeperTest + initialKeeper.Namespace = keeperNamespaceTest + + // Create the keeper + _, err := keeperMetadataStorage.Create(ctx, initialKeeper, "testuser") + require.NoError(t, err) + + // Verify initial AWS config + keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.Equal(t, "AWS_ACCESS_KEY_ID_1", keeper.Spec.AWS.AccessKeyID.ValueFromEnv) + require.Equal(t, "AWS_SECRET_ACCESS_KEY_1", keeper.Spec.AWS.SecretAccessKey.ValueFromEnv) + require.Equal(t, "kms-key-id-1", keeper.Spec.AWS.KMSKeyID) + + // Update with new AWS config + updatedKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "updated description", + AWS: &secretv0alpha1.AWSKeeperConfig{ + AWSCredentials: secretv0alpha1.AWSCredentials{ + AccessKeyID: secretv0alpha1.CredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID_2", + }, + SecretAccessKey: secretv0alpha1.CredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY_2", + }, + KMSKeyID: "kms-key-id-2", + }, + }, + }, + } + updatedKeeper.Name = keeperTest + updatedKeeper.Namespace = keeperNamespaceTest + + // Perform the update + _, err = keeperMetadataStorage.Update(ctx, updatedKeeper, "testuser") + require.NoError(t, err) + + // Verify updated AWS config + updatedKeeper, err = keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.Equal(t, "AWS_ACCESS_KEY_ID_2", updatedKeeper.Spec.AWS.AccessKeyID.ValueFromEnv) + require.Equal(t, "AWS_SECRET_ACCESS_KEY_2", updatedKeeper.Spec.AWS.SecretAccessKey.ValueFromEnv) + require.Equal(t, "kms-key-id-2", updatedKeeper.Spec.AWS.KMSKeyID) + }) + + t.Run("list keepers in empty namespace", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + keeperList, err := keeperMetadataStorage.List(ctx, "") + require.NoError(t, err) + require.Empty(t, keeperList) + }) + + t.Run("read non-existent keeper", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + _, err := keeperMetadataStorage.Read(ctx, "ns", "non-existent", contracts.ReadOpts{}) + require.Error(t, err) + require.Equal(t, contracts.ErrKeeperNotFound, err) + }) + + t.Run("update keeper with different namespace", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + keeperTest := "kp-test5" + keeperNamespaceTest := "ns1" + + // Create initial keeper + initialKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "initial description", + AWS: &secretv0alpha1.AWSKeeperConfig{ + AWSCredentials: secretv0alpha1.AWSCredentials{ + AccessKeyID: secretv0alpha1.CredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID", + }, + SecretAccessKey: secretv0alpha1.CredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY", + }, + }, + }, + }, + } + initialKeeper.Name = keeperTest + initialKeeper.Namespace = keeperNamespaceTest + + // Create the keeper + _, err := keeperMetadataStorage.Create(ctx, initialKeeper, "testuser") + require.NoError(t, err) + + // Try to update with different namespace + updatedKeeper := initialKeeper.DeepCopy() + updatedKeeper.Namespace = "ns2" + updatedKeeper.Spec.Description = "updated description" + + _, err = keeperMetadataStorage.Update(ctx, updatedKeeper, "testuser") + // should this return contracts.ErrKeeperNotFound directly? + // require.Equal(t, contracts.ErrKeeperNotFound, err) + require.Error(t, err, "db failure: keeper not found") + + // Verify original keeper is unchanged + keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) + require.NoError(t, err) + require.Equal(t, "initial description", keeper.Spec.Description) + }) + + t.Run("update non-existent keeper", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + keeperMetadataStorage := initStorage(t) + + nonExistentKeeper := &secretv0alpha1.Keeper{ + Spec: secretv0alpha1.KeeperSpec{ + Description: "some description", + AWS: &secretv0alpha1.AWSKeeperConfig{}, + }, + } + nonExistentKeeper.Name = "non-existent" + nonExistentKeeper.Namespace = "ns" + + _, err := keeperMetadataStorage.Update(ctx, nonExistentKeeper, "testuser") + require.Error(t, err, "db failure: keeper not found") + }) +} + +func initStorage(t *testing.T) contracts.KeeperMetadataStorage { + testDB := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New())) + db := database.ProvideDatabase(testDB) + features := featuremgmt.WithFeatures(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, featuremgmt.FlagSecretsManagementAppPlatform) + + // Initialize the keeper storage + keeperMetadataStorage, err := ProvideKeeperMetadataStorage(db, features) + require.NoError(t, err) + return keeperMetadataStorage +} diff --git a/pkg/storage/secret/metadata/query.go b/pkg/storage/secret/metadata/query.go new file mode 100644 index 00000000000..2a27bdad7ad --- /dev/null +++ b/pkg/storage/secret/metadata/query.go @@ -0,0 +1,106 @@ +package metadata + +import ( + "embed" + "fmt" + "text/template" + + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +var ( + //go:embed data/*.sql + sqlTemplatesFS embed.FS + + sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `data/*.sql`)) + + // The SQL Commands + sqlKeeperCreate = mustTemplate("keeper_create.sql") + sqlKeeperRead = mustTemplate("keeper_read.sql") + sqlKeeperUpdate = mustTemplate("keeper_update.sql") + sqlKeeperList = mustTemplate("keeper_list.sql") + sqlKeeperDelete = mustTemplate("keeper_delete.sql") + + sqlKeeperListByName = mustTemplate("keeper_listByName.sql") +) + +func mustTemplate(filename string) *template.Template { + if t := sqlTemplates.Lookup(filename); t != nil { + return t + } + panic(fmt.Sprintf("template file not found: %s", filename)) +} + +/************************/ +/**-- Keeper Queries --**/ +/************************/ + +// Create +type createKeeper struct { + sqltemplate.SQLTemplate + Row *keeperDB +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r createKeeper) Validate() error { + return nil // TODO +} + +// Read +type readKeeper struct { + sqltemplate.SQLTemplate + Namespace string + Name string + IsForUpdate bool +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r readKeeper) Validate() error { + return nil // TODO +} + +// Update +type updateKeeper struct { + sqltemplate.SQLTemplate + Row *keeperDB +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r updateKeeper) Validate() error { + return nil // TODO +} + +// List +type listKeeper struct { + sqltemplate.SQLTemplate + Namespace string +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r listKeeper) Validate() error { + return nil // TODO +} + +// Delete +type deleteKeeper struct { + sqltemplate.SQLTemplate + Namespace string + Name string +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r deleteKeeper) Validate() error { + return nil // TODO +} + +// This is used at keeper store to validate create & update operations +type listByNameKeeper struct { + sqltemplate.SQLTemplate + Namespace string + KeeperNames []string +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r listByNameKeeper) Validate() error { + return nil // TODO +} diff --git a/pkg/storage/secret/metadata/query_test.go b/pkg/storage/secret/metadata/query_test.go new file mode 100644 index 00000000000..7d0c347db02 --- /dev/null +++ b/pkg/storage/secret/metadata/query_test.go @@ -0,0 +1,108 @@ +package metadata + +import ( + "testing" + "text/template" + + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" +) + +func TestKeeperQueries(t *testing.T) { + mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ + RootDir: "testdata", + Templates: map[*template.Template][]mocks.TemplateTestCase{ + sqlKeeperCreate: { + { + Name: "create", + Data: &createKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Row: &keeperDB{ + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Description: "description", + Type: "sql", + Payload: "", + }, + }, + }, + }, + sqlKeeperDelete: { + { + Name: "delete", + Data: &deleteKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + }, + }, + }, + sqlKeeperList: { + { + Name: "list", + Data: &listKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + }, + }, + }, + sqlKeeperRead: { + { + Name: "read", + Data: &readKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + }, + }, + { + Name: "read-for-update", + Data: &readKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + IsForUpdate: true, + }, + }, + }, + sqlKeeperUpdate: { + { + Name: "update", + Data: &updateKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Row: &keeperDB{ + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Description: "description", + Type: "sql", + Payload: "", + }, + }, + }, + }, + sqlKeeperListByName: { + { + Name: "list", + Data: listByNameKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + KeeperNames: []string{"a", "b"}, + }, + }, + }, + }, + }) +} diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_create-create.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_create-create.sql new file mode 100755 index 00000000000..f27c227a432 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_create-create.sql @@ -0,0 +1,27 @@ +INSERT INTO `secret_keeper` ( + `guid`, + `name`, + `namespace`, + `annotations`, + `labels`, + `created`, + `created_by`, + `updated`, + `updated_by`, + `description`, + `type`, + `payload` +) VALUES ( + 'abc', + 'name', + 'ns', + '{"x":"XXXX"}', + '{"a":"AAA", "b", "BBBB"}', + 1234, + 'user:ryan', + 5678, + 'user:cameron', + 'description', + 'sql', + '' +); diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_delete-delete.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_delete-delete.sql new file mode 100755 index 00000000000..cf8b0b55bda --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_delete-delete.sql @@ -0,0 +1,4 @@ +DELETE FROM `secret_keeper` +WHERE `namespace` = 'ns' AND + `name` = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_list-list.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_list-list.sql new file mode 100755 index 00000000000..40ea5b6c5db --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_list-list.sql @@ -0,0 +1,18 @@ +SELECT + `guid`, + `name`, + `namespace`, + `annotations`, + `labels`, + `created`, + `created_by`, + `updated`, + `updated_by`, + `description`, + `type`, + `payload` +FROM + `secret_keeper` +WHERE `namespace` = 'ns' +ORDER BY `updated` DESC +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_listByName-list.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_listByName-list.sql new file mode 100755 index 00000000000..2deb85b96bb --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_listByName-list.sql @@ -0,0 +1,8 @@ +SELECT + `name` +FROM + `secret_keeper` +WHERE `namespace` = 'ns' AND + `name` IN ('a', 'b') +FOR UPDATE +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_read-read-for-update.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_read-read-for-update.sql new file mode 100755 index 00000000000..d94dc616382 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_read-read-for-update.sql @@ -0,0 +1,19 @@ +SELECT + `guid`, + `name`, + `namespace`, + `annotations`, + `labels`, + `created`, + `created_by`, + `updated`, + `updated_by`, + `description`, + `type`, + `payload` +FROM + `secret_keeper` +WHERE `namespace` = 'ns' AND + `name` = 'name' +FOR UPDATE +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_read-read.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_read-read.sql new file mode 100755 index 00000000000..423c4dab3b1 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_read-read.sql @@ -0,0 +1,18 @@ +SELECT + `guid`, + `name`, + `namespace`, + `annotations`, + `labels`, + `created`, + `created_by`, + `updated`, + `updated_by`, + `description`, + `type`, + `payload` +FROM + `secret_keeper` +WHERE `namespace` = 'ns' AND + `name` = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_update-update.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_update-update.sql new file mode 100755 index 00000000000..0267bf1c6ea --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_update-update.sql @@ -0,0 +1,18 @@ +UPDATE + `secret_keeper` +SET + `guid` = 'abc', + `name` = 'name', + `namespace` = 'ns', + `annotations` = '{"x":"XXXX"}', + `labels` = '{"a":"AAA", "b", "BBBB"}', + `created` = 1234, + `created_by` = 'user:ryan', + `updated` = 5678, + `updated_by` = 'user:cameron', + `description` = 'description', + `type` = 'sql', + `payload` = '' +WHERE `namespace` = 'ns' AND + `name` = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_create-create.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_create-create.sql new file mode 100755 index 00000000000..845aa9c7926 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_create-create.sql @@ -0,0 +1,27 @@ +INSERT INTO "secret_keeper" ( + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +) VALUES ( + 'abc', + 'name', + 'ns', + '{"x":"XXXX"}', + '{"a":"AAA", "b", "BBBB"}', + 1234, + 'user:ryan', + 5678, + 'user:cameron', + 'description', + 'sql', + '' +); diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_delete-delete.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_delete-delete.sql new file mode 100755 index 00000000000..81f6c2c15af --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_delete-delete.sql @@ -0,0 +1,4 @@ +DELETE FROM "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_list-list.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_list-list.sql new file mode 100755 index 00000000000..1eb5e059b72 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_list-list.sql @@ -0,0 +1,18 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' +ORDER BY "updated" DESC +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_listByName-list.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_listByName-list.sql new file mode 100755 index 00000000000..e65dcbc4db2 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_listByName-list.sql @@ -0,0 +1,8 @@ +SELECT + "name" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" IN ('a', 'b') +FOR UPDATE +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_read-read-for-update.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_read-read-for-update.sql new file mode 100755 index 00000000000..4154c4ee2c3 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_read-read-for-update.sql @@ -0,0 +1,19 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" = 'name' +FOR UPDATE +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_read-read.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_read-read.sql new file mode 100755 index 00000000000..6587d5970d8 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_read-read.sql @@ -0,0 +1,18 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_update-update.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_update-update.sql new file mode 100755 index 00000000000..c137c5ca14c --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_update-update.sql @@ -0,0 +1,18 @@ +UPDATE + "secret_keeper" +SET + "guid" = 'abc', + "name" = 'name', + "namespace" = 'ns', + "annotations" = '{"x":"XXXX"}', + "labels" = '{"a":"AAA", "b", "BBBB"}', + "created" = 1234, + "created_by" = 'user:ryan', + "updated" = 5678, + "updated_by" = 'user:cameron', + "description" = 'description', + "type" = 'sql', + "payload" = '' +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_create-create.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_create-create.sql new file mode 100755 index 00000000000..845aa9c7926 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_create-create.sql @@ -0,0 +1,27 @@ +INSERT INTO "secret_keeper" ( + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +) VALUES ( + 'abc', + 'name', + 'ns', + '{"x":"XXXX"}', + '{"a":"AAA", "b", "BBBB"}', + 1234, + 'user:ryan', + 5678, + 'user:cameron', + 'description', + 'sql', + '' +); diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_delete-delete.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_delete-delete.sql new file mode 100755 index 00000000000..81f6c2c15af --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_delete-delete.sql @@ -0,0 +1,4 @@ +DELETE FROM "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_list-list.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_list-list.sql new file mode 100755 index 00000000000..1eb5e059b72 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_list-list.sql @@ -0,0 +1,18 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' +ORDER BY "updated" DESC +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_listByName-list.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_listByName-list.sql new file mode 100755 index 00000000000..8cd91224f6c --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_listByName-list.sql @@ -0,0 +1,7 @@ +SELECT + "name" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" IN ('a', 'b') +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_read-read-for-update.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_read-read-for-update.sql new file mode 100755 index 00000000000..6587d5970d8 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_read-read-for-update.sql @@ -0,0 +1,18 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_read-read.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_read-read.sql new file mode 100755 index 00000000000..6587d5970d8 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_read-read.sql @@ -0,0 +1,18 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_update-update.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_update-update.sql new file mode 100755 index 00000000000..c137c5ca14c --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_update-update.sql @@ -0,0 +1,18 @@ +UPDATE + "secret_keeper" +SET + "guid" = 'abc', + "name" = 'name', + "namespace" = 'ns', + "annotations" = '{"x":"XXXX"}', + "labels" = '{"a":"AAA", "b", "BBBB"}', + "created" = 1234, + "created_by" = 'user:ryan', + "updated" = 5678, + "updated_by" = 'user:cameron', + "description" = 'description', + "type" = 'sql', + "payload" = '' +WHERE "namespace" = 'ns' AND + "name" = 'name' +; diff --git a/pkg/storage/unified/resource/broadcaster.go b/pkg/storage/unified/resource/broadcaster.go index 547bfe576e8..febf13af583 100644 --- a/pkg/storage/unified/resource/broadcaster.go +++ b/pkg/storage/unified/resource/broadcaster.go @@ -250,7 +250,7 @@ type channelCache[T any] interface { ReadInto(dst chan T) error } -type cache[T any] struct { +type localCache[T any] struct { cache []T size int cacheZero int @@ -261,7 +261,7 @@ type cache[T any] struct { } func newChannelCache[T any](ctx context.Context, size int) channelCache[T] { - c := &cache[T]{} + c := &localCache[T]{} c.ctx = ctx if size <= 0 { @@ -278,15 +278,15 @@ func newChannelCache[T any](ctx context.Context, size int) channelCache[T] { return c } -func (c *cache[T]) Len() int { +func (c *localCache[T]) Len() int { return c.cacheLen } -func (c *cache[T]) Add(item T) { +func (c *localCache[T]) Add(item T) { c.add <- item } -func (c *cache[T]) run() { +func (c *localCache[T]) run() { for { select { case <-c.ctx.Done(): @@ -314,7 +314,7 @@ func (c *cache[T]) run() { } } -func (c *cache[T]) Get(i int) T { +func (c *localCache[T]) Get(i int) T { r := make(chan T, c.size) c.read <- r idx := 0 @@ -328,7 +328,7 @@ func (c *cache[T]) Get(i int) T { return zero } -func (c *cache[T]) Range(f func(T) error) error { +func (c *localCache[T]) Range(f func(T) error) error { r := make(chan T, c.size) c.read <- r for item := range r { @@ -340,7 +340,7 @@ func (c *cache[T]) Range(f func(T) error) error { return nil } -func (c *cache[T]) Slice() []T { +func (c *localCache[T]) Slice() []T { s := make([]T, 0, c.size) r := make(chan T, c.size) c.read <- r @@ -350,7 +350,7 @@ func (c *cache[T]) Slice() []T { return s } -func (c *cache[T]) ReadInto(dst chan T) error { +func (c *localCache[T]) ReadInto(dst chan T) error { r := make(chan T, c.size) c.read <- r for item := range r { diff --git a/pkg/storage/unified/resource/cdk_backend.go b/pkg/storage/unified/resource/cdk_backend.go index 725feb11585..c47ec01d378 100644 --- a/pkg/storage/unified/resource/cdk_backend.go +++ b/pkg/storage/unified/resource/cdk_backend.go @@ -235,10 +235,6 @@ func isDeletedValue(raw []byte) bool { } func (s *cdkBackend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(ListIterator) error) (int64, error) { - if req.Source != resourcepb.ListRequest_STORE { - return 0, fmt.Errorf("listing from history not supported in CDK backend") - } - resources, err := buildTree(ctx, s, req.Options.Key) if err != nil { return 0, err @@ -247,6 +243,10 @@ func (s *cdkBackend) ListIterator(ctx context.Context, req *resourcepb.ListReque return resources.listRV, err } +func (s *cdkBackend) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(ListIterator) error) (int64, error) { + return 0, fmt.Errorf("listing from history not supported in CDK backend") +} + func (s *cdkBackend) WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -336,11 +336,6 @@ func (c *cdkListIterator) ContinueToken() string { return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey) } -// ContinueTokenWithCurrentRV implements ListIterator. -func (c *cdkListIterator) ContinueTokenWithCurrentRV() string { - return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey) -} - // Name implements ListIterator. func (c *cdkListIterator) Name() string { return c.currentKey // TODO (parse name from key) diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index c340c7a6e39..2aa15a547ca 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -46,9 +46,6 @@ type ListIterator interface { // ContinueToken returns the token that can be used to start iterating *after* this item ContinueToken() string - // ContinueTokenWithCurrentRV returns the token that can be used to start iterating *before* this item - ContinueTokenWithCurrentRV() string - // ResourceVersion of the current item ResourceVersion() int64 @@ -102,6 +99,9 @@ type StorageBackend interface { // but are the the final answer. ListIterator(context.Context, *resourcepb.ListRequest, func(ListIterator) error) (int64, error) + // ListHistory is like ListIterator, but it returns the history of a resource + ListHistory(context.Context, *resourcepb.ListRequest, func(ListIterator) error) (int64, error) + // Get all events from the store // For HA setups, this will be more events than the local WriteEvent above! WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) @@ -808,7 +808,7 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour }}, nil } - rv, err := s.backend.ListIterator(ctx, req, func(iter ListIterator) error { + iterFunc := func(iter ListIterator) error { for iter.Next() { if err := iter.Error(); err != nil { return err @@ -827,13 +827,6 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour rsp.Items = append(rsp.Items, item) if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes { t := iter.ContinueToken() - if req.Source == resourcepb.ListRequest_HISTORY || req.Source == resourcepb.ListRequest_TRASH { - // For history lists, we need to use the current RV in the continue token - // to ensure consistent pagination. The order depends on VersionMatch: - // - NotOlderThan: ascending order (oldest to newest) - // - Unset: descending order (newest to oldest) - t = iter.ContinueTokenWithCurrentRV() - } if iter.Next() { rsp.NextPageToken = t } @@ -841,7 +834,18 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour } } return iter.Error() - }) + } + + var rv int64 + switch req.Source { + case resourcepb.ListRequest_STORE: + rv, err = s.backend.ListIterator(ctx, req, iterFunc) + case resourcepb.ListRequest_HISTORY, resourcepb.ListRequest_TRASH: + rv, err = s.backend.ListHistory(ctx, req, iterFunc) + default: + return nil, apierrors.NewBadRequest(fmt.Sprintf("invalid list source: %v", req.Source)) + } + if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -918,15 +922,48 @@ func (s *server) Watch(req *resourcepb.WatchRequest, srv resourcepb.ResourceStor } defer s.broadcaster.Unsubscribe(stream) + // Determine a safe starting resource-version for the watch. + // When the client requests SendInitialEvents we will use the resource-version + // of the last object returned from the initial list (handled below). + // When the client supplies an explicit `since` we honour that. + // In the remaining case (SendInitialEvents == false && since == 0) we need + // a high-water-mark representing the current state of storage so that we + // donʼt replay events that happened before the watch was established. Using + // `mostRecentRV` – which is updated asynchronously by the broadcaster – is + // subject to races because the broadcaster may not yet have observed the + // latest committed writes. Instead we ask the backend directly for the + // current resource-version. + var mostRecentRV int64 if !req.SendInitialEvents && req.Since == 0 { - // This is a temporary hack only relevant for tests to ensure that the first events are sent. - // This is required because the SQL backend polls the database every 100ms. - // TODO: Implement a getLatestResourceVersion method in the backend. - time.Sleep(10 * time.Millisecond) + // We only need the current RV. A cheap way to obtain it is to issue a + // List with a very small limit and read the listRV returned by the + // iterator. The callback is a no-op so we avoid materialising any + // items. + listReq := &resourcepb.ListRequest{ + Options: req.Options, + // This has right now no effect, as the list request only uses the limit if it lists from history or trash. + // It might be worth adding it in a subsequent PR. We only list once during setup of the watch, so it's + // fine for now. + Limit: 1, + } + + rv, err := s.backend.ListIterator(ctx, listReq, func(ListIterator) error { return nil }) + if err != nil { + // Fallback to the broadcasterʼs view if the backend lookup fails. + // This preserves previous behaviour while still eliminating the + // common race in the majority of cases. + s.log.Warn("watch: failed to fetch current RV from backend, falling back to broadcaster", "err", err) + mostRecentRV = s.mostRecentRV.Load() + } else { + mostRecentRV = rv + } + } else { + // For all other code-paths we either already have an explicit RV or we + // will derive it from the initial list below. + mostRecentRV = s.mostRecentRV.Load() } - mostRecentRV := s.mostRecentRV.Load() // get the latest resource version - var initialEventsRV int64 // resource version coming from the initial events + var initialEventsRV int64 // resource version coming from the initial events if req.SendInitialEvents { // Backfill the stream by adding every existing entities. initialEventsRV, err = s.backend.ListIterator(ctx, &resourcepb.ListRequest{Options: req.Options}, func(iter ListIterator) error { diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 51d77335c4a..0c5388c71f7 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -569,10 +569,6 @@ func (b *backend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, return 0, fmt.Errorf("missing group or resource") } - if req.Source != resourcepb.ListRequest_STORE { - return b.getHistory(ctx, req, cb) - } - // TODO: think about how to handler VersionMatch. We should be able to use latest for the first page (only). // TODO: add support for RemainingItemCount @@ -583,6 +579,13 @@ func (b *backend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, return b.listLatest(ctx, req, cb) } +func (b *backend) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) { + ctx, span := b.tracer.Start(ctx, tracePrefix+"ListHistory") + defer span.End() + + return b.getHistory(ctx, req, cb) +} + // listLatest fetches the resources from the resource table. func (b *backend) listLatest(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) { ctx, span := b.tracer.Start(ctx, tracePrefix+"listLatest") @@ -733,7 +736,9 @@ func (b *backend) getHistory(ctx context.Context, req *resourcepb.ListRequest, c // for Unset (default) and Exact matching. listReq.SortAscending = req.GetVersionMatchV2() == resourcepb.ResourceVersionMatchV2_NotOlderThan - iter := &listIter{} + iter := &listIter{ + useCurrentRV: true, // use the current RV for the continue token instead of the listRV + } if req.NextPageToken != "" { continueToken, err := resource.GetContinueToken(req.NextPageToken) if err != nil { diff --git a/pkg/storage/unified/sql/db/migrations/migrator.go b/pkg/storage/unified/sql/db/migrations/migrator.go index e63fc7c138a..e8b5faae727 100644 --- a/pkg/storage/unified/sql/db/migrations/migrator.go +++ b/pkg/storage/unified/sql/db/migrations/migrator.go @@ -9,14 +9,16 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func MigrateResourceStore(_ context.Context, engine *xorm.Engine, cfg *setting.Cfg) error { - // TODO: use the context.Context - +func MigrateResourceStore(ctx context.Context, engine *xorm.Engine, cfg *setting.Cfg) error { mg := migrator.NewScopedMigrator(engine, cfg, "resource") mg.AddCreateMigration() initResourceTables(mg) - // since it's a new feature enable migration locking by default - return mg.Start(true, 0) + sec := cfg.Raw.Section("database") + return mg.RunMigrations( + ctx, + sec.Key("migration_locking").MustBool(true), + sec.Key("locking_attempt_timeout_sec").MustInt(), + ) } diff --git a/pkg/storage/unified/sql/list_iterator.go b/pkg/storage/unified/sql/list_iterator.go index 96642bf58f3..8689dc20f45 100644 --- a/pkg/storage/unified/sql/list_iterator.go +++ b/pkg/storage/unified/sql/list_iterator.go @@ -8,10 +8,11 @@ import ( var _ resource.ListIterator = (*listIter)(nil) type listIter struct { - rows db.Rows - offset int64 - listRV int64 - sortAsc bool + rows db.Rows + offset int64 + listRV int64 + sortAsc bool + useCurrentRV bool // any error err error @@ -29,13 +30,12 @@ type listIter struct { // ContinueToken implements resource.ListIterator. func (l *listIter) ContinueToken() string { + if l.useCurrentRV { + return resource.ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset, SortAscending: l.sortAsc}.String() + } return resource.ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset, SortAscending: l.sortAsc}.String() } -func (l *listIter) ContinueTokenWithCurrentRV() string { - return resource.ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset, SortAscending: l.sortAsc}.String() -} - func (l *listIter) Error() error { return l.err } diff --git a/pkg/storage/unified/sql/list_iterator_test.go b/pkg/storage/unified/sql/list_iterator_test.go index ae827237787..e67f5c388db 100644 --- a/pkg/storage/unified/sql/list_iterator_test.go +++ b/pkg/storage/unified/sql/list_iterator_test.go @@ -219,7 +219,7 @@ func TestListIter(t *testing.T) { require.Equal(t, expected, actual) }) - t.Run("ContinueTokenWithCurrentRV uses current row's RV", func(t *testing.T) { + t.Run("ContinueToken uses the current row's RV", func(t *testing.T) { listReq := sqlResourceListRequest{ SQLTemplate: sqltemplate.New(dialect), Request: new(resourcepb.ListRequest), @@ -229,14 +229,15 @@ func TestListIter(t *testing.T) { require.NoError(t, err) iter := &listIter{ - rows: rows, - listRV: 300, - sortAsc: true, + rows: rows, + listRV: 300, + sortAsc: true, + useCurrentRV: true, // use the current RV for the continue token instead of the listRV } require.True(t, iter.Next()) - token := iter.ContinueTokenWithCurrentRV() + token := iter.ContinueToken() var actual resource.ContinueToken b, err := base64.StdEncoding.DecodeString(token) diff --git a/pkg/storage/unified/sql/sqltemplate/dialect.go b/pkg/storage/unified/sql/sqltemplate/dialect.go index 0789ab7f214..918545fdb25 100644 --- a/pkg/storage/unified/sql/sqltemplate/dialect.go +++ b/pkg/storage/unified/sql/sqltemplate/dialect.go @@ -24,8 +24,6 @@ func DialectForDriver(driverName string) Dialect { return PostgreSQL case "sqlite", "sqlite3": return SQLite - case "spanner": - return Spanner default: return nil } diff --git a/pkg/storage/unified/sql/sqltemplate/dialect_spanner.go b/pkg/storage/unified/sql/sqltemplate/dialect_spanner.go deleted file mode 100644 index 4fba8375d3a..00000000000 --- a/pkg/storage/unified/sql/sqltemplate/dialect_spanner.go +++ /dev/null @@ -1,32 +0,0 @@ -package sqltemplate - -// Spanner is an implementation of Dialect for the Google Spanner database. -var Spanner = spanner{} - -var _ Dialect = Spanner - -type spanner struct{} - -func (s spanner) DialectName() string { - return "spanner" -} - -func (s spanner) Ident(a string) (string, error) { - return backtickIdent{}.Ident(a) -} - -func (s spanner) ArgPlaceholder(argNum int) string { - return argFmtSQL92.ArgPlaceholder(argNum) -} - -func (s spanner) SelectFor(a ...string) (string, error) { - return rowLockingClauseSpanner.SelectFor(a...) -} - -func (spanner) CurrentEpoch() string { - return "UNIX_MICROS(CURRENT_TIMESTAMP())" -} - -var rowLockingClauseSpanner = rowLockingClauseMap{ - SelectForUpdate: SelectForUpdate, -} diff --git a/pkg/storage/unified/sql/test/benchmark_test.go b/pkg/storage/unified/sql/test/benchmark_test.go index b29975fc61d..d937abb70ab 100644 --- a/pkg/storage/unified/sql/test/benchmark_test.go +++ b/pkg/storage/unified/sql/test/benchmark_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -15,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" test "github.com/grafana/grafana/pkg/storage/unified/testing" - "github.com/stretchr/testify/require" ) func newTestBackend(b testing.TB) resource.StorageBackend { @@ -40,9 +41,6 @@ func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } - if db.IsTestDBSpanner() { - t.Skip("Skipping benchmark on Spanner") - } opts := test.DefaultBenchmarkOptions() if db.IsTestDbSQLite() { opts.Concurrency = 1 // to avoid SQLite database is locked error @@ -54,9 +52,6 @@ func TestIntegrationBenchmarkResourceServer(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } - if db.IsTestDBSpanner() { - t.Skip("Skipping benchmark on Spanner") - } ctx := context.Background() opts := &test.BenchmarkOptions{ diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 1110498d485..edddcf8ef1d 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -35,9 +35,6 @@ func TestMain(m *testing.M) { } func TestIntegrationStorageServer(t *testing.T) { - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } unitest.RunStorageServerTest(t, func(ctx context.Context) resource.StorageBackend { dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) @@ -58,10 +55,6 @@ func TestIntegrationStorageServer(t *testing.T) { // TestStorageBackend is a test for the StorageBackend interface. func TestIntegrationSQLStorageBackend(t *testing.T) { - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } - t.Run("IsHA (polling notifier)", func(t *testing.T) { unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { dbstore := db.InitTestDB(t) @@ -105,9 +98,6 @@ func TestIntegrationSearchAndStorage(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } - if db.IsTestDBSpanner() { - t.Skip("Skipping benchmark on Spanner") - } ctx := context.Background() @@ -145,9 +135,6 @@ func TestClientServer(t *testing.T) { if db.IsTestDbSQLite() { t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this") } - if db.IsTestDBSpanner() { - t.Skip("skipping integration test") - } ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) dbstore := db.InitTestDB(t) diff --git a/pkg/storage/unified/testing/benchmark.go b/pkg/storage/unified/testing/benchmark.go index dfcacde86c1..14335c5c701 100644 --- a/pkg/storage/unified/testing/benchmark.go +++ b/pkg/storage/unified/testing/benchmark.go @@ -368,7 +368,11 @@ func BenchmarkIndexServer(tb testing.TB, ctx context.Context, backend resource.S // Run the storage backend benchmark write throughput to create events startTime := time.Now() var result *BenchmarkResult + // Channel to signal when the benchmark goroutine completes + benchmarkDone := make(chan struct{}) + go func() { + defer close(benchmarkDone) result, err = runStorageBackendBenchmark(ctx, backend, opts) require.NoError(tb, err) }() @@ -380,6 +384,8 @@ func BenchmarkIndexServer(tb testing.TB, ctx context.Context, backend resource.S latencies = append(latencies, evt.Latency.Seconds()) } totalDuration := time.Since(startTime) + + <-benchmarkDone // Calculate index latency percentiles sort.Float64s(latencies) var p50, p90, p99 float64 diff --git a/pkg/tests/api/alerting/api_testing_test.go b/pkg/tests/api/alerting/api_testing_test.go index ccba1810f65..dd04d3b8301 100644 --- a/pkg/tests/api/alerting/api_testing_test.go +++ b/pkg/tests/api/alerting/api_testing_test.go @@ -33,7 +33,7 @@ const ( ) func TestMain(m *testing.M) { - testsuite.RunButSkipOnSpanner(m) + testsuite.Run(m) } func TestGrafanaRuleConfig(t *testing.T) { diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 88ecf6e96bc..a854671376f 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -54,7 +54,7 @@ import ( var testData embed.FS func TestMain(m *testing.M) { - testsuite.RunButSkipOnSpanner(m) + testsuite.Run(m) } func getTestHelper(t *testing.T) *apis.K8sTestHelper { diff --git a/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go b/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go index fb5ac172eb6..56a9ecf2706 100644 --- a/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go +++ b/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go @@ -39,7 +39,7 @@ import ( ) func TestMain(m *testing.M) { - testsuite.RunButSkipOnSpanner(m) + testsuite.Run(m) } func getTestHelper(t *testing.T) *apis.K8sTestHelper { diff --git a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go index e8b932e2cf0..3d1e83cbd4a 100644 --- a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go +++ b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go @@ -32,7 +32,7 @@ import ( ) func TestMain(m *testing.M) { - testsuite.RunButSkipOnSpanner(m) + testsuite.Run(m) } func getTestHelper(t *testing.T) *apis.K8sTestHelper { diff --git a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go index 4fc752f8db1..8ea61ab6716 100644 --- a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go +++ b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go @@ -44,7 +44,7 @@ import ( var testData embed.FS func TestMain(m *testing.M) { - testsuite.RunButSkipOnSpanner(m) + testsuite.Run(m) } func getTestHelper(t *testing.T) *apis.K8sTestHelper { diff --git a/pkg/tests/apis/secret/keeper_test.go b/pkg/tests/apis/secret/keeper_test.go new file mode 100644 index 00000000000..971295746ce --- /dev/null +++ b/pkg/tests/apis/secret/keeper_test.go @@ -0,0 +1,569 @@ +package secret + +import ( + "context" + "errors" + "math/rand/v2" + "net/http" + "strconv" + "strings" + "testing" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var gvrKeepers = schema.GroupVersionResource{ + Group: secretv0alpha1.GROUP, + Version: secretv0alpha1.VERSION, + Resource: secretv0alpha1.KeeperResourceInfo.GetName(), +} + +func TestIntegrationKeeper(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for experimental APIs + EnableFeatureToggles: []string{ + // Required to start the example service + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagSecretsManagementAppPlatform, + }, + }) + + permissions := map[string]ResourcePermission{ResourceKeepers: {Actions: ActionsAllKeepers}} + + genericUserEditor := mustCreateUsers(t, helper, permissions).Editor + + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: genericUserEditor, + GVR: gvrKeepers, + }) + + t.Run("reading a keeper that does not exist returns a 404", func(t *testing.T) { + raw, err := client.Resource.Get(ctx, "some-keeper-that-does-not-exist", metav1.GetOptions{}) + require.Error(t, err) + require.Nil(t, raw) + + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, "keeper.secret.grafana.app \"some-keeper-that-does-not-exist\" not found", err.Error()) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + }) + + t.Run("deleting a keeper that does not exist returns an error", func(t *testing.T) { + err := client.Resource.Delete(ctx, "some-keeper-that-does-not-exist", metav1.DeleteOptions{}) + require.Error(t, err) + + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, "keeper.secret.grafana.app \"some-keeper-that-does-not-exist\" not found", err.Error()) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + }) + + t.Run("creating a keeper returns it", func(t *testing.T) { + raw := mustGenerateKeeper(t, helper, genericUserEditor, nil, "testdata/keeper-aws-generate.yaml") + + keeper := new(secretv0alpha1.Keeper) + err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, keeper) + require.NoError(t, err) + require.NotNil(t, keeper) + + require.NotEmpty(t, keeper.Spec.Description) + require.NotEmpty(t, keeper.Spec.AWS) + require.Empty(t, keeper.Spec.Azure) + + t.Run("and creating another keeper with the same name in the same namespace returns an error", func(t *testing.T) { + testKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") + testKeeper.SetName(raw.GetName()) + + raw, err := client.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, raw) + }) + + t.Run("and reading th keeper returns it same as if when it was created", func(t *testing.T) { + raw, err := client.Resource.Get(ctx, keeper.Name, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + + anotherKeeper := new(secretv0alpha1.Keeper) + err = runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, anotherKeeper) + require.NoError(t, err) + require.NotNil(t, anotherKeeper) + + require.EqualValues(t, keeper, anotherKeeper) + }) + + t.Run("and listing keepers returns the created keeper", func(t *testing.T) { + rawList, err := client.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, rawList) + require.GreaterOrEqual(t, len(rawList.Items), 1) + require.Equal(t, keeper.Name, rawList.Items[0].GetName()) + }) + + t.Run("and updating the keeper replaces the spec fields and returns them", func(t *testing.T) { + newRaw := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") + newRaw.SetName(raw.GetName()) + newRaw.Object["spec"].(map[string]any)["description"] = "New description" + newRaw.Object["metadata"].(map[string]any)["annotations"] = map[string]any{"newAnnotation": "newValue"} + + updatedRaw, err := client.Resource.Update(ctx, newRaw, metav1.UpdateOptions{}) + require.NoError(t, err) + require.NotNil(t, updatedRaw) + + updatedKeeper := new(secretv0alpha1.Keeper) + err = runtime.DefaultUnstructuredConverter.FromUnstructured(updatedRaw.Object, updatedKeeper) + require.NoError(t, err) + require.NotNil(t, updatedKeeper) + + require.NotEqualValues(t, updatedKeeper.Spec, keeper.Spec) + }) + + t.Run("and updating the keeper to reference securevalues that does not exist returns an error", func(t *testing.T) { + t.Skip("skipping because storing credentials as securevalues is not implemented yet") + newRaw := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + newRaw.SetName(raw.GetName()) + newRaw.Object["spec"].(map[string]any)["aws"] = map[string]any{ + "accessKeyId": map[string]any{ + "secureValueName": "securevalue-does-not-exist-1", + }, + "secretAccessKey": map[string]any{ + "secureValueName": "securevalue-does-not-exist-2", + }, + } + + updatedRaw, err := client.Resource.Update(ctx, newRaw, metav1.UpdateOptions{}) + require.Error(t, err) + require.Nil(t, updatedRaw) + require.Contains(t, err.Error(), "securevalue-does-not-exist-1") + require.Contains(t, err.Error(), "securevalue-does-not-exist-2") + }) + }) + + t.Run("creating an invalid keeper fails validation and returns an error", func(t *testing.T) { + testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testData.Object["spec"].(map[string]any)["description"] = "" + + raw, err := client.Resource.Create(ctx, testData, metav1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, raw) + + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + }) + + t.Run("creating a keeper with a provider then changing the provider does not return an error", func(t *testing.T) { + rawAWS := mustGenerateKeeper(t, helper, genericUserEditor, nil, "testdata/keeper-aws-generate.yaml") + + testDataKeeperGCP := rawAWS.DeepCopy() + testDataKeeperGCP.Object["spec"].(map[string]any)["aws"] = nil + testDataKeeperGCP.Object["spec"].(map[string]any)["gcp"] = map[string]any{ + "projectId": "project-id", + "credentialsFile": "/path/to/file.json", + } + + rawGCP, err := client.Resource.Update(ctx, testDataKeeperGCP, metav1.UpdateOptions{}) + require.NoError(t, err) + require.NotNil(t, rawGCP) + + require.NotEqualValues(t, rawAWS.Object["spec"], rawGCP.Object["spec"]) + }) + + t.Run("creating a keeper that references securevalues that does not exist returns an error", func(t *testing.T) { + t.Skip("skipping because storing credentials as securevalues is not implemented yet") + testDataKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testDataKeeper.Object["spec"].(map[string]any)["aws"] = map[string]any{ + "accessKeyId": map[string]any{ + "secureValueName": "securevalue-does-not-exist-1", + }, + "secretAccessKey": map[string]any{ + "secureValueName": "securevalue-does-not-exist-2", + }, + } + + raw, err := client.Resource.Create(ctx, testDataKeeper, metav1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, raw) + require.Contains(t, err.Error(), "securevalue-does-not-exist-1") + require.Contains(t, err.Error(), "securevalue-does-not-exist-2") + }) + + t.Run("deleting a keeper that exists does not return an error", func(t *testing.T) { + generatePrefix := "generated-" + + testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testData.SetGenerateName(generatePrefix) + + raw, err := client.Resource.Create(ctx, testData, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + + name := raw.GetName() + require.True(t, strings.HasPrefix(name, generatePrefix)) + + err = client.Resource.Delete(ctx, name, metav1.DeleteOptions{}) + require.NoError(t, err) + + t.Run("and then trying to read it returns a 404 error", func(t *testing.T) { + raw, err := client.Resource.Get(ctx, name, metav1.GetOptions{}) + require.Error(t, err) + require.Nil(t, raw) + + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + }) + + t.Run("and listing keepers returns an empty list", func(t *testing.T) { + rawList, err := client.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, rawList) + require.Empty(t, rawList.Items) + }) + }) + + t.Run("creating keepers in multiple namespaces", func(t *testing.T) { + permissions := map[string]ResourcePermission{ + ResourceKeepers: {Actions: ActionsAllKeepers}, + } + + editorOrgA := mustCreateUsers(t, helper, permissions).Editor + editorOrgB := mustCreateUsers(t, helper, permissions).Editor + + keeperOrgA := mustGenerateKeeper(t, helper, editorOrgA, nil, "testdata/keeper-aws-generate.yaml") + keeperOrgB := mustGenerateKeeper(t, helper, editorOrgB, nil, "testdata/keeper-aws-generate.yaml") + + clientOrgA := helper.GetResourceClient(apis.ResourceClientArgs{User: editorOrgA, GVR: gvrKeepers}) + clientOrgB := helper.GetResourceClient(apis.ResourceClientArgs{User: editorOrgB, GVR: gvrKeepers}) + + // Create + t.Run("creating a keeper with the same name as one from another namespace does not return an error", func(t *testing.T) { + // OrgA creating a keeper with the same name from OrgB. + testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testData.SetName(keeperOrgB.GetName()) + + raw, err := clientOrgA.Resource.Create(ctx, testData, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + + // OrgA creating a keeper with the same name from OrgB. + testData = helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testData.SetName(keeperOrgA.GetName()) + + raw, err = clientOrgB.Resource.Create(ctx, testData, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + + require.NoError(t, clientOrgA.Resource.Delete(ctx, keeperOrgB.GetName(), metav1.DeleteOptions{})) + require.NoError(t, clientOrgB.Resource.Delete(ctx, keeperOrgA.GetName(), metav1.DeleteOptions{})) + }) + + // Read + t.Run("fetching a keeper from another namespace returns not found", func(t *testing.T) { + var statusErr *apierrors.StatusError + + // OrgA trying to fetch keeper from OrgB. + raw, err := clientOrgA.Resource.Get(ctx, keeperOrgB.GetName(), metav1.GetOptions{}) + require.Error(t, err) + require.Nil(t, raw) + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + + // OrgB trying to fetch keeper from OrgA. + raw, err = clientOrgB.Resource.Get(ctx, keeperOrgA.GetName(), metav1.GetOptions{}) + require.Error(t, err) + require.Nil(t, raw) + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + }) + + // Update + t.Run("updating a keeper from another namespace returns not found", func(t *testing.T) { + var statusErr *apierrors.StatusError + + // OrgA trying to update securevalue from OrgB. + testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testData.SetName(keeperOrgB.GetName()) + testData.Object["spec"].(map[string]any)["description"] = "New description" + + raw, err := clientOrgA.Resource.Update(ctx, testData, metav1.UpdateOptions{}) + require.Error(t, err) + require.Nil(t, raw) + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + + // OrgB trying to update keeper from OrgA. + testData = helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") + testData.SetName(keeperOrgA.GetName()) + testData.Object["spec"].(map[string]any)["description"] = "New description" + + raw, err = clientOrgB.Resource.Update(ctx, testData, metav1.UpdateOptions{}) + require.Error(t, err) + require.Nil(t, raw) + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + }) + + // Delete + t.Run("deleting a keeper from another namespace returns an error and does not delete it", func(t *testing.T) { + var statusErr *apierrors.StatusError + + // OrgA trying to delete keeper from OrgB. + err := clientOrgA.Resource.Delete(ctx, keeperOrgB.GetName(), metav1.DeleteOptions{}) + require.Error(t, err) + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + + // Check that it still exists from the perspective of OrgB. + raw, err := clientOrgB.Resource.Get(ctx, keeperOrgB.GetName(), metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + + // OrgB trying to delete keeper from OrgA. + err = clientOrgB.Resource.Delete(ctx, keeperOrgA.GetName(), metav1.DeleteOptions{}) + require.Error(t, err) + require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) + + // Check that it still exists from the perspective of OrgA. + raw, err = clientOrgA.Resource.Get(ctx, keeperOrgA.GetName(), metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + }) + + // List + t.Run("listing keeper from a namespace does not return the ones from another namespace", func(t *testing.T) { + // OrgA listing keeper. + listOrgA, err := clientOrgA.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, listOrgA) + require.Len(t, listOrgA.Items, 1) + require.Equal(t, *keeperOrgA, listOrgA.Items[0]) + + // OrgB listing keeper. + listOrgB, err := clientOrgB.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, listOrgB) + require.Len(t, listOrgB.Items, 1) + require.Equal(t, *keeperOrgB, listOrgB.Items[0]) + }) + }) + + t.Run("keeper actions without having required permissions", func(t *testing.T) { + // Create users on a random org without specifying secrets-related permissions. + editorP := mustCreateUsers(t, helper, nil).Editor + + clientP := helper.GetResourceClient(apis.ResourceClientArgs{ + User: editorP, + GVR: gvrKeepers, + }) + + // GET + rawGet, err := clientP.Resource.Get(ctx, "some-keeper", metav1.GetOptions{}) + require.Error(t, err) + require.Nil(t, rawGet) + + var statusGetErr *apierrors.StatusError + require.True(t, errors.As(err, &statusGetErr)) + require.EqualValues(t, http.StatusForbidden, statusGetErr.Status().Code) + + // LIST + rawList, err := clientP.Resource.List(ctx, metav1.ListOptions{}) + require.Error(t, err) + require.Nil(t, rawList) + + var statusListErr *apierrors.StatusError + require.True(t, errors.As(err, &statusListErr)) + require.EqualValues(t, http.StatusForbidden, statusListErr.Status().Code) + + // CREATE + testKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") // to pass validation before authz. + rawCreate, err := clientP.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, rawCreate) + + var statusCreateErr *apierrors.StatusError + require.True(t, errors.As(err, &statusCreateErr)) + require.EqualValues(t, http.StatusForbidden, statusCreateErr.Status().Code) + + // UPDATE + testKeeper.SetName("test") // to pass validation before authz. + rawUpdate, err := clientP.Resource.Update(ctx, testKeeper, metav1.UpdateOptions{}) + require.Error(t, err) + require.Nil(t, rawUpdate) + + var statusUpdateErr *apierrors.StatusError + require.True(t, errors.As(err, &statusUpdateErr)) + require.EqualValues(t, http.StatusForbidden, statusUpdateErr.Status().Code) + + // DELETE + err = clientP.Resource.Delete(ctx, "some-keeper", metav1.DeleteOptions{}) + require.Error(t, err) + + var statusDeleteErr *apierrors.StatusError + require.True(t, errors.As(err, &statusDeleteErr)) + require.EqualValues(t, http.StatusForbidden, statusDeleteErr.Status().Code) + }) + + t.Run("keeper actions with permissions but with limited scope", func(t *testing.T) { + suffix := strconv.FormatInt(rand.Int64(), 10) + + // Fix the Keeper names. + keeperName := "kp-" + suffix + testKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") + testKeeper.SetName(keeperName) + + keeperNameAnother := "kp-another-" + suffix + testKeeperAnother := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") + testKeeperAnother.SetName(keeperNameAnother) + + // Fix the org ID because we will create another user with scope "all" permissions on the same org, to compare. + orgID := rand.Int64() + 2 + + // Permissions which allow any action, but scoped actions (get, update, delete) only on `keeperName` and NO OTHER Keeper. + scopedLimitedPermissions := map[string]ResourcePermission{ + ResourceKeepers: { + Actions: ActionsAllKeepers, + Name: keeperName, + }, + } + + // Create users (+ client) with permission to manage ONLY the Keeper `keeperName`. + editorLimited := mustCreateUsersWithOrg(t, helper, orgID, scopedLimitedPermissions).Editor + + clientScopedLimited := helper.GetResourceClient(apis.ResourceClientArgs{ + User: editorLimited, + GVR: gvrKeepers, + }) + + // Create users (+ client) with permission to manage ANY Keepers. + scopedAllPermissions := map[string]ResourcePermission{ + ResourceKeepers: { + Actions: ActionsAllKeepers, + Name: "*", // this or not sending a `Name` have the same effect. + }, + } + + editorAll := mustCreateUsersWithOrg(t, helper, orgID, scopedAllPermissions).Editor + + clientScopedAll := helper.GetResourceClient(apis.ResourceClientArgs{ + User: editorAll, + GVR: gvrKeepers, + }) + + // For create, we don't have actual granular permissions, so we can use any client that has unscoped create permissions. + // This is because when we don't know yet what the name of the resource will be, the request comes with an empty value. + // And thus the authorizer can't do granular checks. + t.Run("CREATE", func(t *testing.T) { + rawCreateLimited, err := clientScopedAll.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, rawCreateLimited) + + rawCreateLimited, err = clientScopedAll.Resource.Create(ctx, testKeeperAnother, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, rawCreateLimited) + }) + + t.Run("READ", func(t *testing.T) { + // Retrieve `keeperName` from the limited client. + rawGetLimited, err := clientScopedLimited.Resource.Get(ctx, keeperName, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, rawGetLimited) + require.Equal(t, rawGetLimited.GetUID(), rawGetLimited.GetUID()) + + // Retrieve `keeperName` from the scope-all client. + rawGetAll, err := clientScopedAll.Resource.Get(ctx, keeperName, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, rawGetAll) + require.Equal(t, rawGetAll.GetUID(), rawGetLimited.GetUID()) + + // Even though we can create it, we cannot retrieve `keeperNameAnother` from the limited client. + rawGetLimited, err = clientScopedLimited.Resource.Get(ctx, keeperNameAnother, metav1.GetOptions{}) + require.Error(t, err) + require.Nil(t, rawGetLimited) + + var statusGetErr *apierrors.StatusError + require.True(t, errors.As(err, &statusGetErr)) + require.EqualValues(t, http.StatusForbidden, statusGetErr.Status().Code) + + // Retrieve `keeperNameAnother` from the scope-all client. + rawGetAll, err = clientScopedAll.Resource.Get(ctx, keeperNameAnother, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, rawGetAll) + }) + + t.Run("LIST", func(t *testing.T) { + // List Keepers from the limited client should return only 1. + rawList, err := clientScopedLimited.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, rawList) + require.Len(t, rawList.Items, 1) + require.Equal(t, keeperName, rawList.Items[0].GetName()) + + // List Keepers from the scope-all client should return all of them. + rawList, err = clientScopedAll.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, rawList) + require.Len(t, rawList.Items, 2) + }) + + t.Run("UPDATE", func(t *testing.T) { + // Update `keeperName` from the limited client. + testKeeperUpdate := testKeeper.DeepCopy() + testKeeperUpdate.Object["spec"].(map[string]any)["description"] = "keeper-description-1234" + + rawUpdate, err := clientScopedLimited.Resource.Update(ctx, testKeeperUpdate, metav1.UpdateOptions{}) + require.NoError(t, err) + require.NotNil(t, rawUpdate) + + // Try to update `keeperNameAnother` from the limited client. + testKeeperAnotherUpdate := testKeeperAnother.DeepCopy() + testKeeperAnotherUpdate.Object["spec"].(map[string]any)["description"] = "keeper-description-5678" + + rawUpdate, err = clientScopedLimited.Resource.Update(ctx, testKeeperAnotherUpdate, metav1.UpdateOptions{}) + require.Error(t, err) + require.Nil(t, rawUpdate) + + var statusUpdateErr *apierrors.StatusError + require.True(t, errors.As(err, &statusUpdateErr)) + require.EqualValues(t, http.StatusForbidden, statusUpdateErr.Status().Code) + + // Update `keeperNameAnother` from the scope-all client. + rawUpdate, err = clientScopedAll.Resource.Update(ctx, testKeeperAnotherUpdate, metav1.UpdateOptions{}) + require.NoError(t, err) + require.NotNil(t, rawUpdate) + }) + + // Keep this last for cleaning up the resources. + t.Run("DELETE", func(t *testing.T) { + // Try to delete `keeperNameAnother` from the limited client. + err := clientScopedLimited.Resource.Delete(ctx, keeperNameAnother, metav1.DeleteOptions{}) + require.Error(t, err) + + var statusDeleteErr *apierrors.StatusError + require.True(t, errors.As(err, &statusDeleteErr)) + require.EqualValues(t, http.StatusForbidden, statusDeleteErr.Status().Code) + + // Delete `keeperNameAnother` from the scope-all client. + err = clientScopedAll.Resource.Delete(ctx, keeperNameAnother, metav1.DeleteOptions{}) + require.NoError(t, err) + + // Delete `keeperName` from the limited client. + err = clientScopedLimited.Resource.Delete(ctx, keeperName, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + }) +} diff --git a/pkg/tests/apis/secret/main_test.go b/pkg/tests/apis/secret/main_test.go new file mode 100644 index 00000000000..20169fdbaa2 --- /dev/null +++ b/pkg/tests/apis/secret/main_test.go @@ -0,0 +1,150 @@ +package secret + +import ( + "cmp" + "context" + "encoding/json" + "math/rand/v2" + "strconv" + "testing" + + "github.com/grafana/grafana/pkg/registry/apis/secret" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/team" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var ( + ResourceSecureValues = "secret.securevalues" + ResourceKeepers = "secret.keepers" + + ActionsAllKeepers = []string{ + secret.ActionSecretKeepersCreate, + secret.ActionSecretKeepersWrite, + secret.ActionSecretKeepersRead, + secret.ActionSecretKeepersDelete, + } + ActionsAllSecureValues = []string{ + secret.ActionSecretSecureValuesCreate, + secret.ActionSecretSecureValuesWrite, + secret.ActionSecretSecureValuesRead, + secret.ActionSecretSecureValuesDelete, + } +) + +type ResourcePermission struct { + Actions []string + Name string // empty or "*" for all +} + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationDiscoveryClient(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for experimental APIs + EnableFeatureToggles: []string{ + // Required to start the example service + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagSecretsManagementAppPlatform, + }, + }) + + t.Run("check discovery client", func(t *testing.T) { + disco := helper.NewDiscoveryClient() + + resources, err := disco.ServerResourcesForGroupVersion("secret.grafana.app/v0alpha1") + require.NoError(t, err) + + v1Disco, err := json.MarshalIndent(resources, "", " ") + require.NoError(t, err) + + var apiResourceList map[string]any + require.NoError(t, json.Unmarshal(v1Disco, &apiResourceList)) + + groupVersion, ok := apiResourceList["groupVersion"].(string) + require.True(t, ok) + require.Equal(t, "secret.grafana.app/v0alpha1", groupVersion) + + apiResources, ok := apiResourceList["resources"].([]any) + require.True(t, ok) + require.Len(t, apiResources, 2) // securevalue + keeper + (subresources...) + }) +} + +func mustCreateUsersWithOrg(t *testing.T, helper *apis.K8sTestHelper, orgID int64, permissionMap map[string]ResourcePermission) apis.OrgUsers { + t.Helper() + + permissions := make([]resourcepermissions.SetResourcePermissionCommand, 0, len(permissionMap)) + for resource, permission := range permissionMap { + permissions = append(permissions, resourcepermissions.SetResourcePermissionCommand{ + Actions: permission.Actions, + Resource: resource, + ResourceAttribute: "uid", + ResourceID: cmp.Or(permission.Name, "*"), + }) + } + + orgName := "org-" + strconv.FormatInt(orgID, 10) + + userSuffix := strconv.FormatInt(rand.Int64(), 10) + + // Add here admin or viewer if necessary. + editor := helper.CreateUser("editor-"+userSuffix, orgName, org.RoleEditor, permissions) + + staff := helper.CreateTeam("staff-"+userSuffix, "staff-"+userSuffix+"@"+orgName, editor.Identity.GetOrgID()) + + // Also call this method for each new user. + helper.AddOrUpdateTeamMember(editor, staff.ID, team.PermissionTypeMember) + + return apis.OrgUsers{ + Editor: editor, + Staff: staff, + } +} + +func mustCreateUsers(t *testing.T, helper *apis.K8sTestHelper, permissionMap map[string]ResourcePermission) apis.OrgUsers { + orgID := rand.Int64() + 2 // if it is 0, becomes 2 and not 1. + return mustCreateUsersWithOrg(t, helper, orgID, permissionMap) +} + +func mustGenerateKeeper(t *testing.T, helper *apis.K8sTestHelper, user apis.User, specType map[string]any, testFile string) *unstructured.Unstructured { + t.Helper() + + require.NotEmpty(t, testFile, "testFile must not be empty") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + keeperClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: user, + GVR: gvrKeepers, + }) + + testKeeper := helper.LoadYAMLOrJSONFile(testFile) + if specType != nil { + testKeeper.Object["spec"] = specType + } + + raw, err := keeperClient.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, raw) + + t.Cleanup(func() { + require.NoError(t, keeperClient.Resource.Delete(ctx, raw.GetName(), metav1.DeleteOptions{})) + }) + + return raw +} diff --git a/pkg/tests/apis/secret/testdata/keeper-aws-generate.yaml b/pkg/tests/apis/secret/testdata/keeper-aws-generate.yaml new file mode 100644 index 00000000000..c210ab66838 --- /dev/null +++ b/pkg/tests/apis/secret/testdata/keeper-aws-generate.yaml @@ -0,0 +1,15 @@ +apiVersion: secret.grafana.app/v0alpha1 +kind: Keeper +metadata: + annotations: + xx: XXX + labels: + aa: AAA +spec: + description: AWS XYZ value + aws: + accessKeyId: + valueFromEnv: ACCESS_KEY_ID_XYZ + secretAccessKey: + valueFromEnv: SECRET_ACCESS_KEY_XYZ + kmsKeyId: kmsKeyId-xyz diff --git a/pkg/tests/apis/secret/testdata/keeper-gcp-generate.yaml b/pkg/tests/apis/secret/testdata/keeper-gcp-generate.yaml new file mode 100644 index 00000000000..485a4cb2ea5 --- /dev/null +++ b/pkg/tests/apis/secret/testdata/keeper-gcp-generate.yaml @@ -0,0 +1,14 @@ +apiVersion: secret.grafana.app/v0alpha1 +kind: Keeper +metadata: + annotations: + xx: XXX + yy: YYY + labels: + aa: AAA + bb: BBB +spec: + description: GCP XYZ value + gcp: + projectId: project-id + credentialsFile: /path/to/file.json diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index bb59b505c1f..0d6510df906 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -18,6 +18,7 @@ import ( "gopkg.in/ini.v1" "github.com/grafana/dskit/kv" + "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/infra/db" @@ -508,17 +509,9 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = dbSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries)) require.NoError(t, err) - if db.IsTestDBSpanner() { - _, err = dbSection.NewKey("max_open_conn", "20") - } else { - _, err = dbSection.NewKey("max_open_conn", "2") - } + _, err = dbSection.NewKey("max_open_conn", "2") require.NoError(t, err) - if db.IsTestDBSpanner() { - _, err = dbSection.NewKey("max_idle_conn", "20") - } else { - _, err = dbSection.NewKey("max_idle_conn", "2") - } + _, err = dbSection.NewKey("max_idle_conn", "2") require.NoError(t, err) cfgPath := filepath.Join(cfgDir, "test.ini") diff --git a/pkg/tests/testsuite/testsuite.go b/pkg/tests/testsuite/testsuite.go index 80378e2530b..87ce57eaa15 100644 --- a/pkg/tests/testsuite/testsuite.go +++ b/pkg/tests/testsuite/testsuite.go @@ -13,8 +13,3 @@ func Run(m *testing.M) { db.CleanupTestDB() os.Exit(code) } - -func RunButSkipOnSpanner(m *testing.M) { - db.SkipTestsOnSpanner() - Run(m) -} diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index e46af17f0b4..4c0ef150c1b 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -40,6 +40,71 @@ func filterTablesWithData(tables []types.MetadataTable) []types.MetadataTable { return filtered } +func writeErrorResponse(rw http.ResponseWriter, statusCode int, message string) error { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(statusCode) + + // Log the raw error message + backend.Logger.Error(message) + + // Set error response to initial error message + errorBody := map[string]string{"error": message} + + // Attempt to locate JSON portion in error message + re := regexp.MustCompile(`\{.*\}`) + jsonPart := re.FindString(message) + if jsonPart != "" { + var jsonData map[string]interface{} + if unmarshalErr := json.Unmarshal([]byte(jsonPart), &jsonData); unmarshalErr != nil { + errorBody["error"] = fmt.Sprintf("Invalid JSON format in error message. Raw error: %s", message) + backend.Logger.Error("failed to unmarshal JSON error message", "error", unmarshalErr) + } else { + // Extract relevant fields for a formatted error message + errorType, ok := jsonData["error"].(string) + if ok { + errorDescription, ok := jsonData["error_description"].(string) + if !ok { + backend.Logger.Error("unable to convert error_description to string", "rawError", jsonData["error_description"]) + // Attempt to just format the error as a string + errorDescription = fmt.Sprintf("%v", jsonData["error_description"]) + } + if errorType == "" { + errorType = "UnknownError" + } + + errorBody["error"] = fmt.Sprintf("%s: %s", errorType, errorDescription) + } else { + nestedError, ok := jsonData["error"].(map[string]interface{}) + + if !ok { + errorBody["error"] = fmt.Sprintf("Invalid JSON format in error message. Raw error: %s", message) + backend.Logger.Error("failed to unmarshal JSON error message", "error", unmarshalErr) + } + + errorType := nestedError["code"].(string) + errorDescription, ok := nestedError["message"].(string) + if !ok { + backend.Logger.Error("unable to convert error_description to string", "rawError", jsonData["error_description"]) + // Attempt to just format the error as a string + errorDescription = fmt.Sprintf("%v", nestedError["message"]) + } + + if errorType == "" { + errorType = "UnknownError" + } + errorBody["error"] = fmt.Sprintf("%s: %s", errorType, errorDescription) + } + } + } + + jsonRes, _ := json.Marshal(errorBody) + _, err := rw.Write(jsonRes) + if err != nil { + return fmt.Errorf("unable to write HTTP response: %v", err) + } + return nil +} + func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) { if req.URL.Path == "/usage/basiclogs" { newUrl := &url.URL{ @@ -49,15 +114,21 @@ func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, re } return e.GetBasicLogsUsage(req.Context(), newUrl.String(), cli, rw, req.Body) } else if strings.Contains(req.URL.Path, "/metadata") { + isAppInsights := strings.Contains(req.URL.Path, "Microsoft.Insights/components") // Add necessary headers - req.Header.Set("Prefer", "metadata-format-v4,exclude-resourcetypes,exclude-customfunctions") + if isAppInsights { + // metadata-format-v4 is not supported for AppInsights resources + req.Header.Set("Prefer", "metadata-format-v3,exclude-resourcetypes,exclude-customfunctions") + } else { + req.Header.Set("Prefer", "metadata-format-v4,exclude-resourcetypes,exclude-customfunctions") + } queryParams := req.URL.Query() // Add necessary query params queryParams.Add("select", "categories,solutions,tables,workspaces") req.URL.RawQuery = queryParams.Encode() resp, err := cli.Do(req) if err != nil { - return nil, fmt.Errorf("failed to fetch metadata: %w", err) + return nil, writeErrorResponse(rw, resp.StatusCode, fmt.Sprintf("failed to fetch metadata: %s", err)) } defer func() { @@ -69,23 +140,28 @@ func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, re encoding := resp.Header.Get("Content-Encoding") body, err := decode(encoding, resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read metadata response: %w", err) + return nil, writeErrorResponse(rw, resp.StatusCode, fmt.Sprintf("failed to read metadata response: %s", err)) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("metadata API error: %s", string(body)) + return nil, writeErrorResponse(rw, resp.StatusCode, fmt.Sprintf("metadata API error: %s", string(body))) } var metadata types.AzureLogAnalyticsMetadata err = json.Unmarshal(body, &metadata) if err != nil { - return nil, fmt.Errorf("failed to unmarshal metadata response: %w", err) + return nil, writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal metadata response: %s", err)) + } + + // AppInsights metadata requests do not return the HasData field + // So we return all tables + if !isAppInsights { + metadata.Tables = filterTablesWithData(metadata.Tables) } - metadata.Tables = filterTablesWithData(metadata.Tables) responseBody, err := json.Marshal(metadata) if err != nil { - return nil, fmt.Errorf("failed to marshal metadata response: %w", err) + return nil, writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("failed to marshal metadata response: %s", err)) } rw.Header().Set("Content-Type", "application/json") diff --git a/pkg/util/spanner/spanner.go b/pkg/util/spanner/spanner.go deleted file mode 100644 index 4e04f267be2..00000000000 --- a/pkg/util/spanner/spanner.go +++ /dev/null @@ -1,40 +0,0 @@ -// Package spanner should only be used from tests, or from enterprise code (eg. protected by build tags). -package spanner - -import ( - "strconv" - - spannerdriver "github.com/googleapis/go-sql-spanner" - "google.golang.org/api/option" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - -func UsePlainText(connectorConfig spannerdriver.ConnectorConfig) bool { - if strval, ok := connectorConfig.Params["useplaintext"]; ok { - if val, err := strconv.ParseBool(strval); err == nil { - return val - } - } - return false -} - -// ConnectorConfigToClientOptions is adapted from https://github.com/googleapis/go-sql-spanner/blob/main/driver.go#L341-L477, from version 1.11.1. -func ConnectorConfigToClientOptions(connectorConfig spannerdriver.ConnectorConfig) []option.ClientOption { - var opts []option.ClientOption - if connectorConfig.Host != "" { - opts = append(opts, option.WithEndpoint(connectorConfig.Host)) - } - if strval, ok := connectorConfig.Params["credentials"]; ok { - opts = append(opts, option.WithCredentialsFile(strval)) - } - if strval, ok := connectorConfig.Params["credentialsjson"]; ok { - opts = append(opts, option.WithCredentialsJSON([]byte(strval))) - } - if UsePlainText(connectorConfig) { - opts = append(opts, - option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), - option.WithoutAuthentication()) - } - return opts -} diff --git a/pkg/util/xorm/dialect_spanner.go b/pkg/util/xorm/dialect_spanner.go deleted file mode 100644 index fbc361ce6b8..00000000000 --- a/pkg/util/xorm/dialect_spanner.go +++ /dev/null @@ -1,399 +0,0 @@ -//go:build enterprise || pro - -package xorm - -import ( - "database/sql" - "fmt" - "strconv" - "strings" - - spannerclient "cloud.google.com/go/spanner" - _ "github.com/googleapis/go-sql-spanner" - spannerdriver "github.com/googleapis/go-sql-spanner" - "github.com/grafana/grafana/pkg/util/xorm/core" - "google.golang.org/grpc/codes" -) - -func init() { - core.RegisterDriver("spanner", &spannerDriver{}) - core.RegisterDialect("spanner", func() core.Dialect { return &spanner{} }) -} - -// https://cloud.google.com/spanner/docs/reference/standard-sql/lexical#reserved_keywords -var spannerReservedKeywords = map[string]struct{}{ - "ALL": {}, - "AND": {}, - "ANY": {}, - "ARRAY": {}, - "AS": {}, - "ASC": {}, - "ASSERT_ROWS_MODIFIED": {}, - "AT": {}, - "BETWEEN": {}, - "BY": {}, - "CASE": {}, - "CAST": {}, - "COLLATE": {}, - "CONTAINS": {}, - "CREATE": {}, - "CROSS": {}, - "CUBE": {}, - "CURRENT": {}, - "DEFAULT": {}, - "DEFINE": {}, - "DESC": {}, - "DISTINCT": {}, - "ELSE": {}, - "END": {}, - "ENUM": {}, - "ESCAPE": {}, - "EXCEPT": {}, - "EXCLUDE": {}, - "EXISTS": {}, - "EXTRACT": {}, - "FALSE": {}, - "FETCH": {}, - "FOLLOWING": {}, - "FOR": {}, - "FROM": {}, - "FULL": {}, - "GROUP": {}, - "GROUPING": {}, - "GROUPS": {}, - "HASH": {}, - "HAVING": {}, - "IF": {}, - "IGNORE": {}, - "IN": {}, - "INNER": {}, - "INTERSECT": {}, - "INTERVAL": {}, - "INTO": {}, - "IS": {}, - "JOIN": {}, - "LATERAL": {}, - "LEFT": {}, - "LIKE": {}, - "LIMIT": {}, - "LOOKUP": {}, - "MERGE": {}, - "NATURAL": {}, - "NEW": {}, - "NO": {}, - "NOT": {}, - "NULL": {}, - "NULLS": {}, - "OF": {}, - "ON": {}, - "OR": {}, - "ORDER": {}, - "OUTER": {}, - "OVER": {}, - "PARTITION": {}, - "PRECEDING": {}, - "PROTO": {}, - "RANGE": {}, - "RECURSIVE": {}, - "RESPECT": {}, - "RIGHT": {}, - "ROLLUP": {}, - "ROWS": {}, - "SELECT": {}, - "SET": {}, - "SOME": {}, - "STRUCT": {}, - "TABLESAMPLE": {}, - "THEN": {}, - "TO": {}, - "TREAT": {}, - "TRUE": {}, - "UNBOUNDED": {}, - "UNION": {}, - "UNNEST": {}, - "USING": {}, - "WHEN": {}, - "WHERE": {}, - "WINDOW": {}, - "WITH": {}, - "WITHIN": {}, -} - -type spannerDriver struct{} - -func (d *spannerDriver) Parse(_driverName, datasourceName string) (*core.Uri, error) { - return &core.Uri{DbType: "spanner", DbName: datasourceName}, nil -} - -type spanner struct { - core.Base -} - -func (s *spanner) Init(db *core.DB, uri *core.Uri, driverName string, datasourceName string) error { - return s.Base.Init(db, s, uri, driverName, datasourceName) -} -func (s *spanner) Filters() []core.Filter { return []core.Filter{&core.IdFilter{}} } -func (s *spanner) IsReserved(name string) bool { - _, exists := spannerReservedKeywords[name] - return exists -} -func (s *spanner) AndStr() string { return "AND" } -func (s *spanner) OrStr() string { return "OR" } -func (s *spanner) EqStr() string { return "=" } -func (s *spanner) RollBackStr() string { return "ROLL BACK" } -func (s *spanner) AutoIncrStr() string { - // Spanner does not support auto-increment, but supports unique generated IDs (not sequential!). - return "GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE)" -} -func (s *spanner) SupportInsertMany() bool { return false } // Needs manual transaction batching -func (s *spanner) SupportEngine() bool { return false } // No support for engine selection -func (s *spanner) SupportCharset() bool { return false } // ...or charsets -func (s *spanner) SupportDropIfExists() bool { return false } // Drop should be handled differently -func (s *spanner) IndexOnTable() bool { return false } -func (s *spanner) ShowCreateNull() bool { return false } -func (s *spanner) Quote(name string) string { return "`" + name + "`" } -func (s *spanner) SqlType(col *core.Column) string { - switch col.SQLType.Name { - case core.Int, core.SmallInt, core.BigInt: - return "INT64" - case core.Varchar, core.Text, core.MediumText, core.LongText, core.Char, core.NVarchar, core.NChar, core.NText: - l := col.Length - if l == 0 { - l = col.SQLType.DefaultLength - } - if l > 0 { - return fmt.Sprintf("STRING(%d)", l) - } - return "STRING(MAX)" - case core.Jsonb: - return "STRING(MAX)" - case core.Bool, core.TinyInt: - return "BOOL" - case core.Float, core.Double: - return "FLOAT64" - case core.Bytea, core.Blob, core.MediumBlob, core.LongBlob: - l := col.Length - if l == 0 { - l = col.SQLType.DefaultLength - } - if l > 0 { - return fmt.Sprintf("BYTES(%d)", l) - } - return "BYTES(MAX)" - case core.DateTime, core.TimeStamp: - return "TIMESTAMP" - default: - panic("unknown column type: " + col.SQLType.Name) - //default: - // return "STRING(MAX)" // XXX: more types to add - } -} - -func (s *spanner) GetColumns(tableName string) ([]string, map[string]*core.Column, error) { - query := `SELECT COLUMN_NAME, SPANNER_TYPE, IS_NULLABLE, IS_IDENTITY, IDENTITY_GENERATION, IDENTITY_KIND, COLUMN_DEFAULT - FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND TABLE_SCHEMA="" ORDER BY ORDINAL_POSITION` - rows, err := s.DB().Query(query, tableName) - if err != nil { - return nil, nil, err - } - defer rows.Close() - - columns := make(map[string]*core.Column) - var colNames []string - - var name, sqlType, isNullable string - var isIdentity, identityGeneration, identityKind, columnDefault sql.NullString - for rows.Next() { - if err := rows.Scan(&name, &sqlType, &isNullable, &isIdentity, &identityGeneration, &identityKind, &columnDefault); err != nil { - return nil, nil, err - } - - var length int - switch { - case sqlType == "INT64": - sqlType = core.Int - case sqlType == "FLOAT32" || sqlType == "FLOAT64": - sqlType = core.Float - case sqlType == "BOOL": - sqlType = core.Bool - case sqlType == "BYTES(MAX)": - sqlType = core.Blob - case sqlType == "STRING(MAX)": - sqlType = core.NVarchar - case sqlType == "TIMESTAMP": - sqlType = core.DateTime - case strings.HasPrefix(sqlType, "BYTES("): - // 6 == len(`BYTES(`), we also remove ")" from the end. - if l, err := strconv.Atoi(sqlType[6 : len(sqlType)-1]); err == nil { - length = l - } - sqlType = core.Blob - case strings.HasPrefix(sqlType, "STRING("): - // 7 == len(`STRING(`), we also remove ")" from the end. - if l, err := strconv.Atoi(sqlType[7 : len(sqlType)-1]); err == nil { - length = l - } - sqlType = core.Varchar - default: - panic("unknown column type: " + sqlType) - } - - autoincrement := isIdentity.Valid && isIdentity.String == "YES" && - identityGeneration.Valid && identityGeneration.String == "BY DEFAULT" && - identityKind.Valid && identityKind.String == "BIT_REVERSED_POSITIVE_SEQUENCE" - - defValue := "" - defEmpty := true - if columnDefault.Valid { - defValue = columnDefault.String - defEmpty = false - } - - col := &core.Column{ - Name: name, - SQLType: core.SQLType{Name: sqlType}, - Length: length, - Nullable: isNullable == "YES", - IsAutoIncrement: autoincrement, - Indexes: map[string]int{}, - Default: defValue, - DefaultIsEmpty: defEmpty, - } - columns[name] = col - colNames = append(colNames, name) - } - - return colNames, columns, rows.Err() -} - -func (s *spanner) CreateTableSql(table *core.Table, tableName, _, charset string) string { - sql := "CREATE TABLE " + s.Quote(tableName) + " (" - - for i, col := range table.Columns() { - if i > 0 { - sql += ", " - } - - sql += s.Quote(col.Name) + " " + s.SqlType(col) - if !col.Nullable { - sql += " NOT NULL" - } - if col.Default != "" { - sql += " DEFAULT (" + col.Default + ")" - } - if col.IsAutoIncrement { - sql += " GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE)" - } - } - - sql += ") PRIMARY KEY (" + strings.Join(table.PrimaryKeys, ",") + ")" - return sql -} - -func (s *spanner) CreateIndexSql(tableName string, index *core.Index) string { - sql := "CREATE " - if index.Type == core.UniqueType { - sql += "UNIQUE NULL_FILTERED " - } - sql += "INDEX " + s.Quote(index.XName(tableName)) + " ON " + s.Quote(tableName) + " (" + strings.Join(index.Cols, ", ") + ")" - return sql -} - -func (s *spanner) IndexCheckSql(tableName, indexName string) (string, []any) { - return `SELECT index_name FROM information_schema.indexes - WHERE table_name = ? AND table_schema = "" AND index_name = ?`, - []any{tableName, indexName} -} - -func (s *spanner) TableCheckSql(tableName string) (string, []any) { - return `SELECT table_name FROM information_schema.tables - WHERE table_name = ? AND table_schema = ""`, - []any{tableName} -} - -func (s *spanner) GetTables() ([]*core.Table, error) { - res, err := s.DB().Query(`SELECT table_name FROM information_schema.tables WHERE table_schema = ""`) - if err != nil { - return nil, err - } - defer res.Close() - - tables := []*core.Table{} - for res.Next() { - var name string - if err := res.Scan(&name); err != nil { - return nil, err - } - t := core.NewEmptyTable() - t.Name = name - tables = append(tables, t) - } - return tables, res.Err() -} - -func (s *spanner) GetIndexes(tableName string) (map[string]*core.Index, error) { - res, err := s.DB().Query(`SELECT ix.INDEX_NAME, ix.INDEX_TYPE, ix.IS_UNIQUE, c.COLUMN_NAME - FROM INFORMATION_SCHEMA.INDEXES ix - JOIN INFORMATION_SCHEMA.INDEX_COLUMNS c ON (ix.TABLE_NAME=c.TABLE_NAME AND ix.INDEX_NAME=c.INDEX_NAME) - WHERE ix.TABLE_SCHEMA = "" AND ix.TABLE_NAME=? - ORDER BY ix.INDEX_NAME, c.ORDINAL_POSITION`, tableName) - if err != nil { - return nil, err - } - defer res.Close() - - indexes := map[string]*core.Index{} - var ixName, ixType, colName string - var isUnique bool - for res.Next() { - err := res.Scan(&ixName, &ixType, &isUnique, &colName) - if err != nil { - return nil, err - } - - isRegular := false - if strings.HasPrefix(ixName, "IDX_"+tableName) || strings.HasPrefix(ixName, "UQE_"+tableName) { - ixName = ixName[5+len(tableName):] - isRegular = true - } - - var index *core.Index - var ok bool - if index, ok = indexes[ixName]; !ok { - t := core.IndexType // ixType == "INDEX" && !isUnique - if ixType == "PRIMARY KEY" || isUnique { - t = core.UniqueType - } - - index = &core.Index{} - index.IsRegular = isRegular - index.Type = t - index.Name = ixName - indexes[ixName] = index - } - index.AddColumn(colName) - } - return indexes, res.Err() -} - -func (s *spanner) CreateSequenceGenerator(db *sql.DB) (SequenceGenerator, error) { - dsn := s.DataSourceName() - connectorConfig, err := spannerdriver.ExtractConnectorConfig(dsn) - if err != nil { - return nil, err - } - - if connectorConfig.Params[strings.ToLower("inMemSequenceGenerator")] == "true" { - // Switch to using in-memory sequence number generator. - // Using database-based sequence generator doesn't work with emulator, as emulator - // only supports single transaction. If there is already another transaction started - // generating new ID via database-based sequence generator would always fail. - return newInMemSequenceGenerator(), nil - } - - return newSequenceGenerator(db), nil -} - -func (s *spanner) RetryOnError(err error) bool { - return err != nil && spannerclient.ErrCode(spannerclient.ToSpannerError(err)) == codes.Aborted -} diff --git a/pkg/util/xorm/session_insert.go b/pkg/util/xorm/session_insert.go index d411f3d15ef..0b2b79e9f21 100644 --- a/pkg/util/xorm/session_insert.go +++ b/pkg/util/xorm/session_insert.go @@ -15,8 +15,9 @@ import ( "sync" "time" - "github.com/grafana/grafana/pkg/util/xorm/core" "xorm.io/builder" + + "github.com/grafana/grafana/pkg/util/xorm/core" ) // ErrNoElementsOnSlice represents an error there is no element when insert @@ -422,10 +423,6 @@ func (session *Session) innerInsert(bean any) (int64, error) { buf.WriteString(" RETURNING " + session.engine.Quote(table.AutoIncrement)) } - if len(table.AutoIncrement) > 0 && session.engine.dialect.DBType() == "spanner" { - buf.WriteString(" THEN RETURN " + session.engine.Quote(table.AutoIncrement)) - } - sqlStr := buf.String() args = buf.Args() diff --git a/pkg/util/xorm/xorm.go b/pkg/util/xorm/xorm.go index bf2e4bc7ea5..da15e50762d 100644 --- a/pkg/util/xorm/xorm.go +++ b/pkg/util/xorm/xorm.go @@ -24,8 +24,6 @@ import ( const ( // Version show the xorm's version Version string = "0.8.0.1015" - - Spanner = "spanner" ) func regDrvsNDialects() bool { @@ -102,12 +100,6 @@ func NewEngine(driverName string, dataSourceName string) (*Engine, error) { switch uri.DbType { case core.SQLITE: engine.DatabaseTZ = time.UTC - case Spanner: - engine.DatabaseTZ = time.UTC - // We need to specify "Z" to indicate that timestamp is in UTC. - // Otherwise Spanner uses default America/Los_Angeles timezone. - // https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#time_zones - engine.timestampFormat = "2006-01-02 15:04:05Z" default: engine.DatabaseTZ = time.Local } diff --git a/pkg/util/xorm/xorm_spanner_test.go b/pkg/util/xorm/xorm_spanner_test.go deleted file mode 100644 index a9c43a0b77d..00000000000 --- a/pkg/util/xorm/xorm_spanner_test.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build enterprise || pro - -package xorm - -import ( - "fmt" - "testing" - - "cloud.google.com/go/spanner/spannertest" - _ "github.com/mattn/go-sqlite3" - "github.com/stretchr/testify/require" -) - -func TestBasicOperationsWithSpanner(t *testing.T) { - span, err := spannertest.NewServer("localhost:0") - require.NoError(t, err) - defer span.Close() - - eng, err := NewEngine("spanner", fmt.Sprintf("%s/projects/test/instances/test/databases/test;usePlainText=true", span.Addr)) - require.NoError(t, err) - require.NotNil(t, eng) - require.Equal(t, "spanner", eng.DriverName()) - - _, err = eng.Exec("CREATE TABLE test_struct (id int64, comment string(max), json string(max)) primary key (id)") - require.NoError(t, err) - - // Currently broken because simple INSERT into spannertest doesn't work: https://github.com/googleapis/go-sql-spanner/issues/392 - // testBasicOperations(t, eng) -} diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 532f039a68f..08eb4aebd31 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -2369,6 +2369,69 @@ } } } + }, + "/teams/{teamId}/groups/search": { + "get": { + "tags": [ + "sync_team_groups", + "enterprise" + ], + "summary": "Search for team groups with optional filtering and pagination.", + "operationId": "searchTeamGroups", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "teamId", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "default": 1, + "name": "page", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": 1000, + "description": "Number of items per page", + "name": "perpage", + "in": "query" + }, + { + "type": "string", + "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "Filter by exact name match", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/searchTeamGroupsResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } } }, "definitions": { @@ -7370,6 +7433,29 @@ } } }, + "SearchTeamGroupsQueryResult": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "teamGroups": { + "type": "array", + "items": { + "$ref": "#/definitions/TeamGroupDTO" + } + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "SearchTeamQueryResult": { "type": "object", "properties": { @@ -10352,6 +10438,12 @@ "$ref": "#/definitions/SearchResult" } }, + "searchTeamGroupsResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchTeamGroupsQueryResult" + } + }, "searchTeamsResponse": { "description": "", "schema": { diff --git a/public/api-merged.json b/public/api-merged.json index 8da6fc47894..b7e77910163 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -9550,6 +9550,69 @@ } } }, + "/teams/{teamId}/groups/search": { + "get": { + "tags": [ + "sync_team_groups", + "enterprise" + ], + "summary": "Search for team groups with optional filtering and pagination.", + "operationId": "searchTeamGroups", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "teamId", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "default": 1, + "name": "page", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": 1000, + "description": "Number of items per page", + "name": "perpage", + "in": "query" + }, + { + "type": "string", + "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "Filter by exact name match", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/searchTeamGroupsResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/teams/{team_id}": { "get": { "tags": [ @@ -20612,6 +20675,29 @@ } } }, + "SearchTeamGroupsQueryResult": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "teamGroups": { + "type": "array", + "items": { + "$ref": "#/definitions/TeamGroupDTO" + } + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "SearchTeamQueryResult": { "type": "object", "properties": { @@ -24949,6 +25035,12 @@ "$ref": "#/definitions/SearchResult" } }, + "searchTeamGroupsResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/SearchTeamGroupsQueryResult" + } + }, "searchTeamsResponse": { "description": "(empty)", "schema": { diff --git a/public/app/core/components/ThemeSelector/ThemeCard.tsx b/public/app/core/components/ThemeSelector/ThemeCard.tsx index 38892d8fa30..24aed29a467 100644 --- a/public/app/core/components/ThemeSelector/ThemeCard.tsx +++ b/public/app/core/components/ThemeSelector/ThemeCard.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/css'; import { FeatureState, GrafanaTheme2, ThemeRegistryItem } from '@grafana/data'; -import { useTranslate } from '@grafana/i18n'; -import { TFunction } from '@grafana/i18n/internal'; +import { TFunction, useTranslate } from '@grafana/i18n'; import { FeatureBadge, RadioButtonDot, useStyles2 } from '@grafana/ui'; import { ThemePreview } from '../Theme/ThemePreview'; diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index 7d1c9d208a2..c413663c9bd 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -2,8 +2,7 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Trans, useTranslate } from '@grafana/i18n'; -import { TFunction } from '@grafana/i18n/internal'; +import { TFunction, Trans, useTranslate } from '@grafana/i18n'; import { Grid, Modal, useStyles2, Text } from '@grafana/ui'; import { getModKey } from 'app/core/utils/browser'; diff --git a/public/app/core/lifecycle-hooks.ts b/public/app/core/lifecycle-hooks.ts index 1cf2cce1fb8..07cfe490beb 100644 --- a/public/app/core/lifecycle-hooks.ts +++ b/public/app/core/lifecycle-hooks.ts @@ -1,5 +1,5 @@ import { initDevFeatures } from 'app/dev'; -import { notifyIfMockApiEnabled } from 'app/mock-api-utils'; +import { notifyIfMockApiEnabled } from 'app/dev-utils'; /** * Lifecycle tasks that need to be run prior to app initialization, diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index b7a1ea64501..3ff866f92c9 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -2,10 +2,10 @@ import { LegacyGraphHoverClearEvent, SetPanelAttentionEvent, locationUtil } from import { LocationService } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { getExploreUrl } from 'app/core/utils/explore'; +import { toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils'; import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { ShareModal } from 'app/features/dashboard/components/ShareModal/ShareModal'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; -import { toggleMockApiAndReload } from 'app/mock-api-utils'; import { getTimeSrv } from '../../features/dashboard/services/TimeSrv'; import { @@ -58,7 +58,10 @@ export class KeybindingSrv { this.bind('c r', () => toggleTheme(true)); if (process.env.NODE_ENV === 'development') { + // 'change mock' this.bind('c m', () => toggleMockApiAndReload()); + // 'change pseudo locale' + this.bind('c p l', () => togglePseudoLocale()); } } diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 6c78e1de1e6..d32ffcc1840 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -12,7 +12,7 @@ export function getNavTitle(navId: string | undefined) { case 'home': return t('nav.home.title', 'Home'); case 'home-setup-guide': - return t('nav.setup-guide.title', 'Setup guide'); + return t('nav.setup-guide.title', 'Getting started guide'); case 'new': return t('nav.new.title', 'New'); case 'create': diff --git a/public/app/mock-api-utils.ts b/public/app/dev-utils.ts similarity index 68% rename from public/app/mock-api-utils.ts rename to public/app/dev-utils.ts index b530978d013..478af4f8f64 100644 --- a/public/app/mock-api-utils.ts +++ b/public/app/dev-utils.ts @@ -1,6 +1,8 @@ +import { DEFAULT_LANGUAGE, PSEUDO_LOCALE } from '@grafana/i18n'; import store from 'app/core/store'; import { sendAppNotification } from './core/copy/appNotification'; +import { PreferencesService } from './core/services/PreferencesService'; import { AppNotificationSeverity } from './types'; export const STORAGE_MOCK_API_KEY = 'grafana.dev.mockApi'; @@ -41,3 +43,22 @@ export const notifyIfMockApiEnabled = () => { ); } }; + +export const togglePseudoLocale = async () => { + const prefsService = new PreferencesService('user'); + const prefs = await prefsService.load(); + + const isPseudoEnabled = prefs.language === PSEUDO_LOCALE; + + const action = isPseudoEnabled ? 'Disabling' : 'Enabling'; + sendAppNotification(`${action} pseudo locale`, 'Reloading...', AppNotificationSeverity.Info); + + await prefsService.update({ + ...prefs, + language: isPseudoEnabled ? DEFAULT_LANGUAGE : PSEUDO_LOCALE, + }); + + setTimeout(() => { + window.location.reload(); + }, 200); +}; diff --git a/public/app/dev.ts b/public/app/dev.ts index 19c46dbca8d..7ce4811e39a 100644 --- a/public/app/dev.ts +++ b/public/app/dev.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { potentiallySetupMockApi } from './mock-api-utils'; +import { potentiallySetupMockApi } from './dev-utils'; export async function initDevFeatures() { // if why-render is in url enable why did you render react extension diff --git a/public/app/features/actions/ActionEditor.tsx b/public/app/features/actions/ActionEditor.tsx index b9595c8d16a..9e94c088574 100644 --- a/public/app/features/actions/ActionEditor.tsx +++ b/public/app/features/actions/ActionEditor.tsx @@ -1,7 +1,14 @@ import { css } from '@emotion/css'; import { memo } from 'react'; -import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data'; +import { + Action, + GrafanaTheme2, + httpMethodOptions, + HttpRequestMethod, + VariableSuggestion, + ActionVariable, +} from '@grafana/data'; import { useTranslate } from '@grafana/i18n'; import { Switch, @@ -17,6 +24,7 @@ import { import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput'; +import { ActionVariablesEditor } from './ActionVariablesEditor'; import { ParamsEditor } from './ParamsEditor'; interface ActionEditorProps { @@ -75,6 +83,13 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne }); }; + const onVariablesChange = (variables: ActionVariable[]) => { + onChange(index, { + ...value, + variables, + }); + }; + const onQueryParamsChange = (queryParams: Array<[string, string]>) => { onChange(index, { ...value, @@ -205,6 +220,14 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne + + + + { + const mockOnChange = jest.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('renders empty state correctly', () => { + render(); + + expect(screen.getByPlaceholderText('Key')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Type')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + }); + + it('enables add button when both key and name are filled', async () => { + render(); + + const keyInput = screen.getByPlaceholderText('Key'); + const nameInput = screen.getByPlaceholderText('Name'); + const addButton = screen.getByRole('button', { name: 'Add' }); + + expect(addButton).toBeDisabled(); + + await userEvent.type(keyInput, 'testKey'); + await userEvent.type(nameInput, 'testName'); + + expect(addButton).not.toBeDisabled(); + }); + + it('adds a new variable when add button is clicked', async () => { + render(); + + const keyInput = screen.getByPlaceholderText('Key'); + const nameInput = screen.getByPlaceholderText('Name'); + const addButton = screen.getByRole('button', { name: 'Add' }); + + await userEvent.type(keyInput, 'testKey'); + await userEvent.type(nameInput, 'testName'); + await userEvent.click(addButton); + + expect(mockOnChange).toHaveBeenCalledWith([ + { + key: 'testKey', + name: 'testName', + type: ActionVariableType.String, + }, + ]); + + expect(keyInput).toHaveValue(''); + expect(nameInput).toHaveValue(''); + }); + + it('removes a variable when delete button is clicked', async () => { + const existingVariables = [ + { key: 'key1', name: 'name1', type: ActionVariableType.String }, + { key: 'key2', name: 'name2', type: ActionVariableType.String }, + ]; + + render(); + + const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }); + await userEvent.click(deleteButtons[0]); + + expect(mockOnChange).toHaveBeenCalledWith([{ key: 'key2', name: 'name2', type: ActionVariableType.String }]); + }); + + it('sorts variables by key when adding new ones', async () => { + const existingVariables = [{ key: 'key2', name: 'name2', type: ActionVariableType.String }]; + + render(); + + const keyInput = screen.getByPlaceholderText('Key'); + const nameInput = screen.getByPlaceholderText('Name'); + const addButton = screen.getByRole('button', { name: 'Add' }); + + await userEvent.type(keyInput, 'key1'); + await userEvent.type(nameInput, 'name1'); + await userEvent.click(addButton); + + expect(mockOnChange).toHaveBeenCalledWith([ + { key: 'key1', name: 'name1', type: ActionVariableType.String }, + { key: 'key2', name: 'name2', type: ActionVariableType.String }, + ]); + }); + + it('updates existing variable when adding with same key', async () => { + const existingVariables = [{ key: 'key1', name: 'oldName', type: ActionVariableType.String }]; + + render(); + + const keyInput = screen.getByPlaceholderText('Key'); + const nameInput = screen.getByPlaceholderText('Name'); + const addButton = screen.getByRole('button', { name: 'Add' }); + + await userEvent.type(keyInput, 'key1'); + await userEvent.type(nameInput, 'newName'); + await userEvent.click(addButton); + + expect(mockOnChange).toHaveBeenCalledWith([{ key: 'key1', name: 'newName', type: ActionVariableType.String }]); + }); +}); diff --git a/public/app/features/actions/ActionVariablesEditor.tsx b/public/app/features/actions/ActionVariablesEditor.tsx new file mode 100644 index 00000000000..7c264b53a6c --- /dev/null +++ b/public/app/features/actions/ActionVariablesEditor.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; + +import { ActionVariable, ActionVariableType } from '@grafana/data'; +import { useTranslate } from '@grafana/i18n'; +import { IconButton, Input, Stack, Combobox, ComboboxOption } from '@grafana/ui'; + +interface Props { + onChange: (v: ActionVariable[]) => void; + value: ActionVariable[]; +} + +export const ActionVariablesEditor = ({ value, onChange }: Props) => { + const [key, setKey] = useState(''); + const [name, setName] = useState(''); + const [type, setType] = useState(ActionVariableType.String); + + const { t } = useTranslate(); + + const changeKey = (key: string) => { + setKey(key); + }; + + const changeName = (name: string) => { + setName(name); + }; + + const changeType = (type: ComboboxOption) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + setType(type.value as ActionVariableType); + }; + + const addVariable = () => { + let newVariables: ActionVariable[]; + + if (value) { + newVariables = value.filter((e) => e.key !== key); + } else { + newVariables = []; + } + + newVariables.push({ key, name, type }); + newVariables.sort((a, b) => a.key.localeCompare(b.key)); + onChange(newVariables); + + setKey(''); + setName(''); + setType(ActionVariableType.String); + }; + + const removeVariable = (key: string) => () => { + const updatedVariables = value.filter((variable) => variable.key !== key); + onChange(updatedVariables); + }; + + const isAddButtonDisabled = name === '' || key === ''; + + const variableTypeOptions: ComboboxOption[] = [{ label: 'string', value: ActionVariableType.String }]; + + return ( +
+ + changeKey(e.currentTarget.value)} + placeholder={t('actions.params-editor.placeholder-key', 'Key')} + width={300} + /> + changeName(e.currentTarget.value)} + placeholder={t('actions.params-editor.placeholder-name', 'Name')} + width={300} + /> + + addVariable()} + disabled={isAddButtonDisabled} + /> + + + + {value.map((entry) => ( + + + + + + + ))} + +
+ ); +}; diff --git a/public/app/features/actions/utils.test.ts b/public/app/features/actions/utils.test.ts new file mode 100644 index 00000000000..959128f908c --- /dev/null +++ b/public/app/features/actions/utils.test.ts @@ -0,0 +1,151 @@ +import { Action, ActionType, ActionVariableInput, ActionVariableType } from '@grafana/data'; + +import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen'; + +import { buildActionRequest, genReplaceActionVars } from './utils'; + +describe('interpolateActionVariables', () => { + const actionMock = (): Action => ({ + title: 'Thermostat Control', + type: ActionType.Fetch, + variables: [ + { key: 'thermostat1', name: 'First Floor Thermostat', type: ActionVariableType.String }, + { key: 'thermostat2', name: 'Second Floor Thermostat', type: ActionVariableType.String }, + ], + fetch: { + url: 'http://test.com/api/thermostats/$thermostat1/sync/$thermostat2', + method: HttpRequestMethod.POST, + body: JSON.stringify({ + primary: 'Device-$thermostat1', + data: { + secondary: 'Room-$thermostat2', + settings: { + syncMode: 'Mode-$thermostat1', + }, + }, + }), + headers: [ + ['Device-ID', 'Thermostat-$thermostat1'], + ['Content-Type', 'application/json'], + ], + queryParams: [ + ['primary', 'Device-$thermostat1'], + ['secondary', 'Room-$thermostat2'], + ['mode', 'sync'], + ], + }, + }); + + it('should return original action if no variables or actionVars are provided', () => { + const action: Action = { + title: 'Test', + type: ActionType.Fetch, + [ActionType.Fetch]: { + url: 'http://test.com/api/', + method: HttpRequestMethod.GET, + }, + }; + const actionVars: ActionVariableInput = {}; + + const request = buildActionRequest( + action, + genReplaceActionVars((str) => str, action, actionVars) + ); + expect(request).toEqual({ + headers: { + 'X-Grafana-Action': '1', + }, + method: 'GET', + url: 'http://test.com/api/', + }); + }); + + it('should interpolate variables in URL', () => { + const action = actionMock(); + const actionVars: ActionVariableInput = { + thermostat1: 'T-001', + thermostat2: 'T-002', + }; + + const request = buildActionRequest( + action, + genReplaceActionVars((str) => str, action, actionVars) + ); + expect(request.url).toBe( + 'http://test.com/api/thermostats/T-001/sync/T-002?primary=Device-T-001&secondary=Room-T-002&mode=sync' + ); + }); + + it('should interpolate variables in request body', () => { + const action = actionMock(); + const actionVars: ActionVariableInput = { + thermostat1: 'T-001', + thermostat2: 'T-002', + }; + + const request = buildActionRequest( + action, + genReplaceActionVars((str) => str, action, actionVars) + ); + expect(JSON.parse(request.data)).toEqual({ + primary: 'Device-T-001', + data: { + secondary: 'Room-T-002', + settings: { + syncMode: 'Mode-T-001', + }, + }, + }); + }); + + it('should interpolate variables in headers', () => { + const action = actionMock(); + const actionVars: ActionVariableInput = { + thermostat1: 'T-001', + thermostat2: 'T-002', + }; + + const request = buildActionRequest( + action, + genReplaceActionVars((str) => str, action, actionVars) + ); + expect(request.headers).toEqual({ + 'Content-Type': 'application/json', + 'Device-ID': 'Thermostat-T-001', + 'X-Grafana-Action': '1', + }); + }); + + it('should interpolate variables in query params', () => { + const action = actionMock(); + const actionVars: ActionVariableInput = { + thermostat1: 'T-001', + thermostat2: 'T-002', + }; + + const request = buildActionRequest( + action, + genReplaceActionVars((str) => str, action, actionVars) + ); + expect(request.url).toEqual( + 'http://test.com/api/thermostats/T-001/sync/T-002?primary=Device-T-001&secondary=Room-T-002&mode=sync' + ); + }); + + it('should only interpolate provided variables', () => { + const action = actionMock(); + const actionVars: ActionVariableInput = { + thermostat1: 'T-001', + // thermostat2 is not provided + }; + + const request = buildActionRequest( + action, + genReplaceActionVars((str) => str, action, actionVars) + ); + expect(request.url).toBe( + 'http://test.com/api/thermostats/T-001/sync/$thermostat2?primary=Device-T-001&secondary=Room-%24thermostat2&mode=sync' + ); + expect(JSON.parse(request.data).data.secondary).toBe('Room-$thermostat2'); + }); +}); diff --git a/public/app/features/actions/utils.ts b/public/app/features/actions/utils.ts index e8771075178..8c78f5ce6a9 100644 --- a/public/app/features/actions/utils.ts +++ b/public/app/features/actions/utils.ts @@ -1,6 +1,7 @@ import { Action, ActionModel, + ActionVariableInput, AppEvents, DataContextScopedVar, DataFrame, @@ -13,12 +14,35 @@ import { textUtil, ValueLinkConfig, } from '@grafana/data'; -import { BackendSrvRequest, getBackendSrv, config as grafanaConfig } from '@grafana/runtime'; +import { BackendSrvRequest, config as grafanaConfig, getBackendSrv } from '@grafana/runtime'; import { appEvents } from 'app/core/core'; import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen'; import { createAbsoluteUrl, RelativeUrl } from '../alerting/unified/utils/url'; +/** @internal */ +export const genReplaceActionVars = ( + boundReplaceVariables: InterpolateFunction, + action: Action, + actionVars?: ActionVariableInput +): InterpolateFunction => { + return (value, scopedVars, format) => { + if (action.variables && actionVars) { + value = value.replace(/\$\w+/g, (matched) => { + const name = matched.slice(1); + + if (action.variables!.some((action) => action.key === name) && actionVars[name] != null) { + return actionVars[name]; + } + + return matched; + }); + } + + return boundReplaceVariables(value, scopedVars, format); + }; +}; + /** @internal */ export const getActions = ( frame: DataFrame, @@ -50,22 +74,40 @@ export const getActions = ( dataContext.value.calculatedValue = config.calculatedValue; } - const title = replaceVariables(action.title, actionScopedVars); - const confirmation = replaceVariables( - action.confirmation || `Are you sure you want to ${action.title}?`, - actionScopedVars - ); - const actionModel: ActionModel = { - title, - confirmation, - onClick: (evt: MouseEvent, origin: Field) => { - buildActionOnClick(action, boundReplaceVariables); + title: replaceVariables(action.title, actionScopedVars), + confirmation: (actionVars?: ActionVariableInput) => + genReplaceActionVars( + boundReplaceVariables, + action, + actionVars + )(action.confirmation || `Are you sure you want to ${action.title}?`), + onClick: (evt: MouseEvent, origin: Field, actionVars?: ActionVariableInput) => { + let request = buildActionRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars)); + + try { + getBackendSrv() + .fetch(request) + .subscribe({ + error: (error) => { + appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']); + console.error(error); + }, + complete: () => { + appEvents.emit(AppEvents.alertSuccess, ['API call was successful']); + }, + }); + } catch (error) { + appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']); + console.error(error); + return; + } }, oneClick: action.oneClick ?? false, style: { backgroundColor: action.style?.backgroundColor ?? grafanaConfig.theme2.colors.secondary.main, }, + variables: action.variables, }; return actionModel; @@ -75,52 +117,36 @@ export const getActions = ( }; /** @internal */ -const buildActionOnClick = (action: Action, replaceVariables: InterpolateFunction) => { - try { - const url = new URL(getUrl(replaceVariables(action.fetch.url))); +export const buildActionRequest = (action: Action, replaceVariables: InterpolateFunction) => { + const url = new URL(getUrl(replaceVariables(action.fetch.url))); - const requestHeaders: Record = {}; + const requestHeaders: Record = {}; - let request: BackendSrvRequest = { - url: url.toString(), - method: action.fetch.method, - data: getData(action, replaceVariables), - headers: requestHeaders, - }; + let request: BackendSrvRequest = { + url: url.toString(), + method: action.fetch.method, + data: getData(action, replaceVariables), + headers: requestHeaders, + }; - if (action.fetch.headers) { - action.fetch.headers.forEach(([name, value]) => { - requestHeaders[replaceVariables(name)] = replaceVariables(value); - }); - } - - if (action.fetch.queryParams) { - action.fetch.queryParams?.forEach(([name, value]) => { - url.searchParams.append(replaceVariables(name), replaceVariables(value)); - }); - - request.url = url.toString(); - } - - requestHeaders['X-Grafana-Action'] = '1'; - request.headers = requestHeaders; - - getBackendSrv() - .fetch(request) - .subscribe({ - error: (error) => { - appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']); - console.error(error); - }, - complete: () => { - appEvents.emit(AppEvents.alertSuccess, ['API call was successful']); - }, - }); - } catch (error) { - appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']); - console.error(error); - return; + if (action.fetch.headers) { + action.fetch.headers.forEach(([name, value]) => { + requestHeaders[replaceVariables(name)] = replaceVariables(value); + }); } + + if (action.fetch.queryParams) { + action.fetch.queryParams?.forEach(([name, value]) => { + url.searchParams.append(replaceVariables(name), replaceVariables(value)); + }); + + request.url = url.toString(); + } + + requestHeaders['X-Grafana-Action'] = '1'; + request.headers = requestHeaders; + + return request; }; /** @internal */ diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index fd2d6758dec..9d5d02d9ffb 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -1,5 +1,3 @@ -import { set } from 'lodash'; - import { RelativeTimeRange } from '@grafana/data'; import { t } from '@grafana/i18n/internal'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; @@ -49,11 +47,18 @@ export interface Datasource { export const PREVIEW_URL = '/api/v1/rule/test/grafana'; export const PROM_RULES_URL = 'api/prometheus/grafana/api/v1/rules'; +// for some reason vanilla Prometheus uses param notation with [] appended export enum PrometheusAPIFilters { + RuleName = 'rule_name', + RuleNameVanilla = 'rule_name[]', RuleGroup = 'rule_group', + RuleGroupVanilla = 'rule_group[]', Namespace = 'file', + NamespaceVanilla = 'file[]', FolderUID = 'folder_uid', LimitAlerts = 'limit_alerts', + MaxGroups = 'max_groups', + ExcludeAlerts = 'exclude_alerts', } export interface Data { @@ -146,7 +151,9 @@ export const alertRuleApi = alertingApi.injectEndpoints({ if (identifier && (isPrometheusRuleIdentifier(identifier) || isCloudRuleIdentifier(identifier))) { searchParams.set(PrometheusAPIFilters.Namespace, identifier.namespace); + searchParams.set(PrometheusAPIFilters.NamespaceVanilla, identifier.namespace); searchParams.set(PrometheusAPIFilters.RuleGroup, identifier.groupName); + searchParams.set(PrometheusAPIFilters.RuleGroupVanilla, identifier.groupName); } const filterParams = getRulesFilterSearchParams(filter); @@ -193,22 +200,23 @@ export const alertRuleApi = alertingApi.injectEndpoints({ if (namespace) { if (isGrafanaRulesSource(ruleSourceName)) { - set(queryParams, PrometheusAPIFilters.FolderUID, namespace); + queryParams[PrometheusAPIFilters.FolderUID] = namespace; } else { - set(queryParams, PrometheusAPIFilters.Namespace, namespace); + queryParams[PrometheusAPIFilters.Namespace] = namespace; + queryParams[PrometheusAPIFilters.NamespaceVanilla] = namespace; } } if (limitAlerts !== undefined) { - set(queryParams, PrometheusAPIFilters.LimitAlerts, String(limitAlerts)); + queryParams[PrometheusAPIFilters.LimitAlerts] = String(PrometheusAPIFilters.LimitAlerts); } if (maxGroups) { - set(queryParams, 'max_groups', maxGroups); + queryParams[PrometheusAPIFilters.MaxGroups] = String(maxGroups); } if (excludeAlerts) { - set(queryParams, 'exclude_alerts', 'true'); + queryParams[PrometheusAPIFilters.ExcludeAlerts] = 'true'; } return { diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 10f82b1e051..a690fd34ea3 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -3,8 +3,7 @@ import { FC, useCallback, useMemo, useState } from 'react'; import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Trans, useTranslate } from '@grafana/i18n'; -import { TFunction } from '@grafana/i18n/internal'; +import { TFunction, Trans, useTranslate } from '@grafana/i18n'; import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Space, Stack, Text, useStyles2 } from '@grafana/ui'; import { labelsApi } from '../../../api/labelsApi'; diff --git a/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx b/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx index 9e172532ed7..93a9e332588 100644 --- a/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx +++ b/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx @@ -1,7 +1,7 @@ import { HttpResponse } from 'msw'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { Props } from 'react-virtualized-auto-sizer'; -import { render, screen, waitFor } from 'test/test-utils'; +import { render, screen, waitFor, within } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; import { AccessControlAction } from 'app/types'; @@ -100,7 +100,7 @@ describe('GroupDetailsPage', () => { // Assert expect(header).toHaveTextContent('test-group-cpu'); expect(await screen.findByRole('link', { name: /test-folder-title/ })).toBeInTheDocument(); - expect(await screen.findByText(/5m/)).toBeInTheDocument(); + expect(await screen.findByText(group.interval!)).toBeInTheDocument(); expect(editLink).toHaveAttribute( 'href', '/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fgrafana%2Fnamespaces%2Ftest-folder-uid%2Fgroups%2Ftest-group-cpu%2Fview' @@ -109,12 +109,18 @@ describe('GroupDetailsPage', () => { const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find()); expect(tableRows).toHaveLength(2); - expect(tableRows[0]).toHaveTextContent('High CPU Usage'); - expect(tableRows[0]).toHaveTextContent('10m'); + expect(within(tableRows[0]).getByRole('link', { name: rule1.grafana_alert.title })).toHaveAttribute( + 'href', + `/alerting/grafana/${rule1.grafana_alert.uid}/view` + ); + expect(tableRows[0]).toHaveTextContent(String(rule1.for)); expect(tableRows[0]).toHaveTextContent('5'); - expect(tableRows[1]).toHaveTextContent('Memory Pressure'); - expect(tableRows[1]).toHaveTextContent('5m'); + expect(within(tableRows[1]).getByRole('link', { name: rule2.grafana_alert.title })).toHaveAttribute( + 'href', + `/alerting/grafana/${rule2.grafana_alert.uid}/view` + ); + expect(tableRows[1]).toHaveTextContent(String(rule2.for)); expect(tableRows[1]).toHaveTextContent('3'); }); diff --git a/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx b/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx index 4ad7c88b070..505e893fed5 100644 --- a/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx +++ b/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx @@ -16,10 +16,12 @@ import { DynamicTable, DynamicTableColumnProps } from '../components/DynamicTabl import { GrafanaRuleGroupExporter } from '../components/export/GrafanaRuleGroupExporter'; import { useFolder } from '../hooks/useFolder'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults'; +import { createViewLinkFromIdentifier } from '../rule-list/DataSourceRuleListItem'; import { useRulesAccess } from '../utils/accessControlHooks'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME, getDataSourceByUid } from '../utils/datasource'; import { makeFolderLink, stringifyErrorLike } from '../utils/misc'; import { createListFilterLink, groups } from '../utils/navigation'; +import { fromRule, fromRulerRule } from '../utils/rule-id'; import { calcRuleEvalsToStartAlerting, getRuleName, @@ -78,6 +80,7 @@ function GroupDetailsPage() { ); const { t } = useTranslate(); + const ruleSourceName = isGrafanaRuleGroup ? GRAFANA_RULES_SOURCE_NAME : getDataSourceByUid(dataSourceUid)?.name; const isLoading = isFolderLoading || isDsFeaturesLoading || isRuleNamespacesLoading || isRuleGroupLoading; const groupInterval = promGroup?.interval @@ -148,8 +151,12 @@ function GroupDetailsPage() {
{stringifyErrorLike(ruleNamespacesError || ruleGroupError)}
)} - {promGroup && } - {rulerGroup && } + {promGroup && ruleSourceName && ( + + )} + {rulerGroup && ruleSourceName && ( + + )} {!promGroup && !rulerGroup && } @@ -213,12 +220,14 @@ interface RuleGroupDetails { interface AlertingRuleDetails { name: string; + href?: string; type: 'alerting'; pendingPeriod: string; evaluationsToFire: number; } interface RecordingRuleDetails { name: string; + href?: string; type: 'recording'; } @@ -248,8 +257,16 @@ function RulesTable({ rules }: { rules: RuleDetails[] }) { { id: 'alertName', label: t('alerting.group-details.rule-name', 'Rule name'), - renderCell: ({ data }) => { - return {data.name}; + renderCell: ({ data: { name, href } }) => { + if (href) { + return ( + + {name} + + ); + } + + return {name}; }, size: 0.4, }, @@ -285,29 +302,41 @@ function RulesTable({ rules }: { rules: RuleDetails[] }) { return ; } -function promRuleGroupToRuleGroupDetails(group: RuleGroup): RuleGroupDetails { +function promRuleGroupToRuleGroupDetails( + ruleSourceName: string, + namespaceName: string, + group: RuleGroup +): RuleGroupDetails { const groupIntervalMs = group.interval * 1000; return { name: group.name, interval: formatPrometheusDuration(group.interval * 1000), rules: group.rules.map((rule) => { + const ruleIdentifier = fromRule(ruleSourceName, namespaceName, group.name, rule); + const href = ruleIdentifier ? createViewLinkFromIdentifier(ruleIdentifier) : undefined; + switch (rule.type) { case PromRuleType.Alerting: return { name: rule.name, + href, type: 'alerting', pendingPeriod: formatPrometheusDuration(rule.duration ? rule.duration * 1000 : 0), evaluationsToFire: calcRuleEvalsToStartAlerting(rule.duration ? rule.duration * 1000 : 0, groupIntervalMs), }; case PromRuleType.Recording: - return { name: rule.name, type: 'recording' }; + return { name: rule.name, href, type: 'recording' }; } }), }; } -function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDetails { +function rulerRuleGroupToRuleGroupDetails( + ruleSourceName: string, + namespaceName: string, + group: RulerRuleGroupDTO +): RuleGroupDetails { const groupIntervalMs = safeParsePrometheusDuration(group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); return { @@ -316,9 +345,13 @@ function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDe rules: group.rules.map((rule) => { const name = getRuleName(rule); + const ruleIdentifier = fromRulerRule(ruleSourceName, namespaceName, group.name, rule); + const href = createViewLinkFromIdentifier(ruleIdentifier); + if (rulerRuleType.any.alertingRule(rule)) { return { name, + href, type: 'alerting', pendingPeriod: rule.for ?? '0s', evaluationsToFire: calcRuleEvalsToStartAlerting( @@ -328,7 +361,7 @@ function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDe }; } - return { name, type: 'recording' }; + return { name, href, type: 'recording' }; }), }; } diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index e52accf33ad..15fd546a36b 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -6,7 +6,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; @@ -137,16 +137,15 @@ const BrowseDashboardsPage = memo(() => { renderTitle={renderTitle} actions={ <> - {false && - hasAdminRights && ( // TODO: change this to a feature flag when dashboard restore is reworked - - Recently deleted - - )} + {config.featureToggles.restoreDashboards && hasAdminRights && ( + + Recently deleted + + )} {folderDTO && } {(canCreateDashboards || canCreateFolders) && ( - {false && ( // TODO: change this to a feature flag when dashboard restore is reworked + {config.featureToggles.restoreDashboards && ( <> diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 9264d6fd985..a94a46c122d 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -752,7 +752,7 @@ export class ElementState implements LayerElement { { diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index 9e0ebcec665..7f4f7d0adb3 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -5,7 +5,7 @@ import { t } from '@grafana/i18n/internal'; import { enrichHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils'; import { performInviteUserClick, shouldRenderInviteUserButton } from 'app/core/components/InviteUserButton/utils'; import { changeTheme } from 'app/core/services/theme'; -import { currentMockApiState, toggleMockApiAndReload } from 'app/mock-api-utils'; +import { currentMockApiState, toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils'; import { useSelector } from '../../../types'; import { CommandPaletteAction } from '../types'; @@ -116,6 +116,18 @@ function getGlobalActions(): CommandPaletteAction[] { priority: PREFERENCES_PRIORITY, perform: toggleMockApiAndReload, }); + + actions.push({ + id: 'preferences/dev/pseudo-locale', + section, + name: 'Toggle pseudo locale', + subtitle: 'Toggles between default language and pseudo locale', + keywords: 'pseudo locale', + priority: PREFERENCES_PRIORITY, + perform: () => { + togglePseudoLocale(); + }, + }); // eslint-enable @grafana/no-untranslated-strings } diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx index e03d5b8aa34..a0cc03080a7 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx @@ -2,8 +2,7 @@ import { css } from '@emotion/css'; import { Controller, useFormContext } from 'react-hook-form'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; -import { Trans, useTranslate } from '@grafana/i18n'; -import { TFunction } from '@grafana/i18n/internal'; +import { TFunction, Trans, useTranslate } from '@grafana/i18n'; import { Card, Field, FieldSet, Input, useStyles2 } from '@grafana/ui'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index bfce86da4b7..d633c8a6495 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -123,10 +123,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc } public scrollIntoView() { - if (this.panel.parent instanceof AutoGridItem) { - this.panel.parent.scrollIntoView(); - } - if (this.panel.parent instanceof DashboardGridItem) { + if (this.panel.parent instanceof AutoGridItem || this.panel.parent instanceof DashboardGridItem) { this.panel.parent.scrollIntoView(); } } diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx index 7d99dea4247..d78b4971bfe 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx @@ -3,9 +3,12 @@ import userEvent from '@testing-library/user-event'; import { TestProvider } from 'test/helpers/TestProvider'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { sceneGraph, SceneRefreshPicker } from '@grafana/scenes'; +import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; import { SaveDashboardResponseDTO } from 'app/types'; +import { DashboardSceneState } from '../scene/DashboardScene'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; @@ -152,6 +155,70 @@ describe('SaveDashboardDrawer', () => { }); }); + describe('When a dashboard is managed by an external system', () => { + beforeEach(() => { + config.featureToggles.provisioning = true; + }); + + afterEach(() => { + config.featureToggles.provisioning = false; + }); + + it('It should show the changes tab if the resource can be edited', async () => { + const { dashboard, openAndRender } = setup({ + meta: { + k8s: { + annotations: { + [AnnoKeyManagerKind]: ManagerKind.Repo, + }, + }, + }, + }); + + // just changing the title here, in real case scenario changes are reflected through migrations + // eg. panel version - same for other manager tests below + dashboard.setState({ title: 'updated title' }); + openAndRender(); + + expect(screen.queryByRole('tab', { name: /Changes/ })).toBeInTheDocument(); + }); + + it('It should not show the changes tab if the resource cannot be edited; kubectl', async () => { + const { dashboard, openAndRender } = setup({ + meta: { k8s: { annotations: { [AnnoKeyManagerKind]: ManagerKind.Kubectl } } }, + }); + + dashboard.setState({ title: 'updated title' }); + openAndRender(); + + expect(screen.queryByRole('tab', { name: /Changes/ })).not.toBeInTheDocument(); + }); + + it('It should not show the changes tab if the resource cannot be edited; terraform', async () => { + const { dashboard, openAndRender } = setup({ + meta: { k8s: { annotations: { [AnnoKeyManagerKind]: ManagerKind.Terraform } } }, + }); + + dashboard.setState({ title: 'updated title' }); + openAndRender(); + + expect(screen.queryByRole('tab', { name: /Changes/ })).not.toBeInTheDocument(); + }); + + it('It should not show the changes tab if the resource cannot be edited; plugin', async () => { + const { dashboard, openAndRender } = setup({ + meta: { + k8s: { annotations: { [AnnoKeyManagerKind]: ManagerKind.Plugin } }, + }, + }); + + dashboard.setState({ title: 'updated title' }); + openAndRender(); + + expect(screen.queryByRole('tab', { name: /Changes/ })).not.toBeInTheDocument(); + }); + }); + describe('Save as copy', () => { it('Should show save as form', async () => { const { openAndRender } = setup(); @@ -199,7 +266,7 @@ function mockSaveDashboard(options: Partial = {}) { let cleanUp = () => {}; -function setup() { +function setup(overrides?: Partial) { const dashboard = transformSaveModelToScene({ dashboard: { title: 'hello', @@ -209,6 +276,7 @@ function setup() { version: 10, }, meta: {}, + ...overrides, }); // Clear any data layers diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index 9bb2f70c3e7..dbc01acf19c 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -56,6 +56,7 @@ export class SaveDashboardDrawer extends SceneObjectBase model.setState({ showDiff: false })} /> - {changesCount > 0 && ( + {changesCount > 0 && !managedResourceCannotBeEdited && ( ; } - if (isProvisioned) { + if (isProvisioned || managedResourceCannotBeEdited) { return ; } diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx index 12540d371c9..eec8d44b8d8 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx @@ -390,8 +390,9 @@ describe('SaveProvisionedDashboardForm', () => { expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); }); - it('should show read-only alert when repository has no workflows', () => { + it('should properly handle read-only state for a repository without workflows', () => { setup({ + isNew: false, repository: { name: 'repo-abc', type: 'github', @@ -401,6 +402,20 @@ describe('SaveProvisionedDashboardForm', () => { }, }); - expect(screen.getByText('This repository is read only')).toBeInTheDocument(); + // Alert is shown + expect(screen.getByRole('alert', { name: 'This repository is read only' })).toBeInTheDocument(); + + // Save button is disabled + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeDisabled(); + + // Common options are not shown for existing dashboards + expect(screen.queryByTestId('common-options')).not.toBeInTheDocument(); + + // Workflow options are not shown + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + + // Branch field is not shown + expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx index 907a8031ef2..a177964f7e2 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx @@ -159,11 +159,11 @@ export function SaveProvisionedDashboardForm({ }; const workflowOptions = getWorkflowOptions(repository, loadedFromRef); - + const readOnly = !repository?.workflows?.length; return (
- {!repository?.workflows?.length && ( + {readOnly && ( - + )} - {!isNew && } + {!isNew && !readOnly && } - +