diff --git a/.betterer.results b/.betterer.results index fd31bfb5837..8844760fb39 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4298,6 +4298,9 @@ exports[`better eslint`] = { "public/app/features/plugins/admin/components/PluginDetailsBody.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/plugins/admin/components/PluginDetailsPage.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/plugins/admin/components/SearchField.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -4316,9 +4319,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/plugins/admin/pages/PluginDetails.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/plugins/admin/state/actions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -4556,10 +4556,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/scenes/core/sceneGraph.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -5373,8 +5369,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/datasource/cloudwatch/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6838,8 +6833,7 @@ exports[`better eslint`] = { "public/app/plugins/datasource/testdata/nodeGraphUtils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/testdata/runStreams.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -6935,11 +6929,12 @@ exports[`better eslint`] = { "public/app/plugins/panel/barchart/bars.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"] ], "public/app/plugins/panel/barchart/module.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -6950,7 +6945,8 @@ exports[`better eslint`] = { ], "public/app/plugins/panel/barchart/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/panel/candlestick/CandlestickPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -7573,24 +7569,13 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/nodeGraph/usePanning.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/plugins/panel/nodeGraph/useZoom.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/nodeGraph/utils.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], - "public/app/plugins/panel/piechart/PieChart.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], @@ -7603,25 +7588,16 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/panel/stat/StatMigrations.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/stat/StatMigrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/panel/stat/common.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/plugins/panel/state-timeline/TimelineChart.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/panel/state-timeline/migrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -7644,23 +7620,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/state-timeline/utils.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/state-timeline/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/table-old/column_options.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -8026,16 +7989,10 @@ exports[`better eslint`] = { "public/test/core/redux/reduxTester.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/test/core/thunk/thunkTester.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -8049,43 +8006,32 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], "public/test/global-jquery-shim.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/test/helpers/convertToStoreState.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], - "public/test/helpers/createFetchResponse.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/test/helpers/getDashboardModel.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/test/helpers/initTemplateSrv.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/test/jest-setup.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/test/jest-shim.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/test/lib/common.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/test/matchers/index.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -8109,10 +8055,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], "public/test/matchers/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/test/mocks/angular.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -8129,16 +8072,11 @@ exports[`better eslint`] = { ], "public/test/mocks/datasource_srv.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/test/mocks/workers.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/test/specs/helpers.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -8150,14 +8088,13 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "12"], [0, 0, 0, "Unexpected any. Specify a different type.", "13"], [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"] + [0, 0, 0, "Unexpected any. Specify a different type.", "15"] ] }` }; diff --git a/.betterer.ts b/.betterer.ts index dc3789d46ad..6d40d7a9179 100644 --- a/.betterer.ts +++ b/.betterer.ts @@ -36,7 +36,10 @@ function countEslintErrors() { }; const isTestFile = - filePath.endsWith('.test.tsx') || filePath.endsWith('.test.ts') || filePath.includes('__mocks__'); + filePath.endsWith('.test.tsx') || + filePath.endsWith('.test.ts') || + filePath.includes('__mocks__') || + filePath.includes('public/test/'); if (!isTestFile) { rules['@typescript-eslint/consistent-type-assertions'] = [ diff --git a/.eslintrc b/.eslintrc index 9535a971dd8..980edc7e524 100644 --- a/.eslintrc +++ b/.eslintrc @@ -87,7 +87,11 @@ "ignoreNonDOM": true } ], - "jsx-a11y/no-static-element-interactions": "off" + "jsx-a11y/no-static-element-interactions": "off", + "jsx-a11y/label-has-associated-control": [ "error", { + "controlComponents": ["NumberInput"], + "depth": 2 + }] } } ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b7b617c6eac..f6f5271bdba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -69,6 +69,8 @@ go.sum @grafana/backend-platform /pkg/tsdb/loki @grafana/observability-logs /pkg/tsdb/zipkin @grafana/observability-traces-and-profiling /pkg/tsdb/tempo @grafana/observability-traces-and-profiling +/pkg/tsdb/phlare @grafana/observability-traces-and-profiling +/pkg/tsdb/parca @grafana/observability-traces-and-profiling # BI backend code /pkg/tsdb/mysql @grafana/grafana-bi-squad @@ -144,6 +146,7 @@ tsconfig.json @grafana/frontend-ops /public/app/features/geo/ @grafana/grafana-edge-squad /public/app/features/storage/ @grafana/grafana-edge-squad /public/app/features/live/ @grafana/grafana-edge-squad +/public/app/features/query-library/ @grafana/grafana-edge-squad /public/app/features/explore/ @grafana/observability-experience-squad /public/app/features/plugins @grafana/plugins-platform-frontend /public/app/features/transformers/spatial @grafana/grafana-edge-squad @@ -154,6 +157,7 @@ tsconfig.json @grafana/frontend-ops /public/app/plugins/panel/logs @grafana/observability-logs /public/app/plugins/panel/nodeGraph @grafana/observability-traces-and-profiling /public/app/plugins/panel/traces @grafana/observability-traces-and-profiling +/public/app/plugins/panel/flamegraph @grafana/observability-traces-and-profiling /public/app/plugins/panel/piechart @grafana/grafana-bi-squad /public/app/plugins/panel/state-timeline @grafana/grafana-bi-squad /public/app/plugins/panel/status-history @grafana/grafana-bi-squad @@ -196,6 +200,8 @@ lerna.json @grafana/frontend-ops /public/app/plugins/datasource/cloud-monitoring @grafana/cloud-provider-plugins /public/app/plugins/datasource/zipkin @grafana/observability-traces-and-profiling /public/app/plugins/datasource/tempo @grafana/observability-traces-and-profiling +/public/app/plugins/datasource/phlare @grafana/observability-traces-and-profiling +/public/app/plugins/datasource/parca @grafana/observability-traces-and-profiling /public/app/plugins/datasource/alertmanager @grafana/alerting-squad # SSE - Server Side Expressions @@ -219,6 +225,7 @@ lerna.json @grafana/frontend-ops /pkg/services/teamguardian @grafana/grafana-authnz-team /pkg/services/serviceaccounts @grafana/grafana-authnz-team /pkg/services/loginattempt @grafana/grafana-authnz-team +/pkg/services/authn @grafana/grafana-authnz-team # Grafana Partnerships Team /pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team diff --git a/.github/commands.json b/.github/commands.json index 69e618858f9..6664a4f7738 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -155,6 +155,22 @@ "url":"https://github.com/orgs/grafana/projects/221" } }, + { + "type":"label", + "name":"datasource/Phlare", + "action":"addToProject", + "addToProject":{ + "url":"https://github.com/orgs/grafana/projects/221" + } + }, + { + "type":"label", + "name":"datasource/Parca", + "action":"addToProject", + "addToProject":{ + "url":"https://github.com/orgs/grafana/projects/221" + } + }, { "type":"label", "name":"datasource/Elasticsearch", diff --git a/.github/pr-commands.json b/.github/pr-commands.json index addfca786bf..544581a0a7c 100644 --- a/.github/pr-commands.json +++ b/.github/pr-commands.json @@ -66,8 +66,7 @@ "scripts/build/**/*", "scripts/*.sh", "Makefile", - "Dockerfile", - "Dockerfile.ubuntu" + "Dockerfile" ], "action": "updateLabel", "addLabel": "type/build-packaging" diff --git a/.github/workflows/enterprise-pr-check.yml b/.github/workflows/enterprise-pr-check.yml index f5552fe9437..6ad6dd881fb 100644 --- a/.github/workflows/enterprise-pr-check.yml +++ b/.github/workflows/enterprise-pr-check.yml @@ -23,4 +23,4 @@ jobs: repository: grafana/grafana-enterprise event_type: oss-pull-request client_payload: - '{"source_branch": "${{ github.head_ref }}", "target_branch": "${{ github.base_ref }}", "pr_number": "${{ github.event.number }}"}' + '{"source_sha": "${{ github.event.pull_request.head.sha }}", "source_branch": "${{ github.head_ref }}", "target_branch": "${{ github.base_ref }}", "pr_number": "${{ github.event.number }}"}' diff --git a/.github/workflows/publish-technical-documentation-next.yml b/.github/workflows/publish-technical-documentation-next.yml new file mode 100644 index 00000000000..9e060ee0b8b --- /dev/null +++ b/.github/workflows/publish-technical-documentation-next.yml @@ -0,0 +1,30 @@ +name: "publish-technical-documentation-next" + +on: + push: + branches: + - "main" + paths: + - "docs/sources/**" + - "packages/grafana-*/**" + workflow_dispatch: +jobs: + sync: + runs-on: "ubuntu-latest" + steps: + - name: "Checkout Grafana repo" + uses: "actions/checkout@v3" + + - name: "Clone website-sync Action" + run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync" + + - name: "Publish to website repository (next)" + uses: "./.github/actions/website-sync" + id: "publish-next" + with: + repository: "grafana/website" + branch: "master" + host: "github.com" + github_pat: "${{ secrets.GH_BOT_ACCESS_TOKEN }}" + source_folder: "docs/sources" + target_folder: "content/docs/grafana/next" diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml new file mode 100644 index 00000000000..e8864acfcb4 --- /dev/null +++ b/.github/workflows/publish-technical-documentation-release.yml @@ -0,0 +1,60 @@ +name: "publish-technical-documentation-release" + +on: + push: + branches: + - v[0-9]+.[0-9]+.[0-9]+ + tags: + - v[0-9]+.[0-9]+.[0-9]+ + paths: + - "docs/sources/**" + - "packages/grafana-*/**" + workflow_dispatch: +jobs: + sync: + runs-on: "ubuntu-latest" + steps: + - name: "Checkout Grafana repo" + uses: "actions/checkout@v3" + with: + fetch-depth: 0 + + - name: "Checkout Actions library" + uses: "actions/checkout@v3" + with: + repository: "grafana/grafana-github-actions" + path: "./actions" + + - name: "Install Actions from library" + run: "npm install --production --prefix ./actions" + + - name: "Determine if there is a matching release tag" + id: "has-matching-release-tag" + uses: "./actions/has-matching-release-tag" + with: + ref_name: "${{ github.ref_name }}" + release_tag_regexp: "^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" + release_branch_regexp: "^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.x$" + + - name: "Determine technical documentation version" + if: "steps.has-matching-release-tag.outputs.bool == 'true'" + uses: "./actions/docs-target" + id: "target" + with: + ref_name: "${{ github.ref_name }}" + + - name: "Clone website-sync Action" + if: "steps.has-matching-release-tag.outputs.bool == 'true'" + run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync" + + - name: "Publish to website repository (release)" + if: "steps.has-matching-release-tag.outputs.bool == 'true'" + uses: "./.github/actions/website-sync" + id: "publish-release" + with: + repository: "grafana/website" + branch: "master" + host: "github.com" + github_pat: "${{ secrets.GH_BOT_ACCESS_TOKEN }}" + source_folder: "docs/sources" + target_folder: "content/docs/grafana/${{ steps.target.outputs.target }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 95207729772..00000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: publish_docs - -on: - push: - branches: - - main - paths: - - 'docs/sources/**' - - 'packages/grafana-*/**' - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync - - name: setup node - uses: actions/setup-node@v3.5.1 - with: - node-version: '16' - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3.0.11 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - yarn- - - run: yarn install --immutable - - name: publish-to-git - uses: ./.github/actions/website-sync - id: publish - with: - repository: grafana/website - branch: master - host: github.com - github_pat: '${{ secrets.GH_BOT_ACCESS_TOKEN }}' - source_folder: docs/sources - target_folder: content/docs/grafana/next - allow_no_changes: 'true' - - shell: bash - run: | - test -n "${{ steps.publish.outputs.commit_hash }}" - test -n "${{ steps.publish.outputs.working_directory }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ed5a9e9dd..9f819093c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ + + +# 9.3.0 (2022-11-30) + +### Features and enhancements + +- **Alerting:** Enable interpolation for notification policies in file provisioning. [#58956](https://github.com/grafana/grafana/pull/58956), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **Azure Monitor Logs:** Avoid warning when the response is empty. [#59211](https://github.com/grafana/grafana/pull/59211), [@andresmgot](https://github.com/andresmgot) +- **Azure Monitor:** Add support to customized routes. [#54829](https://github.com/grafana/grafana/pull/54829), [@ms-hujia](https://github.com/ms-hujia) +- **Canvas:** Add icon value mapping. [#59013](https://github.com/grafana/grafana/pull/59013), [@nmarrs](https://github.com/nmarrs) +- **CloudWatch:** Cross-account querying support. [#59362](https://github.com/grafana/grafana/pull/59362), [@sunker](https://github.com/sunker) +- **Docs:** Update `merge-pull-request.md` regarding backport policies. [#59239](https://github.com/grafana/grafana/pull/59239), [@dsotirakis](https://github.com/dsotirakis) +- **GaugePanel:** Setting the neutral-point of a gauge. [#53989](https://github.com/grafana/grafana/pull/53989), [@sfranzis](https://github.com/sfranzis) +- **Geomap:** Improve location editor. [#58017](https://github.com/grafana/grafana/pull/58017), [@drew08t](https://github.com/drew08t) +- **Internationalization:** Enable internationalization by default. [#59204](https://github.com/grafana/grafana/pull/59204), [@joshhunt](https://github.com/joshhunt) +- **Logs:** Add `Download logs` button to log log-browser. [#55163](https://github.com/grafana/grafana/pull/55163), [@svennergr](https://github.com/svennergr) +- **Loki:** Add `gzip` compression to resource calls. [#59059](https://github.com/grafana/grafana/pull/59059), [@svennergr](https://github.com/svennergr) +- **Loki:** Add improvements to loki label browser. [#59387](https://github.com/grafana/grafana/pull/59387), [@gwdawson](https://github.com/gwdawson) +- **Loki:** Make label browser accessible in query builder. [#58525](https://github.com/grafana/grafana/pull/58525), [@gwdawson](https://github.com/gwdawson) +- **Loki:** Remove raw query toggle. [#59125](https://github.com/grafana/grafana/pull/59125), [@gwdawson](https://github.com/gwdawson) +- **Middleware:** Add CSP Report Only support. [#58074](https://github.com/grafana/grafana/pull/58074), [@jcalisto](https://github.com/jcalisto) +- **Navigation:** Prevent viewer role accessing dashboard creation, import and folder creation. [#58842](https://github.com/grafana/grafana/pull/58842), [@lpskdl](https://github.com/lpskdl) +- **OAuth:** Refactor OAuth parameters handling to support obtaining refresh tokens for Google OAuth. [#58782](https://github.com/grafana/grafana/pull/58782), [@mgyongyosi](https://github.com/mgyongyosi) +- **Oauth:** Display friendly error message when role_attribute_strict=true and no valid role found. [#57818](https://github.com/grafana/grafana/pull/57818), [@kalleep](https://github.com/kalleep) +- **Preferences:** Add confirmation modal when saving org preferences. [#59119](https://github.com/grafana/grafana/pull/59119), [@JoaoSilvaGrafana](https://github.com/JoaoSilvaGrafana) +- **PublicDashboards:** Orphaned public dashboard deletion script added. [#57917](https://github.com/grafana/grafana/pull/57917), [@juanicabanas](https://github.com/juanicabanas) +- **Query Editor:** Hide overflow for long query names. [#58840](https://github.com/grafana/grafana/pull/58840), [@zuchka](https://github.com/zuchka) +- **Reports:** Configurable timezone. (Enterprise) +- **Solo Panel:** Configurable timezone. [#59153](https://github.com/grafana/grafana/pull/59153), [@spinillos](https://github.com/spinillos) +- **TablePanel:** Add support for Count calculation per column or per entire dataset. [#58134](https://github.com/grafana/grafana/pull/58134), [@mdvictor](https://github.com/mdvictor) +- **Tempo:** Send the correct start time when making a TraceQL query. [#59128](https://github.com/grafana/grafana/pull/59128), [@CrypticSignal](https://github.com/CrypticSignal) +- **Various Panels:** Remove beta label from Bar Chart, Candlestick, Histogram, State Timeline, & Status History Panels. [#58557](https://github.com/grafana/grafana/pull/58557), [@codeincarnate](https://github.com/codeincarnate) + +### Bug fixes + +- **Access Control:** Clear user's permission cache after resource creation. [#59307](https://github.com/grafana/grafana/pull/59307), [@grafanabot](https://github.com/grafanabot) +- **Access Control:** Clear user's permission cache after resource creation. [#59101](https://github.com/grafana/grafana/pull/59101), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Accessibility:** Improve keyboard accessibility in `AnnoListPanel`. [#58971](https://github.com/grafana/grafana/pull/58971), [@ashharrison90](https://github.com/ashharrison90) +- **Accessibility:** Improve keyboard accessibility in `Collapse`. [#59022](https://github.com/grafana/grafana/pull/59022), [@ashharrison90](https://github.com/ashharrison90) +- **Accessibility:** Improve keyboard accessibility in `GettingStarted` panel. [#58966](https://github.com/grafana/grafana/pull/58966), [@ashharrison90](https://github.com/ashharrison90) +- **Accessibility:** Improve keyboard accessibility of `FilterPill`. [#58976](https://github.com/grafana/grafana/pull/58976), [@ashharrison90](https://github.com/ashharrison90) +- **Admin:** Fix broken links to image assets in email templates. [#58729](https://github.com/grafana/grafana/pull/58729), [@zuchka](https://github.com/zuchka) +- **Azure Monitor:** Fix namespace selection for storageaccounts. [#56449](https://github.com/grafana/grafana/pull/56449), [@andresmgot](https://github.com/andresmgot) +- **Calcs:** Fix difference percent in legend. [#59243](https://github.com/grafana/grafana/pull/59243), [@zoltanbedi](https://github.com/zoltanbedi) +- **DataLinks:** Improve Data-Links AutoComplete Logic. [#58934](https://github.com/grafana/grafana/pull/58934), [@zuchka](https://github.com/zuchka) +- **Explore:** Fix a11y issue with logs navigation buttons. [#58944](https://github.com/grafana/grafana/pull/58944), [@Elfo404](https://github.com/Elfo404) +- **Heatmap:** Fix blurry text & rendering. [#59260](https://github.com/grafana/grafana/pull/59260), [@leeoniya](https://github.com/leeoniya) +- **Heatmap:** Fix tooltip y range of top and bottom buckets in calculated heatmaps. [#59172](https://github.com/grafana/grafana/pull/59172), [@leeoniya](https://github.com/leeoniya) +- **Logs:** Fix misalignment of LogRows. [#59279](https://github.com/grafana/grafana/pull/59279), [@svennergr](https://github.com/svennergr) +- **Navigation:** Stop clearing search state when opening a result in a new tab. [#58880](https://github.com/grafana/grafana/pull/58880), [@ashharrison90](https://github.com/ashharrison90) +- **OptionsUI:** SliderValueEditor does not get auto focused on slider change. [#59209](https://github.com/grafana/grafana/pull/59209), [@eledobleefe](https://github.com/eledobleefe) +- **PanelEdit:** Fixes bug with not remembering panel options pane collapse/expand state. [#59265](https://github.com/grafana/grafana/pull/59265), [@torkelo](https://github.com/torkelo) +- **Query Caching:** Skip 207 status codes. (Enterprise) +- **Quota:** Fix failure in store due to missing scope parameters. [#58874](https://github.com/grafana/grafana/pull/58874), [@papagian](https://github.com/papagian) +- **Quota:** Fix failure when checking session limits. [#58865](https://github.com/grafana/grafana/pull/58865), [@papagian](https://github.com/papagian) +- **Reports:** Fix time preview. (Enterprise) +- **StateTimeline:** Prevent label text from overflowing state rects. [#59169](https://github.com/grafana/grafana/pull/59169), [@leeoniya](https://github.com/leeoniya) +- **Tempo:** Fix search table duration unit. [#58642](https://github.com/grafana/grafana/pull/58642), [@joey-grafana](https://github.com/joey-grafana) +- **TraceView:** Fix broken rendering when scrolling in Dashboard panel in Firefox. [#56642](https://github.com/grafana/grafana/pull/56642), [@zdg-github](https://github.com/zdg-github) + +### Plugin development fixes & changes + +- **GrafanaUI:** Add disabled option for menu items. [#58980](https://github.com/grafana/grafana/pull/58980), [@going-confetti](https://github.com/going-confetti) + + # 9.3.0-beta1 (2022-11-15) @@ -167,6 +232,24 @@ The following functions and classes related to logs are deprecated in the `grafa - **Toolkit:** Deprecate `component:create` command. [#56086](https://github.com/grafana/grafana/pull/56086), [@academo](https://github.com/academo) - **Toolkit:** Remove changelog command. [#56073](https://github.com/grafana/grafana/pull/56073), [@gitstart](https://github.com/gitstart) + + +# 9.2.7 (2022-11-29) + +### Bug fixes + +- **Access Control:** Clear user's permission cache after resource creation. [#59318](https://github.com/grafana/grafana/pull/59318), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Azure Monitor:** Fix empty/errored responses for Logs variables. [#59240](https://github.com/grafana/grafana/pull/59240), [@andresmgot](https://github.com/andresmgot) +- **Azure Monitor:** Fix resource picker selection for subresources. [#56392](https://github.com/grafana/grafana/pull/56392), [@andresmgot](https://github.com/andresmgot) +- **Navigation:** Fix crash when Help is disabled. [#58919](https://github.com/grafana/grafana/pull/58919), [@lpskdl](https://github.com/lpskdl) +- **PostgreSQL:** Fix missing CA field from configuration. [#59280](https://github.com/grafana/grafana/pull/59280), [@oscarkilhed](https://github.com/oscarkilhed) +- **SQL Datasources:** Fix annotation migration. [#59438](https://github.com/grafana/grafana/pull/59438), [@zoltanbedi](https://github.com/zoltanbedi) +- **SQL:** Fix code editor for SQL datasources. [#58116](https://github.com/grafana/grafana/pull/58116), [@zoltanbedi](https://github.com/zoltanbedi) +- **SSE:** Make sure to forward headers, user and cookies/OAuth token. [#58897](https://github.com/grafana/grafana/pull/58897), [@kylebrandt](https://github.com/kylebrandt) +- **TimeseriesPanel:** Preserve string fields for data link interpolation. [#58424](https://github.com/grafana/grafana/pull/58424), [@mdvictor](https://github.com/mdvictor) + + + # 9.2.6 (2022-11-22) diff --git a/Dockerfile b/Dockerfile index 966aebfa347..11bb3cebab8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,17 @@ -FROM node:16-alpine3.15 as js-builder +# syntax=docker/dockerfile:1 + +ARG BASE_IMAGE=alpine:3.15 +ARG JS_IMAGE=node:16-alpine3.15 +ARG GO_IMAGE=golang:1.19.3-alpine3.15 + +ARG GO_SRC=go-builder +ARG JS_SRC=js-builder + +FROM ${JS_IMAGE} as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 -WORKDIR /grafana +WORKDIR /tmp/grafana COPY package.json yarn.lock .yarnrc.yml ./ COPY .yarn .yarn @@ -13,82 +22,140 @@ RUN yarn install COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js babel.config.json .linguirc ./ COPY public public -COPY tools tools COPY scripts scripts COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.19.3-alpine3.15 as go-builder +FROM ${GO_IMAGE} as go-builder -RUN apk add --no-cache gcc g++ make +# Install build dependencies +RUN if grep -i -q alpine /etc/issue; then \ + apk add --no-cache gcc g++ make; \ + fi -WORKDIR /grafana +WORKDIR /tmp/grafana -COPY go.mod go.sum embed.go Makefile build.go package.json ./ +COPY go.* ./ +COPY .bingo .bingo + +RUN go mod download && \ + go install github.com/bwplotka/bingo@latest && \ + bingo get + +COPY embed.go Makefile build.go package.json ./ +COPY cue.mod cue.mod +COPY kinds kinds COPY packages/grafana-schema packages/grafana-schema COPY public/app/plugins public/app/plugins COPY public/api-spec.json public/api-spec.json COPY pkg pkg COPY scripts scripts -COPY cue.mod cue.mod -COPY .bingo .bingo +COPY conf conf -RUN go mod verify RUN make build-go -# Final stage -FROM alpine:3.15.6 +FROM ${BASE_IMAGE} as tgz-builder -LABEL maintainer="Grafana team " +WORKDIR /tmp/grafana + +ARG GRAFANA_TGZ="grafana-latest.linux-x64-musl.tar.gz" + +COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz + +# add -v to make tar print every file it extracts +RUN tar x -z -f /tmp/grafana.tar.gz --strip-components=1 + +# helpers for COPY --from +FROM ${GO_SRC} as go-src +FROM ${JS_SRC} as js-src + +# Final stage +FROM ${BASE_IMAGE} + +LABEL maintainer="Grafana Labs " ARG GF_UID="472" ARG GF_GID="0" ENV PATH="/usr/share/grafana/bin:$PATH" \ - GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ - GF_PATHS_DATA="/var/lib/grafana" \ - GF_PATHS_HOME="/usr/share/grafana" \ - GF_PATHS_LOGS="/var/log/grafana" \ - GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ - GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" WORKDIR $GF_PATHS_HOME -RUN apk add --no-cache ca-certificates bash tzdata musl-utils -RUN apk info -vv | sort +# Install dependencies +RUN if grep -i -q alpine /etc/issue; then \ + apk add --no-cache ca-certificates bash tzdata musl-utils && \ + apk info -vv | sort; \ + elif grep -i -q ubuntu /etc/issue; then \ + DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -y ca-certificates curl tzdata && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/*; \ + else \ + echo 'ERROR: Unsupported base image' && /bin/false; \ + fi -COPY conf ./conf +# glibc support for alpine x86_64 only +RUN if grep -i -q alpine /etc/issue && [ `arch` = "x86_64" ]; then \ + wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-2.35-r0.apk \ + -O /tmp/glibc-2.35-r0.apk && \ + wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-bin-2.35-r0.apk \ + -O /tmp/glibc-bin-2.35-r0.apk && \ + apk add --no-cache --allow-untrusted /tmp/glibc-2.35-r0.apk /tmp/glibc-bin-2.35-r0.apk && \ + rm -f /lib64/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ + rm -f /tmp/glibc-2.35-r0.apk && \ + rm -f /tmp/glibc-bin-2.35-r0.apk && \ + rm -f /lib/ld-linux-x86-64.so.2 && \ + rm -f /etc/ld.so.cache; \ + fi + +COPY --from=go-src /tmp/grafana/conf ./conf RUN if [ ! $(getent group "$GF_GID") ]; then \ - addgroup -S -g $GF_GID grafana; \ - fi + if grep -i -q alpine /etc/issue; then \ + addgroup -S -g $GF_GID grafana; \ + else \ + addgroup --system --gid $GF_GID grafana; \ + fi; \ + fi && \ + GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ + mkdir -p "$GF_PATHS_HOME/.aws" && \ + if grep -i -q alpine /etc/issue; then \ + adduser -S -u $GF_UID -G "$GF_GID_NAME" grafana; \ + else \ + adduser --system --uid $GF_UID --ingroup "$GF_GID_NAME" grafana; \ + fi && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ + "$GF_PATHS_PROVISIONING/access-control" \ + "$GF_PATHS_PROVISIONING/alerting" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp conf/sample.ini "$GF_PATHS_CONFIG" && \ + cp conf/ldap.toml /etc/grafana/ldap.toml && \ + chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ + chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" -RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ - mkdir -p "$GF_PATHS_HOME/.aws" && \ - adduser -S -u $GF_UID -G "$GF_GID_NAME" grafana && \ - mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ - "$GF_PATHS_PROVISIONING/dashboards" \ - "$GF_PATHS_PROVISIONING/notifiers" \ - "$GF_PATHS_PROVISIONING/plugins" \ - "$GF_PATHS_PROVISIONING/access-control" \ - "$GF_PATHS_PROVISIONING/alerting" \ - "$GF_PATHS_LOGS" \ - "$GF_PATHS_PLUGINS" \ - "$GF_PATHS_DATA" && \ - cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ - cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ - chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ - chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" - -COPY --from=go-builder /grafana/bin/*/grafana-server /grafana/bin/*/grafana-cli /grafana/bin/*/grafana ./bin/ -COPY --from=js-builder /grafana/public ./public -COPY --from=js-builder /grafana/tools ./tools +COPY --from=go-src /tmp/grafana/bin/grafana* /tmp/grafana/bin/*/grafana* ./bin/ +COPY --from=js-src /tmp/grafana/public ./public EXPOSE 3000 -COPY ./packaging/docker/run.sh /run.sh +ARG RUN_SH=./packaging/docker/run.sh -USER grafana +COPY ${RUN_SH} /run.sh + +USER "$GF_UID" ENTRYPOINT [ "/run.sh" ] diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu deleted file mode 100644 index a6b62ea1e25..00000000000 --- a/Dockerfile.ubuntu +++ /dev/null @@ -1,87 +0,0 @@ -FROM node:16-alpine3.15 as js-builder - -ENV NODE_OPTIONS=--max_old_space_size=8000 - -WORKDIR /usr/src/app/ - -COPY package.json yarn.lock ./ -COPY packages packages -COPY .yarnrc.yml ./ -COPY .yarn .yarn -COPY plugins-bundled plugins-bundled - -RUN yarn install - -COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js babel.config.json .linguirc ./ -COPY public public -COPY tools tools -COPY scripts scripts -COPY emails emails - -ENV NODE_ENV production -RUN yarn build - -FROM golang:1.19.3 AS go-builder - -WORKDIR /src/grafana - -COPY go.mod go.sum embed.go ./ -COPY Makefile build.go package.json ./ -COPY .bingo .bingo -COPY pkg pkg/ -COPY cue.mod cue.mod/ -COPY packages/grafana-schema packages/grafana-schema/ -COPY public/app/plugins public/app/plugins/ -COPY public/api-spec.json public/api-spec.json - -RUN go mod verify -RUN make build-go - -FROM ubuntu:20.04 - -LABEL maintainer="Grafana team " -EXPOSE 3000 - -ARG GF_UID="472" -ARG GF_GID="472" - -ENV PATH="/usr/share/grafana/bin:$PATH" \ - GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ - GF_PATHS_DATA="/var/lib/grafana" \ - GF_PATHS_HOME="/usr/share/grafana" \ - GF_PATHS_LOGS="/var/log/grafana" \ - GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ - GF_PATHS_PROVISIONING="/etc/grafana/provisioning" - -WORKDIR $GF_PATHS_HOME - -COPY conf conf - -# curl should be part of the image -RUN apt-get update && apt-get install -y ca-certificates curl - -RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ - addgroup --system --gid $GF_GID grafana && \ - adduser --uid $GF_UID --system --ingroup grafana grafana && \ - mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ - "$GF_PATHS_PROVISIONING/dashboards" \ - "$GF_PATHS_PROVISIONING/notifiers" \ - "$GF_PATHS_PROVISIONING/plugins" \ - "$GF_PATHS_PROVISIONING/access-control" \ - "$GF_PATHS_PROVISIONING/alerting" \ - "$GF_PATHS_LOGS" \ - "$GF_PATHS_PLUGINS" \ - "$GF_PATHS_DATA" && \ - cp conf/sample.ini "$GF_PATHS_CONFIG" && \ - cp conf/ldap.toml /etc/grafana/ldap.toml && \ - chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ - chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" - -COPY --from=go-builder /src/grafana/bin/*/grafana-server /src/grafana/bin/*/grafana-cli /grafana/bin/*/grafana bin/ -COPY --from=js-builder /usr/src/app/public public -COPY --from=js-builder /usr/src/app/tools tools - -COPY packaging/docker/run.sh / - -USER grafana -ENTRYPOINT [ "/run.sh" ] diff --git a/Makefile b/Makefile index 18c873b8ad2..887df780d6a 100644 --- a/Makefile +++ b/Makefile @@ -158,12 +158,17 @@ shellcheck: $(SH_FILES) ## Run checks for shell scripts. build-docker-full: ## Build Docker image for development. @echo "build docker container" - docker build --tag grafana/grafana:dev . + DOCKER_BUILDKIT=1 \ + docker build \ + --tag grafana/grafana:dev . build-docker-full-ubuntu: ## Build Docker image based on Ubuntu for development. @echo "build docker container" - docker build --tag grafana/grafana:dev-ubuntu -f ./Dockerfile.ubuntu . - + DOCKER_BUILDKIT=1 \ + docker build \ + --build-arg BASE_IMAGE=ubuntu:20.04 \ + --build-arg GO_IMAGE=golang:1.19.3 \ + --tag grafana/grafana:dev-ubuntu . ##@ Services diff --git a/conf/defaults.ini b/conf/defaults.ini index 49c4582a280..c5977c38db9 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -85,6 +85,11 @@ cdn_url = # `0` means there is no timeout for reading the request. read_timeout = 0 +# This setting enables you to specify additional headers that the server adds to HTTP(S) responses. +[server.custom_response_headers] +#exampleHeader1 = exampleValue1 +#exampleHeader2 = exampleValue2 + #################################### Database ############################ [database] # You can configure the database connection by specifying type, host, name, user and password @@ -712,6 +717,10 @@ managed_identity_client_id = # If enabled, cache permissions in a in memory cache permission_cache = true +# Reset basic roles permissions on boot +# Warning left to true, basic roles permissions will be reset on every boot +reset_basic_roles = false + #################################### SMTP / Emailing ##################### [smtp] enabled = false @@ -1359,3 +1368,14 @@ index_update_interval = 10s # Move a specific app plugin page (referenced by its `path` field) to a specific navigation section # Format: =
[navigation.app_standalone_pages] + + +#################################### Secure Socks5 Datasource Proxy ##################################### +[secure_socks_datasource_proxy] +enabled = false +root_ca_cert = +client_key = +client_cert = +server_name = +# The address of the socks5 proxy datasources should connect to +proxy_address = \ No newline at end of file diff --git a/conf/sample.ini b/conf/sample.ini index 7c27b34819b..c3820a13193 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -86,6 +86,11 @@ # `0` means there is no timeout for reading the request. ;read_timeout = 0 +# This setting enables you to specify additional headers that the server adds to HTTP(S) responses. +[server.custom_response_headers] +#exampleHeader1 = exampleValue1 +#exampleHeader2 = exampleValue2 + #################################### Database #################################### [database] # You can configure the database connection by specifying type, host, name, user and password @@ -698,6 +703,11 @@ #################################### Role-based Access Control ########### [rbac] ;permission_cache = true + +# Reset basic roles permissions on boot +# Warning left to true, basic roles permissions will be reset on every boot +#reset_basic_roles = false + #################################### SMTP / Emailing ########################## [smtp] ;enabled = false @@ -1285,3 +1295,13 @@ [navigation.app_standalone_pages] # The following will move the page with the path "/a/my-app-id/starred-content" from `my-app-id` to the `starred` section # /a/my-app-id/starred-content = starred + +#################################### Secure Socks5 Datasource Proxy ##################################### +[secure_socks_datasource_proxy] +; enabled = false +; root_ca_cert = +; client_key = +; client_cert = +; server_name = +# The address of the socks5 proxy datasources should connect to +; proxy_address = \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json b/devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json new file mode 100644 index 00000000000..43d7655053e --- /dev/null +++ b/devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json @@ -0,0 +1,320 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 530, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decmbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 11, + "x": 0, + "y": 0 + }, + "id": 2, + "maxDataPoints": 30, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "vertical", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelMaxLength": 6, + "xTickLabelRotation": 45, + "xTickLabelSpacing": 100 + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"label\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 600000\n }\n },\n {\n \"name\": \"A-series\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {},\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n \"acquisition\",\n \"extension\",\n \"conductor\",\n \"authorise\",\n \"architect\",\n \"illusion\",\n \"congress\",\n \"highlight\",\n \"partnership\",\n \"understanding\",\n \"disagreement\",\n \"personality\",\n \"commerce\",\n \"systematic\",\n \"hesitate\",\n \"business\",\n \"manufacture\",\n \"incredible\",\n \"constitutional\",\n \"prevalence\",\n \"professor\",\n \"entitlement\",\n \"cooperation\",\n \"sickness\",\n \"contrast\",\n \"reference\",\n \"audience\",\n \"discount\",\n \"apparatus\",\n \"disturbance\",\n \"automatic\",\n \"refrigerator\",\n \"elaborate\",\n \"sympathetic\",\n \"integration\",\n \"president\"\n ],\n [\n 306.78931659492116,\n 200.00696051101917,\n 164.90889283973593,\n 518.9385023737021,\n 999.9040675564702,\n 613.9689830172349,\n 773.2337077340269,\n 317.47395634701644,\n 748.3318338316539,\n 606.8039493787173,\n 426.27771317792866,\n 376.47735643253924,\n 66.30635081800493,\n 401.70654338415505,\n 108.86259550477234,\n 182.40284186231278,\n 867.7047958572101,\n 959.3957783599242,\n 396.7606089549935,\n 455.9625595614323,\n 685.4792456298062,\n 368.6567303946707,\n 157.06596562976327,\n 59.54120602048763,\n 406.72723615743973,\n 440.18247585615575,\n 516.0267558264891,\n 258.76006051667315,\n 952.966531725171,\n 554.8746357628739,\n 86.7279280805682,\n 781.2422516386563,\n 754.2723802427706,\n 435.0305712850233,\n 384.43181614983,\n 459.04164596738127\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Panel Title", + "type": "barchart" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 7, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 13, + "x": 11, + "y": 0 + }, + "id": 5, + "options": { + "barRadius": 0, + "barWidth": 1, + "groupWidth": 0.82, + "legend": { + "calcs": [ + "max" + ], + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "text": {}, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 45, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "csvContent": "Name,Stat1,Stat2\nStockholm, 10, 15\nNew York, 19, -5\nLondon, 10, 1\nLong value, 15,10", + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Auto sizing & auto show values", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decmbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 18, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 3, + "maxDataPoints": 20, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelMaxLength": 5, + "xTickLabelRotation": 45, + "xTickLabelSpacing": 100 + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "Panel Title", + "type": "barchart" + } + ], + "schemaVersion": 37, + "style": "dark", + "tags": [ + "gdev", + "panel-tests", + "barchart", + "graph-ng" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "BarChart - Label Rotation & Skipping", + "uid": "xCmMwXdVz", + "version": 20, + "weekStart": "" + } diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index fa47dc594bf..f5787a61e6f 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -107,6 +107,13 @@ local dashboard = grafana.dashboard; id: 0, } }, + dashboard.new('barchart-label-rotation-skipping', import '../dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json') + + resource.addMetadata('folder', 'dev-dashboards') + + { + spec+: { + id: 0, + } + }, dashboard.new('barchart-thresholds-mappings', import '../dev-dashboards/panel-barchart/barchart-thresholds-mappings.json') + resource.addMetadata('folder', 'dev-dashboards') + { diff --git a/docs/sources/administration/organization-preferences/index.md b/docs/sources/administration/organization-preferences/index.md index ccd7c593060..e18fd09294f 100644 --- a/docs/sources/administration/organization-preferences/index.md +++ b/docs/sources/administration/organization-preferences/index.md @@ -101,7 +101,7 @@ Here is an example of the light theme. ### Change server UI theme -Grafana server administrators can change the Grafana UI theme for all users on the server by setting the [default_theme]({{< relref "../../setup-grafana/configure-grafana/#default-theme" >}}) option in the Grafana configuration file. +As a Grafana server administrator, you can change the default Grafana UI theme for all users who are on the server by setting the [default_theme]({{< relref "../../setup-grafana/configure-grafana/#default-theme" >}}) option in the Grafana configuration file. To see what the current settings are, refer to [View server settings]({{< relref "../stats-and-license#view-server-settings" >}}). @@ -111,17 +111,18 @@ Organization administrators can change the UI theme for all users in an organiza 1. Hover your cursor over the **Configuration** (gear) icon. 1. Click **Preferences**. -1. In the Preferences section, select the **UI theme**. +1. In the **Preferences** section, select the **UI theme**. 1. Click **Save**. ### Change team UI theme -Organization and team administrators can change the UI theme for all users in a team. +Organization and team administrators can change the UI theme for all users on a team. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team that you want to change the UI theme for and then navigate to the **Settings** tab. -1. In the Preferences section, select the **UI theme**. +1. Click the team for which you want to change the UI theme. +1. Click **Settings**. +1. In the **Preferences** section, select the **UI theme**. 1. Click **Save**. ### Change your personal UI theme @@ -129,7 +130,7 @@ Organization and team administrators can change the UI theme for all users in a You can change the UI theme for your user account. This setting overrides UI theme settings at higher levels. 1. On the left menu, hover your cursor over your avatar and then click **Preferences**. -1. In the Preferences section, select the **UI theme**. +1. In the **Preferences** section, select the **UI theme**. 1. Click **Save**. ## Change the Grafana default timezone @@ -153,11 +154,12 @@ Organization administrators can choose a default timezone for their organization ### Set team timezone -Organization administrators and team administrators can choose a default timezone for all users in a team. +Organization administrators and team administrators can choose a default timezone for all users on a team. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team you that you want to change the timezone for and then navigate to the **Settings** tab. +1. Click the team for which you want to change the timezone. +1. Click **Settings** 1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. Refer to [[Time range controls]({{< relref "../../dashboards/manage-dashboards/#configure-dashboard-time-range-controls" >}}) for more information about Grafana time settings. 1. Click **Save**. @@ -207,7 +209,7 @@ default_home_dashboard_path = data/main-dashboard.json ### Set the home dashboard for your organization -Organization administrators can choose a home dashboard for their organization. +Organization administrators can choose a default home dashboard for their organization. 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. @@ -218,13 +220,14 @@ Organization administrators can choose a home dashboard for their organization. ### Set home dashboard for your team -Organization administrators and Team Admins can choose a home dashboard for a team. +Organization administrators and Team Admins can set a default home dashboard for all users on a team. 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team that you want to change the home dashboard for and then navigate to the **Settings** tab. +1. Click the team for which you want to change the home dashboard. +1. Click **Settings**. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. 1. Click **Save**. @@ -237,3 +240,37 @@ You can choose your own personal home dashboard. This setting overrides all home 1. On the left menu, hover your cursor over your avatar and then click **Preferences**. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. 1. Click **Save**. + +## Change Grafana language + +### Change server language + +Grafana server administrators can change the default Grafana UI language for all users on the server by setting the [default_language]({{< relref "../../setup-grafana/configure-grafana/#default-language" >}}) option in the Grafana configuration file. + +### Change organization language + +Organization administrators can change the language for all users in an organization. + +1. Hover your cursor over the **Configuration** (gear) icon. +1. Click **Preferences**. +1. In the **Preferences** section, select the **Language**. +1. Click **Save**. + +### Change team language + +Organization and team administrators can set a default language for all users on a team. + +1. Hover your cursor over the **Configuration** (gear) icon in the side menu. +1. Click **Teams**. Grafana displays the team list. +1. Click the team for which you want to change the language. +1. Click **Settings** +1. In the **Preferences** section, select the **Language**. +1. Click **Save**. + +### Change your personal language + +You can change the language for your user account. This setting overrides language settings at higher levels. + +1. On the left menu, hover your cursor over your avatar and then click **Preferences**. +1. In the **Preferences** section, select the **language**. +1. Click **Save**. diff --git a/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md b/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md index 05f9e61cbce..3de508026a2 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md @@ -18,6 +18,7 @@ The table below describes all RBAC configuration options. Like any other Grafana | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `permission_cache` | No | Enable to use in memory cache for loading and evaluating users' permissions. | `true` | | `permission_validation_enabled` | No | Grafana enforces validation for permissions when a user creates or updates a role. The system checks the internal list of scopes and actions for each permission to determine they are valid. By default, if a scope or action is not recognized, Grafana logs a warning message. When set to `true`, Grafana returns an error. | `false` | +| `reset_basic_roles` | No | Reset Grafana's basic roles' (Viewer, Editor, Admin, Grafana Admin) permissions to their default. Warning, if this configuration option is left to `true` this will be done on every reboot. | `true` | ## Example RBAC configuration diff --git a/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md b/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md index 125390be437..1bc77408b09 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md @@ -309,7 +309,29 @@ You can also change basic roles' permissions using the API. Refer to the [RBAC H ## Reset basic roles to their default -This section describes how to reset the basic roles to their default: +This section describes how to reset the basic roles to their default. + +You have two options to reset the basic roles permissions to their default. + +### Use the configuration option + +> **Note**: Available as of Grafana Enterprise 9.4. + +> Warning: If this option is left to true, permissions will be reset on every boot. + +Use the [reset_basic_roles]({{< relref "../configure-rbac/#configure-rbac-in-grafana" >}}) option to reset +basic roles permissions to their default on Grafana instance boot up. + +1. Open you configuration file and update the rbac section as follow: + +```bash +[rbac] +reset_basic_roles = true +``` + +### Use the http endpoint + +An alternative to the configuration option is to use the HTTP endpoint. 1. Open the YAML configuration file and locate the `roles` section. @@ -327,7 +349,7 @@ This section describes how to reset the basic roles to their default: permissions: # Permission allowing to reset basic roles - action: 'roles:write' - scope: 'permissions:type:escalate' + scope: 'permissions:type:escalate' ``` 1. As a `Grafana Admin`, call the API endpoint to reset the basic roles to their default. Refer to the [RBAC HTTP API]({{< relref "../../../../developers/http_api/access_control/#reset-basic-roles-to-their-default" >}}) for more details. diff --git a/docs/sources/administration/user-management/user-preferences/index.md b/docs/sources/administration/user-management/user-preferences/index.md index 3f4cc1e0189..3b654836ffb 100644 --- a/docs/sources/administration/user-management/user-preferences/index.md +++ b/docs/sources/administration/user-management/user-preferences/index.md @@ -29,9 +29,7 @@ You can change your Grafana password at any time. 1. Sign in to Grafana. 1. Hover your mouse over the user icon in the lower-left corner of the page. -1. Click **Change Password**. - Grafana opens the **Change Password** tab. - +1. Click **Change Password**. Grafana opens the **Change Password** tab. 1. Enter your old password and a new password. 1. Confirm your new password. 1. Click **Change Password**. @@ -54,6 +52,7 @@ You can choose the way you would like data to appear in Grafana, including the U - **Home dashboard** refers to the dashboard you see when you sign in to Grafana. By default, this is set to the Home dashboard. - **Timezone** is used by dashboards when you set time ranges, so that you view data in your timezone instead of UTC. - **Week start** is the first day of the week you want to use in dashboard time ranges, for example, `This week`. +- **Language** determines the language used for parts of the Grafana interface. **To edit your preferences**: diff --git a/docs/sources/alerting/fundamentals/annotation-label/_index.md b/docs/sources/alerting/fundamentals/annotation-label/_index.md index e7df1de8a42..0dc9a50252e 100644 --- a/docs/sources/alerting/fundamentals/annotation-label/_index.md +++ b/docs/sources/alerting/fundamentals/annotation-label/_index.md @@ -10,18 +10,34 @@ keywords: - guide - rules - create -title: Annotations and labels for alerting rules +title: Labels and annotations weight: 401 --- -# Annotations and labels for alerting rules +# Labels and annotations -Annotations and labels are key value pairs associated with alerts originating from the alerting rule, datasource response, and as a result of alerting rule evaluation. They can be used in alert notifications directly or in templates and template functions to create notification content dynamically. +Labels and annotations contain information about an alert. Both labels and annotations have the same structure: a set of named values; however their intended uses are different. An example of label, or the equivalent annotation, might be `alertname="test"`. -## Annotations +The main difference between a label and an annotation is that labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert. -Annotations are key-value pairs that provide additional meta-information about an alert. You can use the following annotations: `description`, `summary`, `runbook_url`, `alertId`, `dashboardUid`, and `panelId`. For example, a description, a summary, and a runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates. +For example, consider two high CPU alerts: one for `server1` and another for `server2`. In such an example we might have a label called `server` where the first alert has the label `server="server1"` and the second alert has the label `server="server2"`. However, we might also want to add a description to each alert such as `"The CPU usage for server1 is above 75%."`, where `server1` and `75%` are replaced with the name and CPU usage of the server (please refer to the documentation on [templating labels and annotations]({{< relref "./variables-label-annotation" >}}) for how to do this). This kind of description would be more suitable as an annotation. ## Labels -Labels are key-value pairs that contain information about, and are used to uniquely identify an alert. The label set for an alert is generated and added to throughout the alerting evaluation and notification process. +Labels contain information that identifies an alert. An example of a label might be `server=server1`. Each alert can have more than one label, and the complete set of labels for an alert is called its label set. It is this label set that identifies the alert. + +For example, an alert might have the label set `{alertname="High CPU usage",server="server1"}` while another alert might have the label set `{alertname="High CPU usage",server="server2"}`. These are two separate alerts because although their `alertname` labels are the same, their `server` labels are different. + +The label set for an alert is a combination of the labels from the datasource, custom labels from the alert rule, and a number of reserved labels such as `alertname`. + +### Custom Labels + +Custom labels are additional labels from the alert rule. Like annotations, custom labels must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. Documentation on how to template custom labels can be found [here]({{< relref "./variables-label-annotation" >}}). + +When using custom labels with templates it is important to make sure that the label value does not change between consecutive evaluations of the alert rule as this will end up creating large numbers of distinct alerts. However, it is OK for the template to produce different label values for different alerts. For example, do not put the value of the query in a custom label as this will end up creating a new set of alerts each time the value changes. Instead use annotations. + +It is also important to make sure that the label set for an alert does not have two or more labels with the same name. If a custom label has the same name as a label from the datasource then it will replace that label. However, should a custom label have the same name as a reserved label then the custom label will be omitted from the alert. + +## Annotations + +Annotations are named pairs that add additional information to existing alerts. There are a number of suggested annotations in Grafana such as `description`, `summary`, `runbook_url`, `dashboardUId` and `panelId`. Like custom labels, annotations must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. If an annotation contains template code, the template is evaluated once when the alert is fired. It is not re-evaluated, even when the alert is resolved. Documentation on how to template annotations can be found [here]({{< relref "./variables-label-annotation" >}}). diff --git a/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md b/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md index f4bff768cdb..e3b7be61cbb 100644 --- a/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md +++ b/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md @@ -1,27 +1,23 @@ --- aliases: - /docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation/ -description: Learn about labels and label matchers in alerting +description: Learn about templating of labels and annotations keywords: - grafana - alerting - guide - fundamentals -title: How to template annotations and labels +title: Templating labels and annotations weight: 117 --- -# How to template annotations and labels +# Templating labels and annotations -In Grafana it is possible to template annotations and labels just like you would in Prometheus. Those who have used -Prometheus before should be familiar with the `$labels` variable which holds the label key/value pairs of the alert -instance and the `$value` variable which holds the evaluated value of the alert instance. +In Grafana it is possible to template labels and annotations just like you would in Prometheus. Those who have used Prometheus before should be familiar with the `$labels` variable which holds the label key/value pairs of the alert instance and the `$value` variable which holds the evaluated value of the alert instance. -In Grafana it is possible to use the same variables from Prometheus to template annotations and labels, even if your -alert does not use a Prometheus datasource. +In Grafana it is possible to use the same variables from Prometheus to template labels and annotations, even if your alert does not use a Prometheus datasource. -For example, let's suppose we want to create an alert in Grafana that tells us when one of our instances is down for -more than 5 minutes. Like in Prometheus, we can add a summary annotation to show the instance which is down: +For example, let's suppose we want to create an alert in Grafana that tells us when one of our instances is down for more than 5 minutes. Like in Prometheus, we can add a summary annotation to show the instance which is down: ``` Instance {{ $labels.instance }} has been down for more than 5 minutes @@ -43,17 +39,13 @@ of the condition at the time the alert fired. For example: ## Alert rules with two or more queries or expressions -In the case where an alert rule has two or more queries, or uses reduce and math expressions, it is possible to template -the reduced result of each query and expression with the `$values` variable. This variable holds the labels and value of -each reduced query, and the results of any math expressions. However, it does not hold the samples for each query. +In the case where an alert rule has two or more queries, or uses reduce and math expressions, it is possible to template the reduced result of each query and expression with the `$values` variable. This variable holds the labels and value of each reduced query, and the results of any math expressions. However, it does not hold the samples for each query. For example, suppose you have the following alert rule: {{< figure src="/static/img/docs/alerting/unified/grafana-alerting-histogram-quantile.png" class="docs-image--no-shadow" caption="An alert rule that uses histogram_quantile to compute 95th percentile" >}} -Should this rule create an alert instance `$values` will hold the result of the reduce expression `B` and the math -expression `C`. It will not hold the results returned by query `A` because query `A` does not return a single value -but rather a series of values over time. +Should this rule create an alert instance `$values` will hold the result of the reduce expression `B` and the math expression `C`. It will not hold the results returned by query `A` because query `A` does not return a single value but rather a series of values over time. If we were to write a summary annotation such as: @@ -61,15 +53,13 @@ If we were to write a summary annotation such as: {{ $labels.instance }} has a 95th percentile request latency above 1s: {{ $value }}) ``` -We would find that because the condition of the alert, the math expression `C` must be a boolean comparison, it must -return either a `0` or a `1`. What we want instead is the 95th percentile from the reduce expression `B`: +We would find that because the condition of the alert, the math expression `C` must be a boolean comparison, it must return either a `0` or a `1`. What we want instead is the 95th percentile from the reduce expression `B`: ``` {{ $labels.instance }} has a 95th percentile request latency above 1s: {{ $values.B }}) ``` -We can also show the labels of `B`, however since this alert rule has just one query the labels of `B` are equivalent to -`$labels`: +We can also show the labels of `B`, however since this alert rule has just one query the labels of `B` are equivalent to `$labels`: ``` {{ $values.B.Labels.instance }} has a 95th percentile request latency above 1s: {{ $values.B }}) @@ -78,8 +68,7 @@ We can also show the labels of `B`, however since this alert rule has just one q ### No data and execution errors or timeouts Should query `A` return no data then the reduce expression `B` will also return no data. This means that -`{{ $values.B }}` will be nil. To ensure that annotations and labels can still be templated even when a query returns -no data, we can use an if statement to check for `$values.B`: +`{{ $values.B }}` will be nil. To ensure that labels and annotations can still be templated even when a query returns no data, we can use an if statement to check for `$values.B`: ``` {{ if $values.B }}{{ $labels.instance }} has a 95th percentile request latency above 1s: {{ $values.B }}){{ end }} @@ -87,15 +76,28 @@ no data, we can use an if statement to check for `$values.B`: ## Classic conditions -If the rule uses a classic condition instead of a reduce and math expression, then `$values` contains the combination -of the `refID` and position of the condition. For example, `{{ $values.A0 }}` and `{{ $values.A1 }}`. +If the rule uses a classic condition instead of a reduce and math expression, then `$values` contains the combination of the `refID` and position of the condition. For example, `{{ $values.A0 }}` and `{{ $values.A1 }}`. ## Variables -The following template variables are available when expanding annotations and labels. +The following template variables are available when expanding labels and annotations. | Name | Description | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | $labels | The labels from the query or condition. For example, `{{ $labels.instance }}` and `{{ $labels.job }}`. This is unavailable when the rule uses a [classic condition]({{< relref "../../alerting-rules/create-grafana-managed-rule/#single-and-multi-dimensional-rule" >}}). | | $values | The values of all reduce and math expressions that were evaluated for this alert rule. For example, `{{ $values.A }}`, `{{ $values.A.Labels }}` and `{{ $values.A.Value }}` where `A` is the `refID` of the reduce or math expression. If the rule uses a classic condition instead of a reduce and math expression, then `$values` contains the combination of the `refID` and position of the condition. | | $value | The value string of the alert instance. For example, `[ var='A' labels={instance=foo} value=10 ]`. | + +### Labels with dots + +If a label contains a dot (full stop or period) in its name then the following will not work: + +``` +Instance {{ $labels.instance.name }} has been down for more than 5 minutes +``` + +This is because we are printing a non-existing field `name` in `$labels.instance` rather than `instance.name` in `$labels`. Instead we can use the `index` function to print `instance.name`: + +``` +Instance {{ index $labels "instance.name" }} has been down for more than 5 minutes +``` diff --git a/docs/sources/alerting/fundamentals/contact-points/index.md b/docs/sources/alerting/fundamentals/contact-points/index.md deleted file mode 100644 index caaf4235969..00000000000 --- a/docs/sources/alerting/fundamentals/contact-points/index.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/alerting/contact-points/ - - /docs/grafana/latest/alerting/unified-alerting/contact-points/ - - /docs/grafana/latest/alerting/fundamentals/contact-points/contact-point-types/ -description: Create or edit contact point -keywords: - - grafana - - alerting - - guide - - contact point - - notification channel - - create -title: Contact points -weight: 410 ---- - -# Contact points - -Use contact points to define how your contacts are notified when an alert rule fires. A contact point can have one or more contact point types, for example, email, slack, webhook, and so on. When an alert rule fires, a notification is sent to all contact point types listed for a contact point. Contact points can be configured for the Grafana Alertmanager as well as external alertmanagers. - -You can also use message templating to customize notification messages for contact point types. - -## Supported contact point types - -The following table lists the contact point types supported by Grafana. - -| Name | Type | Grafana Alertmanager | Other Alertmanagers | -| ------------------------------------------------ | ------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- | -| [DingDing](https://www.dingtalk.com/en) | `dingding` | Supported | N/A | -| [Discord](https://discord.com/) | `discord` | Supported | N/A | -| [Email](#email) | `email` | Supported | Supported | -| [Google Hangouts](https://hangouts.google.com/) | `googlechat` | Supported | N/A | -| [Kafka](https://kafka.apache.org/) | `kafka` | Supported | N/A | -| [Line](https://line.me/en/) | `line` | Supported | N/A | -| [Microsoft Teams](https://teams.microsoft.com/) | `teams` | Supported | N/A | -| [Opsgenie](https://atlassian.com/opsgenie/) | `opsgenie` | Supported | Supported | -| [Pagerduty](https://www.pagerduty.com/) | `pagerduty` | Supported | Supported | -| [Prometheus Alertmanager](https://prometheus.io) | `prometheus-alertmanager` | Supported | N/A | -| [Pushover](https://pushover.net/) | `pushover` | Supported | Supported | -| [Sensu Go](https://docs.sensu.io/sensu-go/) | `sensugo` | Supported | N/A | -| [Slack](https://slack.com/) | `slack` | Supported | Supported | -| [Telegram](https://telegram.org/) | `telegram` | Supported | N/A | -| [Threema](https://threema.ch/) | `threema` | Supported | N/A | -| [VictorOps](https://help.victorops.com/) | `victorops` | Supported | Supported | -| [Webhook](#webhook) | `webhook` | Supported | Supported ([different format](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config)) | -| [Cisco Webex Teams](#webex) | `webex` | Supported | Supported | -| [WeCom](#wecom) | `wecom` | Supported | N/A | -| [Zenduty](https://www.zenduty.com/) | `webhook` | Supported | N/A | - -## Useful links - -[Manage contact points](https://grafana.com/docs/grafana/next/alerting/manage-notifications/create-contact-point/) - -[Create and edit message templates](https://grafana.com/docs/grafana/next/alerting/manage-notifications/create-message-template/) diff --git a/docs/sources/alerting/fundamentals/notifications/index.md b/docs/sources/alerting/fundamentals/notifications/index.md new file mode 100644 index 00000000000..87235753bf8 --- /dev/null +++ b/docs/sources/alerting/fundamentals/notifications/index.md @@ -0,0 +1,91 @@ +--- +aliases: + - /docs/grafana/latest/alerting/notifications/ + - /docs/grafana/latest/alerting/contact-points/ + - /docs/grafana/latest/alerting/unified-alerting/contact-points/ + - /docs/grafana/latest/alerting/fundamentals/contact-points/contact-point-types/ +description: Create or edit contact point +keywords: + - grafana + - alerting + - guide + - contact point + - notification channel + - create +title: Notifications +weight: 410 +--- + +# Notifications + +Notifications are sent when an alert is firing or has been resolved. You use notification policies to configure how and where a notification is sent; how often a notification should be sent; and whether alerts should all be sent in the same notification, sent in grouped notifications based on a set of labels, or as separate notifications. + +## Notification policies + +Notification policies control when and where notifications are sent. A notification policy can choose to send all alerts together in the same notification, send alerts in grouped notifications based on a set of labels, or send alerts as separate notifications. You can configure each notification policy to control how often notifications should be sent as well as having one or more mute timings to inhibit notifications at certain times of the day and on certain days of the week. + +Notification policies are organized in a tree structure where at the root of the tree there is a notification policy called the root policy. There can be only one root policy and the root policy cannot be deleted. + +Specific routing policies are descendents of the root policy and can be used to match either all alerts or a subset of alerts based on a set of matching labels. A notification policy matches an alert when its matching labels match the labels in the alert. + +A specific routing policy can have its own descendent policies, called nested policies, which allow for additional matching of alerts. An example of a specific routing policy could be sending infrastructure alerts to the Ops team; while a descendent policy might send high priority alerts to Pagerduty and low priority alerts as emails. + +All alerts, irrespective of their labels, match the root policy. However, when the root policy receives an alert it looks at each specific routing policy and sends the alert to the first specific routing policy that matches the alert. If the specific routing policy has further descendent policies, then it can attempt to the match the alert against one of its nested policies. If no nested policies match the alert then the specific routing policy is the matching policy. If there are no specific routing policies, or no specific routing policies match the alert, then the root policy is the matching policy. + +More information on how to configure notification policies can be found [here]({{< relref "../../manage-notifications/create-notification-policy/" >}}). + +## Contact points + +Contact points contain the configuration for sending notifications. A contact point is a list of integrations, each of which sends a notification to a particular email address, service or URL. Contact points can have multiple integrations of the same kind, or a combination of integrations of different kinds. For example, a contact point could contain a Pagerduty integration; an email and Slack integration; or a Pagerduty integration, a Slack integration, and two email integrations. You can also configure a contact point with no integrations; in which case no notifications are sent. + +A contact point cannot send notifications until it has been added to a notification policy. A notification policy can only send alerts to one contact point, but a contact point can be added to a number of notification policies at the same time. When an alert matches a notification policy, the alert is sent to the contact point in that notification policy, which then sends a notification to each integration in its configuration. + +### Supported integrations + +The following table contains the integrations supported in Grafana: + +| Name | Type | Grafana Alertmanager | Other Alertmanagers | +| ------------------------------------------------ | ------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- | +| [DingDing](https://www.dingtalk.com/en) | `dingding` | Supported | N/A | +| [Discord](https://discord.com/) | `discord` | Supported | N/A | +| [Email](#email) | `email` | Supported | Supported | +| [Google Hangouts](https://hangouts.google.com/) | `googlechat` | Supported | N/A | +| [Kafka](https://kafka.apache.org/) | `kafka` | Supported | N/A | +| [Line](https://line.me/en/) | `line` | Supported | N/A | +| [Microsoft Teams](https://teams.microsoft.com/) | `teams` | Supported | N/A | +| [Opsgenie](https://atlassian.com/opsgenie/) | `opsgenie` | Supported | Supported | +| [Pagerduty](https://www.pagerduty.com/) | `pagerduty` | Supported | Supported | +| [Prometheus Alertmanager](https://prometheus.io) | `prometheus-alertmanager` | Supported | N/A | +| [Pushover](https://pushover.net/) | `pushover` | Supported | Supported | +| [Sensu Go](https://docs.sensu.io/sensu-go/) | `sensugo` | Supported | N/A | +| [Slack](https://slack.com/) | `slack` | Supported | Supported | +| [Telegram](https://telegram.org/) | `telegram` | Supported | N/A | +| [Threema](https://threema.ch/) | `threema` | Supported | N/A | +| [VictorOps](https://help.victorops.com/) | `victorops` | Supported | Supported | +| [Webhook](#webhook) | `webhook` | Supported | Supported ([different format](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config)) | +| [Cisco Webex Teams](#webex) | `webex` | Supported | Supported | +| [WeCom](#wecom) | `wecom` | Supported | N/A | +| [Zenduty](https://www.zenduty.com/) | `webhook` | Supported | N/A | + +## Templating notifications + +You can customize notifications with templates. For example, templates can be used to change the subject and message of an email, or the title and message of notifications sent to Slack. + +Templates are not limited to an individual integration or contact point, but instead can be used in a number of integrations in the same contact point and even integrations across different contact points. For example, a Grafana user can create a template called `custom_subject_or_title` and use it for both templating subjects in emails and titles of Slack messages without having to create two separate templates. + +All notifications templates are written in [Go's templating language](https://pkg.go.dev/text/template), and are in the Contact points tab on the Alerting page. + +More information on how to template notifications can be found [here]({{< relref "../../manage-notifications/create-message-template/" >}}). + +## Silences + +You can use silences to mute notifications from one or more firing rules. Silences do not stop alerts from firing or being resolved, or hide firing alerts in the user interface. A silence lasts as long as its duration which can be configured in minutes, hours, days, months or years. + +More information on how to configure silences can be found [here]({{< relref "../../manage-notifications/create-silence/" >}}). + +## Useful links + +- [Notification policies]({{< relref "../../manage-notifications/create-notification-policy/" >}}) +- [Contact points]({{< relref "../../manage-notifications/create-contact-point/" >}}) +- [Templating notifications]({{< relref "../../manage-notifications/create-message-template/" >}}) +- [Silences]({{< relref "../../manage-notifications/create-silence/" >}}) diff --git a/docs/sources/alerting/fundamentals/silences/index.md b/docs/sources/alerting/fundamentals/silences/index.md deleted file mode 100644 index 33b8dcac3c7..00000000000 --- a/docs/sources/alerting/fundamentals/silences/index.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/alerting/silences/ - - /docs/grafana/latest/alerting/unified-alerting/silences/ - - /docs/grafana/latest/alerting/fundamentals/silences -description: Silences -keywords: - - grafana - - alerting - - silence - - mute -title: Silences -weight: 420 ---- - -# Silences - -Use silences to stop notifications from one or more alerting rules. Silences do not prevent alert rules from being evaluated. They also do not stop alert instances from being shown in the user interface. Silences only stop notifications from getting created. A silence lasts for only a specified window of time. - -You can configure Grafana managed silences as well as silences for an external Alertmanager data source. - -## Useful links - -[Create silences](https://grafana.com/docs/grafana/latest/alerting/manage-notifications/create-silence/) diff --git a/docs/sources/alerting/manage-notifications/images-in-notifications.md b/docs/sources/alerting/manage-notifications/images-in-notifications.md index 5db84b342fb..6a8260a2db9 100644 --- a/docs/sources/alerting/manage-notifications/images-in-notifications.md +++ b/docs/sources/alerting/manage-notifications/images-in-notifications.md @@ -12,34 +12,58 @@ weight: 460 # Use images in notifications -Images in notifications helps recipients of alert notifications better understand why an alert has fired or resolved by including an image of the panel associated with the Grafana managed alert rule. +Images in notifications helps recipients of alert notifications better understand why an alert has fired or resolved by including a screenshot of the panel associated with the alert. -> **Note**: Images in notifications are not available for Grafana Mimir and Loki managed alert rules, or when Grafana is set up to send alert notifications to an external Alertmanager. +> **Note**: This feature is not supported for Mimir or Loki rules, or when Grafana sends alert notifications to an external Alertmanager. -If Grafana is set up to send images in notifications, it takes a screenshot of the panel for the Grafana managed alert rule when either of the following happen: +When an alert is fired or resolved Grafana takes a screenshot of the panel associated with the alert. This is determined via the Dashboard UID and Panel ID annotations of the rule. Grafana cannot take a screenshot for alerts that are not associated with a panel. -1. The alert rule transitions from pending to firing -2. The alert rule transitions from firing to OK +Because a number of contact points, such as email, do not support uploading screenshots at the time of sending a notification; Grafana can also upload the screenshot to a cloud storage service such as Amazon S3, Azure Blob Storage and Google Cloud Storage, where a link to the uploaded screenshot can be added to the notification. However, if using a cloud storage service is not an option then Grafana can be its own cloud storage service such that the screenshot is available under the same domain as Grafana. -Grafana does not support images for alert rules that are not associated with a panel. An alert rule is associated with a panel when it has both Dashboard UID and Panel ID annotations. +Should either the cloud storage service, or Grafana if acting as its own cloud storage service, be protected by a firewall, gateway service or VPN, then screenshots might not be shown in notifications. -Images are stored in the [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) path and so Grafana must have write-access to this path. If Grafana cannot write to this path then screenshots cannot be saved to disk and an error will be logged for each failed screenshot attempt. In addition to storing images on disk, Grafana can also store the image in an external image store such as Amazon S3, Azure Blob Storage, Google Cloud Storage and even Grafana where screenshots are stored in `public/img/attachments`. Screenshots older than `temp_data_lifetime` are deleted from disk but not the external image store. If Grafana is the external image store then screenshots are deleted from `data` but not from `public/img/attachments`. +How to choose between uploading screenshots at the time of sending the notification, using a cloud storage service, or using Grafana as its own cloud storage service, depends on which contact points you plan to use and whether you use a firewall, gateway service or VPN. -> **Note**: It is recommended that you use an external image store, as not all contact points support uploading images from disk. It is also possible that the image on disk is deleted before an alert notification is sent if `temp_data_lifetime` is less than the `group_wait` and `group_interval` options used in Alertmanager. +For example, if a contact point supports uploading images at the time of notification is it not required to use cloud storage. Cloud storage is required when a contact point does not support uploading images at the time of sending a notification, such as email. We don't recommend using cloud storage if the cloud storage service is behind a firewall, gateway service, or VPN, as screenshots might not be shown in notifications. + +Please refer to the table at the end of this page for a list of contact points and their support for images in notifications. ## Requirements -To use images in notifications, Grafana must be set up to use [image rendering](https://grafana.com/docs/grafana/next/setup-grafana/image-rendering/). It is also recommended that Grafana is set up to upload images to an external image store, such as Amazon S3, Azure Blob Storage, Google Cloud Storage or even Grafana. +To use images in notifications, Grafana must be set up to use [image rendering](https://grafana.com/docs/grafana/next/setup-grafana/image-rendering/). You can either install the image rendering plugin or run it as a remote rendering service. + +When a screenshot is taken it is saved to the [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) path. This is where screenshots are stored before being sent in a notification or uploaded to a cloud storage service. Grafana must have write-access to this path. If Grafana cannot write to this path then screenshots cannot be saved to disk and an error will be logged for each failed screenshot attempt. + +If using a [cloud storage service](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_image_storage) such as Amazon S3, Azure Blob Storage or Google Cloud Storage, uploaded images need to be accessible outside of a firewall, gateway service or VPN for screenshots to be shown in notifications. Grafana will not delete screenshots from cloud storage. We recommend configuring a retention policy on the bucket to delete screenshots older than 1 month. + +If using Grafana as its own cloud storage service then screenshots will be saved to `static_root_path/img/attachments`. `static_root_path` is a configuration option for Grafana and can be found in `defaults.ini`. However, like when using a cloud storage service, images need to be accessible outside of a firewall, gateway service or VPN for screenshots to be shown in notifications. + +When using Grafana as its own cloud storage service screenshots are copied from [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) to `static_root_path/img/attachments`. Screenshots older than `temp_data_lifetime` are deleted from [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) but not from `static_root_path/images/attachments`. To delete screenshots from `static_root_path` after a certain amount of time we recommend setting up a CRON job. ## Configuration -If Grafana has been set up to use image rendering, images in notifications can be turned on via the `capture` option in `[unified_alerting.screenshots]`: +Having installed either the image rendering plugin, or set up Grafana to use a remote rendering service, set `capture` in `[unified_alerting.screenshots]` to `true`: # Enable screenshots in notifications. This option requires the Grafana Image Renderer plugin. # For more information on configuration options, refer to [rendering]. - capture = true + capture = false -It is recommended that `max_concurrent_screenshots` is set to a value that is less than or equal to `concurrent_render_request_limit`. The default value for both `max_concurrent_screenshots` and `concurrent_render_request_limit` is `5`: +If screenshots should be uploaded to cloud storage then `upload_external_image_storage` should also be set to `true`: + + # Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please + # see [external_image_storage] for further configuration options. If this option is false, screenshots + # will be persisted to disk for up to temp_data_lifetime. + upload_external_image_storage = false + +Please see [`[external_image_storage]`](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_image_storage) for instructions on how to configure cloud storage. Grafana will not start if `upload_external_image_storage` is `true` and `[external_image_storage]` contains missing or invalid configuration. + +If Grafana is acting as its own cloud storage then `[upload_external_image_storage]` should be set to `true` and the `local` provider should be set in [`[external_image_storage]`](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_image_storage). + +Restart Grafana for the changes to take effect. + +## Advanced configuration + +We recommended that `max_concurrent_screenshots` is less than or equal to `concurrent_render_request_limit`. The default value for both `max_concurrent_screenshots` and `concurrent_render_request_limit` is `5`: # The maximum number of screenshots that can be taken at the same time. This option is different from # concurrent_render_request_limit as max_concurrent_screenshots sets the number of concurrent screenshots @@ -47,41 +71,50 @@ It is recommended that `max_concurrent_screenshots` is set to a value that is le # the total number of concurrent screenshots across all Grafana services. max_concurrent_screenshots = 5 -If Grafana has been set up to use an external image store, `upload_external_image_storage` should be set to `true`: +## Support for images in contact points - # Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please - # see [external_image_storage] for further configuration options. If this option is false, screenshots - # will be persisted to disk for up to temp_data_lifetime. - upload_external_image_storage = false +Grafana supports a wide range of contact points with varied support for images in notifications. The table below shows the list of all contact points supported in Grafana and their support for uploading images at the time of sending the notification and images uploaded to cloud storage, including when Grafana is acting as its own cloud storage service. -Restart Grafana for the changes to take effect. +| Name | Upload image at time of notification | Cloud storage | +| ----------------------- | ------------------------------------ | ------------- | +| DingDing | No | No | +| Discord | Yes | Yes | +| Email | Yes | Yes | +| Google Hangouts Chat | No | Yes | +| Kafka | No | No | +| Line | No | No | +| Microsoft Teams | No | Yes | +| Opsgenie | No | Yes | +| Pagerduty | No | Yes | +| Prometheus Alertmanager | No | No | +| Pushover | Yes | No | +| Sensu Go | No | No | +| Slack | No (will be available in 9.4) | Yes | +| Telegram | Yes | No | +| Threema | No | No | +| VictorOps | No | No | +| Webhook | No | Yes | +| Cisco Webex Teams | No | Yes | -## Supported notifiers +## Limitations -Images in notifications are supported in the following notifiers and additional support will be added in the future: +- This feature is not supported for Mimir or Loki rules, or when Grafana sends alert notifications to an external Alertmanager. +- When multiple alerts are sent in a single notification a screenshot might be included for each alert. The order the images are shown in random. +- Some contact points support at most one image per notification. In this case, the first image associated with an alert will be attached. +- We don't recommend using cloud storage if the cloud storage service is behind a firewall, gateway service, or VPN, as screenshots might not be shown in notifications. -| Name | Upload images from disk | Include images from URL | -| ----------------------- | ----------------------- | ----------------------- | -| DingDing | No | No | -| Discord | Yes | Yes | -| Email | Yes | Yes | -| Google Hangouts Chat | No | Yes | -| Kafka | No | No | -| Line | No | No | -| Microsoft Teams | No | Yes | -| Opsgenie | No | Yes | -| Pagerduty | No | Yes | -| Prometheus Alertmanager | No | No | -| Pushover | Yes | No | -| Sensu Go | No | No | -| Slack | No | Yes | -| Telegram | No | No | -| Threema | No | No | -| VictorOps | No | No | -| Webhook | No | Yes | -| Cisco Webex Teams | No | Yes | +## Troubleshooting -Include images from URL refers to using the external image store. +If Grafana has been set up to send images in notifications, however notifications are still being received without them, follow the troubleshooting steps below: + +1. Check that images in notifications has been set up as per the instructions. +2. Enable debug logging in Grafana and look for logs with the logger `ngalert.image`. +3. If the alert is not associated with a dashboard there will be logs for `Cannot take screenshot for alert rule as it is not associated with a dashboard`. +4. If the alert is associated with a dashboard, but no panel in the dashboard, there will be logs for `Cannot take screenshot for alert rule as it is not associated with a panel`. +5. If images cannot be taken because of mis-configuration or an issue with image rendering there will be logs for `Failed to take an image` including the Dashboard UID, Panel ID, and the error message. +6. Check that the contact point supports images in notifications, and the present configuration, as per the table. +7. If the image was uploaded to cloud storage make sure it is public. +8. If images are made available via Grafana's built in web server make sure it is accessible via the Internet. ## Metrics @@ -95,20 +128,3 @@ For example, if a screenshot could not be taken within the expected time (10 sec - `grafana_screenshot_successes_total` - `grafana_screenshot_upload_failures_total` - `grafana_screenshot_upload_successes_total` - -## Limitations - -- Images in notifications are not available for Grafana Mimir and Loki managed alert rules, or when Grafana is set up to send alert notifications to an external Alertmanager. -- When alerts generated by different alert rules are sent in a single notification, there may be screenshots for each alert rule. This happens if an alert group contains multiple alerting rules. The order the images are attached is random. If you need to guarantee the ordering of images, make sure that your alert groups contain a single alerting rule. -- Some contact points only handle a single image. In this case, the first image associated with an alert will be attached. Because the ordering is random, this may not always be an image for the same alert rule. If you need to guarantee you receive a screenshot for a particular rule, make sure that your alert groups contain a single alerting rule. - -## Troubleshooting - -If Grafana has been set up to send images in notifications, however notifications are still being received without images, then check the troubleshooting instructions below. - -1. Check that Grafana has been set up as per the instructions -2. Check that the notifier supports images in notifications as per the table of supported notifiers -3. Enable debug logging in Grafana and look for logs with the logger `ngalert.image` -4. If the alert is not associated with a dashboard there will be logs for `Cannot take screenshot for alert rule as it is not associated with a dashboard` -5. If the alert is associated with a dashboard, but no panel in the dashboard, there will be logs for `Cannot take screenshot for alert rule as it is not associated with a panel` -6. If images cannot be taken because of mis-configuration or an issue with image rendering there will be logs for `Failed to take an image` including the Dashboard UID, Panel ID, and the error message diff --git a/docs/sources/datasources/aws-cloudwatch/_index.md b/docs/sources/datasources/aws-cloudwatch/_index.md index 2c2ce5f34bd..6a384f95ea6 100644 --- a/docs/sources/datasources/aws-cloudwatch/_index.md +++ b/docs/sources/datasources/aws-cloudwatch/_index.md @@ -168,6 +168,21 @@ You can attach these permissions to the IAM role or IAM user you configured in [ } ``` +**Cross-account observability:** + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["oam:ListSinks", "oam:ListAttachedLinks"], + "Effect": "Allow", + "Resource": "*" + } + ] +} +``` + ### Configure CloudWatch settings #### Namespaces of Custom Metrics diff --git a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md index d58c0402c6e..5110aeab486 100644 --- a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md +++ b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md @@ -214,6 +214,33 @@ When making `stats` queries in [Explore]({{< relref "../../../explore/" >}}), ma {{< figure src="/static/img/docs/v70/explore-mode-switcher.png" max-width="500px" class="docs-image--right" caption="Explore mode switcher" >}} +## Cross-account observability + +The CloudWatch plugin provides the ability to monitor and troubleshoot applications that span across multiple accounts within a region. Using cross-account observability, you can seamlessly search, visualize and analyze metrics and logs, without having to worry about account boundaries. + +> **Note:** This feature is currently behind the `cloudWatchCrossAccountQuerying` feature toggle. + +> You can enable feature toggles through configuration file or environment variables. See configuration [docs]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) for details. +> Grafana Cloud users can access this feature by [opening a support ticket in the Cloud Portal](https://grafana.com/profile/org#support). + +### Getting started + +To enable cross-account observability, first enable it in CloudWatch using the official [CloudWatch docs](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html), then add [two new API actions]({{< relref "../#cross-account-observability" >}}) to the IAM policy attached to the role/user running the plugin. + +Cross-account querying is available in the plugin through the `Logs` mode and the `Metric search` mode. Once you have it configured correctly, you'll see a "Monitoring account" badge displayed in the query editor header. + +{{< figure src="/static/img/docs/cloudwatch/cloudwatch-monitoring-badge-9.3.0.png" max-width="1200px" caption="Monitoring account badge" >}} + +### Metrics editor + +When you select the `Builder` mode within the Metric search editor, a new Account field displays. Use the Account field to specify which of the linked accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts. + +While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default. You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html). + +### Logs editor + +The Log group selector allows you to specify what log groups to target in the logs query. If the Monitoring account badge is displayed in the query editor header, it is possible to search and select log groups across multiple accounts. You can use the Account field in the Log Group Selector to filter Log Groups by Account. If you have many log groups and do not see the log group you'd like to select in the selector, use the prefix search to narrow down the possible log groups. + ### Deep-link Grafana panels to the CloudWatch console {{< figure src="/static/img/docs/v70/cloudwatch-logs-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch Logs deep linking" >}} diff --git a/docs/sources/datasources/jaeger/_index.md b/docs/sources/datasources/jaeger/_index.md index c85e02fcb67..fc606fdd115 100644 --- a/docs/sources/datasources/jaeger/_index.md +++ b/docs/sources/datasources/jaeger/_index.md @@ -53,8 +53,6 @@ You can also configure settings specific to the Jaeger data source: The **Trace to logs** section configures the [trace to logs feature]({{< relref "../../explore/trace-integration/" >}}). Select a target data source, limited to Loki and Splunk \[logs\] data sources, and which tags to use in the logs query. -{{< figure src="/static/img/docs/explore/trace-to-log-8-2.png" class="docs-image--no-shadow" caption="Screenshot of the trace to logs settings" >}} - | Name | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Data source** | Sets the target data source. | diff --git a/docs/sources/datasources/zipkin/_index.md b/docs/sources/datasources/zipkin/_index.md index 941d9390841..202e502ec94 100644 --- a/docs/sources/datasources/zipkin/_index.md +++ b/docs/sources/datasources/zipkin/_index.md @@ -50,10 +50,6 @@ Set the data source's basic configuration options carefully: The **Trace to logs** section configures the [trace to logs feature]({{< relref "../../explore/trace-integration/" >}}). Select a target data source, limited to Loki and Splunk \[logs\] data sources, and which tags to use in the logs query. - - | Name | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Data source** | Sets the target data source. | diff --git a/docs/sources/developers/plugins/sign-a-plugin.md b/docs/sources/developers/plugins/sign-a-plugin.md index 4d5730a1d86..159136beca3 100644 --- a/docs/sources/developers/plugins/sign-a-plugin.md +++ b/docs/sources/developers/plugins/sign-a-plugin.md @@ -40,11 +40,9 @@ Public plugins need to be reviewed by the Grafana team before you can sign them. ```bash export GRAFANA_API_KEY= - npx @grafana/sign-plugin plugin:sign + npx @grafana/sign-plugin ``` -> **Note:** If running NPM 7+ the `npx` commands mentioned in this article may hang. The workaround is to use `npx --legacy-peer-deps `. - ## Sign a private plugin 1. In your plugin directory, sign the plugin with the API key you just created. Grafana Sign Plugin creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. @@ -53,7 +51,7 @@ Public plugins need to be reviewed by the Grafana team before you can sign them. ```bash export GRAFANA_API_KEY= - npx @grafana/sign-plugin plugin:sign --rootUrls https://example.com/grafana + npx @grafana/sign-plugin --rootUrls https://example.com/grafana ``` ## Plugin signature levels diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md index 83bd5ccdc04..d8448b40f6c 100644 --- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md +++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md @@ -85,9 +85,27 @@ Click on the node and select "Show in Graph layout" option to switch back to gra This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it. -Data source needs to return two data frames, one for nodes and one for edges. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render. +Node Graph at minimum requires a data frame describing the edges of the graph. By default, node graph will compute the nodes and any stats based on this data frame. Optionally a second data frame describing the nodes can be sent in case there is need to show more node specific metadata. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render. -### Node parameters +### Edges data frame structure + +Required fields: + +| Field name | Type | Description | +| ---------- | ------ | ------------------------------ | +| id | string | Unique identifier of the edge. | +| source | string | Id of the source node. | +| target | string | Id of the target. | + +Optional fields: + +| Field name | Type | Description | +| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown | +| secondarystat | string/number | Same as mainStat, but shown right under it. | +| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | + +### Nodes data frame structure Required fields: @@ -105,21 +123,3 @@ Optional fields: | secondarystat | string/number | Same as mainStat, but shown under it inside the node. | | arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. | | detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. | - -### Edge parameters - -Required fields: - -| Field name | Type | Description | -| ---------- | ------ | ------------------------------ | -| id | string | Unique identifier of the edge. | -| source | string | Id of the source node. | -| target | string | Id of the target. | - -Optional fields: - -| Field name | Type | Description | -| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown | -| secondarystat | string/number | Same as mainStat, but shown right under it. | -| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index f795848e51b..96e64a028f3 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -160,3 +160,18 @@ Columns with filters applied have a blue funnel displayed next to the title. {{< figure src="/static/img/docs/tables/filtered-column.png" max-width="500px" caption="Filtered column" class="docs-image--no-shadow" >}} To remove the filter, click the blue funnel icon and then click **Clear filter**. + +## Table footer + +You can use the table footer to show [calculations]({{< relref "../../calculation-types/" >}}) on fields. + +After you enable the table footer: + +1. Select the **Calculation** +2. Select the **Fields** that you want to calculate + +The system applies the calculation to all numeric fields if you do not select a field. + +### Count rows + +If you want to show the number of rows in the dataset instead of the number of values in the selected fields, select the **Count** calculation and enable **Count rows**. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index bf938890362..a258f2ab5b7 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -292,6 +292,17 @@ Sets the maximum time using a duration format (5s/5m/5ms) before timing out read
+## [server.custom_response_headers] + +This setting enables you to specify additional headers that the server adds to HTTP(S) responses. + +``` +exampleHeader1 = exampleValue1 +exampleHeader2 = exampleValue2 +``` + +
+ ## [database] Grafana needs a database to store users and dashboards (and other @@ -760,6 +771,10 @@ Text used as placeholder text on login page for password input. Set the default UI theme: `dark` or `light`. Default is `dark`. +### default_language + +This setting configures the default UI language, which must be a supported IETF language tag, such as `en-US`. + ### home_page Path to a custom home page. Users are only redirected to this if the default home dashboard is used. It should match a frontend route and contain a leading slash. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md new file mode 100644 index 00000000000..3e803ad3555 --- /dev/null +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -0,0 +1,111 @@ +--- +aliases: + - /docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/ +description: Learn about toggles for experimental and beta features, which you can enable or disable. +title: Configure feature toggles +weight: 150 +--- + + + + +# Configure feature toggles + +You use feature toggles, also known as feature flags, to turn experimental or beta features on and off in Grafana. Although we do not recommend using these features in production, you can turn on feature toggles to try out new functionality in development or test environments. + +This page contains a list of available feature toggles. To learn how to turn on feature toggles, refer to our [Configure Grafana documentation]({{< relref "../_index.md/#feature_toggles" >}}). Feature toggles are also available to Grafana Cloud Advanced customers. If you use Grafana Cloud Advanced, you can open a support ticket and specify the feature toggles and stack for which you want them enabled. + +## Stable feature toggles + +Some stable features are enabled by default. You can disable a stable feature by setting the feature flag to "false" in the configuration. + +| Feature toggle name | Description | Enabled by default | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------ | +| `promQueryBuilder` | Show Prometheus query builder | Yes | +| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | +| `database_metrics` | Add Prometheus metrics for database tables | | +| `lokiMonacoEditor` | Access to Monaco query editor for Loki | Yes | +| `featureHighlights` | Highlight Grafana Enterprise features | | +| `commandPalette` | Enable command palette | Yes | +| `cloudWatchDynamicLabels` | Use dynamic labels instead of alias patterns in CloudWatch datasource | Yes | +| `prometheusBufferedClient` | Enable buffered (old) client for Prometheus datasource as default instead of streaming JSON parser client (new) | | +| `internationalization` | Enables internationalization | | +| `accessTokenExpirationCheck` | Enable OAuth access_token expiration check and token refresh using the refresh_token | | + +## Beta feature toggles + +| Feature toggle name | Description | +| --------------------------------- | ------------------------------------------------------------------------------- | +| `trimDefaults` | Use cue schema to remove values that will be applied automatically | +| `panelTitleSearch` | Search for dashboards using panel title | +| `prometheusAzureOverrideAudience` | Experimental. Allow override default AAD audience for Azure Prometheus endpoint | +| `swaggerUi` | Serves swagger UI | +| `migrationLocking` | Lock database during migrations | +| `newDBLibrary` | Use jmoiron/sqlx rather than xorm for a few backend services | +| `validateDashboardsOnSave` | Validate dashboard JSON POSTed to api/dashboards/db | +| `autoMigrateGraphPanels` | Replace the angular graph panel with timeseries | +| `interFont` | Switch to inter font | +| `datasourceLogger` | Logs all datasource requests | + +## Alpha feature toggles + +These features are early in their development lifecycle and so are not yet supported in Grafana Cloud. +Alpha features might be changed or removed without prior notice. + +| Feature toggle name | Description | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alertingBigTransactions` | Use big transactions for alerting database writes | +| `dashboardPreviews` | Create and show thumbnails for dashboard search results | +| `live-config` | Save Grafana Live configuration in SQL tables | +| `live-pipeline` | Enable a generic live processing pipeline | +| `live-service-web-worker` | This will use a webworker thread to processes events rather than the main thread | +| `queryOverLive` | Use Grafana Live WebSocket to execute backend queries | +| `tempoApmTable` | Show APM table | +| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | +| `publicDashboards` | Enables public access to dashboards | +| `lokiLive` | Support WebSocket streaming for loki (early prototype) | +| `lokiDataframeApi` | Use experimental loki api for WebSocket streaming (early prototype) | +| `dashboardComments` | Enable dashboard-wide comments | +| `annotationComments` | Enable annotation comments | +| `storage` | Configurable storage for dashboards, datasources, and resources | +| `exploreMixedDatasource` | Enable mixed datasource in Explore | +| `tracing` | Adds trace ID to error notifications | +| `correlations` | Correlations page | +| `datasourceQueryMultiStatus` | Introduce HTTP 207 Multi Status for api/ds/query | +| `traceToMetrics` | Enable trace to metrics links | +| `prometheusWideSeries` | Enable wide series responses in the Prometheus datasource | +| `canvasPanelNesting` | Allow elements nesting | +| `scenes` | Experimental framework to build interactive dashboards | +| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables | +| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown | +| `dataConnectionsConsole` | Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. | +| `topnav` | New top nav and page layouts | +| `traceqlEditor` | Show the TraceQL editor in the explore page | +| `flameGraph` | Show the flame graph | +| `cloudWatchCrossAccountQuerying` | Use cross-account querying in CloudWatch datasource | +| `redshiftAsyncQueryDataSupport` | Enable async query data support for Redshift | +| `athenaAsyncQueryDataSupport` | Enable async query data support for Athena | +| `increaseInMemDatabaseQueryCache` | Enable more in memory caching for database queries | +| `newPanelChromeUI` | Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu | +| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema | +| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query | +| `elasticsearchBackendMigration` | Use Elasticsearch as backend data source | +| `secureSocksDatasourceProxy` | Enable secure socks tunneling for supported core datasources | +| `authnService` | Use new auth service to perform authentication | + +## Development feature toggles + +The following toggles require explicitly setting Grafana's [app mode]({{< relref "../_index.md/#app_mode" >}}) to 'development' before you can enable this feature toggle. These features tend to be experimental. + +| Feature toggle name | Description | +| -------------------------------------- | --------------------------------------------------------- | +| `dashboardPreviewsAdmin` | Manage the dashboard previews crawler process from the UI | +| `showFeatureFlagsInUI` | Show feature flags in the settings UI | +| `dashboardsFromStorage` | Load dashboards from the generic storage interface | +| `export` | Export grafana instance (to git, etc) | +| `azureMonitorResourcePickerForMetrics` | New UI for Azure Monitor Metrics Query | +| `grpcServer` | Run GRPC server | +| `entityStore` | SQL-based entity store (requires storage flag also) | +| `queryLibrary` | Reusable query library | +| `accessControlOnCall` | Access control primitives for OnCall | +| `nestedFolders` | Enable folder nesting | diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index 935ba017a0c..25a34aa1dd8 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -68,6 +68,7 @@ For a complete list of every change, with links to pull requests and related iss ## Grafana 9 +- [What's new in 9.3]({{< relref "whats-new-in-v9-3/" >}}) - [What's new in 9.2]({{< relref "whats-new-in-v9-2/" >}}) - [What's new in 9.1]({{< relref "whats-new-in-v9-1/" >}}) - [What's new in 9.0]({{< relref "whats-new-in-v9-0/" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v9-2.md b/docs/sources/whatsnew/whats-new-in-v9-2.md index baaf519f9fb..d90c9b5a246 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-2.md +++ b/docs/sources/whatsnew/whats-new-in-v9-2.md @@ -1,6 +1,4 @@ --- -_build: - list: false aliases: - /docs/grafana/latest/guides/whats-new-in-v9-2/ description: Feature and improvement highlights for Grafana v9.2 diff --git a/docs/sources/whatsnew/whats-new-in-v9-3.md b/docs/sources/whatsnew/whats-new-in-v9-3.md new file mode 100644 index 00000000000..3e8d030f8ce --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v9-3.md @@ -0,0 +1,256 @@ +--- +aliases: + - /docs/grafana/latest/guides/whats-new-in-v9-3/ +description: Feature and improvement highlights for Grafana v9.3 +keywords: + - grafana + - new + - documentation + - '9.3' + - release notes +title: What's new in Grafana v9.3 +weight: -33 +--- + +# What’s new in Grafana v9.3 + +Welcome to Grafana 9.3! Read on to learn about our navigation overhaul, support for four new languages, new panels and transformations, several often-requested auth improvements, usability improvements to Alerting, and more. For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## New navigation + +Available in **beta** in all editions of Grafana + +Use Grafana’s redesigned navigation to get full visibility into the health of your systems, by quickly jumping between features as part of your incident response workflow. + +As Grafana has grown from a data visualization tool to an observability solution, we’ve added many new features along the way. This has resulted in pages that are visually inconsistent or hard to find. These updates to navigation give Grafana a new look and feel and make page layouts and navigation patterns more consistent. + +We’ve revamped the navigation menu and grouped related tools together, making it easier to find what you need. Pages in Grafana now leverage new layouts that include breadcrumbs and a sidebar, allowing you to quickly jump between pages. We’ve also introduced a header that appears on all pages in Grafana, making dashboard search accessible from any page. + +To try out Grafana’s new navigation, enable the `topnav` feature toggle. If you are a Cloud Advanced customer, open a ticket with our support team and we will enable it for you. + +**Note:** The Grafana and Grafana Cloud documentation has not yet been updated to reflect changes to the navigation - these changes will roll out when the new navigation becomes generally available. + +{{< figure src="/static/img/docs/navigation/navigation-9-3.png" max-width="750px" caption="New navigation for Grafana" >}} + +## View dashboards in Spanish, French, German, and Simplified Chinese + +Generally available in all editions of Grafana + +We have added four new languages to Grafana: Spanish, French, German, and Simplified Chinese. + +With millions of users across the globe, Grafana has a global footprint. In order to make it accessible to a wider audience, we have taken the first steps in localizing key workflows. You can now set Grafana’s language for the navigation, viewing dashboards, and some settings. This will cover the main activities a Viewer performs within Grafana. + +Read more about configuring the [default language for your organization]({{< relref "../administration/organization-preferences/" >}}) and [updating your profile]({{< relref "../administration/user-management/user-preferences/" >}}) in our documentation. + +{{< figure src="/static/img/docs/internationalization/internationalization-9-3.png" max-width="750px" caption="Grafana available in Spanish, French, German, and Simplified Chinese" >}} + +## Geomap panel + +Generally available in all editions of Grafana + +We have added a new alpha layer type in Geomap called photo layer. This layer enables you to render a photo at each data point. To learn more about the photo layer and the geomap panel, refer to [Photos layer]({{< relref "../panels-visualizations/visualizations/geomap/#photos-layer-alpha" >}}). + +{{< figure src="/static/img/docs/geomap-panel/geomap-photos-9-3-0.png" max-width="750px" caption="Geomap panel photos layer" >}} + +## Canvas panel + +Available in **beta** in all editions of Grafana + +Canvas is a new panel that combines the power of Grafana with the flexibility of custom elements. Canvas visualizations are extensible form-built panels that allow you to explicitly place elements within static and dynamic layouts. This empowers you to design custom visualizations and overlay data in ways that aren’t possible with standard Grafana panels, all within Grafana’s UI. If you’ve used popular UI and web design tools, then designing Canvas panels will feel very familiar. + +In Grafana v9.3, we have added icon value mapping support to the Canvas panel. This enables you to dynamically set which icon to display based on your data. To learn more about the Canvas panel, refer to [Canvas]({{< relref "../panels-visualizations/visualizations/canvas" >}}). + +{{< video-embed src="/static/img/docs/canvas-panel/canvas-icon-value-mapping-support-9-3-0.mp4" max-width="750px" caption="Canvas panel icon value mapping support" >}} + +## Public dashboards improvements + +We've made the following improvements to public dashboards. + +### Manage all of your public dashboards in one place + +Available in **experimental** in Grafana Open Source, Enterprise, and Cloud Advanced + +You can use Public Dashboards to make a given dashboard available to anyone on the internet without needing to sign in. In Grafana v9.3, we have introduced a new screen where you can manage all of your public dashboards. From here, you can view a list of all of the public dashboards in your Grafana instance, navigate to the underlying dashboard, see if it is enabled, link out to the public version of the dashboard, or update the public dashboard's configuration. You can see a public dashboard's configuration if you have view access to the dashboard itself, and you can edit its configuration if you have the Admin or Server Admin role or the "Public Dashboard writer" role if you are using RBAC in Grafana Enterprise or Cloud Advanced. + +To check out this new screen and configure your public dashboards, navigate to **Dashboards > Public Dashboards**. + +### Choose to display annotations in public dashboards + +Available in **experimental** in Grafana Open Source, Enterprise, and Cloud Advanced + +Annotations are now supported in public dashboards, with the exception of query annotations. They are turned off by default, but can be turned on in your public dashboard settings. + +Note that because Public Dashboards is an experimental feature, you need to enable it in Grafana using the `publicDashboards` [feature toggle]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}), or open a support ticket requesting public dashboards if you are a Cloud Advanced customer. + +To learn more about public dashboards, refer to [Public dashboards]({{< relref "../dashboards/dashboard-public/" >}}). + +## New transformation: Partition by values + +Available in **experimental** in all editions of Grafana + +This new transformation can help eliminate the need for multiple queries to the same datasource with different WHERE clauses when graphing multiple series. + +Consider a metrics SQL table with the following data: + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | US | 1520 | +| 2022-10-20 12:00:00 | EU | 2936 | +| 2022-10-20 01:00:00 | US | 1327 | +| 2022-10-20 01:00:00 | EU | 912 | + +Prior to v9.3, if you wanted to plot a red trendline for US and a blue one for EU in the same TimeSeries panel, you would likely have to split this into two queries: + +``` + SELECT Time, Value FROM metrics WHERE Time > ‘2022-10-20’ AND Region=’US’ + SELECT Time, Value FROM metrics WHERE Time > ‘2022-10-20’ AND Region=’EU’ +``` + +This approach also requires you to know ahead of time which regions exist in the metrics table. + +With the partition by values transformer, you can issue a single query and split the results by unique (enum) values from one or more columns (fields) of your choosing. In this case, Region. + +``` + SELECT Time, Region, Value FROM metrics WHERE Time > ‘2022-10-20’ +``` + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | US | 1520 | +| 2022-10-20 01:00:00 | US | 1327 | + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | EU | 2936 | +| 2022-10-20 01:00:00 | EU | 912 | + +## Reporting: Zoom in and out to fit your data better into a PDF + +Generally available in Grafana Enterprise, Cloud Pro, and Cloud Advanced. + +Because dashboards appear on a screen and reports are PDFs, it can be challenging to render data just the way you want to. Sometimes the report doesn't show enough columns in a table, or the titles appear too small. Now you can adjust the scale of your report to zoom in and make each text field and panel larger or zoom out to show more data. + +The zoom feature is located in the **Format Report** section of your reporting configuration. To learn more about reporting, refer to [Create and manage reports]({{< relref "../dashboards/create-reports/">}}). + +{{< figure src="/static/img/docs/enterprise/reports/report-zoom.png" max-width="750px" caption="Report zoom feature with PDF documents at three different zoom levels" >}} + +## Users and access + +We've made the following improvements to users and access. + +### OAuth: token handling improvements + +Generally available in all editions of Grafana + +As part of our efforts to improve the security of Grafana, we are introducing a long-awaited feature that enhances Grafana's OAuth 2.0 compatibility. When a user logs in using an OAuth provider, Grafana verifies on each request that the user's access token has not expired. Grafana uses the refresh token provided (if any exists) when an access token expires to obtain a new access token. + +Because this feature introduces a breaking change, it is behind the `accessTokenExpirationCheck` feature toggle and is disabled by default. Enabling this functionality without configuring refresh tokens for the specific OAuth provider will sign users out after their access token has expired, and they would need to sign in again every time. + +Complete documentation on how to configure obtaining a refresh token can be found on the [authentication configuration page]({{< relref "../setup-grafana/configure-security/configure-authentication/" >}}), in the instructions for your Oauth identity provider. + +### Resolve user conflicts in Grafana's CLI + +In the older versions of Grafana, usernames were case-sensitive. This created conflicts, where a user might sign in using two different methods (like SAML and OAuth) and have two accounts created, like `elastigirl@incredibles.com` and `ElastiGirl@incredibles.com`. Users in this situation might think they have lost their preferences and permissions. If this has occurred in your Grafana instance, you can use a new Grafana CLI command to resolve user identity conflicts between users within Grafana. + +> Note: If you use Grafana Cloud or you run Grafana with MySQL as your database, you will not experience any user identity conflicts and you do not need to use this tool. + +```bash +# lists all the conflicting users +$ grafana-cli user-manager conflicts list + +# creates a conflict patch file to edit +$ grafana-cli user-manager conflicts generate-file + +# reads edited conflict patch file for validation +$ grafana-cli user-manager conflicts validate-file + +# ingests the conflict users file. Can be executed once per file and will change the state of the database. +$ grafana-cli user-manager conflicts ingest-file +``` + +### LDAP: Role mapping improvements + +Generally available in all editions of Grafana + +If you use an LDAP directory to authenticate to Grafana but prefer to assign organizations and roles in the Grafana UI +or via API, you can now skip user organization role synchronization with your LDAP +directory. + +Use the `skip_org_role_sync` [LDAP authentication configuration option]({{< relref +"../setup-grafana/configure-security/configure-authentication/ldap/#disable-org-role-synchronization" >}}) +when configuring LDAP authentication to prevent the synchronization between your LDAP groups and organization roles +and make user roles editable manually. + +### Azure AD OAuth2: New option to always fetch groups from the Graph API + +Generally available in all editions of Grafana + +If you use Azure AD OAuth2 authentication and use `SecurityEnabled` groups that you don't want Azure to embed in the +authentication token, you can configure Grafana to use Microsoft's Graph API instead. + +Use the [`force_use_graph_api` configuration option]({{< relref +"../setup-grafana/configure-security/configure-authentication/azuread/#force-fetching-groups-from-microsoft-graph-api" >}}) +when configuring Azure AD authentication to force Grafana to fetch groups using Graph API. + +### RBAC: List token's permissions + +Generally available in Grafana Enterprise and Cloud Advanced + +We added a new endpoint to help users diagnose permissions-related issues with user and token authorization. +[This endpoint]({{< relref "../developers/http_api/access_control/#list-your-permissions" >}}) allows users to get the +full list of RBAC permissions associated with their token. + +For more details, refer to [Debug the permissions of a service account token]({{< relref +"../administration/service-accounts/#debug-the-permissions-of-a-service-account-token" >}}). + +### RBAC with Terraform: Extended support for provisioning permissions + +Generally available in Grafana Enterprise and Cloud Advanced + +All Grafana users can now use the latest release of [Terraform's Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) (version 1.31.1+) to provision [user and team access to service accounts]({{< relref "../administration/service-accounts/#manage-users-and-teams-permissions-for-a-service-account-in-grafana" >}}). + +This allows full management of service accounts through Terraform - from creating a service account and allowing users to access it to assigning roles to the service account and generating service account tokens. + +Grafana Enterprise and Cloud Pro and Advanced users can now provision [access to data sources]({{< relref "../administration/data-source-management/#data-source-permissions" >}}) for Grafana's `Viewer`, `Editor`, and `Admin` basic roles, as well as assign `Edit` permission. + +We have also added [documentation on provisioning RBAC roles and role assignments]({{< relref "../administration/roles-and-permissions/access-control/rbac-terraform-provisioning/" >}}) to guide our Grafana Enterprise and Cloud Pro and Advanced users through this process. + +Finally, we have fixed several access control related bugs to ensure a smoother provisioning experience. + +## Alerting + +All of these new alerting features are generally available in all editions of Grafana. + +### Email templating + +We've improved the design and functionality of email templates to make template creation much easier and more customizable. The email template framework utilizes MJML to define and compile the final email HTML output. Sprig functions in the email templates provide more customizable template functions. + +{{< figure src="/static/img/docs/alerting/alert-templates-whats-new-v9.3.png" max-width="750px" caption="Email template redesign" >}} + +### Support for Webex Teams + +You can now use Cisco Webex Teams as a contact point, to send alerts to a Webex Teams channel. + +### Edit alert rules created using the provisioning API + +Edit API-provisioned alert rules from the Grafana UI. To make a provisioned alert editable, add the `x-disable-provenance` header to the following requests when creating or editing your alert rules in the API: + +POST /api/v1/provisioning/alert-rules + +PUT /api/v1/provisioning/alert-rules/{UID} + +### Support values in notification templates + +Add alert values to notification templates, so that you can create a single template that prints the annotations, labels, and values for your alerts in a format of your choice. + +### View notification errors + +When an alert fails to fire, see when something is wrong with your contact point(s) and the reason for the error. The Receivers API contains information on the error, including a time stamp, duration of the attempt, and the error. You can also view the errors for each contact point in the UI. + +{{< figure src="/static/img/docs/alerting/alert-view-notification-errors-whats-new-v9.3.png" max-width="750px" caption="Alert notification errors" >}} + +### Redesign of the expressions pipeline + +We've redesigned the expressions pipeline editor to combine the expressions editor and the preview into a single view. + +{{< figure src="/static/img/docs/alerting/alert-expression-pipeline-whats-new-v9.3.png" max-width="750px" caption="Expression pipeline redesign" >}} diff --git a/e2e/log-reporter.js b/e2e/log-reporter.js new file mode 100644 index 00000000000..36878fc6bbd --- /dev/null +++ b/e2e/log-reporter.js @@ -0,0 +1,159 @@ +'use strict'; + +const Mocha = require('mocha'); +const { EVENT_TEST_END, EVENT_RUN_END, EVENT_TEST_FAIL, EVENT_TEST_PASS } = Mocha.Runner.constants; + +class LogReporter extends Mocha.reporters.Base { + constructor(runner, options = {}) { + super(runner, options); + + this.tests = []; + this.failures = []; + this.passes = []; + + runner.on(EVENT_TEST_END, (test) => { + this.tests.push(cleanTest(test)); + }); + + runner.on(EVENT_TEST_PASS, (test) => { + this.passes.push(cleanTest(test)); + }); + + runner.on(EVENT_TEST_FAIL, (test) => { + this.failures.push(cleanTest(test)); + }); + + runner.once(EVENT_RUN_END, () => { + this.reportStats(); + this.reportResults(); + this.reportErrors(); + }); + } + + reportStats() { + const stats = { + ...this.stats, + // Format time in epoch + start: this.stats.start.getTime(), + end: this.stats.end.getTime(), + }; + + // Example + // CypressStats suites=1 tests=2 passes=1 pending=0 failures=1 + // start=1668783563731 end=1668783645198 duration=81467 + console.log(`CypressStats ${objToLogAttributes(stats)}`); + } + + reportResults() { + this.tests.map((test) => { + // Example + // CypressTestResult title="Login scenario, create test data source, dashboard, panel, and export scenario" + // suite="Smoke tests" file=../../e2e/smoke-tests-suite/1-smoketests.spec.ts duration=68694 + // currentRetry=0 speed=undefined err=false + console.log(`CypressTestResult ${objToLogAttributes(test)}`); + }); + } + + reportErrors() { + this.failures.forEach((failure) => { + const suite = failure.suite; + const test = failure.title; + const error = failure.err; + + // Example + // CypressError suite="Smoke tests" test="Login scenario, create test data source, dashboard, + // panel, and export scenario" error=false + console.error(`CypressError ${objToLogAttributes({ suite, test, error })}`); + }); + } +} + +/** + * Stringify object to be log friendly + * @param {Object} obj + * @returns {String} + */ +function objToLogAttributes(obj) { + return Object.entries(obj) + .map(([key, value]) => `${key}=${formatValue(value)}`) + .join(' '); +} + +/** + * Escape double quotes + * @param {String} str + * @returns + */ +function escapeQuotes(str) { + return String(str).replaceAll('"', '\\"'); +} + +/** + * Wrap the value within double quote if needed + * @param {*} value + * @returns + */ +function formatValue(value) { + const hasWhiteSpaces = /\s/g.test(value); + + return hasWhiteSpaces ? `"${escapeQuotes(value)}"` : value; +} + +/** + * Simplify the Mocha test object to get the information we need want to report + * @param {Mocha.Test} test + * @returns {Object} + */ +function cleanTest(test) { + const err = test.err instanceof Error ? test.err.toString() : false; + const testLocation = getTestLocation(test); + // Remove the test title + const suite = testLocation.slice(0, testLocation.length - 1).join(' > '); + + return { + currentRetry: test.currentRetry(), + duration: test.duration, + speed: test.speed, + file: getTestFile(test), + suite, + title: test.title, + err, + }; +} + +/** + * Get the test path in the suite herarchy + * @example + * ['Root Suite in a file', 'Nested suite', 'test title'] + * + * @param {Mocha.Test} test + * @returns {Array} + */ +function getTestLocation(test) { + let path = test.title ? [test.title] : []; + + if (test.parent) { + path = getTestLocation(test.parent).concat(path); + } + + return path; +} + +/** + * Get the relative path to the executed spec file + * @param {Mocha.Test} test + * @returns {String | null} + */ +function getTestFile(test) { + if (test?.file) { + return test?.file; + } + + if (test?.parent) { + return getTestFile(test.parent); + } + + return null; +} + +module.exports = LogReporter; diff --git a/e2e/run-suite b/e2e/run-suite index 9b61e3b2e8a..d74a41d67f4 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -37,6 +37,7 @@ declare -A cypressConfig=( [viewportHeight]=1080 [trashAssetsBeforeRuns]=false [videoUploadOnPasses]=false + [reporter]=../../e2e/log-reporter.js ) diff --git a/go.mod b/go.mod index 8f29add2ffd..16fb97f6a2f 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/BurntSushi/toml v1.1.0 github.com/Masterminds/semver v1.5.0 github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f - github.com/aws/aws-sdk-go v1.44.109 + github.com/aws/aws-sdk-go v1.44.146 github.com/beevik/etree v1.1.0 github.com/benbjohnson/clock v1.3.0 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b @@ -48,7 +48,7 @@ require ( github.com/go-sql-driver/mysql v1.6.0 github.com/go-stack/stack v1.8.1 github.com/gobwas/glob v0.2.3 - github.com/gofrs/uuid v4.3.0+incompatible // indirect + github.com/gofrs/uuid v4.3.0+incompatible github.com/gogo/protobuf v1.3.2 github.com/golang/mock v1.6.0 github.com/golang/snappy v0.0.4 @@ -56,7 +56,6 @@ require ( github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 github.com/gorilla/websocket v1.5.0 - github.com/gosimple/slug v1.12.0 github.com/grafana/cuetsy v0.1.1 github.com/grafana/grafana-aws-sdk v0.11.0 github.com/grafana/grafana-azure-sdk-go v1.3.1 @@ -71,7 +70,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 github.com/json-iterator/go v1.1.12 github.com/jung-kurt/gofpdf v1.16.2 - github.com/lib/pq v1.10.4 + github.com/lib/pq v1.10.7 github.com/linkedin/goavro/v2 v2.10.0 github.com/m3db/prometheus_remote_client_golang v0.4.4 github.com/magefile/mage v1.13.0 @@ -90,7 +89,7 @@ require ( github.com/prometheus/prometheus v1.8.2-0.20211011171444-354d8d2ecfac github.com/robfig/cron/v3 v3.0.1 github.com/russellhaering/goxmldsig v1.1.1 - github.com/stretchr/testify v1.8.0 + github.com/stretchr/testify v1.8.1 github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f github.com/uber/jaeger-client-go v2.29.1+incompatible @@ -106,19 +105,19 @@ require ( go.opentelemetry.io/otel/exporters/jaeger v1.0.0 go.opentelemetry.io/otel/sdk v1.7.0 go.opentelemetry.io/otel/trace v1.7.0 - golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d + golang.org/x/crypto v0.3.0 golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d - golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect - golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 + golang.org/x/net v0.2.0 + golang.org/x/oauth2 v0.2.0 golang.org/x/sync v0.1.0 - golang.org/x/time v0.0.0-20220609170525-579cf78fd858 - golang.org/x/tools v0.1.12 + golang.org/x/time v0.2.0 + golang.org/x/tools v0.3.0 gonum.org/v1/gonum v0.11.0 google.golang.org/api v0.84.0 google.golang.org/grpc v1.47.0 google.golang.org/protobuf v1.28.1 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/ini.v1 v1.66.2 + gopkg.in/ini.v1 v1.67.0 gopkg.in/ldap.v3 v3.1.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/square/go-jose.v2 v2.5.1 @@ -204,7 +203,7 @@ require ( github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/olekukonko/tablewriter v0.0.5 github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e // indirect github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -221,16 +220,16 @@ require ( github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/sirupsen/logrus v1.9.0 // indirect - github.com/stretchr/objx v0.4.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/weaveworks/promrus v1.2.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect go.opencensus.io v0.23.0 // indirect - go.uber.org/atomic v1.9.0 + go.uber.org/atomic v1.10.0 go.uber.org/goleak v1.1.12 // indirect - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect + golang.org/x/sys v0.2.0 // indirect golang.org/x/text v0.4.0 golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect google.golang.org/appengine v1.6.7 // indirect @@ -255,8 +254,10 @@ require ( github.com/google/go-github/v45 v45.2.0 github.com/grafana/codejen v0.0.3 github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f + github.com/huandu/xstrings v1.3.1 github.com/jmoiron/sqlx v1.3.5 github.com/matryer/is v1.4.0 + github.com/parca-dev/parca v0.12.1 github.com/urfave/cli v1.22.9 go.etcd.io/etcd/api/v3 v3.5.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.32.0 @@ -287,10 +288,8 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa // indirect github.com/googleapis/go-type-adapters v1.0.0 // indirect - github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/memberlist v0.4.0 // indirect - github.com/huandu/xstrings v1.3.1 // indirect github.com/invopop/yaml v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect @@ -300,7 +299,6 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect - github.com/parca-dev/parca v0.12.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.1.4 // indirect @@ -368,25 +366,11 @@ require ( // Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream replace github.com/crewjam/saml => github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc -replace github.com/apache/thrift => github.com/apache/thrift v0.14.1 - -replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.10.2 - -replace github.com/gin-gonic/gin => github.com/gin-gonic/gin v1.7.7 - -replace github.com/tidwall/gjson => github.com/tidwall/gjson v1.14.1 - -// Upgraded to fix CVE-2020-26066. This can be removed when go.opentelemetry.io/collector and github.com/influxdata/telegraf are upgraded -// github.com/tidwall/match v1.0.1 should not be used. -replace github.com/tidwall/match => github.com/tidwall/match v1.1.1 - // Thema's thema CLI requires cobra, which eventually works its way down to go-hclog@v1.0.0. // Upgrading affects backend plugins: https://github.com/grafana/grafana/pull/47653#discussion_r850508593 // No harm to Thema because it's only a dependency in its main package. replace github.com/hashicorp/go-hclog => github.com/hashicorp/go-hclog v0.16.1 -replace github.com/microcosm-cc/bluemonday => github.com/microcosm-cc/bluemonday v1.0.18 - // This is a patched v0.8.2 intended to fix session.Find (and others) silently ignoring SQLITE_BUSY errors. This could // happen, for example, during a read when the sqlite db is under heavy write load. // This patch cherry picks compatible fixes from upstream xorm PR#1998 and can be reverted on upgrade to xorm v1.2.0+. diff --git a/go.sum b/go.sum index d41b9baea0d..91cfa8ec3a9 100644 --- a/go.sum +++ b/go.sum @@ -144,6 +144,7 @@ github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9B github.com/Azure/go-amqp v0.16.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= github.com/Azure/go-amqp v0.16.4/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= @@ -325,7 +326,9 @@ github.com/apache/arrow/go/arrow v0.0.0-20200923215132-ac86123a3f01/go.mod h1:QN github.com/apache/arrow/go/arrow v0.0.0-20210223225224-5bea62493d91/go.mod h1:c9sxoIT3YgLxH4UhLOCKaBlEojuMhVYpk4Ntv3opUTQ= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6ICHXqG5hm0ZW5IHyeEJXoIJSOZeBLmWPNeIQ= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= -github.com/apache/thrift v0.14.1/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.14.2/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -373,8 +376,9 @@ github.com/aws/aws-sdk-go v1.38.60/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.44.109 h1:+Na5JPeS0kiEHoBp5Umcuuf+IDqXqD0lXnM920E31YI= github.com/aws/aws-sdk-go v1.44.109/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go v1.44.146 h1:7YdGgPxDPRJu/yYffzZp/H7yHzQ6AqmuNFZPYraaN8I= +github.com/aws/aws-sdk-go v1.44.146/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4= github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA= @@ -865,6 +869,9 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew= github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= @@ -1052,10 +1059,13 @@ github.com/go-openapi/validate v0.22.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUri github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.10.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/go-redis/redis/v8 v8.0.0-beta.10.0.20200905143926-df7fe4e2ce72/go.mod h1:CJP1ZIHwhosNYwIdaHPZK9vHsM3+roNBaZ7U9Of1DXc= @@ -1351,10 +1361,6 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc= -github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= -github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= -github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM= @@ -1730,6 +1736,7 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm github.com/leanovate/gopter v0.2.4/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353 h1:X/79QL0b4YJVO5+OsPH9rF2u428CIrGL/jLmPsoOQQ4= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM= @@ -1746,8 +1753,9 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.0/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= @@ -1925,6 +1933,7 @@ github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3P github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mozilla/tls-observatory v0.0.0-20190404164649-a3c1b6cfecfd/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk= github.com/mozillazg/go-cos v0.13.0/go.mod h1:Zp6DvvXn0RUOXGJ2chmWt2bLEqRAnJnS3DnAZsJsoaE= @@ -2350,8 +2359,9 @@ github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -2363,8 +2373,9 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= @@ -2383,12 +2394,12 @@ github.com/thanos-io/thanos v0.13.1-0.20210224074000-659446cab117/go.mod h1:kdqF github.com/thanos-io/thanos v0.13.1-0.20210226164558-03dace0a1aa1/go.mod h1:gMCy4oCteKTT7VuXVvXLTPGzzjovX1VPE5p+HgL1hyU= github.com/thanos-io/thanos v0.13.1-0.20210401085038-d7dff0c84d17/go.mod h1:zU8KqE+6A+HksK4wiep8e/3UvCZLm+Wrw9AqZGaAm9k= github.com/thanos-io/thanos v0.22.0/go.mod h1:SZDWz3phcUcBr4MYFoPFRvl+Z9Nbi45HlwQlwSZSt+Q= -github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/gjson v1.6.1/go.mod h1:BaHyNc5bjzYkPqgLq7mdVzeiRtULKULXLgZFKsxEHI0= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= +github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/tinylru v1.0.2/go.mod h1:HDVL7TsWeezQ4g44Um84TOVBMFcq7Xa9giqNc805KJ8= github.com/tidwall/wal v0.1.4/go.mod h1:ww7Pd44/KnyETODJPUPKrzLlYjI72GZWlucNKt7pOt0= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= @@ -2622,8 +2633,9 @@ go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.2.0/go.mod h1:YfO3fm683kQpzETxlTGZhGIVmXAhaw3gxeBADbpZtnU= go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q= go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= @@ -2708,8 +2720,9 @@ golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2863,8 +2876,9 @@ golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI= -golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2892,8 +2906,8 @@ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 h1:VnGaRqoLmqZH/3TMLJwYCEWkR4j1nuIU1U9TvbqsDUw= -golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.2.0 h1:GtQkldQ9m7yvzCL1V+LrYow3Khe0eJH0w7RbX/VbaIU= +golang.org/x/oauth2 v0.2.0/go.mod h1:Cwn6afJ8jrQwYMxQDTpISoXmXW9I6qF6vDeuuoX3Ibs= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -3085,14 +3099,17 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -3119,8 +3136,8 @@ golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U= -golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE= +golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -3224,8 +3241,9 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -3355,6 +3373,8 @@ gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBl gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -3363,8 +3383,8 @@ gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI= -gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ldap.v3 v3.1.0 h1:DIDWEjI7vQWREh0S8X5/NFPCZ3MCVd55LmXKPW4XLGE= gopkg.in/ldap.v3 v3.1.0/go.mod h1:dQjCc0R0kfyFjIlWNMH1DORwUASZyDxo2Ry1B51dXaQ= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= @@ -3486,7 +3506,6 @@ k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= k8s.io/kube-openapi v0.0.0-20210305001622-591a79e4bda7/go.mod h1:wXW5VT87nVfh/iLV8FpR2uDvrFyomxbtb1KivDbvPTE= -k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e h1:KLHHjkdQFomZy8+06csTWZ0m1343QqxZhR2LJ1OxCYM= k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 h1:Gii5eqf+GmIEwGNKQYQClCayuJCe2/4fZUvF7VG99sU= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= diff --git a/latest.json b/latest.json index 5d0d253a775..8b47f75b668 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "9.2.6", - "testing": "9.3.0-beta1" + "stable": "9.3.0", + "testing": "9.3.0" } diff --git a/package.json b/package.json index c92cedaa789..20e80e39f12 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "prop-types": "15.8.1", "pseudoizer": "^0.1.0", "rc-cascader": "3.7.0", - "rc-drawer": "4.4.3", + "rc-drawer": "6.0.1", "rc-slider": "10.0.1", "rc-time-picker": "3.7.3", "rc-tree": "5.7.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 537beb5f0af..c1a7fcc37c4 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -30,7 +30,9 @@ "scripts": { "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts", "clean": "rimraf ./dist ./compiled ./package.tgz", - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" }, "dependencies": { "@braintree/sanitize-url": "6.0.1", diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index 59691aaf81c..4ce873ab34c 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -291,7 +291,7 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: } as FieldCalcs; const data = field.values; - calcs.count = data.length; + calcs.count = ignoreNulls ? data.length : data.toArray().filter((val) => val != null).length; const isNumberField = field.type === FieldType.number || FieldType.time; diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts index 467222765a4..7e6e1fd5aaa 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts @@ -101,6 +101,26 @@ describe('field convert type', () => { }); }); +it('can convert strings with commas to numbers', () => { + const options = { targetField: 'stringy nums', destinationType: FieldType.number }; + + const stringyNumbers = { + name: 'stringy nums', + type: FieldType.string, + values: new ArrayVector(['1,000', '1,000,000']), + config: {}, + }; + + const numbers = convertFieldType(stringyNumbers, options); + + expect(numbers).toEqual({ + name: 'stringy nums', + type: FieldType.number, + values: new ArrayVector([1000, 1000000]), + config: {}, + }); +}); + describe('field convert types transformer', () => { beforeAll(() => { mockTransformationsRegistry([convertFieldTypeTransformer]); diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts index 0ebf969524f..318f1a3f4eb 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts @@ -142,7 +142,9 @@ function fieldToNumberField(field: Field): Field { const numValues = field.values.toArray().slice(); for (let n = 0; n < numValues.length; n++) { - const number = +numValues[n]; + // some numbers returned from datasources have commas + // strip the commas, coerce the string to a number + const number = +numValues[n].replace(/,/g, ''); numValues[n] = Number.isFinite(number) ? number : null; } diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index 360a299c564..d1f2e4614e1 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -177,3 +177,6 @@ export interface DataConfigSource { getFieldOverrideOptions: () => ApplyFieldOverrideOptions | undefined; snapshotData?: DataFrameDTO[]; } + +type Truthy = T extends false | '' | 0 | null | undefined ? never : T; +export const isTruthy = (value: T): value is Truthy => Boolean(value); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5d9debb1609..7276ab8d74a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -65,9 +65,10 @@ export interface FeatureToggles { internationalization?: boolean; topnav?: boolean; grpcServer?: boolean; - objectStore?: boolean; + entityStore?: boolean; traceqlEditor?: boolean; flameGraph?: boolean; + cloudWatchCrossAccountQuerying?: boolean; redshiftAsyncQueryDataSupport?: boolean; athenaAsyncQueryDataSupport?: boolean; increaseInMemDatabaseQueryCache?: boolean; @@ -81,4 +82,6 @@ export interface FeatureToggles { nestedFolders?: boolean; accessTokenExpirationCheck?: boolean; elasticsearchBackendMigration?: boolean; + secureSocksDatasourceProxy?: boolean; + authnService?: boolean; } diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index b39bdb0a1fe..34fff4877dc 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -58,6 +58,7 @@ export const availableIconsIndex = { compass: true, copy: true, 'credit-card': true, + crosshair: true, cube: true, dashboard: true, database: true, @@ -104,6 +105,7 @@ export const availableIconsIndex = { 'gf-show-context': true, grafana: true, 'graph-bar': true, + 'grafana-ml': true, heart: true, 'heart-rate': true, 'heart-break': true, diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index db889edf461..bcdcc9cb61f 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -34,7 +34,9 @@ "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts", "bundle": "rollup -c rollup.config.ts", "clean": "rimraf ./dist ./compiled ./package.tgz", - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" }, "devDependencies": { "@rollup/plugin-commonjs": "23.0.2", diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 724c15d6c2b..28df5e9f7fe 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -107,7 +107,7 @@ export const Components = { expand: 'Drawer expand', contract: 'Drawer contract', close: 'Drawer close', - rcContentWrapper: () => '.drawer-content-wrapper', + rcContentWrapper: () => '.rc-drawer-content-wrapper', }, }, PanelEditor: { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index a6d0faae018..8fa94cb8d9c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -31,7 +31,7 @@ export const Pages = { url: '/datasources/new', /** @deprecated Use dataSourcePluginsV2 */ dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`, - dataSourcePluginsV2: (pluginName: string) => `Add data source ${pluginName}`, + dataSourcePluginsV2: (pluginName: string) => `Add new data source ${pluginName}`, }, ConfirmModal: { delete: 'Confirm Modal Danger Button', diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 793424e5ca8..783cd4e4d65 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -42,7 +42,9 @@ "start": "cypress run --browser=chrome", "start-benchmark": "CYPRESS_NO_COMMAND_LOG=1 yarn start", "test": "pushd test && node ../dist/bin/grafana-e2e.js run", - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" }, "devDependencies": { "@rollup/plugin-node-resolve": "15.0.1", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index a724775f4fd..162a650a309 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -32,7 +32,9 @@ "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts", "bundle": "rollup -c rollup.config.ts", "clean": "rimraf ./dist ./compiled ./package.tgz", - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" }, "dependencies": { "@grafana/data": "9.4.0-pre", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 6c82f2e2bbb..2da2fd6e2ef 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -31,7 +31,9 @@ "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts", "bundle": "rollup -c rollup.config.ts", "clean": "rimraf ./dist ./compiled ./package.tgz", - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" }, "devDependencies": { "@grafana/tsconfig": "^1.2.0-rc1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index bc938f89625..e3ab4d8f9f1 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -38,7 +38,9 @@ "storybook": "start-storybook -p 9001 -c .storybook", "storybook:build": "build-storybook -o ./dist/storybook -c .storybook", "typecheck": "tsc --emitDeclarationOnly false --noEmit", - "generate-icons-bundle-cache-file": "node ./scripts/generate-icon-bundle.js" + "generate-icons-bundle-cache-file": "node ./scripts/generate-icon-bundle.js", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" }, "browserslist": [ "defaults", @@ -79,7 +81,7 @@ "ol": "7.1.0", "prismjs": "1.29.0", "rc-cascader": "3.7.0", - "rc-drawer": "4.4.3", + "rc-drawer": "6.0.1", "rc-slider": "10.0.1", "rc-time-picker": "^3.7.3", "rc-tooltip": "5.2.2", diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index b97dbbf7499..a5f07e76835 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -54,7 +54,7 @@ const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => { } else { bgColor = tinycolor(sourceColor).setAlpha(0.15).toString(); borderColor = tinycolor(sourceColor).lighten(20).toString(); - textColor = tinycolor(sourceColor).darken(15).toString(); + textColor = tinycolor(sourceColor).darken(20).toString(); } return { diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx index b96f68c3ebc..547e3a48a95 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx @@ -3,7 +3,7 @@ import { useArgs } from '@storybook/client-api'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; -import { SeriesColorPicker, ColorPicker } from '@grafana/ui'; +import { SeriesColorPicker, ColorPicker, clearButtonStyles, useStyles2 } from '@grafana/ui'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; @@ -44,6 +44,7 @@ export const Basic: ComponentStory = ({ color, enableNamedCo export const SeriesPicker: ComponentStory = ({ color, enableNamedColors }) => { const [, updateArgs] = useArgs(); + const clearButton = useStyles2(clearButtonStyles); return ( = ({ color, }} > {({ ref, showColorPicker, hideColorPicker }) => ( -
+
+ )}
); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx index fe493efe315..841d4c492bd 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -69,7 +69,7 @@ export const DataLinkSuggestions: React.FC = ({ sugges const styles = useStyles2(getStyles); return ( -
+
{Object.keys(groupedSuggestions).map((key, i) => { const indexOffset = i === 0 @@ -116,7 +116,11 @@ const DataLinkSuggestionsList: React.FC = React.me renderItem={(item, index) => { const isActive = index + activeIndexOffset === activeIndex; return ( + // key events are handled by DataLinkInput + // eslint-disable-next-line jsx-a11y/click-events-have-key-events
{ diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 9eabed2d788..7c5efda7ba7 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -74,24 +74,37 @@ export function Drawer({ return ( -
+
{typeof title === 'string' && (
@@ -145,33 +158,46 @@ const getStyles = (theme: GrafanaTheme2) => { flex: 1 1 0; `, drawer: css` - .drawer-content { - background-color: ${theme.colors.background.primary}; - display: flex; - flex-direction: column; - overflow: hidden; - } - &.drawer-open .drawer-mask { - background-color: ${theme.components.overlay.background}; - backdrop-filter: blur(1px); - opacity: 1; - } - .drawer-mask { - background-color: ${theme.components.overlay.background}; - backdrop-filter: blur(1px); - } - .drawer-open .drawer-content-wrapper { + .rc-drawer-content-wrapper { box-shadow: ${theme.shadows.z3}; - } - z-index: ${theme.zIndex.dropdown}; - - ${theme.breakpoints.down('sm')} { - .drawer-content-wrapper { + ${theme.breakpoints.down('sm')} { width: 100% !important; } } `, + drawerContent: css` + background-color: ${theme.colors.background.primary} !important; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: ${theme.zIndex.dropdown}; + `, + drawerMotion: css` + &-appear { + transform: translateX(100%); + transition: none !important; + + &-active { + transition: ${theme.transitions.create('transform')} !important; + transform: translateX(0); + } + } + `, + mask: css` + background-color: ${theme.components.overlay.background} !important; + backdrop-filter: blur(1px); + `, + maskMotion: css` + &-appear { + opacity: 0; + + &-active { + opacity: 1; + transition: ${theme.transitions.create('opacity')}; + } + } + `, header: css` background-color: ${theme.colors.background.canvas}; flex-grow: 0; diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index 93f91724e2a..bd96418eb51 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -44,6 +44,11 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) => {visible && ( + {/* + this is handling bubbled events from the inner overlay + see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events + */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
({ row }: CellProps) { +export function ExpanderCell({ row, __rowID }: CellProps & { __rowID: string }) { return (
); diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx new file mode 100644 index 00000000000..00962db19ec --- /dev/null +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx @@ -0,0 +1,166 @@ +import { Meta, Props, Story, Canvas } from '@storybook/addon-docs/blocks'; + +import { InteractiveTable } from './InteractiveTable'; +import { Badge } from '../Badge/Badge'; + + + +# InteractiveTable + + + +The InteractiveTable is used to display and select data efficiently. +It allows for the display and modification of detailed information. +With additional functionality it allows for batch editing, as needed by your feature's users. + +It is a wrapper around [React Table](https://react-table-v7.tanstack.com/), for more informations about it, refer to the [official documentation](https://react-table.tanstack.com/docs/overview). + +### When to use + +The InteractiveTable can be used to allow users to perform administrative tasks workflows. + +### When not to use + +Avoid using the InteractiveTable where mobile or responsiveness may be a requirement. +Consider an alternative pattern where the user is presented with a summary list and can click/tap to an individual page for each row in that list. + +### Usage + + + +#### About `columns` and `data` Props + +To avoid unnecessary rerenders, `columns` and `data` must be memoized. + +Columns are rendered in the same order defined in the `columns` prop. +Each Cell's content is automatically rendered by matching the `id` of the column to the key of each object in the `data` array prop. + +##### Example + +```tsx +interface TableData { + projectName: string; + repository: string; +} + +const columns = useMemo>>( + () => [ + id: 'projectName' + header: "Project Name" + ], + [ + id: 'repository', + header: "Repository" + ], + [] +); + +const data = useMemo>( + () => [ + { + projectName: 'Grafana', + repository: 'https://github.com/grafana/grafana', + } + ], + [ + { + projectName: 'Loki'; + repository: 'https://github.com/grafana/loki'; + } + ], + [] +); +``` + +## Examples + +### With row expansion + +Individual rows can be expanded to display additional details or reconfigure properties previously defined when the row was created. +The expanded row area should be used to declutter the primary presentation of data, carefully consider what the user needs to know at first glance and what can be hidden behind the Row Expander button. + +In general, data-types that are consistent across all dataset are in the primary table, variances are pushed to the expanded section for each individual row. + + + +Row expansion is enabled whenever the `renderExpanded` prop is provided. The `renderExpanded` function is called with the row's data and should return a ReactNode. + +```tsx +interface TableData { + datasource: string; + repo: string; + description: string; +} + +const tableData: TableData[] = [ + //... +]; + +const columns: Array> = [ + //... +]; + +const ExpandedCell = ({ description }: TableData) => { + return

{description}

; +}; + +export const MyComponent = () => { + return ( + r.datasource} + renderExpandedRow={ExpandedCell} + /> + ); +}; +``` + +### Custom Cell Rendering + +Individual cells can be rendered using custom content dy defining a `cell` property on the column definition. + + + +```tsx +interface TableData { + datasource: string; + repo: string; +} + +const RepoCell = ({ + row: { + original: { repo }, + }, +}: CellProps) => { + return ( + + Open on GitHub + + ); +}; + +const tableData: WithCustomCellData[] = [ + { + datasource: 'Prometheus', + repo: 'https://github.com/prometheus/prometheus', + }, + { + datasource: 'Loki', + repo: 'https://github.com/grafana/loki', + }, + { + datasource: 'Tempo', + repo: 'https://github.com/grafana/tempo', + }, +]; + +const columns: Array> = [ + { id: 'datasource', header: 'Data Source' }, + { id: 'repo', header: 'Repo', cell: RepoCell }, +]; + +export const MyComponent = () => { + return r.datasource} />; +}; +``` diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx new file mode 100644 index 00000000000..74982f76248 --- /dev/null +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -0,0 +1,142 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import React, { useMemo } from 'react'; + +import { InteractiveTable, Column, CellProps, LinkButton } from '@grafana/ui'; + +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; + +import mdx from './InteractiveTable.mdx'; + +const EXCLUDED_PROPS = ['className', 'renderExpandedRow', 'getRowId']; + +const meta: ComponentMeta = { + title: 'Experimental/InteractiveTable', + component: InteractiveTable, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + controls: { + exclude: EXCLUDED_PROPS, + }, + }, + args: {}, + argTypes: {}, +}; + +interface TableData { + header1: string; + header2?: number; + noheader?: string; +} + +export const Basic: ComponentStory = (args) => { + const columns = useMemo>>( + () => [ + { id: 'header2', header: 'With missing values', sortType: 'number', disableGrow: true }, + { + id: 'noheader', + sortType: 'number', + }, + ], + [] + ); + const data: TableData[] = useMemo( + () => [ + { header1: 'a', header2: 1 }, + { header1: 'b', noheader: "This column doesn't have an header" }, + { header1: 'c', noheader: "But it's still sortable" }, + ], + [] + ); + + return r.header1} />; +}; + +interface WithRowExpansionData { + datasource: string; + repo: string; + description: string; +} + +const ExpandedCell = ({ description }: WithRowExpansionData) => { + return

{description}

; +}; + +export const WithRowExpansion: ComponentStory = (args) => { + const tableData: WithRowExpansionData[] = [ + { + datasource: 'Prometheus', + repo: 'https://github.com/prometheus/prometheus', + description: 'Open source time series database & alerting.', + }, + { + datasource: 'Loki', + repo: 'https://github.com/grafana/loki', + description: 'Like Prometheus but for logs. OSS logging solution from Grafana Labs.', + }, + { + datasource: 'Tempo', + repo: 'https://github.com/grafana/tempo', + description: 'High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.', + }, + ]; + + const columns: Array> = [ + { id: 'datasource', header: 'Data Source' }, + { id: 'repo', header: 'Repo' }, + ]; + + return ( + r.datasource} + renderExpandedRow={ExpandedCell} + /> + ); +}; + +interface WithCustomCellData { + datasource: string; + repo: string; +} + +const RepoCell = ({ + row: { + original: { repo }, + }, +}: CellProps) => { + return ( + + Open on GithHub + + ); +}; + +export const WithCustomCell: ComponentStory = (args) => { + const tableData: WithCustomCellData[] = [ + { + datasource: 'Prometheus', + repo: 'https://github.com/prometheus/prometheus', + }, + { + datasource: 'Loki', + repo: 'https://github.com/grafana/loki', + }, + { + datasource: 'Tempo', + repo: 'https://github.com/grafana/tempo', + }, + ]; + + const columns: Array> = [ + { id: 'datasource', header: 'Data Source' }, + { id: 'repo', header: 'Repo', cell: RepoCell }, + ]; + + return r.datasource} />; +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx new file mode 100644 index 00000000000..579b5e5793d --- /dev/null +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, getByRole, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { InteractiveTable } from './InteractiveTable'; +import { Column } from './types'; + +interface TableData { + id: string; + country?: string; + value?: string; +} +function getRowId(row: TableData) { + return row.id; +} + +describe('InteractiveTable', () => { + it('should not render hidden columns', () => { + const columns: Array> = [ + { id: 'id', header: 'ID' }, + { id: 'country', header: 'Country', visible: () => false }, + ]; + const data: TableData[] = [ + { id: '1', country: 'Sweden' }, + { id: '2', country: 'Portugal' }, + ]; + render(); + + expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Country' })).not.toBeInTheDocument(); + }); + + it('should correctly sort rows', () => { + // We are not testing the sorting logic here since it is already tested in react-table, + // but instead we are testing that the sorting is applied correctly to the table and correct aria attributes are set + // according to https://www.w3.org/WAI/ARIA/apg/example-index/table/sortable-table + const columns: Array> = [ + { id: 'id', header: 'ID' }, + { id: 'value', header: 'Value', sortType: 'string' }, + { id: 'country', header: 'Country', sortType: 'number' }, + ]; + const data: TableData[] = [ + { id: '1', value: '1', country: 'Sweden' }, + { id: '2', value: '3', country: 'Portugal' }, + { id: '3', value: '2', country: 'Italy' }, + ]; + render(); + + const valueColumnHeader = screen.getByRole('columnheader', { name: 'Value' }); + const countryColumnHeader = screen.getByRole('columnheader', { name: 'Country' }); + const valueColumnSortButton = getByRole(valueColumnHeader, 'button'); + const countryColumnSortButton = getByRole(countryColumnHeader, 'button'); + + expect(valueColumnHeader).not.toHaveAttribute('aria-sort'); + expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); + + fireEvent.click(countryColumnSortButton); + expect(valueColumnHeader).not.toHaveAttribute('aria-sort'); + expect(countryColumnHeader).toHaveAttribute('aria-sort', 'ascending'); + + fireEvent.click(valueColumnSortButton); + expect(valueColumnHeader).toHaveAttribute('aria-sort', 'ascending'); + expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); + + fireEvent.click(valueColumnSortButton); + expect(valueColumnHeader).toHaveAttribute('aria-sort', 'descending'); + expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); + + fireEvent.click(valueColumnSortButton); + expect(valueColumnHeader).not.toHaveAttribute('aria-sort'); + expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); + }); + + it('correctly expands rows', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render( +
{row.country}
} + /> + ); + + const expanderButton = screen.getByRole('button', { name: /toggle row expanded/i }); + fireEvent.click(expanderButton); + + expect(screen.getByTestId('test-1')).toHaveTextContent('Sweden'); + + expect(expanderButton.getAttribute('aria-controls')).toBe( + // anchestor tr's id should match the expander button's aria-controls attribute + screen.getByTestId('test-1').parentElement?.parentElement?.id + ); + }); +}); diff --git a/public/app/features/correlations/components/Table/index.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx similarity index 50% rename from public/app/features/correlations/components/Table/index.tsx rename to packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index 91aa3ed2f97..8801e8ec529 100644 --- a/public/app/features/correlations/components/Table/index.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -1,20 +1,14 @@ import { cx, css } from '@emotion/css'; -import React, { useMemo, Fragment, ReactNode } from 'react'; -import { - CellProps, - SortByFn, - useExpanded, - useSortBy, - useTable, - DefaultSortTypes, - TableOptions, - IdType, -} from 'react-table'; +import { uniqueId } from 'lodash'; +import React, { useMemo, Fragment, ReactNode, useCallback } from 'react'; +import { useExpanded, useSortBy, useTable, TableOptions, Row, HeaderGroup } from 'react-table'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, useStyles2 } from '@grafana/ui'; -import { isTruthy } from 'app/core/utils/types'; +import { GrafanaTheme2, isTruthy } from '@grafana/data'; +import { useStyles2 } from '../../themes'; +import { Icon } from '../Icon/Icon'; + +import { Column } from './types'; import { EXPANDER_CELL_ID, getColumns } from './utils'; const getStyles = (theme: GrafanaTheme2) => ({ @@ -23,47 +17,74 @@ const getStyles = (theme: GrafanaTheme2) => ({ border: solid 1px ${theme.colors.border.weak}; background-color: ${theme.colors.background.secondary}; width: 100%; + + td { + padding: ${theme.spacing(1)}; + } + td, th { - padding: ${theme.spacing(1)}; min-width: ${theme.spacing(3)}; } `, evenRow: css` background: ${theme.colors.background.primary}; `, - shrink: css` + disableGrow: css` width: 0%; `, + header: css` + &, + & > button { + position: relative; + white-space: nowrap; + padding: ${theme.spacing(1)}; + } + & > button { + &:after { + content: '\\00a0'; + } + width: 100%; + height: 100%; + background: none; + border: none; + padding-right: ${theme.spacing(2.5)}; + text-align: left; + &:hover { + background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.05)}; + } + } + `, + sortableHeader: css` + /* increases selector's specificity so that it always takes precedence over default styles */ + && { + padding: 0; + } + `, }); -export interface Column { - /** - * ID of the column. - * Set this to the matching object key of your data or `undefined` if the column doesn't have any associated data with it. - * This must be unique among all other columns. - */ - id?: IdType; - cell?: (props: CellProps) => ReactNode; - header?: (() => ReactNode | string) | string; - sortType?: DefaultSortTypes | SortByFn; - shrink?: boolean; - visible?: (col: TableData[]) => boolean; -} - interface Props { + /** + * Table's columns definition. Must be memoized. + */ columns: Array>; + /** + * The data to display in the table. Must be memoized. + */ data: TableData[]; - renderExpandedRow?: (row: TableData) => JSX.Element; + /** + * Render function for the expanded row. if not provided, the tables rows will not be expandable. + */ + renderExpandedRow?: (row: TableData) => ReactNode; className?: string; + /** + * Must return a unique id for each row + */ getRowId: TableOptions['getRowId']; } -/** - * non-viz table component. - * Will need most likely to be moved in @grafana/ui - */ -export function Table({ +/** @alpha */ +export function InteractiveTable({ data, className, columns, @@ -75,6 +96,13 @@ export function Table({ const cols = getColumns(columns); return cols; }, [columns]); + const id = useUniqueId(); + const getRowHTMLID = useCallback( + (row: Row) => { + return `${id}-${row.id}`.replace(/\s/g, ''); + }, + [id] + ); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable( { @@ -82,12 +110,13 @@ export function Table({ data, autoResetExpanded: false, autoResetSortBy: false, + disableMultiSort: true, getRowId, initialState: { hiddenColumns: [ !renderExpandedRow && EXPANDER_CELL_ID, ...tableColumns - .filter((col) => !(col.visible?.(data) ?? true)) + .filter((col) => !(col.visible ? col.visible(data) : true)) .map((c) => c.id) .filter(isTruthy), ].filter(isTruthy), @@ -96,6 +125,7 @@ export function Table({ useSortBy, useExpanded ); + // This should be called only for rows thar we'd want to actually render, which is all at this stage. // We may want to revisit this if we decide to add pagination and/or virtualized tables. rows.forEach(prepareRow); @@ -109,16 +139,19 @@ export function Table({ return ( {headerGroup.headers.map((column) => { - // TODO: if the column is a function, it should also provide an accessible name as a string to be used a the column title in getSortByToggleProps - const { key, ...headerCellProps } = column.getHeaderProps( - column.canSort ? column.getSortByToggleProps() : undefined - ); + const { key, ...headerCellProps } = column.getHeaderProps(); return ( - - {column.render('Header')} - - {column.isSorted && } + + ); })} @@ -131,6 +164,7 @@ export function Table({ {rows.map((row, rowIndex) => { const className = cx(rowIndex % 2 === 0 && styles.evenRow); const { key, ...otherRowProps } = row.getRowProps(); + const rowId = getRowHTMLID(row); return ( @@ -139,7 +173,7 @@ export function Table({ const { key, ...otherCellProps } = cell.getCellProps(); return ( - {cell.render('Cell')} + {cell.render('Cell', { __rowID: rowId })} ); })} @@ -147,7 +181,7 @@ export function Table({ { // @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz row.isExpanded && renderExpandedRow && ( - + {renderExpandedRow(row.original)} ) @@ -159,3 +193,45 @@ export function Table({ ); } + +const useUniqueId = () => { + return useMemo(() => uniqueId('InteractiveTable'), []); +}; + +const getColumnheaderStyles = (theme: GrafanaTheme2) => ({ + sortIcon: css` + position: absolute; + top: ${theme.spacing(1)}; + `, +}); + +function ColumnHeader({ + column: { canSort, render, isSorted, isSortedDesc, getSortByToggleProps }, +}: { + column: HeaderGroup; +}) { + const styles = useStyles2(getColumnheaderStyles); + const { onClick } = getSortByToggleProps(); + + const children = ( + <> + {render('Header')} + + {isSorted && ( + + )} + + ); + + if (canSort) { + return ( + + ); + } + + return children; +} diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts new file mode 100644 index 00000000000..47263d4730e --- /dev/null +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table'; + +export interface Column { + /** + * ID of the column. Must be unique among all other columns + */ + id: IdType; + /** + * Custom render function for te cell + */ + cell?: (props: CellProps) => ReactNode; + /** + * Header name. if `undefined` the header will be empty. Useful for action columns. + */ + header?: string; + /** + * Column sort type. If `undefined` the column will not be sortable. + * */ + sortType?: DefaultSortTypes | SortByFn; + /** + * If `true` prevents the column from growing more than its content. + */ + disableGrow?: boolean; + /** + * If the provided function returns `false` the column will be hidden. + */ + visible?: (data: TableData[]) => boolean; +} diff --git a/public/app/features/correlations/components/Table/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts similarity index 84% rename from public/app/features/correlations/components/Table/utils.ts rename to packages/grafana-ui/src/components/InteractiveTable/utils.ts index 551157b87a7..7eb970008b1 100644 --- a/public/app/features/correlations/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -1,11 +1,9 @@ -import { uniqueId } from 'lodash'; import { Column as RTColumn } from 'react-table'; import { ExpanderCell } from './ExpanderCell'; +import { Column } from './types'; -import { Column } from '.'; - -export const EXPANDER_CELL_ID = '__expander'; +export const EXPANDER_CELL_ID = '__expander' as const; type InternalColumn = RTColumn & { visible?: (data: T[]) => boolean; @@ -24,11 +22,12 @@ export function getColumns(columns: Array>): Array ({ + id: column.id, + accessor: column.id, Header: column.header || (() => null), - accessor: column.id || uniqueId(), sortType: column.sortType || 'alphanumeric', disableSortBy: !Boolean(column.sortType), - width: column.shrink ? 0 : undefined, + width: column.disableGrow ? 0 : undefined, visible: column.visible, ...(column.cell && { Cell: column.cell }), })), diff --git a/packages/grafana-ui/src/components/Table/FooterRow.tsx b/packages/grafana-ui/src/components/Table/FooterRow.tsx index 59a4659f3d8..8e9cc1e423c 100644 --- a/packages/grafana-ui/src/components/Table/FooterRow.tsx +++ b/packages/grafana-ui/src/components/Table/FooterRow.tsx @@ -40,9 +40,7 @@ export const FooterRow = (props: FooterRowProps) => { data-testid={e2eSelectorsTable.footer} style={height ? { height: `${height}px` } : undefined} > - {footerGroup.headers.map((column: ColumnInstance, index: number) => - renderFooterCell(column, tableStyles, height) - )} + {footerGroup.headers.map((column: ColumnInstance) => renderFooterCell(column, tableStyles, height))}
); })} @@ -71,10 +69,19 @@ function renderFooterCell(column: ColumnInstance, tableStyles: TableStyles, heig ); } -export function getFooterValue(index: number, footerValues?: FooterItem[]) { +export function getFooterValue(index: number, footerValues?: FooterItem[], isCountRowsSet?: boolean) { if (footerValues === undefined) { return EmptyCell; } + if (isCountRowsSet) { + const count = footerValues[index]; + if (typeof count !== 'string') { + return EmptyCell; + } + + return FooterCell({ value: [{ Count: count }] }); + } + return FooterCell({ value: footerValues[index] }); } diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index b6ce1f0d003..95ca40bf7a5 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -399,6 +399,121 @@ describe('Table', () => { }); }); + describe('on table footer enabled and count calculation selected', () => { + it('should show count of non-null values', async () => { + getTestContext({ + footerOptions: { show: true, reducer: ['count'] }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual('4'); + }); + + it('should show count of rows when `count rows` is selected', async () => { + getTestContext({ + footerOptions: { show: true, reducer: ['count'], countRows: true }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number1', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual( + 'Count:' + ); + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5'); + }); + + it('should show correct counts when turning `count rows` on and off', async () => { + const { rerender } = getTestContext({ + footerOptions: { show: true, reducer: ['count'], countRows: true }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number1', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual( + 'Count:' + ); + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5'); + + const onSortByChange = jest.fn(); + const onCellFilterAdded = jest.fn(); + const onColumnResize = jest.fn(); + const props: Props = { + ariaLabel: 'aria-label', + data: getDefaultDataFrame(), + height: 600, + width: 800, + onSortByChange, + onCellFilterAdded, + onColumnResize, + }; + + const propOverrides = { + footerOptions: { show: true, reducer: ['count'], countRows: false }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }; + + Object.assign(props, propOverrides); + + rerender(); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual('4'); + }); + }); + describe('when mounted with data and sub-data', () => { it('then correct rows should be rendered and new table is rendered when expander is clicked', () => { getTestContext({ diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index dc3103067ec..e858c5af919 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -12,7 +12,7 @@ import { import usePrevious from 'react-use/lib/usePrevious'; import { VariableSizeList } from 'react-window'; -import { DataFrame, getFieldDisplayName, Field } from '@grafana/data'; +import { DataFrame, getFieldDisplayName, Field, ReducerID } from '@grafana/data'; import { useStyles2, useTheme2 } from '../../themes'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; @@ -188,10 +188,27 @@ export const Table = memo((props: Props) => { return Array(data.length).fill(0); }, [data]); + const isCountRowsSet = Boolean( + footerOptions?.countRows && + footerOptions.reducer && + footerOptions.reducer.length && + footerOptions.reducer[0] === ReducerID.count + ); + // React-table column definitions const memoizedColumns = useMemo( - () => getColumns(data, width, columnMinWidth, expandedIndexes, setExpandedIndexes, !!subData?.length, footerItems), - [data, width, columnMinWidth, footerItems, subData, expandedIndexes] + () => + getColumns( + data, + width, + columnMinWidth, + expandedIndexes, + setExpandedIndexes, + !!subData?.length, + footerItems, + isCountRowsSet + ), + [data, width, columnMinWidth, footerItems, subData, expandedIndexes, isCountRowsSet] ); // Internal react table state reducer @@ -244,17 +261,24 @@ export const Table = memo((props: Props) => { return; } - if (footerOptions.show) { - setFooterItems( - getFooterItems( - headerGroups[0].headers as unknown as Array<{ field: Field }>, - createFooterCalculationValues(rows), - footerOptions, - theme - ) - ); - } else { + if (!footerOptions.show) { setFooterItems(undefined); + return; + } + + const footerItems = getFooterItems( + headerGroups[0].headers as unknown as Array<{ field: Field }>, + createFooterCalculationValues(rows), + footerOptions, + theme + ); + + if (isCountRowsSet) { + const footerItemsCountRows: FooterItem[] = new Array(footerItems.length).fill(undefined); + footerItemsCountRows[0] = data.length.toString(); + setFooterItems(footerItemsCountRows); + } else { + setFooterItems(footerItems); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [footerOptions, theme, state.filters, data]); @@ -305,6 +329,10 @@ export const Table = memo((props: Props) => { } }); + useEffect(() => { + setExpandedIndexes(new Set()); + }, [data, subData]); + const renderSubTable = React.useCallback( (rowIndex: number) => { if (expandedIndexes.has(rowIndex)) { diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 0f613eda42e..6942ec7594b 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -50,4 +50,5 @@ export interface TableFooterCalc { reducer: string[]; // actually 1 value fields?: string[]; enablePagination?: boolean; + countRows?: boolean; } diff --git a/packages/grafana-ui/src/components/Table/utils.tsx b/packages/grafana-ui/src/components/Table/utils.tsx index fdce74d6853..fd5f1b46c88 100644 --- a/packages/grafana-ui/src/components/Table/utils.tsx +++ b/packages/grafana-ui/src/components/Table/utils.tsx @@ -68,7 +68,8 @@ export function getColumns( expandedIndexes: Set, setExpandedIndexes: (indexes: Set) => void, expander: boolean, - footerValues?: FooterItem[] + footerValues?: FooterItem[], + isCountRowsSet?: boolean ): GrafanaTableColumn[] { const columns: GrafanaTableColumn[] = expander ? [ @@ -134,7 +135,7 @@ export function getColumns( minWidth: fieldTableOptions.minWidth ?? columnMinWidth, filter: memoizeOne(filterByValue(field)), justifyContent: getTextAlign(field), - Footer: getFooterValue(fieldIndex, footerValues), + Footer: getFooterValue(fieldIndex, footerValues, isCountRowsSet), }); } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index be1987ed037..8497191c78f 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -42,6 +42,7 @@ export { } from './DateTimePickers/DatePickerWithInput/DatePickerWithInput'; export { DateTimePicker } from './DateTimePickers/DateTimePicker/DateTimePicker'; export { List } from './List/List'; +export { InteractiveTable } from './InteractiveTable/InteractiveTable'; export { TagsInput } from './TagsInput/TagsInput'; export { Pagination } from './Pagination/Pagination'; export { Tag, type OnTagClick } from './Tags/Tag'; diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx index 5f465b4c597..12dbefd7d04 100644 --- a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -80,7 +80,7 @@ export const PlotLegend: React.FC = React.memo( ...field, config: { ...field.config, - unit: 'percent', + unit: 'percentunit', }, }, theme, diff --git a/packages/grafana-ui/src/themes/GlobalStyles/page.ts b/packages/grafana-ui/src/themes/GlobalStyles/page.ts index 033d0beac4a..1c301bc188d 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/page.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/page.ts @@ -90,7 +90,8 @@ export function getPageStyles(theme: GrafanaTheme2) { align-items: flex-start; > a, - > button { + > button, + > div:nth-child(2) { margin-left: ${theme.spacing(2)}; } } diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 79e87f3c5b6..042ee209e34 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -6,3 +6,4 @@ export * from './forms'; export * from './icon'; export * from './select'; export * from './size'; +export * from './interactiveTable'; diff --git a/packages/grafana-ui/src/types/interactiveTable.ts b/packages/grafana-ui/src/types/interactiveTable.ts new file mode 100644 index 00000000000..f999cb9aa5b --- /dev/null +++ b/packages/grafana-ui/src/types/interactiveTable.ts @@ -0,0 +1,2 @@ +export type { Column } from '../components/InteractiveTable/types'; +export type { CellProps, SortByFn } from 'react-table'; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx similarity index 58% rename from packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx index bb6955051dd..65f8dd706d8 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx @@ -19,20 +19,22 @@ import traceGenerator from '../demo/trace-generators'; import { getTraceName } from '../model/trace-viewer'; import transformTraceData from '../model/transform-trace-data'; -import TracePageHeader from './TracePageHeader'; +import TracePageHeader, { TracePageHeaderEmbedProps } from './TracePageHeader'; const trace = transformTraceData(traceGenerator.trace({})); -const setup = (propOverrides) => { +const setup = (propOverrides?: TracePageHeaderEmbedProps) => { const defaultProps = { + canCollapse: false, + hideSummary: false, + onSlimViewClicked: () => {}, + onTraceGraphViewClicked: () => {}, + slimView: false, trace, hideMap: false, - showArchiveButton: false, - showShortcutsHelp: false, - showStandaloneLink: false, - showViewOptions: false, - textFilter: '', - viewRange: { time: { current: [10, 20] } }, - updateTextFilter: () => {}, + timeZone: '', + viewRange: { time: { current: [10, 20] as [number, number] } }, + updateNextViewRangeTime: () => {}, + updateViewRangeTime: () => {}, ...propOverrides, }; @@ -42,23 +44,23 @@ const setup = (propOverrides) => { describe('TracePageHeader test', () => { it('should render a header ', () => { setup(); - expect(screen.getByRole('banner')).toBeInTheDocument(); }); it('should render nothing if a trace is not present', () => { - setup({ trace: null }); - + setup({ trace: null } as TracePageHeaderEmbedProps); expect(screen.queryByRole('banner')).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { traceName: getTraceName(trace.spans) })).not.toBeInTheDocument(); expect(screen.queryAllByRole('listitem')).toHaveLength(0); expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument(); }); it('should render the trace title', () => { setup(); - - expect(screen.getByRole('heading', { traceName: getTraceName(trace.spans) })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { + name: (content) => content.replace(/ /g, '').startsWith(getTraceName(trace!.spans).replace(/ /g, '')), + }) + ).toBeInTheDocument(); }); it('should render the header items', () => { @@ -68,61 +70,51 @@ describe('TracePageHeader test', () => { expect(headerItems).toHaveLength(5); // Year-month-day hour-minute-second - expect(headerItems[0].textContent.match(/Trace Start:\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}/g)).toBeTruthy(); - expect(headerItems[1].textContent.match(/Duration:[\d|\.][\.|\d|s][\.|\d|s]?[\d]?/)).toBeTruthy(); - expect(headerItems[2].textContent.match(/Services:\d\d?/g)).toBeTruthy(); - expect(headerItems[3].textContent.match(/Depth:\d\d?/)).toBeTruthy(); - expect(headerItems[4].textContent.match(/Total Spans:\d\d?\d?\d?/)).toBeTruthy(); + expect(headerItems[0].textContent?.match(/Trace Start:\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}/g)).toBeTruthy(); + expect(headerItems[1].textContent?.match(/Duration:[\d|\.][\.|\d|s][\.|\d|s]?[\d]?/)).toBeTruthy(); + expect(headerItems[2].textContent?.match(/Services:\d\d?/g)).toBeTruthy(); + expect(headerItems[3].textContent?.match(/Depth:\d\d?/)).toBeTruthy(); + expect(headerItems[4].textContent?.match(/Total Spans:\d\d?\d?\d?/)).toBeTruthy(); }); it('should render a ', () => { setup(); - expect(screen.getByText(/Reset Selection/)).toBeInTheDocument(); }); describe('observes the visibility toggles for various UX elements', () => { it('hides the minimap when hideMap === true', () => { - setup({ hideMap: true }); - + setup({ hideMap: true } as TracePageHeaderEmbedProps); expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument(); }); it('hides the summary when hideSummary === true', () => { - const { rerender } = setup({ hideSummary: false }); + const { rerender } = setup({ hideSummary: false } as TracePageHeaderEmbedProps); expect(screen.queryAllByRole('listitem')).toHaveLength(5); - rerender(); + rerender(); expect(screen.queryAllByRole('listitem')).toHaveLength(0); rerender( {}} + {...({ + trace: trace, + hideSummary: true, + hideMap: false, + viewRange: { time: { current: [10, 20] } }, + } as TracePageHeaderEmbedProps)} /> ); expect(screen.queryAllByRole('listitem')).toHaveLength(0); rerender( {}} + {...({ + trace: trace, + hideSummary: false, + hideMap: false, + viewRange: { time: { current: [10, 20] } }, + } as TracePageHeaderEmbedProps)} /> ); expect(screen.queryAllByRole('listitem')).toHaveLength(5); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx index 5c78064d18f..a18fb38e213 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx @@ -140,14 +140,14 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -type TracePageHeaderEmbedProps = { +export type TracePageHeaderEmbedProps = { canCollapse: boolean; hideMap: boolean; hideSummary: boolean; onSlimViewClicked: () => void; onTraceGraphViewClicked: () => void; slimView: boolean; - trace: Trace; + trace: Trace | null; updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; updateViewRangeTime: TUpdateViewRangeTimeFunction; viewRange: ViewRange; diff --git a/packages/jaeger-ui-components/src/common/UiFindInput.test.js b/packages/jaeger-ui-components/src/common/UiFindInput.test.js deleted file mode 100644 index 43f3fad8869..00000000000 --- a/packages/jaeger-ui-components/src/common/UiFindInput.test.js +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2019 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { render, screen } from '@testing-library/react'; -import * as React from 'react'; - -import UiFindInput from './UiFindInput'; - -describe('UiFindInput', () => { - describe('rendering', () => { - it('renders as expected with no value', () => { - render(); - const uiFindInput = screen.queryByPlaceholderText('Find...'); - expect(uiFindInput).toBeInTheDocument(); - expect(uiFindInput['value']).toEqual(''); - }); - - it('renders as expected with value', () => { - render(); - const uiFindInput = screen.queryByPlaceholderText('Find...'); - expect(uiFindInput).toBeInTheDocument(); - expect(uiFindInput['value']).toEqual('value'); - }); - }); -}); diff --git a/packages/jaeger-ui-components/src/selectors/span.test.js b/packages/jaeger-ui-components/src/selectors/span.test.ts similarity index 84% rename from packages/jaeger-ui-components/src/selectors/span.test.js rename to packages/jaeger-ui-components/src/selectors/span.test.ts index 12893f81628..0771b01283a 100644 --- a/packages/jaeger-ui-components/src/selectors/span.test.js +++ b/packages/jaeger-ui-components/src/selectors/span.test.ts @@ -12,11 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { TraceResponse } from 'src/types'; +import { TraceSpan, TraceSpanData } from 'src/types/trace'; + import traceGenerator from '../demo/trace-generators'; import * as spanSelectors from './span'; -const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 }); +const generatedTrace: TraceResponse = traceGenerator.trace({ numberOfSpans: 45 }); it('getSpanId() should return the name of the span', () => { const span = generatedTrace.spans[0]; @@ -46,8 +49,10 @@ it('getSpanReferences() should return the span reference array', () => { expect(spanSelectors.getSpanReferences(generatedTrace.spans[0])).toEqual(generatedTrace.spans[0].references); }); -it('getSpanReferences() should return empty array for null references', () => { - expect(spanSelectors.getSpanReferences({ references: null })).toEqual([]); +it('getSpanReferences() should return an empty array when references is undefined', () => { + const span = generatedTrace.spans[0]; + span.references = undefined; + expect(spanSelectors.getSpanReferences(span)).toEqual([]); }); it('getSpanReferenceByType() should return the span reference requested', () => { @@ -55,7 +60,7 @@ it('getSpanReferenceByType() should return the span reference requested', () => spanSelectors.getSpanReferenceByType({ span: generatedTrace.spans[1], type: 'CHILD_OF', - }).refType + })?.refType ).toBe('CHILD_OF'); }); @@ -70,7 +75,7 @@ it('getSpanReferenceByType() should return undefined if one does not exist', () it('getSpanParentId() should return the spanID of the parent span', () => { expect(spanSelectors.getSpanParentId(generatedTrace.spans[1])).toBe( - generatedTrace.spans[1].references.find(({ refType }) => refType === 'CHILD_OF').spanID + generatedTrace.spans[1].references!.find(({ refType }: { refType: string }) => refType === 'CHILD_OF')!.spanID ); }); @@ -85,16 +90,17 @@ it('getSpanProcessId() should return the processID of the span', () => { }); it('getSpanProcess() should return the process of the span', () => { + const serviceName = 'bagel'; const span = { ...generatedTrace.spans[0], - process: {}, - }; + process: { serviceName }, + } as TraceSpan; expect(spanSelectors.getSpanProcess(span)).toBe(span.process); }); it('getSpanProcess() should throw if no process exists', () => { - expect(() => spanSelectors.getSpanProcess(generatedTrace.spans[0])).toThrow(); + expect(() => spanSelectors.getSpanProcess(generatedTrace.spans[0] as TraceSpan)).toThrow(); }); it('getSpanServiceName() should return the service name of the span', () => { @@ -102,7 +108,7 @@ it('getSpanServiceName() should return the service name of the span', () => { const span = { ...generatedTrace.spans[0], process: { serviceName }, - }; + } as TraceSpan; expect(spanSelectors.getSpanServiceName(span)).toBe(serviceName); }); @@ -112,17 +118,17 @@ it('filterSpansForTimestamps() should return a filtered list of spans between th const spans = [ { startTime: now - 1000, - id: 'start-time-1', + spanID: 'start-time-1', }, { startTime: now, - id: 'start-time-2', + spanID: 'start-time-2', }, { startTime: now + 1000, - id: 'start-time-3', + spanID: 'start-time-3', }, - ]; + ] as TraceSpanData[]; expect( spanSelectors.filterSpansForTimestamps({ @@ -164,23 +170,23 @@ it('filterSpansForText() should return a filtered list of spans between the time process: { serviceName: 'alpha', }, - id: 'start-time-1', + spanID: 'start-time-1', }, { operationName: 'GET /another', process: { serviceName: 'beta', }, - id: 'start-time-1', + spanID: 'start-time-1', }, { operationName: 'POST /mything', process: { serviceName: 'alpha', }, - id: 'start-time-1', + spanID: 'start-time-1', }, - ]; + ] as TraceSpan[]; expect( spanSelectors.filterSpansForText({ diff --git a/packages/jaeger-ui-components/src/selectors/span.js b/packages/jaeger-ui-components/src/selectors/span.ts similarity index 56% rename from packages/jaeger-ui-components/src/selectors/span.js rename to packages/jaeger-ui-components/src/selectors/span.ts index cd0a827dc4c..179dd1b8483 100644 --- a/packages/jaeger-ui-components/src/selectors/span.js +++ b/packages/jaeger-ui-components/src/selectors/span.ts @@ -15,25 +15,27 @@ import fuzzy from 'fuzzy'; import { createSelector } from 'reselect'; +import { TraceSpan, TraceSpanData, TraceSpanReference } from '../types/trace'; + import { getProcessServiceName } from './process'; -export const getSpanId = (span) => span.spanID; -export const getSpanName = (span) => span.operationName; -export const getSpanDuration = (span) => span.duration; -export const getSpanTimestamp = (span) => span.startTime; -export const getSpanProcessId = (span) => span.processID; -export const getSpanReferences = (span) => span.references || []; +export const getSpanId = (span: TraceSpanData) => span.spanID; +export const getSpanName = (span: TraceSpanData) => span.operationName; +export const getSpanDuration = (span: TraceSpanData) => span.duration; +export const getSpanTimestamp = (span: TraceSpanData) => span.startTime; +export const getSpanProcessId = (span: TraceSpanData) => span.processID; +export const getSpanReferences = (span: TraceSpanData) => span.references || []; export const getSpanReferenceByType = createSelector( - createSelector(({ span }) => span, getSpanReferences), - ({ type }) => type, - (references, type) => references.find((ref) => ref.refType === type) + createSelector(({ span }: { span: TraceSpanData }) => span, getSpanReferences), + ({ type }: { type: string }) => type, + (references, type) => references.find((ref: TraceSpanReference) => ref.refType === type) ); export const getSpanParentId = createSelector( - (span) => getSpanReferenceByType({ span, type: 'CHILD_OF' }), + (span: TraceSpanData) => getSpanReferenceByType({ span, type: 'CHILD_OF' }), (childOfRef) => (childOfRef ? childOfRef.spanID : null) ); -export const getSpanProcess = (span) => { +export const getSpanProcess = (span: TraceSpan) => { if (!span.process) { throw new Error( ` @@ -42,23 +44,22 @@ export const getSpanProcess = (span) => { ` ); } - return span.process; }; export const getSpanServiceName = createSelector(getSpanProcess, getProcessServiceName); export const filterSpansForTimestamps = createSelector( - ({ spans }) => spans, - ({ leftBound }) => leftBound, - ({ rightBound }) => rightBound, + ({ spans }: { spans: TraceSpanData[] }) => spans, + ({ leftBound }: { leftBound: number }) => leftBound, + ({ rightBound }: { rightBound: number }) => rightBound, (spans, leftBound, rightBound) => spans.filter((span) => getSpanTimestamp(span) >= leftBound && getSpanTimestamp(span) <= rightBound) ); export const filterSpansForText = createSelector( - ({ spans }) => spans, - ({ text }) => text, + ({ spans }: { spans: TraceSpan[] }) => spans, + ({ text }: { text: string }) => text, (spans, text) => fuzzy .filter(text, spans, { @@ -67,7 +68,7 @@ export const filterSpansForText = createSelector( .map(({ original }) => original) ); -const getTextFilterdSpansAsMap = createSelector(filterSpansForText, (matchingSpans) => +const getTextFilteredSpansAsMap = createSelector(filterSpansForText, (matchingSpans) => matchingSpans.reduce( (obj, span) => ({ ...obj, @@ -77,11 +78,12 @@ const getTextFilterdSpansAsMap = createSelector(filterSpansForText, (matchingSpa ) ); +// TODO: delete this function as it is not used? export const highlightSpansForTextFilter = createSelector( - ({ spans }) => spans, - getTextFilterdSpansAsMap, - (spans, textFilteredSpansMap) => - spans.map((span) => ({ + ({ spans }: { spans: TraceSpanData[] }) => spans, + getTextFilteredSpansAsMap, + (spans, textFilteredSpansMap: { [key: string]: TraceSpanData }) => + spans.map((span: TraceSpanData) => ({ ...span, muted: !textFilteredSpansMap[getSpanId(span)], })) diff --git a/packages/jaeger-ui-components/src/selectors/trace.fixture.js b/packages/jaeger-ui-components/src/selectors/trace.fixture.ts similarity index 85% rename from packages/jaeger-ui-components/src/selectors/trace.fixture.js rename to packages/jaeger-ui-components/src/selectors/trace.fixture.ts index 9c73a7a7bcb..6fdad979d6f 100644 --- a/packages/jaeger-ui-components/src/selectors/trace.fixture.js +++ b/packages/jaeger-ui-components/src/selectors/trace.fixture.ts @@ -14,6 +14,16 @@ // See https://github.com/jaegertracing/jaeger-ui/issues/115 for details. +import { TraceSpanReference } from '../types/trace'; + +const references: TraceSpanReference[] = [ + { + refType: 'FOLLOWS_FROM', + spanID: 'ea7cfaca83f0724b', + traceID: '2992f2a5b5d037a8aabffd08ef384237', + }, +]; + export const followsFromRef = { processes: { p1: { @@ -28,13 +38,7 @@ export const followsFromRef = { logs: [], operationName: 'thread', processID: 'p1', - references: [ - { - refType: 'FOLLOWS_FROM', - spanID: 'ea7cfaca83f0724b', - traceID: '2992f2a5b5d037a8aabffd08ef384237', - }, - ], + references: references, spanID: '1bdf4201221bb2ac', startTime: 1509533706521220, tags: [], diff --git a/packages/jaeger-ui-components/src/selectors/trace.test.js b/packages/jaeger-ui-components/src/selectors/trace.test.ts similarity index 88% rename from packages/jaeger-ui-components/src/selectors/trace.test.js rename to packages/jaeger-ui-components/src/selectors/trace.test.ts index 3e20f4d7060..dfaa9d28b93 100644 --- a/packages/jaeger-ui-components/src/selectors/trace.test.js +++ b/packages/jaeger-ui-components/src/selectors/trace.test.ts @@ -13,8 +13,10 @@ // limitations under the License. import { values as _values } from 'lodash'; +import TreeNode from 'src/utils/TreeNode'; import traceGenerator from '../demo/trace-generators'; +import { TraceResponse, TraceSpan, TraceSpanData } from '../types/trace'; import { numberSortComparator } from '../utils/sort'; import { @@ -29,7 +31,7 @@ import { import * as traceSelectors from './trace'; import { followsFromRef } from './trace.fixture'; -const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 }); +const generatedTrace: TraceResponse = traceGenerator.trace({ numberOfSpans: 45 }); it('getTraceId() should return the traceID', () => { expect(traceSelectors.getTraceId(generatedTrace)).toBe(generatedTrace.traceID); @@ -39,7 +41,7 @@ it('hydrateSpansWithProcesses() should return the trace with processes on each s const hydratedTrace = traceSelectors.hydrateSpansWithProcesses(generatedTrace); hydratedTrace.spans.forEach((span) => - expect(getSpanProcess(span)).toBe(generatedTrace.processes[getSpanProcessId(span)]) + expect(getSpanProcess(span as TraceSpan)).toBe(generatedTrace.processes[getSpanProcessId(span)]) ); }); @@ -55,7 +57,7 @@ describe('getTraceSpanIdsAsTree()', () => { const tree = traceSelectors.getTraceSpanIdsAsTree(generatedTrace); const spanMap = traceSelectors.getTraceSpansAsMap(generatedTrace); - tree.walk((value, node) => { + tree.walk((value: string | number | undefined, node: TreeNode) => { const expectedParentValue = value === traceSelectors.TREE_ROOT_ID ? null : value; node.children.forEach((childNode) => { expect(getSpanParentId(spanMap.get(childNode.value))).toBe(expectedParentValue); @@ -98,7 +100,7 @@ it('getParentSpan() should return the first span if there are multiple parents', references: [], }, ], - }; + } as unknown as TraceResponse; expect(traceSelectors.getParentSpan(trace)).toBe(firstSpan); }); @@ -131,14 +133,14 @@ it('getTraceDepth() should determine the total depth of the trace tree', () => { }); it('getSpanDepthForTrace() should determine the depth of a given span in the parent', () => { - function testDepthCalc(span) { + function testDepthCalc(span: TraceSpanData) { let depth = 2; let currentId = getSpanParentId(span); - const findCurrentSpanById = (item) => getSpanId(item) === currentId; + const findCurrentSpanById = (item: TraceSpanData) => getSpanId(item) === currentId; while (currentId !== getSpanId(generatedTrace.spans[0])) { depth++; - currentId = getSpanParentId(generatedTrace.spans.find(findCurrentSpanById)); + currentId = getSpanParentId(generatedTrace.spans.find(findCurrentSpanById)!); } // console.log('hypothetical depth', depth); @@ -183,10 +185,13 @@ it('formatDurationForUnit() should use the formatters to return the proper value }); it('formatDurationForTrace() should return a ms value for traces shorter than a second', () => { + const firstSpan = generatedTrace.spans[0]; + firstSpan.duration = 600000; expect( traceSelectors.formatDurationForTrace({ trace: { - spans: [{ duration: 600000 }], + ...generatedTrace, + spans: [firstSpan], }, duration: 302000, }) @@ -266,14 +271,17 @@ it('getTreeSizeForTraceSpan() should return the size for a child span', () => { trace: generatedTrace, span: generatedTrace.spans[1], }) - ).toBe(traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(generatedTrace.spans[1].spanID).size - 1); + ).toBe(traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(generatedTrace.spans[1].spanID)!.size - 1); }); it('getTreeSizeForTraceSpan() should return -1 for an absent span', () => { + const absentSpan = generatedTrace.spans[0]; + absentSpan.spanID = 'whatever'; + expect( traceSelectors.getTreeSizeForTraceSpan({ trace: generatedTrace, - span: { spanID: 'whatever' }, + span: absentSpan, }) ).toBe(-1); }); @@ -287,7 +295,7 @@ it('getTraceName() should return the trace name based on the parentSpan', () => it('omitCollapsedSpans() should filter out collapsed spans', () => { const span = generatedTrace.spans[1]; - const size = traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(span.spanID).size - 1; + const size = traceSelectors.getTraceSpanIdsAsTree(generatedTrace).find(span.spanID)!.size - 1; expect( traceSelectors.omitCollapsedSpans({ @@ -299,15 +307,13 @@ it('omitCollapsedSpans() should filter out collapsed spans', () => { }); it('getTicksForTrace() should return a list of ticks given interval parameters', () => { + const trace = generatedTrace; const timestamp = new Date().getTime() * 1000; - const trace = { - spans: [ - { - startTime: timestamp, - duration: 3000000, - }, - ], - }; + + trace.spans.forEach((span) => { + span.duration = 3000000; + span.startTime = timestamp; + }); expect( traceSelectors.getTicksForTrace({ @@ -325,14 +331,20 @@ it('getTicksForTrace() should return a list of ticks given interval parameters', it('getTicksForTrace() should use defaults', () => { const timestamp = new Date().getTime() * 1000; - const trace = { - spans: [ - { - startTime: timestamp, - duration: 4000000, - }, - ], - }; + const trace = traceGenerator.trace({ numberOfSpans: 1 }); + + trace.spans = [ + { + traceID: '5031233a-d0b5-5d41-9b4b-4c072bcf5020', + processID: 'b5f4e0ff-7318-5017-a3f3-9c7b423a82aa', + spanID: 'e871771f-f1b4-54af-9e9d-826259c2915e', + flags: 0, + logs: [], + operationName: 'POST', + startTime: timestamp, + duration: 4000000, + }, + ]; expect(traceSelectors.getTicksForTrace({ trace })).toEqual([ { timestamp, width: traceSelectors.DEFAULT_TICK_WIDTH }, diff --git a/packages/jaeger-ui-components/src/selectors/trace.js b/packages/jaeger-ui-components/src/selectors/trace.ts similarity index 61% rename from packages/jaeger-ui-components/src/selectors/trace.js rename to packages/jaeger-ui-components/src/selectors/trace.ts index 35776aef0df..f9c0ebb85ca 100644 --- a/packages/jaeger-ui-components/src/selectors/trace.js +++ b/packages/jaeger-ui-components/src/selectors/trace.ts @@ -14,6 +14,7 @@ import { createSelector, createStructuredSelector } from 'reselect'; +import { Trace, TraceData, TraceProcess, TraceResponse, TraceSpan, TraceSpanData } from '../types/trace'; import TreeNode from '../utils/TreeNode'; import { formatMillisecondTime, formatSecondTime, ONE_SECOND } from '../utils/date'; import { numberSortComparator } from '../utils/sort'; @@ -28,15 +29,13 @@ import { getSpanProcessId, } from './span'; -export const getTraceId = (trace) => trace.traceID; - -export const getTraceSpans = (trace) => trace.spans; - -const getTraceProcesses = (trace) => trace.processes; +export const getTraceId = (trace: TraceData) => trace.traceID; +export const getTraceSpans = (trace: TraceResponse) => trace.spans; +const getTraceProcesses = (trace: TraceData | Trace) => trace.processes; const getSpanWithProcess = createSelector( - (state) => state.span, - (state) => state.processes, + (state: { span: TraceSpanData; processes: Record }) => state.span, + (state: { span: TraceSpanData; processes: Record }) => state.processes, (span, processes) => ({ ...span, process: processes[getSpanProcessId(span)], @@ -44,7 +43,7 @@ const getSpanWithProcess = createSelector( ); export const getTraceSpansAsMap = createSelector(getTraceSpans, (spans) => - spans.reduce((map, span) => map.set(getSpanId(span), span), new Map()) + spans.reduce((map, span: TraceSpanData) => map.set(getSpanId(span), span), new Map()) ); export const TREE_ROOT_ID = '__root__'; @@ -63,17 +62,17 @@ export const TREE_ROOT_ID = '__root__'; * @return {TreeNode} A tree of spanIDs derived from the relationships * between spans in the trace. */ -export function getTraceSpanIdsAsTree(trace) { - const nodesById = new Map(trace.spans.map((span) => [span.spanID, new TreeNode(span.spanID)])); - const spansById = new Map(trace.spans.map((span) => [span.spanID, span])); +export function getTraceSpanIdsAsTree(trace: TraceResponse) { + const nodesById = new Map(trace.spans.map((span: TraceSpanData) => [span.spanID, new TreeNode(span.spanID)])); + const spansById = new Map(trace.spans.map((span: TraceSpanData) => [span.spanID, span])); const root = new TreeNode(TREE_ROOT_ID); - trace.spans.forEach((span) => { - const node = nodesById.get(span.spanID); + trace.spans.forEach((span: TraceSpanData) => { + const node = nodesById.get(span.spanID)!; if (Array.isArray(span.references) && span.references.length) { const { refType, spanID: parentID } = span.references[0]; if (refType === 'CHILD_OF' || refType === 'FOLLOWS_FROM') { const parent = nodesById.get(parentID) || root; - parent.children.push(node); + parent.children?.push(node); } else { throw new Error(`Unrecognized ref type: ${refType}`); } @@ -81,15 +80,15 @@ export function getTraceSpanIdsAsTree(trace) { root.children.push(node); } }); - const comparator = (nodeA, nodeB) => { - const a = spansById.get(nodeA.value); - const b = spansById.get(nodeB.value); - return +(a.startTime > b.startTime) || +(a.startTime === b.startTime) - 1; + const comparator = (nodeA: TreeNode | undefined, nodeB: TreeNode | undefined) => { + const a: TraceSpanData | undefined = nodeA?.value ? spansById.get(nodeA.value.toString()) : undefined; + const b: TraceSpanData | undefined = nodeB?.value ? spansById.get(nodeB.value.toString()) : undefined; + return +(a?.startTime! > b?.startTime!) || +(a?.startTime === b?.startTime) - 1; }; - trace.spans.forEach((span) => { - const node = nodesById.get(span.spanID); - if (node.children.length > 1) { - node.children.sort(comparator); + trace.spans.forEach((span: TraceSpanData) => { + const node: TreeNode | undefined = nodesById.get(span.spanID); + if (node!.children.length > 1) { + node?.children.sort(comparator); } }); root.children.sort(comparator); @@ -97,13 +96,13 @@ export function getTraceSpanIdsAsTree(trace) { } // attach "process" as an object to each span. -export const hydrateSpansWithProcesses = (trace) => { +export const hydrateSpansWithProcesses = (trace: TraceResponse) => { const spans = getTraceSpans(trace); const processes = getTraceProcesses(trace); return { ...trace, - spans: spans.map((span) => getSpanWithProcess({ span, processes })), + spans: spans.map((span: TraceSpanData) => getSpanWithProcess({ span, processes })), }; }; @@ -111,25 +110,26 @@ export const getTraceSpanCount = createSelector(getTraceSpans, (spans) => spans. export const getTraceTimestamp = createSelector(getTraceSpans, (spans) => spans.reduce( - (prevTimestamp, span) => (prevTimestamp ? Math.min(prevTimestamp, getSpanTimestamp(span)) : getSpanTimestamp(span)), - null + (prevTimestamp: number, span: TraceSpanData) => + prevTimestamp ? Math.min(prevTimestamp, getSpanTimestamp(span)) : getSpanTimestamp(span), + 0 ) ); export const getTraceDuration = createSelector(getTraceSpans, getTraceTimestamp, (spans, timestamp) => spans.reduce( - (prevDuration, span) => + (prevDuration: number, span: TraceSpanData) => prevDuration - ? Math.max(getSpanTimestamp(span) - timestamp + getSpanDuration(span), prevDuration) + ? Math.max(getSpanTimestamp(span) - timestamp! + getSpanDuration(span), prevDuration) : getSpanDuration(span), - null + 0 ) ); export const getTraceEndTimestamp = createSelector( getTraceTimestamp, getTraceDuration, - (timestamp, duration) => timestamp + duration + (timestamp: number, duration: number) => timestamp! + duration ); export const getParentSpan = createSelector( @@ -137,16 +137,18 @@ export const getParentSpan = createSelector( getTraceSpansAsMap, (tree, spanMap) => tree.children - .map((node) => spanMap.get(node.value)) - .sort((spanA, spanB) => numberSortComparator(getSpanTimestamp(spanA), getSpanTimestamp(spanB)))[0] + .map((node: TreeNode) => spanMap.get(node.value)) + .sort((spanA: TraceSpanData, spanB: TraceSpanData) => + numberSortComparator(getSpanTimestamp(spanA), getSpanTimestamp(spanB)) + )[0] ); export const getTraceDepth = createSelector(getTraceSpanIdsAsTree, (spanTree) => spanTree.depth - 1); export const getSpanDepthForTrace = createSelector( - createSelector((state) => state.trace, getTraceSpanIdsAsTree), - createSelector((state) => state.span, getSpanId), - (node, spanID) => node.getPath(spanID).length - 1 + createSelector((state: { trace: TraceResponse }) => state.trace, getTraceSpanIdsAsTree), + createSelector((state: { span: TraceSpanData }) => state.span, getSpanId), + (node, spanID) => node.getPath(spanID)!.length - 1 ); export const getTraceServices = createSelector(getTraceProcesses, (processes) => @@ -165,26 +167,34 @@ export const DURATION_FORMATTERS = { s: formatSecondTime, }; -const getDurationFormatterForTrace = createSelector(getTraceDuration, (totalDuration) => +const getDurationFormatterForTrace = createSelector(getTraceDuration, (totalDuration: number) => totalDuration >= ONE_SECOND ? DURATION_FORMATTERS.s : DURATION_FORMATTERS.ms ); export const formatDurationForUnit = createSelector( - ({ duration }) => duration, - ({ unit }) => DURATION_FORMATTERS[unit], + ({ duration }: { duration: number }) => duration, + ({ unit }: { unit: 'ms' | 's' }) => DURATION_FORMATTERS[unit], (duration, formatter) => formatter(duration) ); export const formatDurationForTrace = createSelector( - ({ duration }) => duration, - createSelector(({ trace }) => trace, getDurationFormatterForTrace), + ({ duration }: { duration: number }) => duration, + createSelector(({ trace }: { trace: TraceResponse }) => trace, getDurationFormatterForTrace), (duration, formatter) => formatter(duration) ); export const getSortedSpans = createSelector( - ({ trace }) => trace, - ({ spans }) => spans, - ({ sort }) => sort, + ({ trace }: { trace: TraceResponse }) => trace, + ({ spans }: { spans: TraceSpanData[] }) => spans, + ({ + sort, + }: { + sort: { + dir: number; + comparator: (itemA: number, itemB: number) => number; + selector: (itemA: TraceSpanData, itemB: TraceResponse) => number; + }; + }) => sort, (trace, spans, { dir, comparator, selector }) => [...spans].sort((spanA, spanB) => dir * comparator(selector(spanA, trace), selector(spanB, trace))) ); @@ -192,13 +202,13 @@ export const getSortedSpans = createSelector( const getTraceSpansByHierarchyPosition = createSelector(getTraceSpanIdsAsTree, (tree) => { const hierarchyPositionMap = new Map(); let i = 0; - tree.walk((spanID) => hierarchyPositionMap.set(spanID, i++)); + tree.walk((spanID: string | number | undefined) => hierarchyPositionMap.set(spanID, i++)); return hierarchyPositionMap; }); export const getTreeSizeForTraceSpan = createSelector( - createSelector((state) => state.trace, getTraceSpanIdsAsTree), - createSelector((state) => state.span, getSpanId), + createSelector((state: { trace: TraceResponse }) => state.trace, getTraceSpanIdsAsTree), + createSelector((state: { span: TraceSpanData }) => state.span, getSpanId), (tree, spanID) => { const node = tree.find(spanID); if (!node) { @@ -209,8 +219,8 @@ export const getTreeSizeForTraceSpan = createSelector( ); export const getSpanHierarchySortPositionForTrace = createSelector( - createSelector(({ trace }) => trace, getTraceSpansByHierarchyPosition), - ({ span }) => span, + createSelector(({ trace }: { trace: Trace }) => trace, getTraceSpansByHierarchyPosition), + ({ span }: { span: TraceSpan }) => span, (hierarchyPositionMap, span) => hierarchyPositionMap.get(getSpanId(span)) ); @@ -222,16 +232,16 @@ export const getTraceName = createSelector( serviceName: getSpanServiceName, }) ), - ({ name, serviceName }) => `${serviceName}: ${name}` + ({ name, serviceName }: { name: string; serviceName: string }) => `${serviceName}: ${name}` ); export const omitCollapsedSpans = createSelector( - ({ spans }) => spans, - createSelector(({ trace }) => trace, getTraceSpanIdsAsTree), - ({ collapsed }) => collapsed, + ({ spans }: { spans: TraceSpanData[] }) => spans, + createSelector(({ trace }: { trace: TraceResponse }) => trace, getTraceSpanIdsAsTree), + ({ collapsed }: { collapsed: string[] }) => collapsed, (spans, tree, collapse) => { const hiddenSpanIds = collapse.reduce((result, collapsedSpanId) => { - tree.find(collapsedSpanId).walk((id) => id !== collapsedSpanId && result.add(id)); + tree.find(collapsedSpanId)!.walk((id: string | number | undefined) => id !== collapsedSpanId && result.add(id)); return result; }, new Set()); @@ -242,13 +252,13 @@ export const omitCollapsedSpans = createSelector( export const DEFAULT_TICK_INTERVAL = 4; export const DEFAULT_TICK_WIDTH = 3; export const getTicksForTrace = createSelector( - ({ trace }) => trace, - ({ interval = DEFAULT_TICK_INTERVAL }) => interval, - ({ width = DEFAULT_TICK_WIDTH }) => width, + ({ trace }: { trace: TraceResponse }) => trace, + ({ interval = DEFAULT_TICK_INTERVAL }: { interval?: number }) => interval, + ({ width = DEFAULT_TICK_WIDTH }: { width?: number }) => width, ( trace, - interval, - width + interval: number, + width: number // timestamps will be spaced over the interval, starting from the initial timestamp ) => [...Array(interval + 1).keys()].map((num) => ({ @@ -260,14 +270,16 @@ export const getTicksForTrace = createSelector( // TODO: delete this when the backend can ensure uniqueness /* istanbul ignore next */ export const enforceUniqueSpanIds = createSelector( - /* istanbul ignore next */ (trace) => trace, + /* istanbul ignore next */ (trace: Trace) => trace, getTraceSpans, /* istanbul ignore next */ (trace, spans) => { const map = new Map(); + const spanArray: TraceSpanData[] = []; + return { ...trace, - spans: spans.reduce((result, span) => { + spans: spans.reduce((result: TraceSpanData[], span: TraceSpanData) => { const spanID = map.has(getSpanId(span)) ? `${getSpanId(span)}_${map.get(getSpanId(span))}` : getSpanId(span); const updatedSpan = { ...span, spanID }; @@ -280,17 +292,17 @@ export const enforceUniqueSpanIds = createSelector( map.set(getSpanId(span), (map.get(getSpanId(span)) || 0) + 1); return result.concat([updatedSpan]); - }, []), + }, spanArray), }; } ); // TODO: delete this when the backend can ensure uniqueness export const dropEmptyStartTimeSpans = createSelector( - /* istanbul ignore next */ (trace) => trace, + /* istanbul ignore next */ (trace: Trace) => trace, getTraceSpans, /* istanbul ignore next */ (trace, spans) => ({ ...trace, - spans: spans.filter((span) => !!getSpanTimestamp(span)), + spans: spans.filter((span: TraceSpanData) => !!getSpanTimestamp(span)), }) ); diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.ts b/packages/jaeger-ui-components/src/utils/TreeNode.ts index 8f8edeeea1e..168e1eec803 100644 --- a/packages/jaeger-ui-components/src/utils/TreeNode.ts +++ b/packages/jaeger-ui-components/src/utils/TreeNode.ts @@ -22,7 +22,7 @@ export default class TreeNode { return (node: TreeNode) => fn(node.value, node, depth); } - static searchFunction(search: TreeNode | number | SearchFn) { + static searchFunction(search: TreeNode | number | SearchFn | string) { if (typeof search === 'function') { return search; } @@ -51,7 +51,7 @@ export default class TreeNode { return this; } - find(search: TreeNode | number | SearchFn): TreeNode | null { + find(search: TreeNode | number | SearchFn | string): TreeNode | null { const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); if (searchFn(this)) { return this; @@ -65,7 +65,7 @@ export default class TreeNode { return null; } - getPath(search: TreeNode) { + getPath(search: TreeNode | string) { const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); const findPath = (currentNode: TreeNode, currentPath: TreeNode[]): TreeNode[] | null => { diff --git a/packages/jaeger-ui-components/src/utils/color-generator.test.js b/packages/jaeger-ui-components/src/utils/color-generator.test.js index b71eed2644c..8da84c832bd 100644 --- a/packages/jaeger-ui-components/src/utils/color-generator.test.js +++ b/packages/jaeger-ui-components/src/utils/color-generator.test.js @@ -26,7 +26,7 @@ it('gives the same color for the same key', () => { expect(colorOne).toBe(colorTwo); }); -it('gives different colors for each for each key', () => { +it('gives different colors for each key', () => { clear(); const colorOne = getColorByKey('serviceA', createTheme()); const colorTwo = getColorByKey('serviceB', createTheme()); @@ -35,8 +35,10 @@ it('gives different colors for each for each key', () => { it('should not allow red', () => { expect(colorsToFilter.indexOf('#E24D42')).toBe(4); + expect(colorsToFilter.indexOf('#BF1B00')).toBe(28); const filteredColors = getFilteredColors(colorsToFilter, createTheme()); expect(filteredColors.indexOf('#E24D42')).toBe(-1); + expect(filteredColors.indexOf('#BF1B00')).toBe(-1); }); it('should not allow colors with a contrast ratio < 3 in light mode', () => { @@ -54,3 +56,23 @@ it('should not allow colors with a contrast ratio < 3 in dark mode', () => { expect(filteredColors.indexOf('#890F02')).toBe(-1); expect(filteredColors.indexOf('#0A437C')).toBe(-1); }); + +it('should not allow a color that is the same as the previous color', () => { + clear(); + const theme = createTheme(); + const colorOne = getColorByKey('random4', theme); // #447EBC + const colorTwo = getColorByKey('random17', theme); // #447EBC + expect(colorOne).not.toBe(colorTwo); + expect(colorOne).toBe('#447EBC'); + expect(colorTwo).toBe('#B7DBAB'); +}); + +it('should not allow a color that looks similar to the previous color', () => { + clear(); + const theme = createTheme({ colors: { mode: 'light' } }); + const colorOne = getColorByKey('random9', theme); // #58140C + const colorTwo = getColorByKey('random18', theme); // #511749 + expect(colorOne).toBe('#58140C'); + // #1F78C1 is the next color that has a contrast ratio >= 3 for the current theme + expect(colorTwo).toBe('#1F78C1'); +}); diff --git a/packages/jaeger-ui-components/src/utils/color-generator.tsx b/packages/jaeger-ui-components/src/utils/color-generator.tsx index 2f12451d940..cc108cb038d 100644 --- a/packages/jaeger-ui-components/src/utils/color-generator.tsx +++ b/packages/jaeger-ui-components/src/utils/color-generator.tsx @@ -45,13 +45,40 @@ class ColorGenerator { let i = this.cache.get(key); if (i == null) { const hash = this.hashCode(key.toLowerCase()); - const hashIndex = Math.abs(hash % this.colorsHex.length); - i = hashIndex === 4 ? hashIndex + 1 : hashIndex; + i = Math.abs(hash % this.colorsHex.length); + + const prevCacheItem = Array.from(this.cache).pop(); + if (prevCacheItem && prevCacheItem[1]) { + // disallow a color that is the same as the previous color + if (prevCacheItem[1] === i) { + i = this.getNextIndex(i); + } + + // disallow a color that looks very similar to the previous color + const prevColor = this.colorsHex[prevCacheItem[1]]; + if (tinycolor.readability(prevColor, this.colorsHex[i]) < 1.5) { + let newIndex = i; + for (let j = 0; j < this.colorsHex.length; j++) { + newIndex = this.getNextIndex(newIndex); + + if (tinycolor.readability(prevColor, this.colorsHex[newIndex]) > 1.5) { + i = newIndex; + break; + } + } + } + } + this.cache.set(key, i); } return i; } + getNextIndex(i: number) { + // get next index or go back to 0 + return i + 1 < this.colorsHex.length ? i + 1 : 0; + } + hashCode(key: string) { let hash = 0, i, @@ -110,6 +137,10 @@ export function getFilteredColors(colorsHex: string[], theme: GrafanaTheme2) { if (redIndex > -1) { colorsHex.splice(redIndex, 1); } + const redIndex2 = colorsHex.indexOf('#BF1B00'); + if (redIndex2 > -1) { + colorsHex.splice(redIndex2, 1); + } // Only add colors that have a contrast ratio >= 3 for the current theme let filteredColors = []; diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile deleted file mode 100644 index 7acefcc0b5a..00000000000 --- a/packaging/docker/Dockerfile +++ /dev/null @@ -1,75 +0,0 @@ -ARG BASE_IMAGE=alpine:3.15 -FROM ${BASE_IMAGE} - -ARG GRAFANA_TGZ="grafana-latest.linux-x64-musl.tar.gz" - -# Make sure we have Gnu tar -RUN apk add --no-cache tar - -COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz - -# Change to tar xfzv to make tar print every file it extracts -RUN mkdir /tmp/grafana && tar xzf /tmp/grafana.tar.gz --strip-components=1 -C /tmp/grafana - -FROM ${BASE_IMAGE} - -ARG GF_UID="472" -ARG GF_GID="0" - -ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ - GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ - GF_PATHS_DATA="/var/lib/grafana" \ - GF_PATHS_HOME="/usr/share/grafana" \ - GF_PATHS_LOGS="/var/log/grafana" \ - GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ - GF_PATHS_PROVISIONING="/etc/grafana/provisioning" - -WORKDIR $GF_PATHS_HOME - -RUN apk add --no-cache ca-certificates bash tzdata musl-utils -RUN apk info -vv | sort - -# Oracle Support for x86_64 only -RUN if [ `arch` = "x86_64" ]; then \ - wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-2.35-r0.apk \ - -O /tmp/glibc-2.35-r0.apk && \ - wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-bin-2.35-r0.apk \ - -O /tmp/glibc-bin-2.35-r0.apk && \ - apk add --no-cache --allow-untrusted /tmp/glibc-2.35-r0.apk /tmp/glibc-bin-2.35-r0.apk && \ - rm -f /lib64/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ - rm -f /tmp/glibc-2.35-r0.apk && \ - rm -f /tmp/glibc-bin-2.35-r0.apk && \ - rm -f /lib/ld-linux-x86-64.so.2 && \ - rm -f /etc/ld.so.cache; \ - fi - -COPY --from=0 /tmp/grafana "$GF_PATHS_HOME" - -RUN if [ ! $(getent group "$GF_GID") ]; then \ - addgroup -S -g $GF_GID grafana; \ - fi - -RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ - mkdir -p "$GF_PATHS_HOME/.aws" && \ - adduser -S -u $GF_UID -G "$GF_GID_NAME" grafana && \ - mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ - "$GF_PATHS_PROVISIONING/dashboards" \ - "$GF_PATHS_PROVISIONING/notifiers" \ - "$GF_PATHS_PROVISIONING/plugins" \ - "$GF_PATHS_PROVISIONING/access-control" \ - "$GF_PATHS_PROVISIONING/alerting" \ - "$GF_PATHS_LOGS" \ - "$GF_PATHS_PLUGINS" \ - "$GF_PATHS_DATA" && \ - cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ - cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ - chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ - chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" - -EXPOSE 3000 - -COPY ./run.sh /run.sh - -USER "$GF_UID" -ENTRYPOINT [ "/run.sh" ] diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh deleted file mode 100755 index a55696c4cef..00000000000 --- a/packaging/docker/build-enterprise.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/sh -set -e - -UBUNTU_BASE=0 - -while [ "$1" != "" ]; do - case "$1" in - "--ubuntu") - UBUNTU_BASE=1 - echo "Ubuntu base image enabled" - shift - ;; - * ) - # unknown param causes args to be passed through to $@ - break - ;; - esac -done - -_raw_grafana_tag=$1 -_docker_repo=${2:-grafana/grafana-enterprise} - -if echo "$_raw_grafana_tag" | grep -q "^v"; then - _grafana_tag=$(echo "${_raw_grafana_tag}" | cut -d "v" -f 2) -elif echo "$_raw_grafana_tag" | grep -q "^main-"; then - _grafana_tag="main" -else - _grafana_tag="${_raw_grafana_tag}" -fi - -if [ ${UBUNTU_BASE} = "0" ]; then - TAG_SUFFIX="" - DOCKERFILE="Dockerfile" -else - TAG_SUFFIX="-ubuntu" - DOCKERFILE="ubuntu.Dockerfile" -fi - -echo "Building and deploying ${_docker_repo}:${_grafana_tag}${TAG_SUFFIX}" - -docker build \ - --tag "${_docker_repo}:${_grafana_tag}${TAG_SUFFIX}" \ - --no-cache=true \ - -f ${DOCKERFILE} \ - . - -docker push "${_docker_repo}:${_grafana_tag}${TAG_SUFFIX}" - -if echo "$_raw_grafana_tag" | grep -q "^v" && echo "$_raw_grafana_tag" | grep -qv "beta"; then - docker tag "${_docker_repo}:${_grafana_tag}${TAG_SUFFIX}" "${_docker_repo}:latest${TAG_SUFFIX}" - docker push "${_docker_repo}:latest${TAG_SUFFIX}" -fi - - -if echo "${_raw_grafana_tag}" | grep -q "^main-" && [ ${UBUNTU_BASE} = "1" ]; then - docker tag "${_docker_repo}:${_grafana_tag}${TAG_SUFFIX}" "grafana/grafana-enterprise-dev:${_raw_grafana_tag}" - docker push "grafana/grafana-enterprise-dev:${_raw_grafana_tag}" -fi - diff --git a/packaging/docker/build.sh b/packaging/docker/build.sh index 533284ac8af..f1fcf443886 100755 --- a/packaging/docker/build.sh +++ b/packaging/docker/build.sh @@ -28,7 +28,7 @@ done _grafana_tag=${1:-} _docker_repo=${2:-grafana/grafana} -# If the tag starts with v, treat this as a official release +# If the tag starts with v, treat this as an official release if echo "$_grafana_tag" | grep -q "^v"; then _grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2) else @@ -59,23 +59,25 @@ docker_build () { esac if [ $UBUNTU_BASE = "0" ]; then libc="-musl" - dockerfile="Dockerfile" base_image="${base_arch}alpine:3.15" else libc="" - dockerfile="ubuntu.Dockerfile" base_image="${base_arch}ubuntu:20.04" fi - grafana_tgz="grafana-latest.linux-${arch}${libc}.tar.gz" + grafana_tgz=${GRAFANA_TGZ:-"grafana-latest.linux-${arch}${libc}.tar.gz"} tag="${_docker_repo}${repo_arch}:${_grafana_version}${TAG_SUFFIX}" + DOCKER_BUILDKIT=1 \ docker build \ --build-arg BASE_IMAGE=${base_image} \ --build-arg GRAFANA_TGZ=${grafana_tgz} \ + --build-arg GO_SRC=tgz-builder \ + --build-arg JS_SRC=tgz-builder \ + --build-arg RUN_SH=./run.sh \ --tag "${tag}" \ --no-cache=true \ - -f "${dockerfile}" \ + --file ../../Dockerfile \ . } diff --git a/packaging/docker/custom/Dockerfile b/packaging/docker/custom/Dockerfile index 0fce1a09d22..bc914e680ed 100644 --- a/packaging/docker/custom/Dockerfile +++ b/packaging/docker/custom/Dockerfile @@ -1,48 +1,44 @@ -ARG GRAFANA_VERSION="latest" - -FROM grafana/grafana:${GRAFANA_VERSION} - -USER root - -ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" - -ARG GF_GID="0" -ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" - -RUN mkdir -p "$GF_PATHS_PLUGINS" && \ - chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" - -RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ - apk --no-cache upgrade && \ - apk add --no-cache udev ttf-opensans chromium && \ - rm -rf /tmp/* && \ - rm -rf /usr/share/grafana/tools/phantomjs; \ -fi - -USER grafana - -ENV GF_PLUGIN_RENDERING_CHROME_BIN="/usr/bin/chromium-browser" - -RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ - grafana-cli \ - --pluginsDir "$GF_PATHS_PLUGINS" \ - --pluginUrl https://github.com/grafana/grafana-image-renderer/releases/latest/download/plugin-linux-x64-glibc-no-chromium.zip \ - plugins install grafana-image-renderer; \ -fi - -ARG GF_INSTALL_PLUGINS="" - -RUN if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then \ - OLDIFS=$IFS; \ - IFS=','; \ - for plugin in ${GF_INSTALL_PLUGINS}; do \ - IFS=$OLDIFS; \ - if expr match "$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 +ARG GRAFANA_VERSION="latest" + +FROM grafana/grafana:${GRAFANA_VERSION} + +USER root + +ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" + +ARG GF_GID="0" +ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" + +RUN mkdir -p "$GF_PATHS_PLUGINS" && \ + chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" && \ + if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ + apk add --no-cache udev ttf-opensans chromium \ + fi + +USER grafana + +ENV GF_PLUGIN_RENDERING_CHROME_BIN="/usr/bin/chromium-browser" + +RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ + grafana-cli \ + --pluginsDir "$GF_PATHS_PLUGINS" \ + --pluginUrl https://github.com/grafana/grafana-image-renderer/releases/latest/download/plugin-linux-x64-glibc-no-chromium.zip \ + plugins install grafana-image-renderer; \ + fi + +ARG GF_INSTALL_PLUGINS="" + +RUN if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then \ + OLDIFS=$IFS; \ + IFS=','; \ + for plugin in ${GF_INSTALL_PLUGINS}; do \ + IFS=$OLDIFS; \ + if expr match "$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 diff --git a/packaging/docker/custom/ubuntu.Dockerfile b/packaging/docker/custom/ubuntu.Dockerfile index 470ec50e6ab..6b4c59a107f 100644 --- a/packaging/docker/custom/ubuntu.Dockerfile +++ b/packaging/docker/custom/ubuntu.Dockerfile @@ -1,55 +1,50 @@ -ARG GRAFANA_VERSION="latest" - -FROM grafana/grafana:${GRAFANA_VERSION}-ubuntu - -USER root - -# Set DEBIAN_FRONTEND=noninteractive in environment at build-time -ARG DEBIAN_FRONTEND=noninteractive - -ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" - -ARG GF_GID="0" -ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" - -RUN mkdir -p "$GF_PATHS_PLUGINS" && \ - chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" - -RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ - apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y gdebi-core && \ - cd /tmp && \ - curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ - gdebi --n google-chrome-stable_current_amd64.deb && \ - apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/*; \ -fi - -USER grafana - -ENV GF_PLUGIN_RENDERING_CHROME_BIN="/usr/bin/google-chrome" - -RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ - grafana-cli \ - --pluginsDir "$GF_PATHS_PLUGINS" \ - --pluginUrl https://github.com/grafana/grafana-image-renderer/releases/latest/download/plugin-linux-x64-glibc-no-chromium.zip \ - plugins install grafana-image-renderer; \ -fi - -ARG GF_INSTALL_PLUGINS="" - -RUN if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then \ - OLDIFS=$IFS; \ - IFS=','; \ - for plugin in ${GF_INSTALL_PLUGINS}; do \ - IFS=$OLDIFS; \ - if expr match "$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 +ARG GRAFANA_VERSION="latest" + +FROM grafana/grafana:${GRAFANA_VERSION}-ubuntu + +USER root + +ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" + +ARG GF_GID="0" +ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" + +RUN mkdir -p "$GF_PATHS_PLUGINS" && \ + chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" && \ + if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ + cd /tmp && \ + curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ + DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -y ./google-chrome-stable_current_amd64.deb && \ + rm -rf /var/lib/apt/lists/* && \ + rm ./google-chrome-stable_current_amd64.deb \ + fi + +USER grafana + +ENV GF_PLUGIN_RENDERING_CHROME_BIN="/usr/bin/google-chrome" + +RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ + grafana-cli \ + --pluginsDir "$GF_PATHS_PLUGINS" \ + --pluginUrl https://github.com/grafana/grafana-image-renderer/releases/latest/download/plugin-linux-x64-glibc-no-chromium.zip \ + plugins install grafana-image-renderer; \ + fi + +ARG GF_INSTALL_PLUGINS="" + +RUN if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then \ + OLDIFS=$IFS; \ + IFS=','; \ + for plugin in ${GF_INSTALL_PLUGINS}; do \ + IFS=$OLDIFS; \ + if expr match "$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 diff --git a/packaging/docker/push_to_docker_hub.sh b/packaging/docker/push_to_docker_hub.sh index 09f483965de..cd5a8fd548c 100755 --- a/packaging/docker/push_to_docker_hub.sh +++ b/packaging/docker/push_to_docker_hub.sh @@ -24,18 +24,18 @@ _docker_repo=${2:-grafana/grafana} # If the tag starts with v, treat this as an official release if echo "$_grafana_tag" | grep -q "^v"; then - _grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2) + _grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2) else - _grafana_version=$_grafana_tag + _grafana_version=$_grafana_tag fi -export DOCKER_CLI_EXPERIMENTAL=enabled - echo "pushing ${_docker_repo}:${_grafana_version}${TAG_SUFFIX}" +export DOCKER_CLI_EXPERIMENTAL=enabled + docker_push_all () { - repo=$1 - tag=$2 + repo=$1 + tag=$2 # Push each image individually docker push "${repo}:${tag}${TAG_SUFFIX}" @@ -52,18 +52,18 @@ docker_push_all () { } if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"; then - echo "pushing ${_docker_repo}:latest${TAG_SUFFIX}" - docker_push_all "${_docker_repo}" "latest" - docker_push_all "${_docker_repo}" "${_grafana_version}" - # Push to the grafana-dev repository with the expected tag - # for running the end to end tests successfully + echo "pushing ${_docker_repo}:latest${TAG_SUFFIX}" + docker_push_all "${_docker_repo}" "latest" + docker_push_all "${_docker_repo}" "${_grafana_version}" + # Push to the grafana-dev repository with the expected tag + # for running the end to end tests successfully docker push "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}" elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then - docker_push_all "${_docker_repo}" "${_grafana_version}" - # Push to the grafana-dev repository with the expected tag - # for running the end to end tests successfully + docker_push_all "${_docker_repo}" "${_grafana_version}" + # Push to the grafana-dev repository with the expected tag + # for running the end to end tests successfully docker push "grafana/grafana-dev:${_grafana_tag}${TAG_SUFFIX}" elif echo "$_grafana_tag" | grep -q "main"; then - docker_push_all "${_docker_repo}" "main" + docker_push_all "${_docker_repo}" "main" docker push "grafana/grafana-dev:${_grafana_version}${TAG_SUFFIX}" fi diff --git a/packaging/docker/ubuntu.Dockerfile b/packaging/docker/ubuntu.Dockerfile deleted file mode 100644 index c4bc984edf6..00000000000 --- a/packaging/docker/ubuntu.Dockerfile +++ /dev/null @@ -1,60 +0,0 @@ -ARG BASE_IMAGE=ubuntu:20.04 -FROM ${BASE_IMAGE} AS grafana-builder - -ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz" - -COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz - -RUN mkdir /tmp/grafana && tar xzf /tmp/grafana.tar.gz --strip-components=1 -C /tmp/grafana - -FROM ${BASE_IMAGE} - -EXPOSE 3000 - -# Set DEBIAN_FRONTEND=noninteractive in environment at build-time -ARG DEBIAN_FRONTEND=noninteractive -ARG GF_UID="472" -ARG GF_GID="0" - -ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ - GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ - GF_PATHS_DATA="/var/lib/grafana" \ - GF_PATHS_HOME="/usr/share/grafana" \ - GF_PATHS_LOGS="/var/log/grafana" \ - GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ - GF_PATHS_PROVISIONING="/etc/grafana/provisioning" - -WORKDIR $GF_PATHS_HOME - -# Install dependencies -# We need curl in the image -RUN apt-get update && apt-get install -y ca-certificates curl tzdata && \ - apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; - -COPY --from=grafana-builder /tmp/grafana "$GF_PATHS_HOME" - -RUN if [ ! $(getent group "$GF_GID") ]; then \ - addgroup --system --gid $GF_GID grafana; \ - fi - -RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ - mkdir -p "$GF_PATHS_HOME/.aws" && \ - adduser --system --uid $GF_UID --ingroup "$GF_GID_NAME" grafana && \ - mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ - "$GF_PATHS_PROVISIONING/dashboards" \ - "$GF_PATHS_PROVISIONING/notifiers" \ - "$GF_PATHS_PROVISIONING/plugins" \ - "$GF_PATHS_PROVISIONING/access-control" \ - "$GF_PATHS_PROVISIONING/alerting" \ - "$GF_PATHS_LOGS" \ - "$GF_PATHS_PLUGINS" \ - "$GF_PATHS_DATA" && \ - cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ - cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ - chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ - chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" - -COPY ./run.sh /run.sh - -USER "$GF_UID" -ENTRYPOINT [ "/run.sh" ] diff --git a/pkg/api/admin.go b/pkg/api/admin.go index d569e658db2..21603822519 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -47,7 +47,7 @@ func (hs *HTTPServer) AdminGetSettings(c *models.ReqContext) response.Response { func (hs *HTTPServer) AdminGetStats(c *models.ReqContext) response.Response { statsQuery := models.GetAdminStatsQuery{} - if err := hs.SQLStore.GetAdminStats(c.Req.Context(), &statsQuery); err != nil { + if err := hs.statsService.GetAdminStats(c.Req.Context(), &statsQuery); err != nil { return response.Error(500, "Failed to get admin stats from database", err) } diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 54855e7d281..9510e1e575c 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -65,7 +65,7 @@ func (hs *HTTPServer) AdminCreateUser(c *models.ReqContext) response.Response { return response.Error(400, "Password is missing or too short", nil) } - usr, err := hs.Login.CreateUser(cmd) + usr, err := hs.userService.Create(c.Req.Context(), &cmd) if err != nil { if errors.Is(err, models.ErrOrgNotFound) { return response.Error(400, err.Error(), nil) diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 3de90077df9..5f26ffd63f7 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/authtest" - "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -168,8 +167,8 @@ func TestAdminAPIEndpoint(t *testing.T) { Login: testLogin, Password: testPassword, } - - adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { + usrSvc := &usertest.FakeUserService{ExpectedUser: &user.User{ID: testUserID}} + adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, usrSvc, func(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) @@ -186,8 +185,8 @@ func TestAdminAPIEndpoint(t *testing.T) { Password: testPassword, OrgId: testOrgID, } - - adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { + usrSvc := &usertest.FakeUserService{ExpectedUser: &user.User{ID: testUserID}} + adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, usrSvc, func(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) @@ -204,8 +203,8 @@ func TestAdminAPIEndpoint(t *testing.T) { Password: testPassword, OrgId: nonExistingOrgID, } - - adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { + usrSvc := &usertest.FakeUserService{ExpectedError: models.ErrOrgNotFound} + adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, usrSvc, func(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 400, sc.resp.Code) @@ -221,8 +220,8 @@ func TestAdminAPIEndpoint(t *testing.T) { Login: existingTestLogin, Password: testPassword, } - - adminCreateUserScenario(t, "Should return an error", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { + usrSvc := &usertest.FakeUserService{ExpectedError: user.ErrUserAlreadyExists} + adminCreateUserScenario(t, "Should return an error", "/api/admin/users", "/api/admin/users", createCmd, usrSvc, func(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 412, sc.resp.Code) @@ -397,15 +396,10 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern }) } -func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.AdminCreateUserForm, fn scenarioFunc) { +func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.AdminCreateUserForm, svc *usertest.FakeUserService, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := HTTPServer{ - Login: loginservice.LoginServiceMock{ - ExpectedUserForm: cmd, - NoExistingOrgId: nonExistingOrgID, - AlreadyExitingLogin: existingTestLogin, - GeneratedUserId: testUserID, - }, + userService: svc, } sc := setupScenarioContext(t, url) diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 6abdceba3f2..0064cd7eb30 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -385,7 +385,7 @@ func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePatte func TestAPI_Annotations_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInEditor(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) dashboardAnnotation := &annotations.Item{Id: 1, DashboardId: 1} @@ -787,7 +787,7 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) { func TestAPI_MassDeleteAnnotations_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInEditor(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) type args struct { diff --git a/pkg/api/api.go b/pkg/api/api.go index ef976fd10eb..75d9cf3b763 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -103,7 +103,12 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/configuration", reqGrafanaAdmin, hs.Index) r.Get("/admin", reqGrafanaAdmin, hs.Index) r.Get("/admin/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Index) - r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) + // Show the combined users page for org admins if topnav is enabled + if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { + r.Get("/admin/users", authorize(reqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))), hs.Index) + } else { + r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) + } r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index) r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) @@ -117,10 +122,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index) r.Get("/live/cloud", reqGrafanaAdmin, hs.Index) - r.Get("/plugins", reqSignedIn, hs.Index) - r.Get("/plugins/:id/", reqSignedIn, hs.Index) - r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) // deprecated - r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index) + r.Get("/plugins", middleware.CanAdminPlugins(hs.Cfg), hs.Index) + r.Get("/plugins/:id/", middleware.CanAdminPlugins(hs.Cfg), hs.Index) + r.Get("/plugins/:id/edit", middleware.CanAdminPlugins(hs.Cfg), hs.Index) // deprecated + r.Get("/plugins/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index) // App Root Page appPluginIDScope := plugins.ScopeProvider.GetResourceScope(ac.Parameter(":id")) r.Get("/a/:id/*", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, appPluginIDScope)), hs.Index) @@ -280,11 +285,11 @@ func (hs *HTTPServer) registerRoutes() { if hs.Features.IsEnabled(featuremgmt.FlagStorage) { // Will eventually be replaced with the 'object' route apiRoute.Group("/storage", hs.StorageService.RegisterHTTPRoutes) + } - // Allow HTTP access to the object storage feature (dev only for now) - if hs.Features.IsEnabled(featuremgmt.FlagGrpcServer) { - apiRoute.Group("/object", hs.httpObjectStore.RegisterHTTPRoutes) - } + // Allow HTTP access to the entity storage feature (dev only for now) + if hs.Features.IsEnabled(featuremgmt.FlagEntityStore) { + apiRoute.Group("/entity", hs.httpEntityStore.RegisterHTTPRoutes) } if hs.Features.IsEnabled(featuremgmt.FlagPanelTitleSearch) { diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 20650b3ff93..3f07d38d494 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -40,6 +41,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/licensing" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" @@ -52,6 +54,7 @@ import ( "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" + "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -249,16 +252,19 @@ func (s *fakeRenderService) Init() error { func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []accesscontrol.Permission) (*scenarioContext, *HTTPServer) { store := sqlstore.InitTestDB(t) + statsService := statsimpl.ProvideService(store) hs := &HTTPServer{ - Cfg: cfg, - Live: newTestLive(t, store), - License: &licensing.OSSLicensingService{}, - Features: featuremgmt.WithFeatures(), - QuotaService: quotatest.New(false, nil), - RouteRegister: routing.NewRouteRegister(), - AccessControl: accesscontrolmock.New().WithPermissions(permissions), - searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()), - ldapGroups: ldap.ProvideGroupsService(), + Cfg: cfg, + Live: newTestLive(t, store), + License: &licensing.OSSLicensingService{}, + Features: featuremgmt.WithFeatures(), + QuotaService: quotatest.New(false, nil), + RouteRegister: routing.NewRouteRegister(), + AccessControl: accesscontrolmock.New().WithPermissions(permissions), + searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()), + ldapGroups: ldap.ProvideGroupsService(), + accesscontrolService: actest.FakeService{}, + statsService: statsService, } sc := setupScenarioContext(t, url) @@ -352,6 +358,9 @@ func setupSimpleHTTPServer(features *featuremgmt.FeatureManager) *HTTPServer { License: &licensing.OSSLicensingService{}, AccessControl: acimpl.ProvideAccessControl(cfg), annotationsRepo: annotationstest.NewFakeAnnotationsRepo(), + authInfoService: &logintest.AuthInfoServiceFake{ + ExpectedLabels: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)}, + }, } } @@ -398,11 +407,11 @@ func setupHTTPServerWithCfgDb( userSvc = userMock } else { var err error - acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), featuremgmt.WithFeatures()) - require.NoError(t, err) ac = acimpl.ProvideAccessControl(cfg) userSvc, err = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService(), quotatest.New(false, nil)) require.NoError(t, err) + acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), ac, featuremgmt.WithFeatures()) + require.NoError(t, err) } teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, license, acService, teamService, userSvc) require.NoError(t, err) @@ -429,6 +438,9 @@ func setupHTTPServerWithCfgDb( orgService: orgMock, teamService: teamService, annotationsRepo: annotationstest.NewFakeAnnotationsRepo(), + authInfoService: &logintest.AuthInfoServiceFake{ + ExpectedLabels: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)}, + }, } for _, o := range options { diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 84fc103a2d2..42b2d976114 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -471,7 +471,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa } if liveerr != nil { - hs.log.Warn("unable to broadcast save event", "uid", dashboard.Uid, "error", err) + hs.log.Warn("unable to broadcast save event", "uid", dashboard.Uid, "error", liveerr) } } @@ -479,6 +479,12 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa return apierrors.ToDashboardErrorResponse(ctx, hs.pluginStore, err) } + // Clear permission cache for the user who's created the dashboard, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly created object + if newDashboard && !hs.accesscontrolService.IsDisabled() { + hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) + } + // connect library panels for this dashboard after the dashboard is stored and has an ID err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(ctx, c.SignedInUser, dashboard) if err != nil { diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 7ebb0e37a53..eccae6871b0 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -156,7 +156,7 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response. return dashboardGuardianResponse(err) } - var items []*models.DashboardACL + items := make([]*models.DashboardACL, 0, len(apiCmd.Items)) for _, item := range apiCmd.Items { items = append(items, &models.DashboardACL{ OrgID: c.OrgID, diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index c18d815629d..afc8772f871 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -130,11 +130,6 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res metrics.MApiDashboardSnapshotExternal.Inc() } else { - if cmd.Dashboard.Get("id").MustInt64() == 0 { - c.JSON(http.StatusBadRequest, "Creating a local snapshot requires a dashboard") - return nil - } - if cmd.Key == "" { var err error cmd.Key, err = util.GetRandomString(32) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 42bdd60a957..980642c1dfd 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry/corekind" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -1093,6 +1094,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s folderService: folderService, Features: featuremgmt.WithFeatures(), Kinds: corekind.NewBase(nil), + accesscontrolService: actest.FakeService{}, } sc := setupScenarioContext(t, url) @@ -1201,6 +1203,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout Features: featuremgmt.WithFeatures(), dashboardVersionService: fakeDashboardVersionService, Kinds: corekind.NewBase(nil), + accesscontrolService: actest.FakeService{}, } sc := setupScenarioContext(t, url) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 2ad595a1ba9..45458f36a26 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -396,6 +396,12 @@ func (hs *HTTPServer) AddDataSource(c *models.ReqContext) response.Response { return response.Error(500, "Failed to add datasource", err) } + // Clear permission cache for the user who's created the data source, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly created object + if !hs.AccessControl.IsDisabled() { + hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) + } + ds := hs.convertModelToDtos(c.Req.Context(), cmd.Result) return response.JSON(http.StatusOK, util.DynMap{ "message": "Datasource added", diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 7ef272919f9..ccfd15be3a1 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -19,6 +19,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/services/org" @@ -112,7 +114,9 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) { DataSourcesService: &dataSourcesServiceMock{ expectedDatasource: &datasources.DataSource{}, }, - Cfg: setting.NewCfg(), + Cfg: setting.NewCfg(), + AccessControl: acimpl.ProvideAccessControl(setting.NewCfg()), + accesscontrolService: actest.FakeService{}, } sc := setupScenarioContext(t, "/api/datasources") @@ -224,7 +228,9 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) { DataSourcesService: &dataSourcesServiceMock{ expectedDatasource: &datasources.DataSource{}, }, - Cfg: setting.NewCfg(), + Cfg: setting.NewCfg(), + AccessControl: acimpl.ProvideAccessControl(setting.NewCfg()), + accesscontrolService: actest.FakeService{}, } sc := setupScenarioContext(t, "/api/datasources/1234") diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 0c7432da976..688222c3474 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -129,6 +129,12 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { return apierrors.ToFolderErrorResponse(err) } + // Clear permission cache for the user who's created the folder, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly created object + if !hs.AccessControl.IsDisabled() { + hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) + } + g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser) // TODO set ParentUID if nested folders are enabled return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, folder)) diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 09ccd441e11..c48dda8f108 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -105,7 +105,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return apierrors.ToFolderErrorResponse(dashboards.ErrFolderAccessDenied) } - var items []*models.DashboardACL + items := make([]*models.DashboardACL, 0, len(apiCmd.Items)) for _, item := range apiCmd.Items { items = append(items, &models.DashboardACL{ OrgID: c.OrgID, diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 5c6ebabf06c..777fe6a281e 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -242,10 +243,11 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st store := mockstore.NewSQLStoreMock() guardian.InitLegacyGuardian(store, dashSvc, teamSvc) hs := HTTPServer{ - AccessControl: acmock.New(), - folderService: folderService, - Cfg: setting.NewCfg(), - Features: featuremgmt.WithFeatures(), + AccessControl: acmock.New(), + folderService: folderService, + Cfg: setting.NewCfg(), + Features: featuremgmt.WithFeatures(), + accesscontrolService: actest.FakeService{}, } sc := setupScenarioContext(t, url) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 222b6923990..d620e2a1f86 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -20,7 +20,8 @@ import ( "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/querylibrary" "github.com/grafana/grafana/pkg/services/searchV2" - "github.com/grafana/grafana/pkg/services/store/object/httpobjectstore" + "github.com/grafana/grafana/pkg/services/stats" + "github.com/grafana/grafana/pkg/services/store/entity/httpentitystore" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -144,7 +145,7 @@ type HTTPServer struct { ThumbService thumbs.Service ExportService export.ExportService StorageService store.StorageService - httpObjectStore httpobjectstore.HTTPObjectStore + httpEntityStore httpentitystore.HTTPEntityStore SearchV2HTTPService searchV2.SearchHTTPService QueryLibraryHTTPService querylibrary.HTTPService QueryLibraryService querylibrary.Service @@ -207,6 +208,7 @@ type HTTPServer struct { annotationsRepo annotations.Repository tagService tag.Service oauthTokenService oauthtoken.OAuthTokenService + statsService stats.Service } type ServerOptions struct { @@ -232,7 +234,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, dataSourcesService datasources.DataSourceService, queryDataService *query.Service, ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service, - authInfoService login.AuthInfoService, storageService store.StorageService, httpObjectStore httpobjectstore.HTTPObjectStore, + authInfoService login.AuthInfoService, storageService store.StorageService, httpEntityStore httpentitystore.HTTPEntityStore, notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service, datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, @@ -249,6 +251,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi accesscontrolService accesscontrol.Service, dashboardThumbsService thumbs.DashboardThumbService, navTreeService navtree.Service, annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService, + statsService stats.Service, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -309,7 +312,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi secretsMigrator: secretsMigrator, secretsPluginMigrator: secretsPluginMigrator, secretsStore: secretsStore, - httpObjectStore: httpObjectStore, + httpEntityStore: httpEntityStore, DataSourcesService: dataSourcesService, searchUsersService: searchUsersService, ldapGroups: ldapGroups, @@ -353,6 +356,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi QueryLibraryHTTPService: queryLibraryHTTPService, QueryLibraryService: queryLibraryService, oauthTokenService: oauthTokenService, + statsService: statsService, } if hs.Listener != nil { hs.log.Debug("Using provided listener") @@ -594,6 +598,10 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { hs.mapStatic(m, hs.Cfg.ImagesDir, "", "/public/img/attachments") } + if len(hs.Cfg.CustomResponseHeaders) > 0 { + m.Use(middleware.AddCustomResponseHeaders(hs.Cfg)) + } + m.Use(middleware.AddDefaultResponseHeaders(hs.Cfg)) if hs.Cfg.ServeFromSubPath && hs.Cfg.AppSubURL != "" { diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 0f4eecc4522..9a1b7610dce 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -259,7 +259,7 @@ func (hs *HTTPServer) CompleteInvite(c *models.ReqContext) response.Response { SkipOrgSetup: true, } - usr, err := hs.Login.CreateUser(cmd) + usr, err := hs.userService.Create(c.Req.Context(), &cmd) if err != nil { if errors.Is(err, user.ErrUserAlreadyExists) { return response.Error(412, fmt.Sprintf("User with email '%s' or username '%s' already exists", completeInvite.Email, completeInvite.Username), err) diff --git a/pkg/api/org_test.go b/pkg/api/org_test.go index 4dcd2fe2e3e..37b36dd8bde 100644 --- a/pkg/api/org_test.go +++ b/pkg/api/org_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -51,7 +51,7 @@ func TestAPIEndpoint_GetCurrentOrg_LegacyAccessControl(t *testing.T) { sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) t.Run("Viewer can view CurrentOrg", func(t *testing.T) { @@ -70,7 +70,7 @@ func TestAPIEndpoint_GetCurrentOrg_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) t.Run("AccessControl allows viewing CurrentOrg with correct permissions", func(t *testing.T) { @@ -91,11 +91,14 @@ func TestAPIEndpoint_GetCurrentOrg_AccessControl(t *testing.T) { } func TestAPIEndpoint_PutCurrentOrg_LegacyAccessControl(t *testing.T) { + var err error cfg := setting.NewCfg() cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, sc.hs.QuotaService) + require.NoError(t, err) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err = sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgNameForm) @@ -116,13 +119,14 @@ func TestAPIEndpoint_PutCurrentOrg_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_PutCurrentOrg_AccessControl(t *testing.T) { + var err error sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: sc.initCtx.UserID}) + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, sc.hs.QuotaService) require.NoError(t, err) - sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + _, err = sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: sc.initCtx.UserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgNameForm) @@ -150,7 +154,7 @@ func TestAPIEndpoint_PutCurrentOrgAddress_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgAddressForm) @@ -172,7 +176,7 @@ func TestAPIEndpoint_PutCurrentOrgAddress_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgAddressForm) @@ -208,7 +212,7 @@ func setupOrgsDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore, c acc // Create `orgsCount` orgs for i := 1; i <= int(orgID); i++ { - err := db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: fmt.Sprintf("TestOrg%v", i), UserId: 0}) + _, err := c.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: fmt.Sprintf("TestOrg%v", i), UserID: 0}) require.NoError(t, err) } } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index d37b652d99c..7d229c6e33c 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -211,6 +212,7 @@ func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *org.GetOrgU filteredUsers := make([]*org.OrgUserDTO, 0, len(result)) userIDs := map[string]bool{} + authLabelsUserIDs := make([]int64, 0, len(result)) for _, user := range result { if dtos.IsHiddenUser(user.Login, signedInUser, hs.Cfg) { continue @@ -218,14 +220,24 @@ func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *org.GetOrgU user.AvatarURL = dtos.GetGravatarUrl(user.Email) userIDs[fmt.Sprint(user.UserID)] = true + authLabelsUserIDs = append(authLabelsUserIDs, user.UserID) filteredUsers = append(filteredUsers, user) } - // Get accesscontrol metadata for users in the target org + modules, err := hs.authInfoService.GetUserLabels(c.Req.Context(), models.GetUserLabelsQuery{ + UserIDs: authLabelsUserIDs, + }) + + if err != nil { + hs.log.Warn("failed to retrieve users IDP label", err) + } + + // Get accesscontrol metadata and IPD labels for users in the target org accessControlMetadata := hs.getMultiAccessControlMetadata(c, query.OrgID, "users:id:", userIDs) - if len(accessControlMetadata) > 0 { - for i := range filteredUsers { - filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserID)] + for i := range filteredUsers { + filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserID)] + if module, ok := modules[filteredUsers[i].UserID]; ok { + filteredUsers[i].AuthLabels = []string{login.GetAuthProviderLabel(module)} } } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index caf88809c64..7a418591664 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -341,9 +341,9 @@ func setupOrgUsersDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore, o require.NoError(t, err) // Create both orgs with server admin - err = db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: testServerAdminViewer.OrgName, UserId: testServerAdminViewer.UserID}) + _, err = orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: testServerAdminViewer.OrgName, UserID: testServerAdminViewer.UserID}) require.NoError(t, err) - err = db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: testAdminOrg2.OrgName, UserId: testServerAdminViewer.UserID}) + _, err = orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: testAdminOrg2.OrgName, UserID: testServerAdminViewer.UserID}) require.NoError(t, err) err = orgService.AddOrgUser(context.Background(), &org.AddOrgUserCommand{LoginOrEmail: testAdminOrg2.Login, Role: testAdminOrg2.OrgRole, OrgID: testAdminOrg2.OrgID, UserID: testAdminOrg2.UserID}) @@ -377,10 +377,11 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { enableAccessControl: true, expectedCode: http.StatusOK, expectedMetadata: map[string]bool{ - "org.users:write": true, - "org.users:add": true, - "org.users:read": true, - "org.users:remove": true}, + "org.users:write": true, + "org.users:add": true, + "org.users:read": true, + "org.users:remove": true, + "users.permissions:read": true}, user: testServerAdminViewer, targetOrg: testServerAdminViewer.OrgID, }, @@ -728,6 +729,7 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { var err error sc := setupHTTPServer(t, true, func(hs *HTTPServer) { hs.tempUserService = tempuserimpl.ProvideService(hs.SQLStore) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, setting.NewCfg(), quotatest.New(false, nil)) hs.userService, err = userimpl.ProvideService( hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), quotatest.New(false, nil)) require.NoError(t, err) diff --git a/pkg/api/preferences_test.go b/pkg/api/preferences_test.go index 2edd6d75951..751f23f9a5a 100644 --- a/pkg/api/preferences_test.go +++ b/pkg/api/preferences_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/preference/preftest" ) @@ -51,7 +52,7 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_LegacyAccessControl(t *testing.T) prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} sc.hs.preferenceService = prefService - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) setInitCtxSignedInViewer(sc.initCtx) @@ -80,7 +81,7 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_AccessControl(t *testing.T) { prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} sc.hs.preferenceService = prefService - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) t.Run("AccessControl allows getting org preferences with correct permissions", func(t *testing.T) { @@ -105,7 +106,7 @@ func TestAPIEndpoint_PutCurrentOrgPreferences_LegacyAccessControl(t *testing.T) cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) setInitCtxSignedInViewer(sc.initCtx) @@ -127,7 +128,7 @@ func TestAPIEndpoint_PutCurrentOrgPreferences_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgPreferencesCmd) @@ -157,7 +158,7 @@ func TestAPIEndpoint_PatchUserPreferences(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) setInitCtxSignedInOrgAdmin(sc.initCtx) @@ -190,7 +191,7 @@ func TestAPIEndpoint_PatchOrgPreferences(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) + _, err := sc.hs.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "TestOrg", UserID: testUserID}) require.NoError(t, err) setInitCtxSignedInOrgAdmin(sc.initCtx) diff --git a/pkg/api/signup.go b/pkg/api/signup.go index c1cd5b5bd57..a654b8e5e22 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -102,7 +102,7 @@ func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response { createUserCmd.EmailVerified = true } - usr, err := hs.Login.CreateUser(createUserCmd) + usr, err := hs.userService.Create(c.Req.Context(), &createUserCmd) if err != nil { if errors.Is(err, user.ErrUserAlreadyExists) { return response.Error(401, "User with same email address already exists", nil) diff --git a/pkg/api/team.go b/pkg/api/team.go index 01d33d69fa1..d85416e512b 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -41,6 +41,12 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { return response.Error(500, "Failed to create Team", err) } + // Clear permission cache for the user who's created the team, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly created object + if !hs.AccessControl.IsDisabled() { + hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) + } + if accessControlEnabled || (c.OrgRole == org.RoleEditor && hs.Cfg.EditorsCanAdmin) { // if the request is authenticated using API tokens // the SignedInUser is an empty struct therefore diff --git a/pkg/api/team_members_test.go b/pkg/api/team_members_test.go index 41128c9cf37..5336cdb0768 100644 --- a/pkg/api/team_members_test.go +++ b/pkg/api/team_members_test.go @@ -17,6 +17,8 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -123,27 +125,26 @@ func createUser(db sqlstore.Store, orgId int64, t *testing.T) int64 { return user.ID } -func setupTeamTestScenario(userCount int, db *sqlstore.SQLStore, t *testing.T) int64 { +func setupTeamTestScenario(userCount int, db *sqlstore.SQLStore, orgService org.Service, t *testing.T) int64 { teamService := teamimpl.ProvideService(db, setting.NewCfg()) // FIXME user, err := db.CreateUser(context.Background(), user.CreateUserCommand{SkipOrgSetup: true, Login: testUserLogin}) require.NoError(t, err) - cmd := &models.CreateOrgCommand{Name: "TestOrg", UserId: user.ID} - err = db.CreateOrg(context.Background(), cmd) + cmd := &org.CreateOrgCommand{Name: "TestOrg", UserID: user.ID} + testOrg, err := orgService.CreateWithMember(context.Background(), cmd) require.NoError(t, err) - testOrg := cmd.Result - team, err := teamService.CreateTeam("test", "test@test.com", testOrg.Id) + team, err := teamService.CreateTeam("test", "test@test.com", testOrg.ID) require.NoError(t, err) for i := 0; i < userCount; i++ { - userId := createUser(db, testOrg.Id, t) + userId := createUser(db, testOrg.ID, t) require.NoError(t, err) - err = teamService.AddTeamMember(userId, testOrg.Id, team.Id, false, 0) + err = teamService.AddTeamMember(userId, testOrg.ID, team.Id, false, 0) require.NoError(t, err) } - return testOrg.Id + return testOrg.ID } var ( @@ -160,11 +161,12 @@ func TestAddTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false cfg.EditorsCanAdmin = true sc := setupHTTPServerWithCfg(t, true, cfg) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, cfg, quotatest.New(false, nil)) guardian := manager.ProvideService(database.ProvideTeamGuardianStore(sc.db, sc.teamService)) sc.hs.teamGuardian = guardian teamMemberCount := 3 - testOrgId := setupTeamTestScenario(teamMemberCount, sc.db, t) + testOrgId := setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInOrgAdmin(sc.initCtx) newUserId := createUser(sc.db, testOrgId, t) @@ -207,11 +209,12 @@ func TestAddTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { func TestGetTeamMembersAPIEndpoint_RBAC(t *testing.T) { sc := setupHTTPServer(t, true) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) sc.hs.License = &licensing.OSSLicensingService{} teamMemberCount := 3 // setupTeamTestScenario sets up 3 user (id: 2,3,4) in the team (id: 1) - testOrgId := setupTeamTestScenario(teamMemberCount, sc.db, t) + testOrgId := setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInViewer(sc.initCtx) t.Run("Access control allows getting a team members with the right permissions", func(t *testing.T) { @@ -261,10 +264,11 @@ func TestGetTeamMembersAPIEndpoint_RBAC(t *testing.T) { func TestAddTeamMembersAPIEndpoint_RBAC(t *testing.T) { sc := setupHTTPServer(t, true) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) sc.hs.License = &licensing.OSSLicensingService{} teamMemberCount := 3 - testOrgId := setupTeamTestScenario(teamMemberCount, sc.db, t) + testOrgId := setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInViewer(sc.initCtx) newUserId := createUser(sc.db, testOrgId, t) @@ -297,11 +301,12 @@ func TestUpdateTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false cfg.EditorsCanAdmin = true sc := setupHTTPServerWithCfg(t, true, cfg) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, cfg, quotatest.New(false, nil)) guardian := manager.ProvideService(database.ProvideTeamGuardianStore(sc.db, sc.teamService)) sc.hs.teamGuardian = guardian teamMemberCount := 3 - setupTeamTestScenario(teamMemberCount, sc.db, t) + setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInOrgAdmin(sc.initCtx) input := strings.NewReader(fmt.Sprintf(updateTeamMemberCmd, models.PERMISSION_ADMIN)) @@ -342,10 +347,11 @@ func TestUpdateTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { func TestUpdateTeamMembersAPIEndpoint_RBAC(t *testing.T) { sc := setupHTTPServer(t, true) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) sc.hs.License = &licensing.OSSLicensingService{} teamMemberCount := 3 - setupTeamTestScenario(teamMemberCount, sc.db, t) + setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInViewer(sc.initCtx) input := strings.NewReader(fmt.Sprintf(updateTeamMemberCmd, models.PERMISSION_ADMIN)) @@ -376,11 +382,12 @@ func TestDeleteTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false cfg.EditorsCanAdmin = true sc := setupHTTPServerWithCfg(t, true, cfg) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) guardian := manager.ProvideService(database.ProvideTeamGuardianStore(sc.db, sc.teamService)) sc.hs.teamGuardian = guardian teamMemberCount := 3 - setupTeamTestScenario(teamMemberCount, sc.db, t) + setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInOrgAdmin(sc.initCtx) t.Run("Organisation admins can remove a team member", func(t *testing.T) { @@ -417,10 +424,11 @@ func TestDeleteTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { func TestDeleteTeamMembersAPIEndpoint_RBAC(t *testing.T) { sc := setupHTTPServer(t, true) + sc.hs.orgService, _ = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) sc.hs.License = &licensing.OSSLicensingService{} teamMemberCount := 3 - setupTeamTestScenario(teamMemberCount, sc.db, t) + setupTeamTestScenario(teamMemberCount, sc.db, sc.hs.orgService, t) setInitCtxSignedInViewer(sc.initCtx) t.Run("Access control allows removing a team member with the right permissions", func(t *testing.T) { diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index af010b06ab0..3d5cb0e4d1f 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/preference/preftest" @@ -213,6 +215,8 @@ func TestTeamAPIEndpoint_CreateTeam_RBAC(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() hs.teamService = teamtest.NewFakeService() + hs.AccessControl = acimpl.ProvideAccessControl(setting.NewCfg()) + hs.accesscontrolService = actest.FakeService{} }) input := strings.NewReader(fmt.Sprintf(teamCmd, 1)) diff --git a/pkg/build/cmd/argcount_wrapper.go b/pkg/build/cmd/argcount_wrapper.go index d3a3cfd67f6..690695cd350 100644 --- a/pkg/build/cmd/argcount_wrapper.go +++ b/pkg/build/cmd/argcount_wrapper.go @@ -2,7 +2,22 @@ package main import "github.com/urfave/cli/v2" -func ArgCountWrapper(max int, action cli.ActionFunc) cli.ActionFunc { +// ArgCountWrapper will cause the action to fail if there were not exactly `num` args provided. +func ArgCountWrapper(num int, action cli.ActionFunc) cli.ActionFunc { + return func(ctx *cli.Context) error { + if ctx.NArg() != num { + if err := cli.ShowSubcommandHelp(ctx); err != nil { + return cli.Exit(err.Error(), 1) + } + return cli.Exit("", 1) + } + + return action(ctx) + } +} + +// ArgCountWrapper will cause the action to fail if there were more than `num` args provided. +func MaxArgCountWrapper(max int, action cli.ActionFunc) cli.ActionFunc { return func(ctx *cli.Context) error { if ctx.NArg() > max { if err := cli.ShowSubcommandHelp(ctx); err != nil { diff --git a/pkg/build/cmd/enterprisecheck.go b/pkg/build/cmd/enterprisecheck.go new file mode 100644 index 00000000000..396c123df2d --- /dev/null +++ b/pkg/build/cmd/enterprisecheck.go @@ -0,0 +1,128 @@ +package main + +import ( + "os" + "strconv" + + "github.com/grafana/grafana/pkg/build/env" + "github.com/grafana/grafana/pkg/build/git" + "github.com/urfave/cli/v2" +) + +// checkOpts are options used to create a new GitHub check for the enterprise downstream test. +type checkOpts struct { + SHA string + URL string + Branch string + PR int +} + +func getCheckOpts(args []string) (*checkOpts, error) { + branch, ok := env.Lookup("DRONE_SOURCE_BRANCH", args) + if !ok { + return nil, cli.Exit("Unable to retrieve build source branch", 1) + } + + var ( + rgx = git.PRCheckRegexp() + matches = rgx.FindStringSubmatch(branch) + ) + + sha, ok := env.Lookup("SOURCE_COMMIT", args) + if !ok { + if matches == nil || len(matches) <= 1 { + return nil, cli.Exit("Unable to retrieve source commit", 1) + } + sha = matches[2] + } + + url, ok := env.Lookup("DRONE_BUILD_LINK", args) + if !ok { + return nil, cli.Exit(`missing environment variable "DRONE_BUILD_LINK"`, 1) + } + + prStr, ok := env.Lookup("OSS_PULL_REQUEST", args) + if !ok { + if matches == nil || len(matches) <= 1 { + return nil, cli.Exit("Unable to retrieve PR number", 1) + } + + prStr = matches[1] + } + + pr, err := strconv.Atoi(prStr) + if err != nil { + return nil, err + } + + return &checkOpts{ + Branch: branch, + PR: pr, + SHA: sha, + URL: url, + }, nil +} + +// EnterpriseCheckBegin creates the GitHub check and signals the beginning of the downstream build / test process +func EnterpriseCheckBegin(c *cli.Context) error { + var ( + ctx = c.Context + client = git.NewGitHubClient(ctx, c.String("github-token")) + ) + + opts, err := getCheckOpts(os.Environ()) + if err != nil { + return err + } + + if _, err = git.CreateEnterpriseStatus(ctx, client.Repositories, opts.SHA, opts.URL, "pending"); err != nil { + return err + } + + return nil +} + +func EnterpriseCheckSuccess(c *cli.Context) error { + return completeEnterpriseCheck(c, true) +} + +func EnterpriseCheckFail(c *cli.Context) error { + return completeEnterpriseCheck(c, false) +} + +func completeEnterpriseCheck(c *cli.Context, success bool) error { + var ( + ctx = c.Context + client = git.NewGitHubClient(ctx, c.String("github-token")) + ) + + // Update the pull request labels + opts, err := getCheckOpts(os.Environ()) + if err != nil { + return err + } + + status := "failure" + if success { + status = "success" + } + + // Update the GitHub check... + if _, err := git.CreateEnterpriseStatus(ctx, client.Repositories, opts.SHA, opts.URL, status); err != nil { + return err + } + + // Delete branch if needed + if git.PRCheckRegexp().MatchString(opts.Branch) { + if err := git.DeleteEnterpriseBranch(ctx, client.Git, opts.Branch); err != nil { + return nil + } + } + + label := "enterprise-failed" + if success { + label = "enterprise-ok" + } + + return git.AddLabelToPR(ctx, client.Issues, opts.PR, label) +} diff --git a/pkg/build/cmd/enterprisecheck_test.go b/pkg/build/cmd/enterprisecheck_test.go new file mode 100644 index 00000000000..0eeb5bd5741 --- /dev/null +++ b/pkg/build/cmd/enterprisecheck_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetCheckOpts(t *testing.T) { + t.Run("it should return the checkOpts if the correct environment variables are set", func(t *testing.T) { + args := []string{ + "SOURCE_COMMIT=1234", + "DRONE_SOURCE_BRANCH=test", + "DRONE_BUILD_LINK=http://example.com", + "OSS_PULL_REQUEST=1", + } + + opts, err := getCheckOpts(args) + require.NoError(t, err) + require.Equal(t, opts.SHA, "1234") + require.Equal(t, opts.URL, "http://example.com") + }) + t.Run("it should return an error if SOURCE_COMMIT is not set", func(t *testing.T) { + args := []string{ + "DRONE_BUILD_LINK=http://example.com", + "DRONE_SOURCE_BRANCH=test", + "DRONE_BUILD_LINK=http://example.com", + "OSS_PULL_REQUEST=1", + } + + opts, err := getCheckOpts(args) + require.Nil(t, opts) + require.Error(t, err) + }) + t.Run("it should return an error if DRONE_BUILD_LINK is not set", func(t *testing.T) { + args := []string{ + "SOURCE_COMMIT=1234", + "DRONE_SOURCE_BRANCH=test", + "OSS_PULL_REQUEST=1", + } + + opts, err := getCheckOpts(args) + require.Nil(t, opts) + require.Error(t, err) + }) + t.Run("it should return an error if OSS_PULL_REQUEST is not set", func(t *testing.T) { + args := []string{ + "SOURCE_COMMIT=1234", + "DRONE_SOURCE_BRANCH=test", + "DRONE_BUILD_LINK=http://example.com", + } + + opts, err := getCheckOpts(args) + require.Nil(t, opts) + require.Error(t, err) + }) + t.Run("it should return an error if OSS_PULL_REQUEST is not an integer", func(t *testing.T) { + args := []string{ + "SOURCE_COMMIT=1234", + "DRONE_SOURCE_BRANCH=test", + "DRONE_BUILD_LINK=http://example.com", + "OSS_PULL_REQUEST=http://example.com", + } + + opts, err := getCheckOpts(args) + require.Nil(t, opts) + require.Error(t, err) + }) +} diff --git a/pkg/build/cmd/flags.go b/pkg/build/cmd/flags.go index 7aa6d98d026..e92ce0022d2 100644 --- a/pkg/build/cmd/flags.go +++ b/pkg/build/cmd/flags.go @@ -46,4 +46,10 @@ var ( Usage: "Google Cloud Platform key file", Required: true, } + gitHubTokenFlag = cli.StringFlag{ + Name: "github-token", + Value: "", + EnvVars: []string{"GITHUB_TOKEN"}, + Usage: "GitHub token", + } ) diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index 658cceb9011..d8863710085 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -16,7 +16,7 @@ func main() { Name: "build-backend", Usage: "Build one or more variants of back-end binaries", ArgsUsage: "[version]", - Action: ArgCountWrapper(1, BuildBackend), + Action: MaxArgCountWrapper(1, BuildBackend), Flags: []cli.Flag{ &jobsFlag, &variantsFlag, @@ -67,7 +67,7 @@ func main() { Name: "build-frontend", Usage: "Build front-end artifacts", ArgsUsage: "[version]", - Action: ArgCountWrapper(1, BuildFrontend), + Action: MaxArgCountWrapper(1, BuildFrontend), Flags: []cli.Flag{ &jobsFlag, &editionFlag, @@ -77,7 +77,7 @@ func main() { { Name: "build-docker", Usage: "Build Grafana Docker images", - Action: ArgCountWrapper(1, BuildDocker), + Action: MaxArgCountWrapper(1, BuildDocker), Flags: []cli.Flag{ &jobsFlag, &editionFlag, @@ -112,7 +112,7 @@ func main() { { Name: "build-plugins", Usage: "Build internal plug-ins", - Action: ArgCountWrapper(1, BuildInternalPlugins), + Action: MaxArgCountWrapper(1, BuildInternalPlugins), Flags: []cli.Flag{ &jobsFlag, &editionFlag, @@ -125,7 +125,7 @@ func main() { Name: "publish-metrics", Usage: "Publish a set of metrics from stdin", ArgsUsage: "", - Action: ArgCountWrapper(1, PublishMetrics), + Action: MaxArgCountWrapper(1, PublishMetrics), }, { Name: "verify-drone", @@ -141,7 +141,7 @@ func main() { Name: "package", Usage: "Package one or more Grafana variants", ArgsUsage: "[version]", - Action: ArgCountWrapper(1, Package), + Action: MaxArgCountWrapper(1, Package), Flags: []cli.Flag{ &jobsFlag, &variantsFlag, @@ -182,7 +182,7 @@ func main() { Name: "fetch", Usage: "Fetch Grafana Docker images", ArgsUsage: "[version]", - Action: ArgCountWrapper(1, FetchImages), + Action: MaxArgCountWrapper(1, FetchImages), Flags: []cli.Flag{ &editionFlag, }, @@ -277,6 +277,36 @@ func main() { }, }, }, + { + Name: "enterprise-check", + Usage: "Commands for testing against Grafana Enterprise", + Subcommands: cli.Commands{ + { + Name: "begin", + Usage: "Creates the GitHub check in a pull request and begins the tests", + Action: EnterpriseCheckBegin, + Flags: []cli.Flag{ + &gitHubTokenFlag, + }, + }, + { + Name: "success", + Usage: "Updates the GitHub check in a pull request to show a successful build and updates the pull request labels", + Action: EnterpriseCheckSuccess, + Flags: []cli.Flag{ + &gitHubTokenFlag, + }, + }, + { + Name: "fail", + Usage: "Updates the GitHub check in a pull request to show a failed build and updates the pull request labels", + Action: EnterpriseCheckFail, + Flags: []cli.Flag{ + &gitHubTokenFlag, + }, + }, + }, + }, } if err := app.Run(os.Args); err != nil { diff --git a/pkg/build/docker/build.go b/pkg/build/docker/build.go index 7267d4a094b..fabc06c1644 100644 --- a/pkg/build/docker/build.go +++ b/pkg/build/docker/build.go @@ -70,12 +70,10 @@ func BuildImage(version string, arch config.Architecture, grafanaDir string, use } libc := "-musl" - dockerfile := "Dockerfile" baseImage := fmt.Sprintf("%salpine:3.15", baseArch) tagSuffix := "" if useUbuntu { libc = "" - dockerfile = "ubuntu.Dockerfile" baseImage = fmt.Sprintf("%subuntu:20.04", baseArch) tagSuffix = "-ubuntu" } @@ -118,33 +116,49 @@ func BuildImage(version string, arch config.Architecture, grafanaDir string, use tags = append(tags, tag) args := []string{ - "build", "--build-arg", fmt.Sprintf("BASE_IMAGE=%s", baseImage), - "--build-arg", fmt.Sprintf("GRAFANA_TGZ=%s", archive), "--tag", tag, "--no-cache", - "-f", dockerfile, ".", + "build", + "-q", + "--build-arg", fmt.Sprintf("BASE_IMAGE=%s", baseImage), + "--build-arg", fmt.Sprintf("GRAFANA_TGZ=%s", archive), + "--build-arg", "GO_SRC=tgz-builder", + "--build-arg", "JS_SRC=tgz-builder", + "--build-arg", "RUN_SH=./run.sh", + "--tag", tag, + "--no-cache", + "--file", "../../Dockerfile", + ".", } - log.Printf("Running Docker: docker %s", strings.Join(args, " ")) //nolint:gosec cmd := exec.Command("docker", args...) cmd.Dir = buildDir - cmd.Env = append(os.Environ(), "DOCKER_CLI_EXPERIMENTAL=enabled") - if output, err := cmd.CombinedOutput(); err != nil { - return []string{}, fmt.Errorf("building Docker image failed: %w\n%s", err, output) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), "DOCKER_CLI_EXPERIMENTAL=enabled", "DOCKER_BUILDKIT=1") + log.Printf("Running Docker: DOCKER_CLI_EXPERIMENTAL=enabled DOCKER_BUILDKIT=1 %s", cmd) + if err := cmd.Run(); err != nil { + return []string{}, fmt.Errorf("building Docker image failed: %w", err) } if shouldSave { imageFile := fmt.Sprintf("%s-%s%s-%s.img", imageFileBase, version, tagSuffix, arch) //nolint:gosec cmd = exec.Command("docker", "save", tag, "-o", imageFile) cmd.Dir = buildDir - if output, err := cmd.CombinedOutput(); err != nil { - return []string{}, fmt.Errorf("saving Docker image failed: %w\n%s", err, output) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Printf("Running Docker: %s", cmd) + if err := cmd.Run(); err != nil { + return []string{}, fmt.Errorf("saving Docker image failed: %w", err) } gcsURL := fmt.Sprintf("gs://grafana-prerelease/artifacts/docker/%s/%s", version, imageFile) //nolint:gosec - cmd = exec.Command("gsutil", "cp", imageFile, gcsURL) + cmd = exec.Command("gsutil", "-q", "cp", imageFile, gcsURL) cmd.Dir = buildDir - if output, err := cmd.CombinedOutput(); err != nil { - return []string{}, fmt.Errorf("storing Docker image failed: %w\n%s", err, output) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Printf("Running gsutil: %s", cmd) + if err := cmd.Run(); err != nil { + return []string{}, fmt.Errorf("storing Docker image failed: %w", err) } log.Printf("Docker image %s stored to grafana-prerelease GCS bucket", imageFile) } @@ -154,8 +168,11 @@ func BuildImage(version string, arch config.Architecture, grafanaDir string, use //nolint:gosec cmd = exec.Command("docker", "tag", tag, additionalTag) cmd.Dir = buildDir - if output, err := cmd.CombinedOutput(); err != nil { - return []string{}, fmt.Errorf("tagging Docker image failed: %w\n%s", err, output) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Printf("Running Docker: %s", cmd) + if err := cmd.Run(); err != nil { + return []string{}, fmt.Errorf("tagging Docker image failed: %w", err) } tags = append(tags, additionalTag) } diff --git a/pkg/build/env/lookup.go b/pkg/build/env/lookup.go new file mode 100644 index 00000000000..993b7259e14 --- /dev/null +++ b/pkg/build/env/lookup.go @@ -0,0 +1,18 @@ +package env + +import ( + "strings" +) + +// Lookup is the equivalent of os.LookupEnv, only you are able to provide the list of environment variables. +// To use this as os.LookupEnv would be used, simply call +// `env.Lookup("ENVIRONMENT_VARIABLE", os.Environ())` +func Lookup(name string, vars []string) (string, bool) { + for _, v := range vars { + if strings.HasPrefix(v, name) { + return strings.TrimPrefix(v, name+"="), true + } + } + + return "", false +} diff --git a/pkg/build/env/lookup_test.go b/pkg/build/env/lookup_test.go new file mode 100644 index 00000000000..cebfb4fac29 --- /dev/null +++ b/pkg/build/env/lookup_test.go @@ -0,0 +1,43 @@ +package env_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/build/env" + + "github.com/stretchr/testify/require" +) + +func TestLookup(t *testing.T) { + values := []string{"ENV_1=a", "ENV_2=b", "ENV_3=c", "ENV_4_TEST="} + + { + v, ok := env.Lookup("ENV_1", values) + require.Equal(t, v, "a") + require.True(t, ok) + } + + { + v, ok := env.Lookup("ENV_2", values) + require.Equal(t, v, "b") + require.True(t, ok) + } + + { + v, ok := env.Lookup("ENV_3", values) + require.Equal(t, v, "c") + require.True(t, ok) + } + + { + v, ok := env.Lookup("ENV_4_TEST", values) + require.Equal(t, v, "") + require.True(t, ok) + } + + { + v, ok := env.Lookup("NOT_THERE", values) + require.Equal(t, v, "") + require.False(t, ok) + } +} diff --git a/pkg/build/git/git.go b/pkg/build/git/git.go new file mode 100644 index 00000000000..518a2c6bf7a --- /dev/null +++ b/pkg/build/git/git.go @@ -0,0 +1,143 @@ +package git + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + + "github.com/google/go-github/v45/github" + "github.com/grafana/grafana/pkg/build/stringutil" + "golang.org/x/oauth2" +) + +const ( + MainBranch = "main" + HomeDir = "." + RepoOwner = "grafana" + OSSRepo = "grafana" + EnterpriseRepo = "grafana-enterprise" + EnterpriseCheckName = "Grafana Enterprise" + EnterpriseCheckDescription = "Downstream tests to ensure that your changes are compatible with Grafana Enterprise" +) + +var EnterpriseCheckLabels = []string{"enterprise-ok", "enterprise-failed", "enterprise-override"} + +var ( + ErrorNoDroneBuildLink = errors.New("no drone build link") +) + +type GitService interface { + DeleteRef(ctx context.Context, owner string, repo string, ref string) (*github.Response, error) +} + +type LabelsService interface { + ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) + RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error) + AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) +} + +type CommentService interface { + CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) +} + +type StatusesService interface { + CreateStatus(ctx context.Context, owner, repo, ref string, status *github.RepoStatus) (*github.RepoStatus, *github.Response, error) +} + +// NewGitHubClient creates a new Client using the provided GitHub token if not empty. +func NewGitHubClient(ctx context.Context, token string) *github.Client { + var tc *http.Client + if token != "" { + ts := oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: token, + }) + tc = oauth2.NewClient(ctx, ts) + } + + return github.NewClient(tc) +} + +func PRCheckRegexp() *regexp.Regexp { + reBranch, err := regexp.Compile(`^prc-([0-9]+)-([A-Za-z0-9]+)\/(.+)$`) + if err != nil { + panic(fmt.Sprintf("Failed to compile regexp: %s", err)) + } + + return reBranch +} + +func AddLabelToPR(ctx context.Context, client LabelsService, prID int, newLabel string) error { + // Check existing labels + labels, _, err := client.ListLabelsByIssue(ctx, RepoOwner, OSSRepo, prID, nil) + if err != nil { + return err + } + + duplicate := false + for _, label := range labels { + if *label.Name == newLabel { + duplicate = true + continue + } + + // Delete existing "enterprise-xx" labels + if stringutil.Contains(EnterpriseCheckLabels, *label.Name) { + _, err := client.RemoveLabelForIssue(ctx, RepoOwner, OSSRepo, prID, *label.Name) + if err != nil { + return err + } + } + } + + if duplicate { + return nil + } + + _, _, err = client.AddLabelsToIssue(ctx, RepoOwner, OSSRepo, prID, []string{newLabel}) + if err != nil { + return err + } + + return nil +} + +func DeleteEnterpriseBranch(ctx context.Context, client GitService, branchName string) error { + ref := "heads/" + branchName + _, err := client.DeleteRef(ctx, RepoOwner, EnterpriseRepo, ref) + if err != nil { + return err + } + + return nil +} + +// CreateEnterpriseStatus sets the status on a commit for the enterprise build check. +func CreateEnterpriseStatus(ctx context.Context, client StatusesService, sha, link, status string) (*github.RepoStatus, error) { + check, _, err := client.CreateStatus(ctx, RepoOwner, OSSRepo, sha, &github.RepoStatus{ + Context: github.String(EnterpriseCheckName), + Description: github.String(EnterpriseCheckDescription), + TargetURL: github.String(link), + State: github.String(status), + }) + + if err != nil { + return nil, err + } + + return check, nil +} + +func CreateEnterpriseBuildFailedComment(ctx context.Context, client CommentService, link string, prID int) error { + body := fmt.Sprintf("Drone build failed: %s", link) + + _, _, err := client.CreateComment(ctx, RepoOwner, OSSRepo, prID, &github.IssueComment{ + Body: &body, + }) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/build/git/git_checks_test.go b/pkg/build/git/git_checks_test.go new file mode 100644 index 00000000000..ed3d34a8d28 --- /dev/null +++ b/pkg/build/git/git_checks_test.go @@ -0,0 +1,55 @@ +package git_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-github/v45/github" + "github.com/grafana/grafana/pkg/build/git" + "github.com/stretchr/testify/require" +) + +type TestChecksService struct { + CreateCheckRunError error +} + +func (s *TestChecksService) CreateStatus(ctx context.Context, owner, repo, ref string, status *github.RepoStatus) (*github.RepoStatus, *github.Response, error) { + if s.CreateCheckRunError != nil { + return nil, nil, s.CreateCheckRunError + } + + return &github.RepoStatus{ + ID: github.Int64(1), + URL: status.URL, + }, nil, nil +} + +func TestCreateEnterpriseRepoStatus(t *testing.T) { + t.Run("It should create a repo status", func(t *testing.T) { + var ( + ctx = context.Background() + client = &TestChecksService{} + link = "http://example.com" + sha = "1234" + ) + + _, err := git.CreateEnterpriseStatus(ctx, client, link, sha, "success") + + require.NoError(t, err) + }) + t.Run("It should return an error if GitHub fails to create the status", func(t *testing.T) { + var ( + ctx = context.Background() + createCheckError = errors.New("create check run error") + client = &TestChecksService{ + CreateCheckRunError: createCheckError, + } + link = "http://example.com" + sha = "1234" + ) + + _, err := git.CreateEnterpriseStatus(ctx, client, link, sha, "success") + require.ErrorIs(t, err, createCheckError) + }) +} diff --git a/pkg/build/git/git_issues_test.go b/pkg/build/git/git_issues_test.go new file mode 100644 index 00000000000..4eaa3bb6169 --- /dev/null +++ b/pkg/build/git/git_issues_test.go @@ -0,0 +1,134 @@ +package git_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-github/v45/github" + "github.com/grafana/grafana/pkg/build/git" + "github.com/stretchr/testify/require" +) + +type TestLabelsService struct { + Labels []*github.Label + ListLabelsError error + RemoveLabelError error + AddLabelsError error +} + +func (s *TestLabelsService) ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { + if s.ListLabelsError != nil { + return nil, nil, s.ListLabelsError + } + + labels := s.Labels + if labels == nil { + labels = []*github.Label{} + } + + return labels, nil, nil +} + +func (s *TestLabelsService) RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error) { + if s.RemoveLabelError != nil { + return nil, s.RemoveLabelError + } + + return &github.Response{}, nil +} + +func (s *TestLabelsService) AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) { + if s.AddLabelsError != nil { + return nil, nil, s.AddLabelsError + } + + l := make([]*github.Label, len(labels)) + for i, v := range labels { + l[i] = &github.Label{ + Name: github.String(v), + } + } + + return l, nil, nil +} + +func TestAddLabelToPR(t *testing.T) { + t.Run("It should add a label to a pull request", func(t *testing.T) { + var ( + ctx = context.Background() + client = &TestLabelsService{} + pr = 20 + label = "test-label" + ) + + require.NoError(t, git.AddLabelToPR(ctx, client, pr, label)) + }) + t.Run("It should not return an error if the label already exists", func(t *testing.T) { + var ( + ctx = context.Background() + client = &TestLabelsService{ + Labels: []*github.Label{ + { + Name: github.String("test-label"), + }, + }, + } + pr = 20 + label = "test-label" + ) + + require.NoError(t, git.AddLabelToPR(ctx, client, pr, label)) + }) + + t.Run("It should return an error if GitHub returns an error when listing labels", func(t *testing.T) { + var ( + ctx = context.Background() + listLabelsError = errors.New("list labels error") + client = &TestLabelsService{ + ListLabelsError: listLabelsError, + Labels: []*github.Label{}, + } + pr = 20 + label = "test-label" + ) + + require.ErrorIs(t, git.AddLabelToPR(ctx, client, pr, label), listLabelsError) + }) + + t.Run("It should not return an error if there are existing enterprise-check labels.", func(t *testing.T) { + var ( + ctx = context.Background() + client = &TestLabelsService{ + Labels: []*github.Label{ + { + Name: github.String("enterprise-failed"), + }, + }, + } + pr = 20 + label = "test-label" + ) + + require.NoError(t, git.AddLabelToPR(ctx, client, pr, label)) + }) + + t.Run("It should return an error if GitHub returns an error when removing existing enterprise-check labels", func(t *testing.T) { + var ( + ctx = context.Background() + removeLabelError = errors.New("remove label error") + client = &TestLabelsService{ + RemoveLabelError: removeLabelError, + Labels: []*github.Label{ + { + Name: github.String("enterprise-failed"), + }, + }, + } + pr = 20 + label = "test-label" + ) + + require.ErrorIs(t, git.AddLabelToPR(ctx, client, pr, label), removeLabelError) + }) +} diff --git a/pkg/build/git/git_test.go b/pkg/build/git/git_test.go new file mode 100644 index 00000000000..c0cbd4c8cd8 --- /dev/null +++ b/pkg/build/git/git_test.go @@ -0,0 +1,56 @@ +package git_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/build/git" + "github.com/stretchr/testify/assert" +) + +func TestPRCheckRegexp(t *testing.T) { + type match struct { + String string + Commit string + Branch string + PR string + } + + var ( + shouldMatch = []match{ + { + String: "prc-1-a1b2c3d4/branch-name", + Branch: "branch-name", + Commit: "a1b2c3d4", + PR: "1", + }, + { + String: "prc-111-a1b2c3d4/branch/name", + Branch: "branch/name", + Commit: "a1b2c3d4", + PR: "111", + }, + { + String: "prc-102930122-a1b2c3d4/branch-name", + Branch: "branch-name", + Commit: "a1b2c3d4", + PR: "102930122", + }, + } + + shouldNotMatch = []string{"prc-a/branch", "km/test", "test", "prc", "prc/test", "price"} + ) + + regex := git.PRCheckRegexp() + + for _, v := range shouldMatch { + assert.Truef(t, regex.MatchString(v.String), "regex '%s' should match %s", regex.String(), v) + m := regex.FindStringSubmatch(v.String) + assert.Equal(t, m[1], v.PR) + assert.Equal(t, m[2], v.Commit) + assert.Equal(t, m[3], v.Branch) + } + + for _, v := range shouldNotMatch { + assert.False(t, regex.MatchString(v), "regex '%s' should not match %s", regex.String(), v) + } +} diff --git a/pkg/build/packaging/grafana.go b/pkg/build/packaging/grafana.go index 84e083c3844..7c8331f8a70 100644 --- a/pkg/build/packaging/grafana.go +++ b/pkg/build/packaging/grafana.go @@ -400,6 +400,7 @@ func executeFPM(options linuxPackageOptions, packageRoot, srcDir string) error { switch options.packageType { case packageTypeRpm: args = append(args, "-t", "rpm", "--rpm-posttrans", "packaging/rpm/control/posttrans") + args = append(args, "--rpm-digest", "sha256") case packageTypeDeb: args = append(args, "-t", "deb", "--deb-no-default-config-files") default: diff --git a/pkg/build/stringutil/contains.go b/pkg/build/stringutil/contains.go new file mode 100644 index 00000000000..b53efe70759 --- /dev/null +++ b/pkg/build/stringutil/contains.go @@ -0,0 +1,10 @@ +package stringutil + +func Contains(arr []string, s string) bool { + for _, e := range arr { + if e == s { + return true + } + } + return false +} diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index ff647d66fc5..f30be391856 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -14,12 +14,16 @@ import ( "github.com/fatih/color" "github.com/urfave/cli/v2" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -36,6 +40,7 @@ func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, error) { HomePath: cmd.HomePath(), Args: append(configOptions, "cfg:log.level=error"), // tailing arguments have precedence over the options string }) + if err != nil { return nil, err } @@ -55,7 +60,21 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) } - resolver := ConflictResolver{Users: conflicts, Store: s} + quotaService := quotaimpl.ProvideService(s, cfg) + userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, quotaService) + if err != nil { + return nil, fmt.Errorf("%v: %w", "failed to get user service", err) + } + routing := routing.ProvideRegister() + featMgmt, err := featuremgmt.ProvideManagerService(cfg, nil) + if err != nil { + return nil, fmt.Errorf("%v: %w", "failed to get feature management service", err) + } + acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, featMgmt) + if err != nil { + return nil, fmt.Errorf("%v: %w", "failed to get access control", err) + } + resolver := ConflictResolver{Users: conflicts, Store: s, userService: userService, ac: acService} resolver.BuildConflictBlocks(conflicts, f) return &resolver, nil } @@ -333,11 +352,7 @@ func (r *ConflictResolver) MergeConflictingUsers(ctx context.Context) error { // creating a session for each block of users // we want to rollback incase something happens during update / delete - if err := r.Store.WithDbSession(ctx, func(sess *db.Session) error { - err := sess.Begin() - if err != nil { - return fmt.Errorf("could not open a db session: %w", err) - } + if err := r.Store.InTransaction(ctx, func(ctx context.Context) error { for _, u := range users { if u.Direction == "+" { id, err := strconv.ParseInt(u.ID, 10, 64) @@ -353,36 +368,34 @@ func (r *ConflictResolver) MergeConflictingUsers(ctx context.Context) error { fromUserIds = append(fromUserIds, id) } } - if _, err := sess.ID(intoUserId).Where(sqlstore.NotServiceAccountFilter(r.Store)).Get(&intoUser); err != nil { + if _, err := r.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: intoUserId}); err != nil { return fmt.Errorf("could not find intoUser: %w", err) } - for _, fromUserId := range fromUserIds { - var fromUser user.User - exists, err := sess.ID(fromUserId).Where(sqlstore.NotServiceAccountFilter(r.Store)).Get(&fromUser) + _, err := r.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: fromUserId}) + if err != nil && errors.Is(err, user.ErrUserNotFound) { + fmt.Printf("user with id %d does not exist, skipping\n", fromUserId) + } if err != nil { return fmt.Errorf("could not find fromUser: %w", err) } - if !exists { - fmt.Printf("user with id %d does not exist, skipping\n", fromUserId) - } - // // delete the user - delErr := r.Store.DeleteUserInSession(ctx, sess, &models.DeleteUserCommand{UserId: fromUserId}) + // delete the user + delErr := r.userService.Delete(ctx, &user.DeleteUserCommand{UserID: fromUserId}) if delErr != nil { return fmt.Errorf("error during deletion of user: %w", delErr) } + delACErr := r.ac.DeleteUserPermissions(ctx, 0, fromUserId) + if delACErr != nil { + return fmt.Errorf("error during deletion of user access control: %w", delACErr) + } } - commitErr := sess.Commit() - if commitErr != nil { - return fmt.Errorf("could not commit operation for useridentification %s: %w", block, commitErr) - } - userStore := userimpl.ProvideStore(r.Store, setting.NewCfg()) + updateMainCommand := &user.UpdateUserCommand{ UserID: intoUser.ID, Login: strings.ToLower(intoUser.Login), Email: strings.ToLower(intoUser.Email), } - updateErr := userStore.Update(ctx, updateMainCommand) + updateErr := r.userService.Update(ctx, updateMainCommand) if updateErr != nil { return fmt.Errorf("could not update user: %w", updateErr) } @@ -598,6 +611,8 @@ func (r *ConflictResolver) ToStringPresentation() string { type ConflictResolver struct { Store *sqlstore.SQLStore + userService user.Service + ac accesscontrol.Service Config *setting.Cfg Users ConflictingUsers ValidUsers ConflictingUsers diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index 5dabb855245..194edfeac4b 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -11,9 +11,11 @@ import ( "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" ) @@ -578,6 +580,9 @@ func TestRunValidateConflictUserFile(t *testing.T) { } func TestIntegrationMergeUser(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Run("should be able to merge user", func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) @@ -613,7 +618,11 @@ func TestIntegrationMergeUser(t *testing.T) { // get users conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) require.NoError(t, err) - r := ConflictResolver{Store: sqlStore} + r := ConflictResolver{ + Store: sqlStore, + userService: usertest.NewUserServiceFake(), + ac: actest.FakeService{}, + } r.BuildConflictBlocks(conflictUsers, fmt.Sprintf) tmpFile, err := generateConflictUsersFile(&r) require.NoError(t, err) @@ -633,6 +642,9 @@ func TestIntegrationMergeUser(t *testing.T) { } func TestIntegrationMergeUserFromNewFileInput(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Run("should be able to merge users after choosing a different user to keep", func(t *testing.T) { type testBuildConflictBlock struct { desc string @@ -748,7 +760,13 @@ conflict: test2 // add additional user with conflicting login where DOMAIN is upper case conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) require.NoError(t, err) - r := ConflictResolver{Store: sqlStore} + userFake := usertest.NewUserServiceFake() + userFake.ExpectedUser = &user.User{Email: "test", Login: "test", OrgID: int64(testOrgID)} + r := ConflictResolver{ + Store: sqlStore, + userService: userFake, + ac: actest.FakeService{}, + } r.BuildConflictBlocks(conflictUsers, fmt.Sprintf) require.NoError(t, err) // validation to get newConflicts diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 3276c5cc6ff..8a40ca839a9 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -117,7 +117,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/star/starimpl" "github.com/grafana/grafana/pkg/services/store" - objectdummyserver "github.com/grafana/grafana/pkg/services/store/object/dummy" + entitystoredummy "github.com/grafana/grafana/pkg/services/store/entity/dummy" "github.com/grafana/grafana/pkg/services/store/sanitizer" "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -326,7 +326,7 @@ var wireSet = wire.NewSet( teamimpl.ProvideService, ngmetrics.ProvideServiceForTest, notifications.MockNotificationService, - objectdummyserver.ProvideFakeObjectServer, + entitystoredummy.ProvideFakeEntityServer, wire.Bind(new(notifications.TempUserStore), new(*dbtest.FakeDB)), wire.Bind(new(notifications.Service), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)), diff --git a/pkg/codegen/jenny_tsveneerindex.go b/pkg/codegen/jenny_tsveneerindex.go index 6052cc94bf5..1d5b11dea2c 100644 --- a/pkg/codegen/jenny_tsveneerindex.go +++ b/pkg/codegen/jenny_tsveneerindex.go @@ -306,7 +306,7 @@ var allowedTSVeneers = map[string]bool{ } func allowedTSVeneersString() string { - var list []string + list := make([]string, 0, len(allowedTSVeneers)) for tgt := range allowedTSVeneers { list = append(list, tgt) } diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 33e57d5e6a5..29e376238d6 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -146,7 +146,6 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V matches = append(matches, EvalMatch{ Metric: "NoData", }) - noDataFound = true } firingCount = 0 diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index fd656b2de4a..de159f32f21 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -502,7 +502,6 @@ func TestConditionsCmd(t *testing.T) { return newResults(v) }, }, { - // TODO: NoData behavior is different if the last condition is no data name: "two queries with two conditions using and operator and last is No Data", vars: mathexp.Vars{ "A": mathexp.Results{ @@ -529,7 +528,7 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) + v := newNumber(ptr.Float64(0)) v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}, {Metric: "NoData"}}) return newResults(v) }, diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go index d473af64566..deb915e9f81 100644 --- a/pkg/expr/graph.go +++ b/pkg/expr/graph.go @@ -144,11 +144,12 @@ func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) { } rn := &rawNode{ - Query: rawQueryProp, - RefID: query.RefID, - TimeRange: query.TimeRange, - QueryType: query.QueryType, - DataSource: query.DataSource, + Query: rawQueryProp, + RefID: query.RefID, + TimeRange: query.TimeRange, + QueryType: query.QueryType, + DataSource: query.DataSource, + QueryEnricher: query.QueryEnricher, } var node Node diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index cd6fca63b8b..f679ec38354 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -42,11 +42,12 @@ type baseNode struct { } type rawNode struct { - RefID string `json:"refId"` - Query map[string]interface{} - QueryType string - TimeRange TimeRange - DataSource *datasources.DataSource + RefID string `json:"refId"` + Query map[string]interface{} + QueryType string + TimeRange TimeRange + DataSource *datasources.DataSource + QueryEnricher QueryDataRequestEnricher } func (rn *rawNode) GetCommandType() (c CommandType, err error) { @@ -139,8 +140,9 @@ const ( // DSNode is a DPNode that holds a datasource request. type DSNode struct { baseNode - query json.RawMessage - datasource *datasources.DataSource + query json.RawMessage + datasource *datasources.DataSource + queryEnricher QueryDataRequestEnricher orgID int64 queryType string @@ -169,14 +171,15 @@ func (s *Service) buildDSNode(dp *simple.DirectedGraph, rn *rawNode, req *Reques id: dp.NewNode().ID(), refID: rn.RefID, }, - orgID: req.OrgId, - query: json.RawMessage(encodedQuery), - queryType: rn.QueryType, - intervalMS: defaultIntervalMS, - maxDP: defaultMaxDP, - timeRange: rn.TimeRange, - request: *req, - datasource: rn.DataSource, + orgID: req.OrgId, + query: json.RawMessage(encodedQuery), + queryType: rn.QueryType, + intervalMS: defaultIntervalMS, + maxDP: defaultMaxDP, + timeRange: rn.TimeRange, + request: *req, + datasource: rn.DataSource, + queryEnricher: rn.QueryEnricher, } var floatIntervalMS float64 @@ -211,24 +214,29 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s OrgID: dn.orgID, DataSourceInstanceSettings: dsInstanceSettings, PluginID: dn.datasource.Type, + User: dn.request.User, } - q := []backend.DataQuery{ - { - RefID: dn.refID, - MaxDataPoints: dn.maxDP, - Interval: time.Duration(int64(time.Millisecond) * dn.intervalMS), - JSON: dn.query, - TimeRange: dn.timeRange.AbsoluteTime(now), - QueryType: dn.queryType, - }, - } - - resp, err := s.dataService.QueryData(ctx, &backend.QueryDataRequest{ + req := &backend.QueryDataRequest{ PluginContext: pc, - Queries: q, - Headers: dn.request.Headers, - }) + Queries: []backend.DataQuery{ + { + RefID: dn.refID, + MaxDataPoints: dn.maxDP, + Interval: time.Duration(int64(time.Millisecond) * dn.intervalMS), + JSON: dn.query, + TimeRange: dn.timeRange.AbsoluteTime(now), + QueryType: dn.queryType, + }, + }, + Headers: dn.request.Headers, + } + + if dn.queryEnricher != nil { + ctx = dn.queryEnricher(ctx, req) + } + + resp, err := s.dataService.QueryData(ctx, req) if err != nil { return mathexp.Results{}, err } diff --git a/pkg/expr/transform.go b/pkg/expr/transform.go index a128ab9ec88..5d5351b04ef 100644 --- a/pkg/expr/transform.go +++ b/pkg/expr/transform.go @@ -35,14 +35,19 @@ type Request struct { Debug bool OrgId int64 Queries []Query + User *backend.User } +// QueryDataRequestEnricher function definition for enriching a backend.QueryDataRequest request. +type QueryDataRequestEnricher func(ctx context.Context, req *backend.QueryDataRequest) context.Context + // Query is like plugins.DataSubQuery, but with a a time range, and only the UID // for the data source. Also interval is a time.Duration. type Query struct { RefID string TimeRange TimeRange DataSource *datasources.DataSource `json:"datasource"` + QueryEnricher QueryDataRequestEnricher JSON json.RawMessage Interval time.Duration QueryType string diff --git a/pkg/infra/grn/grn.go b/pkg/infra/grn/grn.go index 69716086f88..f85cf5eba23 100644 --- a/pkg/infra/grn/grn.go +++ b/pkg/infra/grn/grn.go @@ -7,7 +7,7 @@ import ( ) // Grafana resource name. See also: -// https://github.com/grafana/grafana/blob/main/pkg/services/store/object/object.proto#L6 +// https://github.com/grafana/grafana/blob/main/pkg/services/store/entity/entity.proto#L6 // NOTE: This structure/format is still under active development and is subject to change type GRN struct { // TenantID contains the ID of the tenant (in hosted grafana) or diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 81d45aabaa9..054f045471d 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/mwitkow/go-conntrack" ) @@ -50,10 +51,18 @@ func New(cfg *setting.Cfg, validator models.PluginRequestValidator, tracer traci return } datasourceLabelName, err := metricutil.SanitizeLabelName(datasourceName) - if err != nil { return } + + if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && + cfg.SecureSocksDSProxy.Enabled && secureSocksProxyEnabledOnDS(opts) { + err = newSecureSocksProxy(&cfg.SecureSocksDSProxy, transport) + if err != nil { + logger.Error("Failed to enable secure socks proxy", "error", err.Error(), "datasource", datasourceName) + } + } + newConntrackRoundTripper(datasourceLabelName, transport) }, }) diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go new file mode 100644 index 00000000000..65e4da5bdff --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go @@ -0,0 +1,72 @@ +package httpclientprovider + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "os" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/setting" + "golang.org/x/net/proxy" +) + +// newSecureSocksProxy takes a http.DefaultTransport and wraps it in a socks5 proxy with TLS +func newSecureSocksProxy(cfg *setting.SecureSocksDSProxySettings, transport *http.Transport) error { + certPool := x509.NewCertPool() + for _, rootCAFile := range strings.Split(cfg.RootCA, " ") { + // nolint:gosec + // The gosec G304 warning can be ignored because `rootCAFile` comes from config ini. + pem, err := os.ReadFile(rootCAFile) + if err != nil { + return err + } + if !certPool.AppendCertsFromPEM(pem) { + return errors.New("failed to append CA certificate " + rootCAFile) + } + } + + cert, err := tls.LoadX509KeyPair(cfg.ClientCert, cfg.ClientKey) + if err != nil { + return err + } + + tlsDialer := &tls.Dialer{ + Config: &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: cfg.ServerName, + RootCAs: certPool, + }, + } + dialSocksProxy, err := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, tlsDialer) + if err != nil { + return err + } + + contextDialer, ok := dialSocksProxy.(proxy.ContextDialer) + if !ok { + return errors.New("unable to cast socks proxy dialer to context proxy dialer") + } + + transport.DialContext = contextDialer.DialContext + + return nil +} + +// secureSocksProxyEnabledOnDS checks the datasource json data to see if the secure socks proxy is enabled on it +func secureSocksProxyEnabledOnDS(opts sdkhttpclient.Options) bool { + jsonData := backend.JSONDataFromHTTPClientOptions(opts) + res, enabled := jsonData["enableSecureSocksProxy"] + if !enabled { + return false + } + + if val, ok := res.(bool); ok { + return val + } + + return false +} diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go new file mode 100644 index 00000000000..857141c5d03 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go @@ -0,0 +1,177 @@ +package httpclientprovider + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSecureSocksProxy(t *testing.T) { + proxyAddress := "localhost:3000" + serverName := "localhost" + tempDir := t.TempDir() + + // create empty file for testing invalid configs + tempEmptyFile := filepath.Join(tempDir, "emptyfile.txt") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + _, err := os.Create(tempEmptyFile) + require.NoError(t, err) + + // generate test rootCA + ca := &x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + Organization: []string{"Grafana Labs"}, + CommonName: "Grafana", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) + require.NoError(t, err) + rootCACert := filepath.Join(tempDir, "ca.cert") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + caCertFile, err := os.Create(rootCACert) + require.NoError(t, err) + err = pem.Encode(caCertFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + }) + require.NoError(t, err) + + // generate test client cert & key + cert := &x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + Organization: []string{"Grafana Labs"}, + CommonName: "Grafana", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + SubjectKeyId: []byte{1, 2, 3, 4, 6}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey) + require.NoError(t, err) + clientCert := filepath.Join(tempDir, "client.cert") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + certFile, err := os.Create(clientCert) + require.NoError(t, err) + err = pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + require.NoError(t, err) + clientKey := filepath.Join(tempDir, "client.key") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + keyFile, err := os.Create(clientKey) + require.NoError(t, err) + err = pem.Encode(keyFile, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), + }) + require.NoError(t, err) + + settings := &setting.SecureSocksDSProxySettings{ + ClientCert: clientCert, + ClientKey: clientKey, + RootCA: rootCACert, + ServerName: serverName, + ProxyAddress: proxyAddress, + } + + t.Run("New socks proxy should be properly configured when all settings are valid", func(t *testing.T) { + require.NoError(t, newSecureSocksProxy(settings, &http.Transport{})) + }) + + t.Run("Client cert must be valid", func(t *testing.T) { + settings.ClientCert = tempEmptyFile + t.Cleanup(func() { + settings.ClientCert = clientCert + }) + require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) + }) + + t.Run("Client key must be valid", func(t *testing.T) { + settings.ClientKey = tempEmptyFile + t.Cleanup(func() { + settings.ClientKey = clientKey + }) + require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) + }) + + t.Run("Root CA must be valid", func(t *testing.T) { + settings.RootCA = tempEmptyFile + t.Cleanup(func() { + settings.RootCA = rootCACert + }) + require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) + }) +} + +func TestSecureSocksProxyEnabledOnDS(t *testing.T) { + t.Run("Secure socks proxy should only be enabled when the json data contains enableSecureSocksProxy=true", func(t *testing.T) { + tests := []struct { + instanceSettings *backend.AppInstanceSettings + enabled bool + }{ + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{}"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": \"nonbool\" }"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": false }"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": true }"), + }, + enabled: true, + }, + } + + for _, tt := range tests { + opts, err := tt.instanceSettings.HTTPClientOptions() + assert.NoError(t, err) + + assert.Equal(t, tt.enabled, secureSocksProxyEnabledOnDS(opts)) + } + }) +} diff --git a/pkg/infra/slugify/slugify.go b/pkg/infra/slugify/slugify.go new file mode 100644 index 00000000000..3011d55c1cf --- /dev/null +++ b/pkg/infra/slugify/slugify.go @@ -0,0 +1,370 @@ +/* + * This file evolved from the MIT licensed: https://github.com/machiel/slugify + */ + +/* + +The MIT License (MIT) + +Copyright (c) 2015 Machiel Molenaar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +package slugify + +import ( + "bytes" + "encoding/base64" + "strings" + "unicode/utf8" + + "github.com/gofrs/uuid" +) + +var ( + simpleSlugger = &slugger{ + isValidCharacter: validCharacter, + replaceCharacter: '-', + replacementMap: getDefaultReplacements(), + } +) + +// Slugify creates a URL safe latin slug for a given value +func Slugify(value string) string { + s := simpleSlugger.Slugify(value) + if s == "" { + s = base64.RawURLEncoding.EncodeToString([]byte(value)) + if len(s) > 50 || s == "" { + s = uuid.NewV5(uuid.NamespaceOID, value).String() + } + } + return s +} + +func validCharacter(c rune) bool { + if c >= 'a' && c <= 'z' { + return true + } + if c >= '0' && c <= '9' { + return true + } + return false +} + +// Slugifier based on settings +type slugger struct { + isValidCharacter func(c rune) bool + replaceCharacter rune + replacementMap map[rune]string +} + +// Slugify creates a slug for a string +func (s slugger) Slugify(value string) string { + value = strings.ToLower(value) + var buffer bytes.Buffer + lastCharacterWasInvalid := false + + for len(value) > 0 { + c, size := utf8.DecodeRuneInString(value) + value = value[size:] + + if newCharacter, ok := s.replacementMap[c]; ok { + buffer.WriteString(newCharacter) + lastCharacterWasInvalid = false + continue + } + + if s.isValidCharacter(c) { + buffer.WriteRune(c) + lastCharacterWasInvalid = false + } else if !lastCharacterWasInvalid { + buffer.WriteRune(s.replaceCharacter) + lastCharacterWasInvalid = true + } + } + + return strings.Trim(buffer.String(), string(s.replaceCharacter)) +} + +func getDefaultReplacements() map[rune]string { + return map[rune]string{ + '&': "and", + '@': "at", + '©': "c", + '®': "r", + 'Æ': "ae", + 'ß': "ss", + 'à': "a", + 'á': "a", + 'â': "a", + 'ä': "a", // or "ae" + 'å': "a", + 'æ': "ae", + 'ç': "c", + 'è': "e", + 'é': "e", + 'ê': "e", + 'ë': "e", + 'ì': "i", + 'í': "i", + 'î': "i", + 'ï': "i", + 'ò': "o", + 'ó': "o", + 'ô': "o", + 'õ': "o", + 'ö': "o", // or "oe"? + 'ø': "o", + 'ù': "u", + 'ú': "u", + 'û': "u", + 'ü': "ue", + 'ý': "y", + 'þ': "p", + 'ÿ': "y", + 'ā': "a", + 'ă': "a", + 'Ą': "a", + 'ą': "a", + 'ć': "c", + 'ĉ': "c", + 'ċ': "c", + 'č': "c", + 'ď': "d", + 'đ': "d", + 'ē': "e", + 'ĕ': "e", + 'ė': "e", + 'ę': "e", + 'ě': "e", + 'ĝ': "g", + 'ğ': "g", + 'ġ': "g", + 'ģ': "g", + 'ĥ': "h", + 'ħ': "h", + 'ĩ': "i", + 'ī': "i", + 'ĭ': "i", + 'į': "i", + 'ı': "i", + 'ij': "ij", + 'ĵ': "j", + 'ķ': "k", + 'ĸ': "k", + 'Ĺ': "l", + 'ĺ': "l", + 'ļ': "l", + 'ľ': "l", + 'ŀ': "l", + 'ł': "l", + 'ń': "n", + 'ņ': "n", + 'ň': "n", + 'ʼn': "n", + 'ŋ': "n", + 'ō': "o", + 'ŏ': "o", + 'ő': "o", + 'Œ': "oe", + 'œ': "oe", + 'ŕ': "r", + 'ŗ': "r", + 'ř': "r", + 'ś': "s", + 'ŝ': "s", + 'ş': "s", + 'š': "s", + 'ţ': "t", + 'ť': "t", + 'ŧ': "t", + 'ũ': "u", + 'ū': "u", + 'ŭ': "u", + 'ů': "u", + 'ű': "u", + 'ų': "u", + 'ŵ': "w", + 'ŷ': "y", + 'ź': "z", + 'ż': "z", + 'ž': "z", + 'ſ': "z", + 'Ə': "e", + 'ƒ': "f", + 'Ơ': "o", + 'ơ': "o", + 'Ư': "u", + 'ư': "u", + 'ǎ': "a", + 'ǐ': "i", + 'ǒ': "o", + 'ǔ': "u", + 'ǖ': "u", + 'ǘ': "u", + 'ǚ': "u", + 'ǜ': "u", + 'ǻ': "a", + 'Ǽ': "ae", + 'ǽ': "ae", + 'Ǿ': "o", + 'ǿ': "o", + 'ə': "e", + 'Є': "e", + 'Б': "b", + 'Г': "g", + 'Д': "d", + 'Ж': "zh", + 'З': "z", + 'У': "u", + 'Ф': "f", + 'Х': "h", + 'Ц': "c", + 'Ч': "ch", + 'Ш': "sh", + 'Щ': "sch", + 'Ъ': "-", + 'Ы': "y", + 'Ь': "-", + 'Э': "je", + 'Ю': "ju", + 'Я': "ja", + 'а': "a", + 'б': "b", + 'в': "v", + 'г': "g", + 'д': "d", + 'е': "e", + 'ж': "zh", + 'з': "z", + 'и': "i", + 'й': "j", + 'к': "k", + 'л': "l", + 'м': "m", + 'н': "n", + 'о': "o", + 'п': "p", + 'р': "r", + 'с': "s", + 'т': "t", + 'у': "u", + 'ф': "f", + 'х': "h", + 'ц': "c", + 'ч': "ch", + 'ш': "sh", + 'щ': "sch", + 'ъ': "-", + 'ы': "y", + 'ь': "-", + 'э': "je", + 'ю': "ju", + 'я': "ja", + 'ё': "jo", + 'є': "e", + 'і': "i", + 'ї': "i", + 'Ґ': "g", + 'ґ': "g", + 'א': "a", + 'ב': "b", + 'ג': "g", + 'ד': "d", + 'ה': "h", + 'ו': "v", + 'ז': "z", + 'ח': "h", + 'ט': "t", + 'י': "i", + 'ך': "k", + 'כ': "k", + 'ל': "l", + 'ם': "m", + 'מ': "m", + 'ן': "n", + 'נ': "n", + 'ס': "s", + 'ע': "e", + 'ף': "p", + 'פ': "p", + 'ץ': "C", + 'צ': "c", + 'ק': "q", + 'ר': "r", + 'ש': "w", + 'ת': "t", + '™': "tm", + 'ả': "a", + 'ã': "a", + 'ạ': "a", + + 'ắ': "a", + 'ằ': "a", + 'ẳ': "a", + 'ẵ': "a", + 'ặ': "a", + + 'ấ': "a", + 'ầ': "a", + 'ẩ': "a", + 'ẫ': "a", + 'ậ': "a", + + 'ẻ': "e", + 'ẽ': "e", + 'ẹ': "e", + 'ế': "e", + 'ề': "e", + 'ể': "e", + 'ễ': "e", + 'ệ': "e", + + 'ỉ': "i", + 'ị': "i", + + 'ỏ': "o", + 'ọ': "o", + 'ố': "o", + 'ồ': "o", + 'ổ': "o", + 'ỗ': "o", + 'ộ': "o", + 'ớ': "o", + 'ờ': "o", + 'ở': "o", + 'ỡ': "o", + 'ợ': "o", + + 'ủ': "u", + 'ụ': "u", + 'ứ': "u", + 'ừ': "u", + 'ử': "u", + 'ữ': "u", + 'ự': "u", + + 'ỳ': "y", + 'ỷ': "y", + 'ỹ': "y", + 'ỵ': "y", + } +} diff --git a/pkg/infra/slugify/slugify_test.go b/pkg/infra/slugify/slugify_test.go new file mode 100644 index 00000000000..7f88b7a962a --- /dev/null +++ b/pkg/infra/slugify/slugify_test.go @@ -0,0 +1,54 @@ +package slugify + +import ( + "testing" +) + +func TestSlugify(t *testing.T) { + results := make(map[string]string) + results["hello-playground"] = "Hello, playground" + results["hello-it-s-paradise"] = "😢 😣 😤 😥 😦 😧 😨 😩 😪 😫 😬 Hello, it's paradise" + results["61db60b5-f1e7-5853-9b81-0f074fc268ea"] = "😢 😣 😤 😥 😦 😧 😨 😩 😪 😫 😬" + results["8J-YoiAt"] = "😢 -" + results["a"] = "?,a . \n " + results["0a68eb57-c88a-5f34-9e9d-27f85e68af4f"] = "" // empty input has a slug! + results["hi-this-is-a-test"] = "方向盤後面 hi this is a test خلف المقو" + results["cong-hoa-xa-hoi-chu-nghia-viet-nam"] = "Cộng hòa xã hội chủ nghĩa Việt Nam" + results["noi-nang-canh-canh-ben-long-bieng-khuay"] = "Nỗi nàng canh cánh bên lòng biếng khuây" // This line in a poem called Truyen Kieu + + for slug, original := range results { + actual := Slugify(original) + + if actual != slug { + t.Errorf("Expected '%s', got: %s", slug, actual) + } + } +} + +func BenchmarkSlugify(b *testing.B) { + for i := 0; i < b.N; i++ { + Slugify("Hello, world!") + } +} + +func BenchmarkSlugifyLongString(b *testing.B) { + for i := 0; i < b.N; i++ { + Slugify(` + 😢 😣 😤 😥 😦 😧 😨 😩 😪 😫 😬 Hello, it's paradise + 😢 😣 😤 😥 😦 😧 😨 😩 😪 😫 😬 Hello, it's paradise + 😢 😣 😤 😥 😦 😧 😨 😩 😪 😫 😬 Hello, it's paradise + 😢 😣 😤 😥 😦 😧 😨 😩 😪 😫 😬 Hello, it's paradise + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Aliquam sapien nisl, laoreet quis vestibulum ut, cursus + in turpis. Sed magna mi, blandit id nisi vel, imperdiet + mollis turpis. Fusce vel fringilla mauris. Donec cursus + rhoncus bibendum. Aliquam erat volutpat. Maecenas + faucibus turpis ex, quis lacinia ligula ultrices non. + Sed gravida justo augue. Nulla bibendum dignissim tellus + vitae lobortis. Suspendisse fermentum vel purus in pulvinar. + Vivamus eu fermentum purus, sit amet tempor orci. + Praesent congue convallis turpis, ac ullamcorper lorem + semper id. + `) + } +} diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index 7185c28b6fe..da61dbba372 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -12,12 +12,14 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/util" ) func TestConcurrentUsersMetrics(t *testing.T) { sqlStore, cfg := db.InitTestDBwithCfg(t) - s := createService(t, cfg, sqlStore) + statsService := statsimpl.ProvideService(sqlStore) + s := createService(t, cfg, sqlStore, statsService) createConcurrentTokens(t, sqlStore) @@ -34,7 +36,8 @@ func TestConcurrentUsersMetrics(t *testing.T) { func TestConcurrentUsersStats(t *testing.T) { sqlStore, cfg := db.InitTestDBwithCfg(t) - s := createService(t, cfg, sqlStore) + statsService := statsimpl.ProvideService(sqlStore) + s := createService(t, cfg, sqlStore, statsService) createConcurrentTokens(t, sqlStore) diff --git a/pkg/infra/usagestats/statscollector/prometheus_flavor_test.go b/pkg/infra/usagestats/statscollector/prometheus_flavor_test.go index dbf04514b48..77bb7ec08ca 100644 --- a/pkg/infra/usagestats/statscollector/prometheus_flavor_test.go +++ b/pkg/infra/usagestats/statscollector/prometheus_flavor_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" + "github.com/grafana/grafana/pkg/services/stats/statstest" "github.com/grafana/grafana/pkg/setting" ) @@ -33,10 +34,12 @@ func TestDetectPrometheusVariant(t *testing.T) { t.Cleanup(cortex.Close) sqlStore := mockstore.NewSQLStoreMock() + statsService := statstest.NewFakeService() s := createService( t, setting.NewCfg(), sqlStore, + statsService, withDatasources(mockDatasourceService{datasources: []*datasources.DataSource{ { Id: 1, diff --git a/pkg/infra/usagestats/statscollector/service.go b/pkg/infra/usagestats/statscollector/service.go index ae503063342..1372bd90318 100644 --- a/pkg/infra/usagestats/statscollector/service.go +++ b/pkg/infra/usagestats/statscollector/service.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/setting" ) @@ -28,6 +29,7 @@ type Service struct { plugins plugins.Store social social.Service usageStats usagestats.Service + statsService stats.Service features *featuremgmt.FeatureManager datasources datasources.DataSourceService httpClientProvider httpclient.Provider @@ -42,6 +44,7 @@ type Service struct { func ProvideService( us usagestats.Service, + statsService stats.Service, cfg *setting.Cfg, store sqlstore.Store, social social.Service, @@ -56,6 +59,7 @@ func ProvideService( plugins: plugins, social: social, usageStats: us, + statsService: statsService, features: features, datasources: datasourceService, httpClientProvider: httpClientProvider, @@ -106,7 +110,7 @@ func (s *Service) collectSystemStats(ctx context.Context) (map[string]interface{ m := map[string]interface{}{} statsQuery := models.GetSystemStatsQuery{} - if err := s.sqlstore.GetSystemStats(ctx, &statsQuery); err != nil { + if err := s.statsService.GetSystemStats(ctx, &statsQuery); err != nil { s.log.Error("Failed to get system stats", "error", err) return nil, err } @@ -219,7 +223,7 @@ func (s *Service) collectAlertNotifierStats(ctx context.Context) (map[string]int m := map[string]interface{}{} // get stats about alert notifier usage anStats := models.GetAlertNotifierUsageStatsQuery{} - if err := s.sqlstore.GetAlertNotifiersUsageStats(ctx, &anStats); err != nil { + if err := s.statsService.GetAlertNotifiersUsageStats(ctx, &anStats); err != nil { s.log.Error("Failed to get alert notification stats", "error", err) return nil, err } @@ -233,7 +237,7 @@ func (s *Service) collectAlertNotifierStats(ctx context.Context) (map[string]int func (s *Service) collectDatasourceStats(ctx context.Context) (map[string]interface{}, error) { m := map[string]interface{}{} dsStats := models.GetDataSourceStatsQuery{} - if err := s.sqlstore.GetDataSourceStats(ctx, &dsStats); err != nil { + if err := s.statsService.GetDataSourceStats(ctx, &dsStats); err != nil { s.log.Error("Failed to get datasource stats", "error", err) return nil, err } @@ -280,7 +284,7 @@ func (s *Service) collectDatasourceAccess(ctx context.Context) (map[string]inter // fetch datasource access stats dsAccessStats := models.GetDataSourceAccessStatsQuery{} - if err := s.sqlstore.GetDataSourceAccessStats(ctx, &dsAccessStats); err != nil { + if err := s.statsService.GetDataSourceAccessStats(ctx, &dsAccessStats); err != nil { s.log.Error("Failed to get datasource access stats", "error", err) return nil, err } @@ -316,11 +320,16 @@ func (s *Service) updateTotalStats(ctx context.Context) bool { } statsQuery := models.GetSystemStatsQuery{} - if err := s.sqlstore.GetSystemStats(ctx, &statsQuery); err != nil { + if err := s.statsService.GetSystemStats(ctx, &statsQuery); err != nil { s.log.Error("Failed to get system stats", "error", err) return false } + if statsQuery.Result == nil { + s.log.Error("Cannot retrieve system stats") + return false + } + metrics.MStatTotalDashboards.Set(float64(statsQuery.Result.Dashboards)) metrics.MStatTotalFolders.Set(float64(statsQuery.Result.Folders)) metrics.MStatTotalUsers.Set(float64(statsQuery.Result.Users)) @@ -346,7 +355,7 @@ func (s *Service) updateTotalStats(ctx context.Context) bool { metrics.MStatTotalPublicDashboards.Set(float64(statsQuery.Result.PublicDashboards)) dsStats := models.GetDataSourceStatsQuery{} - if err := s.sqlstore.GetDataSourceStats(ctx, &dsStats); err != nil { + if err := s.statsService.GetDataSourceStats(ctx, &dsStats); err != nil { s.log.Error("Failed to get datasource stats", "error", err) return true } diff --git a/pkg/infra/usagestats/statscollector/service_test.go b/pkg/infra/usagestats/statscollector/service_test.go index 1b3aea6d286..5ff592f89eb 100644 --- a/pkg/infra/usagestats/statscollector/service_test.go +++ b/pkg/infra/usagestats/statscollector/service_test.go @@ -24,16 +24,19 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" + "github.com/grafana/grafana/pkg/services/stats" + "github.com/grafana/grafana/pkg/services/stats/statstest" "github.com/grafana/grafana/pkg/setting" ) func TestTotalStatsUpdate(t *testing.T) { sqlStore := mockstore.NewSQLStoreMock() - s := createService(t, setting.NewCfg(), sqlStore) + statsService := statstest.NewFakeService() + s := createService(t, setting.NewCfg(), sqlStore, statsService) s.cfg.MetricsEndpointEnabled = true s.cfg.MetricsEndpointDisableTotalStats = false - sqlStore.ExpectedSystemStats = &models.SystemStats{} + statsService.ExpectedSystemStats = &models.SystemStats{} tests := []struct { MetricsEndpointEnabled bool @@ -94,8 +97,9 @@ func TestUsageStatsProviders(t *testing.T) { provider2 := &dummyUsageStatProvider{stats: map[string]interface{}{"my_stat_x": "valx", "my_stat_z": "valz"}} store := mockstore.NewSQLStoreMock() - mockSystemStats(store) - s := createService(t, setting.NewCfg(), store) + statsService := statstest.NewFakeService() + mockSystemStats(statsService) + s := createService(t, setting.NewCfg(), store, statsService) s.RegisterProviders([]registry.ProvidesUsageStats{provider1, provider2}) m, err := s.collectAdditionalMetrics(context.Background()) @@ -109,8 +113,9 @@ func TestUsageStatsProviders(t *testing.T) { func TestFeatureUsageStats(t *testing.T) { store := mockstore.NewSQLStoreMock() - mockSystemStats(store) - s := createService(t, setting.NewCfg(), store) + statsService := statstest.NewFakeService() + mockSystemStats(statsService) + s := createService(t, setting.NewCfg(), store, statsService) m, err := s.collectSystemStats(context.Background()) require.NoError(t, err, "Expected no error") @@ -121,6 +126,7 @@ func TestFeatureUsageStats(t *testing.T) { func TestCollectingUsageStats(t *testing.T) { sqlStore := mockstore.NewSQLStoreMock() + statsService := statstest.NewFakeService() expectedDataSources := []*datasources.DataSource{ { JsonData: simplejson.NewFromAny(map[string]interface{}{ @@ -148,12 +154,12 @@ func TestCollectingUsageStats(t *testing.T) { AuthProxyEnabled: true, Packaging: "deb", ReportingDistributor: "hosted-grafana", - }, sqlStore, + }, sqlStore, statsService, withDatasources(mockDatasourceService{datasources: expectedDataSources})) s.startTime = time.Now().Add(-1 * time.Minute) - mockSystemStats(sqlStore) + mockSystemStats(statsService) createConcurrentTokens(t, sqlStore) @@ -203,6 +209,7 @@ func TestCollectingUsageStats(t *testing.T) { func TestElasticStats(t *testing.T) { sqlStore := mockstore.NewSQLStoreMock() + statsService := statstest.NewFakeService() expectedDataSources := []*datasources.DataSource{ { @@ -231,7 +238,7 @@ func TestElasticStats(t *testing.T) { AuthProxyEnabled: true, Packaging: "deb", ReportingDistributor: "hosted-grafana", - }, sqlStore, + }, sqlStore, statsService, withDatasources(mockDatasourceService{datasources: expectedDataSources})) metrics, err := s.collectElasticStats(context.Background()) @@ -242,11 +249,12 @@ func TestElasticStats(t *testing.T) { } func TestDatasourceStats(t *testing.T) { sqlStore := mockstore.NewSQLStoreMock() - s := createService(t, &setting.Cfg{}, sqlStore) + statsService := statstest.NewFakeService() + s := createService(t, &setting.Cfg{}, sqlStore, statsService) setupSomeDataSourcePlugins(t, s) - sqlStore.ExpectedDataSourceStats = []*models.DataSourceStats{ + statsService.ExpectedDataSourceStats = []*models.DataSourceStats{ { Type: datasources.DS_ES, Count: 9, @@ -283,7 +291,7 @@ func TestDatasourceStats(t *testing.T) { }, } - sqlStore.ExpectedDataSourcesAccessStats = []*models.DataSourceAccessStats{ + statsService.ExpectedDataSourcesAccessStats = []*models.DataSourceAccessStats{ { Type: datasources.DS_ES, Access: "direct", @@ -349,9 +357,10 @@ func TestDatasourceStats(t *testing.T) { func TestAlertNotifiersStats(t *testing.T) { sqlStore := mockstore.NewSQLStoreMock() - s := createService(t, &setting.Cfg{}, sqlStore) + statsService := statstest.NewFakeService() + s := createService(t, &setting.Cfg{}, sqlStore, statsService) - sqlStore.ExpectedNotifierUsageStats = []*models.NotifierUsageStats{ + statsService.ExpectedNotifierUsageStats = []*models.NotifierUsageStats{ { Type: "slack", Count: 1, @@ -369,8 +378,8 @@ func TestAlertNotifiersStats(t *testing.T) { assert.EqualValues(t, 2, metrics["stats.alert_notifiers.webhook.count"]) } -func mockSystemStats(sqlStore *mockstore.SQLStoreMock) { - sqlStore.ExpectedSystemStats = &models.SystemStats{ +func mockSystemStats(statsService *statstest.FakeService) { + statsService.ExpectedSystemStats = &models.SystemStats{ Dashboards: 1, Datasources: 2, Users: 3, @@ -437,7 +446,7 @@ func setupSomeDataSourcePlugins(t *testing.T, s *Service) { } } -func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, opts ...func(*serviceOptions)) *Service { +func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, statsService stats.Service, opts ...func(*serviceOptions)) *Service { t.Helper() o := &serviceOptions{datasources: mockDatasourceService{}} @@ -448,6 +457,7 @@ func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, opts .. return ProvideService( &usagestats.UsageStatsMock{}, + statsService, cfg, store, &mockSocial{}, diff --git a/pkg/kindsys/load.go b/pkg/kindsys/load.go index 29448f9052b..81bf9073c9f 100644 --- a/pkg/kindsys/load.go +++ b/pkg/kindsys/load.go @@ -8,11 +8,8 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/errors" - "github.com/grafana/thema" - tload "github.com/grafana/thema/load" - - "github.com/grafana/grafana" "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/thema" ) // CoreStructuredDeclParentPath is the path, relative to the repository root, where @@ -49,20 +46,14 @@ func loadpFrameworkOnce() { }) } -var prefix = filepath.Join("/pkg", "kindsys") - func doLoadFrameworkCUE(ctx *cue.Context) (cue.Value, error) { - var v cue.Value - var err error - - bi, err := tload.InstancesWithThema(grafana.CueSchemaFS, prefix) + v, err := cuectx.BuildGrafanaInstance(ctx, filepath.Join("pkg", "kindsys"), "kindsys", nil) if err != nil { return v, err } - v = ctx.BuildInstance(bi) if err = v.Validate(cue.Concrete(false), cue.All()); err != nil { - return cue.Value{}, fmt.Errorf("coremodel framework loaded cue.Value has err: %w", err) + return cue.Value{}, fmt.Errorf("kindsys framework loaded cue.Value has err: %w", err) } return v, nil diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index b7e00465586..9b0484eb51a 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" @@ -86,6 +87,15 @@ func EnsureEditorOrViewerCanEdit(c *models.ReqContext) { } } +func CanAdminPlugins(cfg *setting.Cfg) func(c *models.ReqContext) { + return func(c *models.ReqContext) { + if !plugins.ReqCanAdminPlugins(cfg)(c) { + accessForbidden(c) + return + } + } +} + func RoleAuth(roles ...org.RoleType) web.Handler { return func(c *models.ReqContext) { ok := false diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 22225cecd75..940fd5e36c1 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -77,3 +77,21 @@ func addNoCacheHeaders(w web.ResponseWriter) { func addXFrameOptionsDenyHeader(w web.ResponseWriter) { w.Header().Set("X-Frame-Options", "deny") } + +func AddCustomResponseHeaders(cfg *setting.Cfg) web.Handler { + return func(c *web.Context) { + c.Resp.Before(func(w web.ResponseWriter) { + if w.Written() { + return + } + + for header, value := range cfg.CustomResponseHeaders { + // do not override existing headers + if w.Header().Get(header) != "" { + continue + } + w.Header().Set(header, value) + } + }) + } +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 8c029c2f597..1c45065c374 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -514,6 +514,17 @@ func TestMiddlewareContext(t *testing.T) { cfg.AnonymousOrgRole = string(org.RoleEditor) }) + middlewareScenario(t, "middleware should add custom response headers", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/").exec() + assert.Regexp(t, "test", sc.resp.Header().Get("X-Custom-Header")) + assert.Regexp(t, "other-test", sc.resp.Header().Get("X-Other-Header")) + }, func(cfg *setting.Cfg) { + cfg.CustomResponseHeaders = map[string]string{ + "X-Custom-Header": "test", + "X-Other-Header": "other-test", + } + }) + t.Run("auth_proxy", func(t *testing.T) { const userID int64 = 33 const orgID int64 = 4 @@ -811,6 +822,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( require.Truef(t, exists, "Views directory should exist at %q", viewsPath) sc.m = web.New() + sc.m.Use(AddCustomResponseHeaders(cfg)) sc.m.Use(AddDefaultResponseHeaders(cfg)) sc.m.UseMiddleware(ContentSecurityPolicy(cfg, logger)) sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]")) diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index fc8f781909e..a89f451e007 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -1,14 +1,11 @@ package models import ( - "encoding/base64" "fmt" - "strings" "time" - "github.com/gosimple/slug" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/setting" ) @@ -147,22 +144,7 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { // UpdateSlug updates the slug func (d *Dashboard) UpdateSlug() { title := d.Data.Get("title").MustString() - d.Slug = SlugifyTitle(title) -} - -func SlugifyTitle(title string) string { - s := slug.Make(strings.ToLower(title)) - if s == "" { - // If the dashboard name is only characters outside of the - // sluggable characters, the slug creation will return an - // empty string which will mess up URLs. This failsafe picks - // that up and creates the slug as a base64 identifier instead. - s = base64.RawURLEncoding.EncodeToString([]byte(title)) - if slug.MaxLength != 0 && len(s) > slug.MaxLength { - s = s[:slug.MaxLength] - } - } - return s + d.Slug = slugify.Slugify(title) } // GetUrl return the html url for a folder if it's folder, otherwise for a dashboard diff --git a/pkg/models/dashboards_test.go b/pkg/models/dashboards_test.go index b8c5c78f1cc..2bebedcbb41 100644 --- a/pkg/models/dashboards_test.go +++ b/pkg/models/dashboards_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -72,14 +73,14 @@ func TestSlugifyTitle(t *testing.T) { testCases := map[string]string{ "Grafana Play Home": "grafana-play-home", "snöräv-över-ån": "snorav-over-an", - "漢字": "han-zi", // Hanzi for hanzi + "漢字": "5ryi5a2X", // "han-zi", // Hanzi for hanzi "🇦🇶": "8J-HpvCfh7Y", // flag of Antarctica-emoji, using fallback "𒆠": "8JKGoA", // cuneiform Ki, using fallback } for input, expected := range testCases { t.Run(input, func(t *testing.T) { - slug := SlugifyTitle(input) + slug := slugify.Slugify(input) assert.Equal(t, expected, slug) }) } diff --git a/pkg/models/object.go b/pkg/models/object.go index 039aea86f73..b798aae2514 100644 --- a/pkg/models/object.go +++ b/pkg/models/object.go @@ -52,17 +52,11 @@ const ( // ExternalEntityReferenceRuntime_Transformer is a "type" under runtime // UIDs include: joinByField, organize, seriesToColumns, etc ExternalEntityReferenceRuntime_Transformer = "transformer" - - // ObjectStoreScopeEntity is organized in: {kind}/{uid} - ObjectStoreScopeEntity = "entity" - - // ObjectStoreScopeDrive is organized in: {uid/with/slash}.{kind} - ObjectStoreScopeDrive = "drive" ) -// ObjectKindInfo describes information needed from the object store +// EntityKindInfo describes information needed from the object store // All non-raw types will have a schema that can be used to validate -type ObjectKindInfo struct { +type EntityKindInfo struct { // Unique short id for this kind ID string `json:"id,omitempty"` @@ -82,10 +76,10 @@ type ObjectKindInfo struct { MimeType string `json:"mimeType,omitempty"` } -// ObjectSummary represents common data derived from a raw object bytes. +// EntitySummary represents common data derived from a raw object bytes. // The values should not depend on system state, and are derived from the raw object. // This summary is used for a unified search and object listing -type ObjectSummary struct { +type EntitySummary struct { UID string `json:"uid,omitempty"` Kind string `json:"kind,omitempty"` Name string `json:"name,omitempty"` @@ -99,23 +93,23 @@ type ObjectSummary struct { URL string `json:"URL,omitempty"` // When errors exist - Error *ObjectErrorInfo `json:"error,omitempty"` + Error *EntityErrorInfo `json:"error,omitempty"` // Optional field values. The schema will define and document possible values for a given kind Fields map[string]interface{} `json:"fields,omitempty"` // eg: panels within dashboard - Nested []*ObjectSummary `json:"nested,omitempty"` + Nested []*EntitySummary `json:"nested,omitempty"` // Optional references to external things - References []*ObjectExternalReference `json:"references,omitempty"` + References []*EntityExternalReference `json:"references,omitempty"` // The summary can not be extended _ interface{} } // This will likely get replaced with a more general error framework. -type ObjectErrorInfo struct { +type EntityErrorInfo struct { // TODO: Match an error code registry? Code int64 `json:"code,omitempty"` @@ -129,7 +123,7 @@ type ObjectErrorInfo struct { // Reference to another object outside itself // This message is derived from the object body and can be used to search for references. // This does not represent a method to declare a reference to another object. -type ObjectExternalReference struct { +type EntityExternalReference struct { // datasource (instance), dashboard (instance), Kind string `json:"kind,omitempty"` @@ -140,6 +134,6 @@ type ObjectExternalReference struct { UID string `json:"UID,omitempty"` } -// ObjectSummaryBuilder will read an object, validate it, and return a summary, sanitized payload, or an error +// EntitySummaryBuilder will read an object, validate it, and return a summary, sanitized payload, or an error // This should not include values that depend on system state, only the raw object -type ObjectSummaryBuilder = func(ctx context.Context, uid string, body []byte) (*ObjectSummary, []byte, error) +type EntitySummaryBuilder = func(ctx context.Context, uid string, body []byte) (*EntitySummary, []byte, error) diff --git a/pkg/models/user_auth.go b/pkg/models/user_auth.go index 9bbd3b62a13..ec1f70e75a0 100644 --- a/pkg/models/user_auth.go +++ b/pkg/models/user_auth.go @@ -125,6 +125,10 @@ type GetAuthInfoQuery struct { Result *UserAuth } +type GetUserLabelsQuery struct { + UserIDs []int64 +} + type TeamOrgGroupDTO struct { TeamName string `json:"teamName"` OrgName string `json:"orgName"` diff --git a/pkg/models/usertoken/user_token.go b/pkg/models/usertoken/user_token.go index beb2ac1355f..9b350f300b7 100644 --- a/pkg/models/usertoken/user_token.go +++ b/pkg/models/usertoken/user_token.go @@ -1,12 +1,23 @@ package usertoken +import ( + "errors" + "fmt" +) + +var ErrInvalidSessionToken = errors.New("invalid session token") + type TokenRevokedError struct { UserID int64 TokenID int64 MaxConcurrentSessions int64 } -func (e *TokenRevokedError) Error() string { return "user token revoked" } +func (e *TokenRevokedError) Error() string { + return fmt.Sprintf("%s: user token revoked", ErrInvalidSessionToken) +} + +func (e *TokenRevokedError) Unwrap() error { return ErrInvalidSessionToken } // UserToken represents a user token type UserToken struct { diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index fb8f0ee5b29..4a42e941f92 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -12,11 +12,10 @@ import ( "runtime" "strings" - "github.com/gosimple/slug" - "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" @@ -186,7 +185,7 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO } if plugin.Parent != nil && plugin.Parent.IsApp() { - configureAppChildOPlugin(plugin.Parent, plugin) + configureAppChildPlugin(plugin.Parent, plugin) } verifiedPlugins = append(verifiedPlugins, plugin) @@ -343,7 +342,7 @@ func setDefaultNavURL(p *plugins.Plugin) { // slugify pages for _, include := range p.Includes { if include.Slug == "" { - include.Slug = slug.Make(include.Name) + include.Slug = slugify.Slugify(include.Name) } if !include.DefaultNav { @@ -365,7 +364,7 @@ func setDefaultNavURL(p *plugins.Plugin) { } } -func configureAppChildOPlugin(parent *plugins.Plugin, child *plugins.Plugin) { +func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) { if !parent.IsApp() { return } diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index bb03cd48cf5..4c0ccbbcd6c 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -1311,7 +1311,7 @@ func Test_setPathsBasedOnApp(t *testing.T) { BaseURL: "public/app/plugins/app/testdata-app", } - configureAppChildOPlugin(parent, child) + configureAppChildPlugin(parent, child) require.Equal(t, "app/plugins/app/testdata-app/datasources/datasource/module", child.Module) require.Equal(t, "testdata-app", child.IncludedInAppID) diff --git a/pkg/plugins/pfs/pfs.go b/pkg/plugins/pfs/pfs.go index 04682f07380..3de4651ef8c 100644 --- a/pkg/plugins/pfs/pfs.go +++ b/pkg/plugins/pfs/pfs.go @@ -49,7 +49,7 @@ type slotandname struct { var allslots []slotandname func init() { - var all []string + all := make([]string, 0, len(PermittedCUEImports())) for _, im := range PermittedCUEImports() { all = append(all, fmt.Sprintf("\t%s", im)) } diff --git a/pkg/plugins/storage/fs.go b/pkg/plugins/storage/fs.go index 0660e648159..302305a1196 100644 --- a/pkg/plugins/storage/fs.go +++ b/pkg/plugins/storage/fs.go @@ -55,7 +55,7 @@ func (fs *FS) Add(ctx context.Context, pluginID string, pluginArchive *zip.ReadC fs.log.Successf("Downloaded and extracted %s v%s zip successfully to %s", res.ID, res.Info.Version, pluginDir) - var deps []*Dependency + deps := make([]*Dependency, 0, len(res.Dependencies.Plugins)) for _, plugin := range res.Dependencies.Plugins { deps = append(deps, &Dependency{ ID: plugin.ID, diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 66181694739..0ae9a20470a 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -30,7 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/serviceaccounts" samanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" "github.com/grafana/grafana/pkg/services/store" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/sanitizer" "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/services/updatechecker" @@ -52,7 +52,7 @@ func ProvideBackgroundServiceRegistry( _ dashboardsnapshots.Service, _ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, _ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider, - _ *grpcserver.HealthService, _ object.ObjectStoreServer, _ *grpcserver.ReflectionService, + _ *grpcserver.HealthService, _ entity.EntityStoreServer, _ *grpcserver.ReflectionService, ) *BackgroundServiceRegistry { return NewBackgroundServiceRegistry( httpServer, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 7c4dfeadb54..bfac09f2aec 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -124,10 +124,11 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/star/starimpl" + "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity/httpentitystore" + "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" "github.com/grafana/grafana/pkg/services/store/kind" - "github.com/grafana/grafana/pkg/services/store/object/httpobjectstore" - "github.com/grafana/grafana/pkg/services/store/object/sqlstash" "github.com/grafana/grafana/pkg/services/store/resolver" "github.com/grafana/grafana/pkg/services/store/sanitizer" "github.com/grafana/grafana/pkg/services/tag" @@ -246,6 +247,7 @@ var wireBasicSet = wire.NewSet( searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, + store.ProvideSystemUsersService, export.ProvideService, live.ProvideService, pushhttp.ProvideService, @@ -352,15 +354,16 @@ var wireBasicSet = wire.NewSet( publicdashboardsApi.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, + statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideService, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, interceptors.ProvideAuthenticator, kind.ProvideService, // The registry of known kinds - sqlstash.ProvideSQLObjectServer, - resolver.ProvideObjectReferenceResolver, - httpobjectstore.ProvideHTTPObjectStore, + sqlstash.ProvideSQLEntityServer, + resolver.ProvideEntityReferenceResolver, + httpentitystore.ProvideHTTPEntityStore, teamimpl.ProvideService, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 5bba73ea4fd..965cd622249 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -26,6 +26,10 @@ type Service interface { registry.ProvidesUsageStats // GetUserPermissions returns user permissions with only action and scope fields set. GetUserPermissions(ctx context.Context, user *user.SignedInUser, options Options) ([]Permission, error) + // SearchUsersPermissions returns all users' permissions filtered by an action prefix + SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options SearchOptions) (map[int64][]Permission, error) + // ClearUserPermissionCache removes the permission cache entry for the given user + ClearUserPermissionCache(user *user.SignedInUser) // DeleteUserPermissions removes all permissions user has in org and all permission to that user // If orgID is set to 0 remove permissions from all orgs DeleteUserPermissions(ctx context.Context, orgID, userID int64) error @@ -45,6 +49,12 @@ type Options struct { ReloadCache bool } +type SearchOptions struct { + ActionPrefix string // Needed for the PoC v1, it's probably going to be removed. + Action string + Scope string +} + type TeamPermissionsService interface { GetPermissions(ctx context.Context, user *user.SignedInUser, resourceID string) ([]ResourcePermission, error) SetUserPermission(ctx context.Context, orgID int64, user User, resourceID, permission string) (*ResourcePermission, error) diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 9bab820f8eb..d5d479c64ac 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -3,6 +3,8 @@ package acimpl import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/prometheus/client_golang/prometheus" @@ -30,11 +32,11 @@ const ( ) func ProvideService(cfg *setting.Cfg, store db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, - features *featuremgmt.FeatureManager) (*Service, error) { + accessControl accesscontrol.AccessControl, features *featuremgmt.FeatureManager) (*Service, error) { service := ProvideOSSService(cfg, database.ProvideService(store), cache, features) if !accesscontrol.IsDisabled(cfg) { - api.NewAccessControlAPI(routeRegister, service).RegisterAPIEndpoints() + api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() if err := accesscontrol.DeclareFixedRoles(service); err != nil { return nil, err } @@ -58,6 +60,8 @@ func ProvideOSSService(cfg *setting.Cfg, store store, cache *localcache.CacheSer type store interface { GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) + SearchUsersPermissions(ctx context.Context, orgID int64, option accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) + GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error } @@ -147,6 +151,14 @@ func (s *Service) getCachedUserPermissions(ctx context.Context, user *user.Signe return permissions, nil } +func (s *Service) ClearUserPermissionCache(user *user.SignedInUser) { + key, err := permissionCacheKey(user) + if err != nil { + return + } + s.cache.Delete(key) +} + func (s *Service) DeleteUserPermissions(ctx context.Context, orgID int64, userID int64) error { return s.store.DeleteUserPermissions(ctx, orgID, userID) } @@ -236,3 +248,90 @@ func (s *Service) DeclarePluginRoles(_ context.Context, ID, name string, regs [] return nil } + +// SearchUsersPermissions returns all users' permissions filtered by action prefixes +func (s *Service) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, + options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + // Filter ram permissions + basicPermissions := map[string][]accesscontrol.Permission{} + for role, basicRole := range s.roles { + for i := range basicRole.Permissions { + if options.ActionPrefix != "" { + if strings.HasPrefix(basicRole.Permissions[i].Action, options.ActionPrefix) { + basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) + } + } + if options.Action != "" { + if basicRole.Permissions[i].Action == options.Action { + basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) + } + } + } + } + + usersRoles, err := s.store.GetUsersBasicRoles(ctx, orgID) + if err != nil { + return nil, err + } + + // Get managed permissions (DB) + usersPermissions, err := s.store.SearchUsersPermissions(ctx, orgID, options) + if err != nil { + return nil, err + } + + // helper to filter out permissions the signed in users cannot see + canView := func() func(userID int64) bool { + siuPermissions, ok := user.Permissions[orgID] + if !ok { + return func(_ int64) bool { return false } + } + scopes, ok := siuPermissions[accesscontrol.ActionUsersPermissionsRead] + if !ok { + return func(_ int64) bool { return false } + } + + ids := map[int64]bool{} + for i := range scopes { + if strings.HasSuffix(scopes[i], "*") { + return func(_ int64) bool { return true } + } + parts := strings.Split(scopes[i], ":") + if len(parts) != 3 { + continue + } + id, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + continue + } + ids[id] = true + } + + return func(userID int64) bool { return ids[userID] } + }() + + // Merge stored (DB) and basic role permissions (RAM) + // Assumes that all users with stored permissions have org roles + res := map[int64][]accesscontrol.Permission{} + for userID, roles := range usersRoles { + if !canView(userID) { + continue + } + perms := []accesscontrol.Permission{} + for i := range roles { + basicPermission, ok := basicPermissions[roles[i]] + if !ok { + continue + } + perms = append(perms, basicPermission...) + } + if dbPerms, ok := usersPermissions[userID]; ok { + perms = append(perms, dbPerms...) + } + if len(perms) > 0 { + res[userID] = perms + } + } + + return res, nil +} diff --git a/pkg/services/accesscontrol/acimpl/service_bench_test.go b/pkg/services/accesscontrol/acimpl/service_bench_test.go new file mode 100644 index 00000000000..df6660bebe0 --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/service_bench_test.go @@ -0,0 +1,210 @@ +package acimpl + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +const batchSize = 500 + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + + if err := eachFn(i, end); err != nil { + return err + } + + i = end + } + + return nil +} + +func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.Service, *user.SignedInUser) { + now := time.Now() + sqlStore := db.InitTestDB(b) + store := database.ProvideService(sqlStore) + acService := &Service{ + cfg: setting.NewCfg(), + log: log.New("accesscontrol-test"), + registrations: accesscontrol.RegistrationList{}, + store: store, + roles: accesscontrol.BuildBasicRoleDefinitions(), + } + + // Prepare default permissions + action1 := "resources:action1" + err := acService.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{Name: "fixed:test:role", Permissions: []accesscontrol.Permission{{Action: action1}}}, + Grants: []string{string(org.RoleViewer)}, + }) + require.NoError(b, err) + err = acService.RegisterFixedRoles(context.Background()) + require.NoError(b, err) + + // Prepare managed permissions + action2 := "resources:action2" + users := make([]user.User, 0, usersCount) + orgUsers := make([]org.OrgUser, 0, usersCount) + roles := make([]accesscontrol.Role, 0, usersCount) + userRoles := make([]accesscontrol.UserRole, 0, usersCount) + permissions := make([]accesscontrol.Permission, 0, resourceCount*usersCount) + for u := 1; u < usersCount+1; u++ { + users = append(users, user.User{ + ID: int64(u), + Name: fmt.Sprintf("user%v", u), + Login: fmt.Sprintf("user%v", u), + Email: fmt.Sprintf("user%v@example.org", u), + Created: now, + Updated: now, + }) + orgUsers = append(orgUsers, org.OrgUser{ + ID: int64(u), + UserID: int64(u), + OrgID: 1, + Role: org.RoleViewer, + Created: now, + Updated: now, + }) + roles = append(roles, accesscontrol.Role{ + ID: int64(u), + UID: fmt.Sprintf("managed_users_%v_permissions", u), + Name: fmt.Sprintf("managed:users:%v:permissions", u), + Version: 1, + Created: now, + Updated: now, + }) + userRoles = append(userRoles, accesscontrol.UserRole{ + ID: int64(u), + OrgID: 1, + RoleID: int64(u), + UserID: int64(u), + Created: now, + }) + + for r := 1; r < resourceCount+1; r++ { + permissions = append(permissions, accesscontrol.Permission{ + RoleID: int64(u), + Action: action2, + Scope: fmt.Sprintf("resources:id:%v", r), + Created: now, + Updated: now, + }) + } + } + + // Populate store + if err := batch(len(roles), batchSize, func(start, end int) error { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + if _, err := sess.Insert(users[start:end]); err != nil { + return err + } + if _, err := sess.Insert(orgUsers[start:end]); err != nil { + return err + } + if _, err := sess.Insert(roles[start:end]); err != nil { + return err + } + _, err := sess.Insert(userRoles[start:end]) + return err + }) + return err + }); err != nil { + require.NoError(b, err, "could not insert users and roles") + return nil, nil + } + if err := batch(len(permissions), batchSize, func(start, end int) error { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Insert(permissions[start:end]) + return err + }) + return err + }); err != nil { + require.NoError(b, err, "could not insert permissions") + return nil, nil + } + + // Allow signed in user to view all users permissions in the worst way + userPermissions := map[string][]string{} + for u := 1; u < usersCount+1; u++ { + userPermissions[accesscontrol.ActionUsersPermissionsRead] = + append(userPermissions[accesscontrol.ActionUsersPermissionsRead], fmt.Sprintf("users:id:%v", u)) + } + return acService, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: userPermissions}} +} + +func benchSearchUsersPermissions(b *testing.B, usersCount, resourceCount int) { + acService, siu := setupBenchEnv(b, usersCount, resourceCount) + b.ResetTimer() + + for n := 0; n < b.N; n++ { + usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu, 1, accesscontrol.SearchOptions{ActionPrefix: "resources:"}) + require.NoError(b, err) + require.Len(b, usersPermissions, usersCount) + for _, permissions := range usersPermissions { + // action1 on all resource + action2 + require.Len(b, permissions, resourceCount+1) + } + } +} + +// Lots of resources +func BenchmarkSearchUsersPermissions_10_1K(b *testing.B) { benchSearchUsersPermissions(b, 10, 1000) } // ~0.047s/op +func BenchmarkSearchUsersPermissions_10_10K(b *testing.B) { benchSearchUsersPermissions(b, 10, 10000) } // ~0.5s/op +func BenchmarkSearchUsersPermissions_10_100K(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10, 100000) +} // ~4.6s/op +func BenchmarkSearchUsersPermissions_10_1M(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10, 1000000) +} // ~55.36s/op + +// Lots of users (most probable case) +func BenchmarkSearchUsersPermissions_1K_10(b *testing.B) { benchSearchUsersPermissions(b, 1000, 10) } // ~0.056s/op +func BenchmarkSearchUsersPermissions_10K_10(b *testing.B) { benchSearchUsersPermissions(b, 10000, 10) } // ~0.58s/op +func BenchmarkSearchUsersPermissions_100K_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 100000, 10) +} // ~6.21s/op +func BenchmarkSearchUsersPermissions_1M_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 1000000, 10) +} // ~57s/op + +// Lots of both +func BenchmarkSearchUsersPermissions_10K_100(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10000, 100) +} // ~1.45s/op +func BenchmarkSearchUsersPermissions_10K_1K(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10000, 1000) +} // ~50s/op diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index c635d506e91..fd9d6bb24a8 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -12,8 +12,10 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" @@ -65,6 +67,7 @@ func TestUsageMetrics(t *testing.T) { db.InitTestDB(t), routing.NewRouteRegister(), localcache.ProvideService(), + actest.FakeAccessControl{}, featuremgmt.WithFeatures(), ) require.NoError(t, errInitAc) @@ -189,7 +192,7 @@ func TestService_DeclarePluginRoles(t *testing.T) { Role: plugins.Role{ Name: "Tester", Permissions: []plugins.Permission{ - {Action: "plugins.app:access"}, + {Action: "plugins.app:access", Scope: "plugins:id:test-app"}, {Action: "test-app:read"}, {Action: "test-app.resource:read"}, }, @@ -373,6 +376,153 @@ func TestService_RegisterFixedRoles(t *testing.T) { } } +func TestService_SearchUsersPermissions(t *testing.T) { + searchOption := accesscontrol.SearchOptions{ActionPrefix: "teams"} + ctx := context.Background() + listAllPerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:*"}} + listSomePerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:id:2"}} + tests := []struct { + name string + siuPermissions map[string][]string + ramRoles map[string]*accesscontrol.RoleDTO // BasicRole => RBAC BasicRole + storedPerms map[int64][]accesscontrol.Permission // UserID => Permissions + storedRoles map[int64][]string // UserID => Roles + want map[int64][]accesscontrol.Permission + wantErr bool + }{ + { + name: "ram only", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "stored only", + siuPermissions: listAllPerms, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "ram and stored", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "view permission on subset of users only", + siuPermissions: listSomePerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "check action filter on RAM permissions works correctly", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionUsersCreate}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{1: {accesscontrol.RoleGrafanaAdmin}}, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac := setupTestEnv(t) + + ac.roles = tt.ramRoles + ac.store = actest.FakeStore{ + ExpectedUsersPermissions: tt.storedPerms, + ExpectedUsersRoles: tt.storedRoles, + } + + siu := &user.SignedInUser{OrgID: 2, Permissions: map[int64]map[string][]string{2: tt.siuPermissions}} + got, err := ac.SearchUsersPermissions(ctx, siu, 2, searchOption) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + + require.Len(t, got, len(tt.want), "expected more users permissions") + for userID, wantPerm := range tt.want { + gotPerm, ok := got[userID] + require.True(t, ok, "expected permissions for user", userID) + + require.ElementsMatch(t, gotPerm, wantPerm) + } + }) + } +} + func TestPermissionCacheKey(t *testing.T) { testcases := []struct { name string diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index 66fffd38079..3f6bad09e01 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -11,9 +11,10 @@ var _ accesscontrol.Service = new(FakeService) var _ accesscontrol.RoleRegistry = new(FakeService) type FakeService struct { - ExpectedErr error - ExpectedDisabled bool - ExpectedPermissions []accesscontrol.Permission + ExpectedErr error + ExpectedDisabled bool + ExpectedPermissions []accesscontrol.Permission + ExpectedUsersPermissions map[int64][]accesscontrol.Permission } func (f FakeService) GetUsageStats(ctx context.Context) map[string]interface{} { @@ -24,6 +25,12 @@ func (f FakeService) GetUserPermissions(ctx context.Context, user *user.SignedIn return f.ExpectedPermissions, f.ExpectedErr } +func (f FakeService) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + return f.ExpectedUsersPermissions, f.ExpectedErr +} + +func (f FakeService) ClearUserPermissionCache(user *user.SignedInUser) {} + func (f FakeService) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { return f.ExpectedErr } @@ -58,3 +65,26 @@ func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolve func (f FakeAccessControl) IsDisabled() bool { return f.ExpectedDisabled } + +type FakeStore struct { + ExpectedUserPermissions []accesscontrol.Permission + ExpectedUsersPermissions map[int64][]accesscontrol.Permission + ExpectedUsersRoles map[int64][]string + ExpectedErr error +} + +func (f FakeStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) { + return f.ExpectedUserPermissions, f.ExpectedErr +} + +func (f FakeStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + return f.ExpectedUsersPermissions, f.ExpectedErr +} + +func (f FakeStore) GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) { + return f.ExpectedUsersRoles, f.ExpectedErr +} + +func (f FakeStore) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { + return f.ExpectedErr +} diff --git a/pkg/services/accesscontrol/api/api.go b/pkg/services/accesscontrol/api/api.go index 04e90e276c4..5c06a9ef239 100644 --- a/pkg/services/accesscontrol/api/api.go +++ b/pkg/services/accesscontrol/api/api.go @@ -8,25 +8,36 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) -func NewAccessControlAPI(router routing.RouteRegister, service ac.Service) *AccessControlAPI { +func NewAccessControlAPI(router routing.RouteRegister, accesscontrol ac.AccessControl, service ac.Service, + features *featuremgmt.FeatureManager) *AccessControlAPI { return &AccessControlAPI{ RouteRegister: router, Service: service, + AccessControl: accesscontrol, + features: features, } } type AccessControlAPI struct { Service ac.Service + AccessControl ac.AccessControl RouteRegister routing.RouteRegister + features *featuremgmt.FeatureManager } func (api *AccessControlAPI) RegisterAPIEndpoints() { + authorize := ac.Middleware(api.AccessControl) // Users api.RouteRegister.Group("/api/access-control", func(rr routing.RouteRegister) { rr.Get("/user/actions", middleware.ReqSignedIn, routing.Wrap(api.getUserActions)) rr.Get("/user/permissions", middleware.ReqSignedIn, routing.Wrap(api.getUserPermissions)) + if api.features.IsEnabled(featuremgmt.FlagAccessControlOnCall) { + rr.Get("/users/permissions/search", authorize(middleware.ReqSignedIn, + ac.EvalPermission(ac.ActionUsersPermissionsRead)), routing.Wrap(api.SearchUsersPermissions)) + } }) } @@ -53,3 +64,30 @@ func (api *AccessControlAPI) getUserPermissions(c *models.ReqContext) response.R return response.JSON(http.StatusOK, ac.GroupScopesByAction(permissions)) } + +// GET /api/access-control/users/permissions +func (api *AccessControlAPI) SearchUsersPermissions(c *models.ReqContext) response.Response { + searchOptions := ac.SearchOptions{ + ActionPrefix: c.Query("actionPrefix"), + Action: c.Query("action"), + Scope: c.Query("scope"), + } + + // Validate inputs + if (searchOptions.ActionPrefix != "") == (searchOptions.Action != "") { + return response.JSON(http.StatusBadRequest, "provide one of 'action' or 'actionPrefix'") + } + + // Compute metadata + permissions, err := api.Service.SearchUsersPermissions(c.Req.Context(), c.SignedInUser, c.OrgID, searchOptions) + if err != nil { + return response.Error(http.StatusInternalServerError, "could not get org user permissions", err) + } + + permsByAction := map[int64]map[string][]string{} + for userID, userPerms := range permissions { + permsByAction[userID] = ac.GroupScopesByAction(userPerms) + } + + return response.JSON(http.StatusOK, permsByAction) +} diff --git a/pkg/services/accesscontrol/api/api_test.go b/pkg/services/accesscontrol/api/api_test.go index 3be8ee82c30..13a2f6161fc 100644 --- a/pkg/services/accesscontrol/api/api_test.go +++ b/pkg/services/accesscontrol/api/api_test.go @@ -9,6 +9,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web/webtest" @@ -38,7 +39,7 @@ func TestAPI_getUserActions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { acSvc := actest.FakeService{ExpectedPermissions: tt.permissions} - api := NewAccessControlAPI(routing.NewRouteRegister(), acSvc) + api := NewAccessControlAPI(routing.NewRouteRegister(), actest.FakeAccessControl{}, acSvc, featuremgmt.WithFeatures()) api.RegisterAPIEndpoints() server := webtest.NewServer(t, api.RouteRegister) @@ -91,7 +92,7 @@ func TestAPI_getUserPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { acSvc := actest.FakeService{ExpectedPermissions: tt.permissions} - api := NewAccessControlAPI(routing.NewRouteRegister(), acSvc) + api := NewAccessControlAPI(routing.NewRouteRegister(), actest.FakeAccessControl{}, acSvc, featuremgmt.WithFeatures()) api.RegisterAPIEndpoints() server := webtest.NewServer(t, api.RouteRegister) diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index a53674c9bd5..eed62580b2b 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -55,6 +55,110 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces return result, err } +// SearchUsersPermissions returns the list of user permissions indexed by UserID +func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + type UserRBACPermission struct { + UserID int64 `xorm:"user_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + dbPerms := make([]UserRBACPermission, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Find permissions + q := ` + SELECT + user_id, + action, + scope + FROM ( + SELECT ur.user_id, ur.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN user_role AS ur on ur.role_id = p.role_id + UNION ALL + SELECT tm.user_id, tr.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN team_role AS tr ON tr.role_id = p.role_id + INNER JOIN team_member AS tm ON tm.team_id = tr.team_id + UNION ALL + SELECT ou.user_id, br.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN builtin_role AS br ON br.role_id = p.role_id + INNER JOIN org_user AS ou ON ou.role = br.role + UNION ALL + SELECT sa.user_id, br.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN builtin_role AS br ON br.role_id = p.role_id + INNER JOIN ( + SELECT u.id AS user_id + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u WHERE u.is_admin + ) AS sa ON 1 = 1 + WHERE br.role = ? + ) AS up + WHERE (org_id = ? OR org_id = ?) + ` + params := []interface{}{accesscontrol.RoleGrafanaAdmin, accesscontrol.GlobalOrgID, orgID} + + if options.ActionPrefix != "" { + q += ` AND action LIKE ?` + params = append(params, options.ActionPrefix+"%") + } + if options.Action != "" { + q += ` AND action = ?` + params = append(params, options.Action) + } + if options.Scope != "" { + q += ` AND scope = ?` + params = append(params, options.Scope) + } + + return sess.SQL(q, params...). + Find(&dbPerms) + }); err != nil { + return nil, err + } + + mapped := map[int64][]accesscontrol.Permission{} + for i := range dbPerms { + mapped[dbPerms[i].UserID] = append(mapped[dbPerms[i].UserID], accesscontrol.Permission{Action: dbPerms[i].Action, Scope: dbPerms[i].Scope}) + } + + return mapped, nil +} + +// GetUsersBasicRoles returns the list of user basic roles (Admin, Editor, Viewer, Grafana Admin) indexed by UserID +func (s *AccessControlStore) GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) { + type UserOrgRole struct { + UserID int64 `xorm:"id"` + OrgRole string `xorm:"role"` + IsAdmin bool `xorm:"is_admin"` + } + dbRoles := make([]UserOrgRole, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Find roles + q := ` + SELECT u.id, ou.role, u.is_admin + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u + LEFT JOIN org_user AS ou ON u.id = ou.user_id + WHERE u.is_admin OR ou.org_id = ? + ` + + return sess.SQL(q, orgID).Find(&dbRoles) + }); err != nil { + return nil, err + } + + roles := map[int64][]string{} + for i := range dbRoles { + if dbRoles[i].OrgRole != "" { + roles[dbRoles[i].UserID] = []string{dbRoles[i].OrgRole} + } + if dbRoles[i].IsAdmin { + roles[dbRoles[i].UserID] = append(roles[dbRoles[i].UserID], accesscontrol.RoleGrafanaAdmin) + } + } + return roles, nil +} + func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM user_role WHERE user_id = ?" diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 8ea2c933d82..57c482c0f91 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -2,20 +2,24 @@ package database import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" rs "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) type getUserPermissionsTestCase struct { @@ -82,7 +86,7 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - store, permissionStore, sql, teamSvc := setupTestEnv(t) + store, permissionStore, sql, teamSvc, _ := setupTestEnv(t) user, team := createUserAndTeam(t, sql, teamSvc, tt.orgID) @@ -145,7 +149,7 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { t.Run("expect permissions in all orgs to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc := setupTestEnv(t) + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, teamSvc, 1) // generate permissions in org 1 @@ -185,7 +189,7 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) t.Run("expect permissions in org 1 to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc := setupTestEnv(t) + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, teamSvc, 1) // generate permissions in org 1 @@ -225,10 +229,10 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) } -func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, teamSvc team.Service, orgID int64) (*user.User, models.Team) { +func createUserAndTeam(t *testing.T, userSrv user.Service, teamSvc team.Service, orgID int64) (*user.User, models.Team) { t.Helper() - user, err := sql.CreateUser(context.Background(), user.CreateUserCommand{ + user, err := userSrv.Create(context.Background(), &user.CreateUserCommand{ Login: "user", OrgID: orgID, }) @@ -243,10 +247,339 @@ func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, teamSvc team.Servic return user, team } -func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, *sqlstore.SQLStore, team.Service) { +type helperServices struct { + userSvc user.Service + teamSvc team.Service + orgSvc org.Service +} + +type testUser struct { + orgRole org.RoleType + isAdmin bool +} + +type dbUser struct { + userID int64 + teamID int64 +} + +func createUsersAndTeams(t *testing.T, svcs helperServices, orgID int64, users []testUser) []dbUser { + t.Helper() + res := []dbUser{} + + for i := range users { + user, err := svcs.userSvc.Create(context.Background(), &user.CreateUserCommand{ + Login: fmt.Sprintf("user%v", i+1), + OrgID: orgID, + IsAdmin: users[i].isAdmin, + }) + require.NoError(t, err) + + // User is not member of the org + if users[i].orgRole == "" { + err = svcs.orgSvc.RemoveOrgUser(context.Background(), + &org.RemoveOrgUserCommand{OrgID: orgID, UserID: user.ID}) + require.NoError(t, err) + + res = append(res, dbUser{userID: user.ID}) + continue + } + + team, err := svcs.teamSvc.CreateTeam(fmt.Sprintf("team%v", i+1), "", orgID) + require.NoError(t, err) + + err = svcs.teamSvc.AddTeamMember(user.ID, orgID, team.Id, false, models.PERMISSION_VIEW) + require.NoError(t, err) + + err = svcs.orgSvc.UpdateOrgUser(context.Background(), + &org.UpdateOrgUserCommand{Role: users[i].orgRole, OrgID: orgID, UserID: user.ID}) + require.NoError(t, err) + + res = append(res, dbUser{userID: user.ID, teamID: team.Id}) + } + + return res +} + +func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, user.Service, team.Service, org.Service) { sql, cfg := db.InitTestDBwithCfg(t) acstore := ProvideService(sql) permissionStore := rs.NewStore(sql) teamService := teamimpl.ProvideService(sql, cfg) - return acstore, permissionStore, sql, teamService + orgService, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userService, err := userimpl.ProvideService(sql, orgService, cfg, teamService, localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) + return acstore, permissionStore, userService, teamService, orgService +} + +func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { + ctx := context.Background() + readTeamPerm := func(teamID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"teams:read"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: teamID, + } + } + writeTeamPerm := func(teamID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"teams:read", "teams:write"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: teamID, + } + } + readDashPerm := func(dashUID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"dashboards:read"}, + Resource: "dashboards", + ResourceAttribute: "uid", + ResourceID: dashUID, + } + } + tests := []struct { + name string + users []testUser + permCmds []rs.SetResourcePermissionsCommand + options accesscontrol.SearchOptions + wantPerm map[int64][]accesscontrol.Permission + wantErr bool + }{ + { + name: "user assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "users assignment by actionPrefix", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: false}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + {User: accesscontrol.User{ID: 2, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:write", Scope: "teams:id:1"}}, + 2: {{Action: "teams:read", Scope: "teams:id:2"}}, + }, + }, + { + name: "team assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{{TeamID: 1, SetResourcePermissionCommand: readTeamPerm("1")}}, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "basic role assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "server admin assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: true}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "all assignments by actionPrefix", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: true}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + permCmds: []rs.SetResourcePermissionsCommand{ + // User assignments + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 2, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + // Team assignments + {TeamID: 1, SetResourcePermissionCommand: readTeamPerm("10")}, + {TeamID: 2, SetResourcePermissionCommand: readTeamPerm("20")}, + // Basic Assignments + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("100")}, + {BuiltinRole: string(org.RoleEditor), SetResourcePermissionCommand: readTeamPerm("200")}, + // Server Admin Assignment + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1000")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, + {Action: "teams:read", Scope: "teams:id:100"}, {Action: "teams:read", Scope: "teams:id:1000"}}, + 2: {{Action: "teams:read", Scope: "teams:id:2"}, {Action: "teams:read", Scope: "teams:id:20"}, + {Action: "teams:read", Scope: "teams:id:200"}}, + }, + }, + { + name: "filter permissions by action prefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: true}}, + permCmds: []rs.SetResourcePermissionsCommand{ + // User assignments + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readDashPerm("d1")}, + // Team assignments + {TeamID: 1, SetResourcePermissionCommand: readTeamPerm("10")}, + {TeamID: 1, SetResourcePermissionCommand: readDashPerm("d10")}, + // Basic Assignments + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("100")}, + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readDashPerm("d100")}, + // Server Admin Assignment + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1000")}, + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readDashPerm("d1000")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, + {Action: "teams:read", Scope: "teams:id:100"}, {Action: "teams:read", Scope: "teams:id:1000"}}, + }, + }, + { + name: "include not org member server admin permissions by actionPrefix", + // Three users, one member, one not member but Server Admin, one not member and not server admin + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}, {isAdmin: true}, {}}, + permCmds: []rs.SetResourcePermissionsCommand{{BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1")}}, + wantPerm: map[int64][]accesscontrol.Permission{ + 2: {{Action: "teams:read", Scope: "teams:id:1"}}, + }, + }, + { + name: "user assignment by action", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{Action: "teams:read"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:read", Scope: "teams:id:2"}}, + }, + }, + { + name: "user assignment by scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:write", Scope: "teams:id:1"}, + }}, + }, + { + name: "user assignment by action and scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{Action: "teams:read", Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acStore, permissionsStore, userSvc, teamSvc, orgSvc := setupTestEnv(t) + dbUsers := createUsersAndTeams(t, helperServices{userSvc, teamSvc, orgSvc}, 1, tt.users) + + // Switch userID and TeamID by the real stored ones + for i := range tt.permCmds { + if tt.permCmds[i].User.ID != 0 { + tt.permCmds[i].User.ID = dbUsers[tt.permCmds[i].User.ID-1].userID + } + if tt.permCmds[i].TeamID != 0 { + tt.permCmds[i].TeamID = dbUsers[tt.permCmds[i].TeamID-1].teamID + } + } + _, err := permissionsStore.SetResourcePermissions(ctx, 1, tt.permCmds, rs.ResourceHooks{}) + require.NoError(t, err) + + // Test + dbPermissions, err := acStore.SearchUsersPermissions(ctx, 1, tt.options) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + require.Len(t, dbPermissions, len(tt.wantPerm)) + + for userID, expectedUserPerms := range tt.wantPerm { + dbUserPerms, ok := dbPermissions[dbUsers[userID-1].userID] + require.True(t, ok, "expected permissions for user", userID) + require.ElementsMatch(t, expectedUserPerms, dbUserPerms) + } + }) + } +} + +func TestAccessControlStore_GetUsersBasicRoles(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + users []testUser + wantRoles map[int64][]string + wantErr bool + }{ + { + name: "user with basic role", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + wantRoles: map[int64][]string{1: {string(org.RoleAdmin)}}, + }, + { + name: "one admin, one editor", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: false}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + wantRoles: map[int64][]string{ + 1: {string(org.RoleAdmin)}, + 2: {string(org.RoleEditor)}, + }, + }, + { + name: "one org member, one not member but Server Admin, one not member and not server admin", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}, {isAdmin: true}, {}}, + wantRoles: map[int64][]string{ + 1: {string(org.RoleAdmin)}, + 2: {accesscontrol.RoleGrafanaAdmin}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acStore, _, userSvc, teamSvc, orgSvc := setupTestEnv(t) + dbUsers := createUsersAndTeams(t, helperServices{userSvc, teamSvc, orgSvc}, 1, tt.users) + + // Test + dbRoles, err := acStore.GetUsersBasicRoles(ctx, 1) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + require.Len(t, dbRoles, len(tt.wantRoles)) + + for userID, expectedUserRoles := range tt.wantRoles { + dbUserRoles, ok := dbRoles[dbUsers[userID-1].userID] + require.True(t, ok, "expected organization role for user", userID) + require.ElementsMatch(t, expectedUserRoles, dbUserRoles) + } + }) + } } diff --git a/pkg/services/accesscontrol/errors.go b/pkg/services/accesscontrol/errors.go index 91f06a38440..27047abb596 100644 --- a/pkg/services/accesscontrol/errors.go +++ b/pkg/services/accesscontrol/errors.go @@ -44,3 +44,17 @@ func (e *ErrorActionPrefixMissing) Error() string { func (e *ErrorActionPrefixMissing) Unwrap() error { return &ErrorInvalidRole{} } + +type ErrorScopeTarget struct { + Action string + Scope string + ExpectedScope string +} + +func (e *ErrorScopeTarget) Error() string { + return fmt.Sprintf("expected action '%s' to be scoped with '%v', found '%v'", e.Action, e.ExpectedScope, e.Scope) +} + +func (e *ErrorScopeTarget) Unwrap() error { + return &ErrorInvalidRole{} +} diff --git a/pkg/services/accesscontrol/evaluator.go b/pkg/services/accesscontrol/evaluator.go index 67f43fc0495..fe84337f617 100644 --- a/pkg/services/accesscontrol/evaluator.go +++ b/pkg/services/accesscontrol/evaluator.go @@ -124,7 +124,7 @@ func (a allEvaluator) Evaluate(permissions map[string][]string) bool { } func (a allEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) { - var modified []Evaluator + modified := make([]Evaluator, 0, len(a.allOf)) for _, e := range a.allOf { i, err := e.MutateScopes(ctx, mutate) if err != nil { @@ -174,7 +174,7 @@ func (a anyEvaluator) Evaluate(permissions map[string][]string) bool { } func (a anyEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) { - var modified []Evaluator + modified := make([]Evaluator, 0, len(a.anyOf)) for _, e := range a.anyOf { i, err := e.MutateScopes(ctx, mutate) if err != nil { diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index a06beb9df3b..c1f3e944b2f 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -20,6 +20,7 @@ type fullAccessControl interface { type Calls struct { Evaluate []interface{} GetUserPermissions []interface{} + ClearUserPermissionCache []interface{} IsDisabled []interface{} DeclareFixedRoles []interface{} DeclarePluginRoles []interface{} @@ -27,6 +28,7 @@ type Calls struct { RegisterFixedRoles []interface{} RegisterAttributeScopeResolver []interface{} DeleteUserPermissions []interface{} + SearchUsersPermissions []interface{} } type Mock struct { @@ -43,6 +45,7 @@ type Mock struct { // Override functions EvaluateFunc func(context.Context, *user.SignedInUser, accesscontrol.Evaluator) (bool, error) GetUserPermissionsFunc func(context.Context, *user.SignedInUser, accesscontrol.Options) ([]accesscontrol.Permission, error) + ClearUserPermissionCacheFunc func(*user.SignedInUser) IsDisabledFunc func() bool DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error DeclarePluginRolesFunc func(context.Context, string, string, []plugins.RoleRegistration) error @@ -50,6 +53,7 @@ type Mock struct { RegisterFixedRolesFunc func() error RegisterScopeAttributeResolverFunc func(string, accesscontrol.ScopeAttributeResolver) DeleteUserPermissionsFunc func(context.Context, int64) error + SearchUsersPermissionsFunc func(context.Context, *user.SignedInUser, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) scopeResolvers accesscontrol.Resolvers } @@ -138,6 +142,14 @@ func (m *Mock) GetUserPermissions(ctx context.Context, user *user.SignedInUser, return m.permissions, nil } +func (m *Mock) ClearUserPermissionCache(user *user.SignedInUser) { + m.Calls.ClearUserPermissionCache = append(m.Calls.ClearUserPermissionCache, []interface{}{user}) + // Use override if provided + if m.ClearUserPermissionCacheFunc != nil { + m.ClearUserPermissionCacheFunc(user) + } +} + // Middleware checks if service disabled or not to switch to fallback authorization. // This mock return m.disabled unless an override is provided. func (m *Mock) IsDisabled() bool { @@ -202,3 +214,13 @@ func (m *Mock) DeleteUserPermissions(ctx context.Context, orgID, userID int64) e } return nil } + +// GetSimplifiedUsersPermissions returns all users' permissions filtered by an action prefix +func (m *Mock) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + m.Calls.SearchUsersPermissions = append(m.Calls.SearchUsersPermissions, []interface{}{ctx, user, orgID, options}) + // Use override if provided + if m.SearchUsersPermissionsFunc != nil { + return m.SearchUsersPermissionsFunc(ctx, user, orgID, options) + } + return nil, nil +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 143e9f8845a..e32bb26a2b7 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -309,6 +309,7 @@ const ( ActionUsersLogout = "users:logout" ActionUsersQuotasList = "users.quotas:read" ActionUsersQuotasUpdate = "users.quotas:write" + ActionUsersPermissionsRead = "users.permissions:read" // Org actions ActionOrgsRead = "orgs:read" diff --git a/pkg/services/accesscontrol/pluginutils/utils.go b/pkg/services/accesscontrol/pluginutils/utils.go index eac5effdcf0..b246706668f 100644 --- a/pkg/services/accesscontrol/pluginutils/utils.go +++ b/pkg/services/accesscontrol/pluginutils/utils.go @@ -17,6 +17,11 @@ func ValidatePluginPermissions(pluginID string, permissions []ac.Permission) err return &ac.ErrorActionPrefixMissing{Action: permissions[i].Action, Prefixes: []string{plugins.ActionAppAccess, pluginID + ":", pluginID + "."}} } + if strings.HasPrefix(permissions[i].Action, plugins.ActionAppAccess) && + permissions[i].Scope != plugins.ScopeProvider.GetResourceScope(pluginID) { + return &ac.ErrorScopeTarget{Action: permissions[i].Action, Scope: permissions[i].Scope, + ExpectedScope: plugins.ScopeProvider.GetResourceScope(pluginID)} + } } return nil diff --git a/pkg/services/accesscontrol/pluginutils/utils_test.go b/pkg/services/accesscontrol/pluginutils/utils_test.go index 8722c8e747a..0284ebbb49e 100644 --- a/pkg/services/accesscontrol/pluginutils/utils_test.go +++ b/pkg/services/accesscontrol/pluginutils/utils_test.go @@ -122,12 +122,23 @@ func TestValidatePluginRole(t *testing.T) { role: ac.RoleDTO{ Name: "plugins:test-app:reader", Permissions: []ac.Permission{ - {Action: "plugins.app:access"}, + {Action: "plugins.app:access", Scope: "plugins:id:test-app"}, {Action: "test-app:read"}, {Action: "test-app.resources:read"}, }, }, }, + { + name: "invalid permission targets other plugin", + pluginID: "test-app", + role: ac.RoleDTO{ + Name: "plugins:test-app:reader", + Permissions: []ac.Permission{ + {Action: "plugins.app:access", Scope: "plugins:id:other-app"}, + }, + }, + wantErr: &ac.ErrorInvalidRole{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/services/accesscontrol/resourcepermissions/service.go b/pkg/services/accesscontrol/resourcepermissions/service.go index c0f236c330a..9e57be89ffa 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service.go +++ b/pkg/services/accesscontrol/resourcepermissions/service.go @@ -53,7 +53,7 @@ func New( ac accesscontrol.AccessControl, service accesscontrol.Service, sqlStore db.DB, teamService team.Service, userService user.Service, ) (*Service, error) { - var permissions []string + permissions := make([]string, 0, len(options.PermissionsToActions)) actionSet := make(map[string]struct{}) for permission, actions := range options.PermissionsToActions { permissions = append(permissions, permission) diff --git a/pkg/services/accesscontrol/resourcepermissions/store.go b/pkg/services/accesscontrol/resourcepermissions/store.go index 0b9f6925a61..1ee3bba77ac 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store.go +++ b/pkg/services/accesscontrol/resourcepermissions/store.go @@ -608,7 +608,7 @@ func (s *store) createPermissions(sess *db.Session, roleID int64, resource, reso if len(actions) == 0 { return nil } - var permissions []accesscontrol.Permission + permissions := make([]accesscontrol.Permission, 0, len(actions)) for action := range actions { p := managedPermission(action, resource, resourceID, resourceAttribute) p.RoleID = roleID diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index a852d79e682..81affa327ab 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -10,8 +10,10 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" ) @@ -435,8 +437,10 @@ func TestIntegrationStore_GetResourcePermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { store, sql := setupTestEnv(t) + orgService, err := orgimpl.ProvideService(sql, sql.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) - err := sql.WithDbSession(context.Background(), func(sess *db.Session) error { + err = sql.WithDbSession(context.Background(), func(sess *db.Session) error { role := &accesscontrol.Role{ OrgID: tt.user.OrgID, UID: "seeded", @@ -471,7 +475,7 @@ func TestIntegrationStore_GetResourcePermissions(t *testing.T) { }) require.NoError(t, err) - seedResourcePermissions(t, store, sql, tt.query.Actions, tt.query.Resource, tt.query.ResourceID, tt.query.ResourceAttribute, tt.numUsers) + seedResourcePermissions(t, store, sql, orgService, tt.query.Actions, tt.query.Resource, tt.query.ResourceID, tt.query.ResourceAttribute, tt.numUsers) tt.query.User = tt.user permissions, err := store.GetResourcePermissions(context.Background(), tt.user.OrgID, tt.query) @@ -481,21 +485,20 @@ func TestIntegrationStore_GetResourcePermissions(t *testing.T) { } } -func seedResourcePermissions(t *testing.T, store *store, sql *sqlstore.SQLStore, actions []string, resource, resourceID, resourceAttribute string, numUsers int) { +func seedResourcePermissions(t *testing.T, store *store, sql *sqlstore.SQLStore, orgService org.Service, actions []string, resource, resourceID, resourceAttribute string, numUsers int) { t.Helper() - var org *models.Org + var orgModel *org.Org for i := 0; i < numUsers; i++ { - if org == nil { - cmd := &models.CreateOrgCommand{Name: "test", UserId: int64(i)} - err := sql.CreateOrg(context.Background(), cmd) + if orgModel == nil { + cmd := &org.CreateOrgCommand{Name: "test", UserID: int64(i)} + addedOrg, err := orgService.CreateWithMember(context.Background(), cmd) require.NoError(t, err) - addedOrg := cmd.Result - org = &addedOrg + orgModel = addedOrg } u, err := sql.CreateUser(context.Background(), user.CreateUserCommand{ Login: fmt.Sprintf("user:%s%d", resourceID, i), - OrgID: org.Id, + OrgID: orgModel.ID, }) require.NoError(t, err) diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index e906f9f9c11..67beeb00fb3 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -71,6 +71,10 @@ var ( Action: ActionOrgUsersRead, Scope: ScopeUsersAll, }, + { + Action: ActionUsersPermissionsRead, + Scope: ScopeUsersAll, + }, }, } diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index 28d17307047..3d1f381ea80 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "fmt" "net" "github.com/grafana/grafana/pkg/models/usertoken" @@ -18,10 +19,14 @@ const ( // Typed errors var ( - ErrUserTokenNotFound = errors.New("user token not found") + ErrUserTokenNotFound = errors.New("user token not found") + ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken ) -type TokenRevokedError = usertoken.TokenRevokedError +type ( + TokenRevokedError = usertoken.TokenRevokedError + UserToken = usertoken.UserToken +) // CreateTokenErr represents a token creation error; used in Enterprise type CreateTokenErr struct { @@ -42,9 +47,11 @@ type TokenExpiredError struct { TokenID int64 } -func (e *TokenExpiredError) Error() string { return "user token expired" } +func (e *TokenExpiredError) Unwrap() error { return ErrInvalidSessionToken } -type UserToken = usertoken.UserToken +func (e *TokenExpiredError) Error() string { + return fmt.Sprintf("%s: user token expired", ErrInvalidSessionToken) +} type RevokeAuthTokenCmd struct { AuthTokenId int64 `json:"authTokenId"` diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go new file mode 100644 index 00000000000..ac9b9b64a63 --- /dev/null +++ b/pkg/services/authn/authn.go @@ -0,0 +1,4 @@ +package authn + +type Service interface { +} diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go new file mode 100644 index 00000000000..cb6e846aad8 --- /dev/null +++ b/pkg/services/authn/authnimpl/service.go @@ -0,0 +1,8 @@ +package authnimpl + +import "github.com/grafana/grafana/pkg/services/authn" + +var _ authn.Service = new(Service) + +type Service struct { +} diff --git a/pkg/services/authn/authntest/fake.go b/pkg/services/authn/authntest/fake.go new file mode 100644 index 00000000000..c1e306ac15d --- /dev/null +++ b/pkg/services/authn/authntest/fake.go @@ -0,0 +1,7 @@ +package authntest + +import "github.com/grafana/grafana/pkg/services/authn" + +type FakeService struct { + authn.Service +} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index f09015707d3..0b2c3de850e 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -429,9 +429,12 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org token, err := h.AuthTokenService.LookupToken(ctx, rawToken) if err != nil { - reqContext.Logger.Warn("Failed to look up user based on cookie", "error", err) - // Burn the cookie in case of failure - reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) + reqContext.Logger.Warn("failed to look up session from cookie", "error", err) + if errors.Is(err, auth.ErrUserTokenNotFound) || errors.Is(err, auth.ErrInvalidSessionToken) { + // Burn the cookie in case of invalid, expired or missing token + reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) + } + reqContext.LookupTokenErr = err return false diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index e2ae5c80bcd..3464677f40e 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -255,4 +255,26 @@ func TestDashboardService(t *testing.T) { err := service.DeleteACLByUser(context.Background(), 1) require.NoError(t, err) }) + + t.Run("When org user is deleted", func(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + fakeStore.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*models.GetDashboardACLInfoListQuery")).Return(nil) + t.Run("Should remove dependent permissions for deleted org user", func(t *testing.T) { + permQuery := &models.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: 1, Result: nil} + + err := fakeStore.GetDashboardACLInfoList(context.Background(), permQuery) + require.NoError(t, err) + + require.Equal(t, len(permQuery.Result), 0) + }) + + t.Run("Should not remove dashboard permissions for same user in another org", func(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + fakeStore.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*models.GetDashboardACLInfoListQuery")).Return(nil) + permQuery := &models.GetDashboardACLInfoListQuery{DashboardID: 2, OrgID: 3} + + err := fakeStore.GetDashboardACLInfoList(context.Background(), permQuery) + require.NoError(t, err) + }) + }) } diff --git a/pkg/services/datasources/service/cache.go b/pkg/services/datasources/service/cache.go index 3b87f1bdb8f..dff1551101b 100644 --- a/pkg/services/datasources/service/cache.go +++ b/pkg/services/datasources/service/cache.go @@ -49,7 +49,7 @@ func (dc *CacheServiceImpl) GetDatasource( } } - dc.logger.Debug("Querying for data source via SQL store", "id", datasourceID, "orgId", user.OrgID) + dc.logger.FromContext(ctx).Debug("Querying for data source via SQL store", "id", datasourceID, "orgId", user.OrgID) query := &datasources.GetDataSourceQuery{Id: datasourceID, OrgId: user.OrgID} ss := SqlStore{db: dc.SQLStore, logger: dc.logger} @@ -90,7 +90,7 @@ func (dc *CacheServiceImpl) GetDatasourceByUID( } } - dc.logger.Debug("Querying for data source via SQL store", "uid", datasourceUID, "orgId", user.OrgID) + dc.logger.FromContext(ctx).Debug("Querying for data source via SQL store", "uid", datasourceUID, "orgId", user.OrgID) query := &datasources.GetDataSourceQuery{Uid: datasourceUID, OrgId: user.OrgID} ss := SqlStore{db: dc.SQLStore, logger: dc.logger} err := ss.GetDataSource(ctx, query) diff --git a/pkg/services/export/object_store.go b/pkg/services/export/entity_store.go similarity index 84% rename from pkg/services/export/object_store.go rename to pkg/services/export/entity_store.go index 10d3ec96719..630e683cec9 100644 --- a/pkg/services/export/object_store.go +++ b/pkg/services/export/entity_store.go @@ -13,14 +13,14 @@ import ( "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind/snapshot" - "github.com/grafana/grafana/pkg/services/store/object" "github.com/grafana/grafana/pkg/services/user" ) -var _ Job = new(objectStoreJob) +var _ Job = new(entityStoreJob) -type objectStoreJob struct { +type entityStoreJob struct { logger log.Logger statusMu sync.Mutex @@ -32,19 +32,19 @@ type objectStoreJob struct { sess *session.SessionDB playlistService playlist.Service - store object.ObjectStoreServer + store entity.EntityStoreServer dashboardsnapshots dashboardsnapshots.Service } -func startObjectStoreJob(ctx context.Context, +func startEntityStoreJob(ctx context.Context, cfg ExportConfig, broadcaster statusBroadcaster, db db.DB, playlistService playlist.Service, - store object.ObjectStoreServer, + store entity.EntityStoreServer, dashboardsnapshots dashboardsnapshots.Service, ) (Job, error) { - job := &objectStoreJob{ + job := &entityStoreJob{ logger: log.New("export_to_object_store_job"), cfg: cfg, ctx: ctx, @@ -67,11 +67,11 @@ func startObjectStoreJob(ctx context.Context, return job, nil } -func (e *objectStoreJob) requestStop() { +func (e *entityStoreJob) requestStop() { e.stopRequested = true } -func (e *objectStoreJob) start(ctx context.Context) { +func (e *entityStoreJob) start(ctx context.Context) { defer func() { e.logger.Info("Finished dummy export job") @@ -123,11 +123,10 @@ func (e *objectStoreJob) start(ctx context.Context) { rowUser.UserID = 0 // avoid Uint64Val issue???? } - _, err = e.store.AdminWrite(ctx, &object.AdminWriteObjectRequest{ - GRN: &object.GRN{ - Scope: models.ObjectStoreScopeEntity, - UID: dash.UID, - Kind: models.StandardKindDashboard, + _, err = e.store.AdminWrite(ctx, &entity.AdminWriteEntityRequest{ + GRN: &entity.GRN{ + UID: dash.UID, + Kind: models.StandardKindDashboard, }, ClearHistory: true, Version: fmt.Sprintf("%d", dash.Version), @@ -135,9 +134,11 @@ func (e *objectStoreJob) start(ctx context.Context) { UpdatedAt: dash.Updated.UnixMilli(), UpdatedBy: fmt.Sprintf("user:%d", dash.UpdatedBy), CreatedBy: fmt.Sprintf("user:%d", dash.CreatedBy), - Origin: "export-from-sql", Body: dash.Data, Comment: "(exported from SQL)", + Origin: &entity.EntityOriginInfo{ + Source: "export-from-sql", + }, }) if err != nil { e.status.Status = "error: " + err.Error() @@ -173,11 +174,10 @@ func (e *objectStoreJob) start(ctx context.Context) { return } - _, err = e.store.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - Scope: models.ObjectStoreScopeEntity, - UID: playlist.Uid, - Kind: models.StandardKindPlaylist, + _, err = e.store.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + UID: playlist.Uid, + Kind: models.StandardKindPlaylist, }, Body: prettyJSON(playlist), Comment: "export from playlists", @@ -238,11 +238,10 @@ func (e *objectStoreJob) start(ctx context.Context) { m.Snapshot = b } - _, err = e.store.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - Scope: models.ObjectStoreScopeEntity, - UID: dto.Key, - Kind: models.StandardKindSnapshot, + _, err = e.store.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + UID: dto.Key, + Kind: models.StandardKindSnapshot, }, Body: prettyJSON(m), Comment: "export from snapshtts", @@ -273,7 +272,7 @@ type dashInfo struct { } // TODO, paging etc -func (e *objectStoreJob) getDashboards(ctx context.Context) ([]dashInfo, error) { +func (e *entityStoreJob) getDashboards(ctx context.Context) ([]dashInfo, error) { e.status.Last = "find dashbaords...." e.broadcaster(e.status) @@ -282,14 +281,14 @@ func (e *objectStoreJob) getDashboards(ctx context.Context) ([]dashInfo, error) return dash, err } -func (e *objectStoreJob) getStatus() ExportStatus { +func (e *entityStoreJob) getStatus() ExportStatus { e.statusMu.Lock() defer e.statusMu.Unlock() return e.status } -func (e *objectStoreJob) getConfig() ExportConfig { +func (e *entityStoreJob) getConfig() ExportConfig { e.statusMu.Lock() defer e.statusMu.Unlock() diff --git a/pkg/services/export/export_dash.go b/pkg/services/export/export_dash.go index 574c83d046f..976abdb8a9a 100644 --- a/pkg/services/export/export_dash.go +++ b/pkg/services/export/export_dash.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/filestorage" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store/kind/dashboard" ) @@ -31,7 +30,7 @@ func exportDashboards(helper *commitHelper, job *gitExportJob) error { return err } - rootDir := path.Join(helper.orgDir, models.ObjectStoreScopeDrive) + rootDir := path.Join(helper.orgDir, "drive") folderStructure := commitOptions{ when: time.Now(), comment: "Exported folder structure", diff --git a/pkg/services/export/export_sys_playlists.go b/pkg/services/export/export_sys_playlists.go index 5faf70414aa..285f8b85ce9 100644 --- a/pkg/services/export/export_sys_playlists.go +++ b/pkg/services/export/export_sys_playlists.go @@ -40,7 +40,7 @@ func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error { gitcmd.body = append(gitcmd.body, commitBody{ fpath: filepath.Join( helper.orgDir, - models.ObjectStoreScopeEntity, + "entity", models.StandardKindPlaylist, fmt.Sprintf("%s.json", playlist.Uid)), body: prettyJSON(playlist), diff --git a/pkg/services/export/service.go b/pkg/services/export/service.go index 9867dfc279a..a1e7950e784 100644 --- a/pkg/services/export/service.go +++ b/pkg/services/export/service.go @@ -21,7 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/store" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/setting" ) @@ -159,7 +159,7 @@ type StandardExport struct { playlistService playlist.Service orgService org.Service datasourceService datasources.DataSourceService - store object.ObjectStoreServer + store entity.EntityStoreServer // updated with mutex exportJob Job @@ -167,7 +167,7 @@ type StandardExport struct { func ProvideService(db db.DB, features featuremgmt.FeatureToggles, gl *live.GrafanaLive, cfg *setting.Cfg, dashboardsnapshotsService dashboardsnapshots.Service, playlistService playlist.Service, orgService org.Service, - datasourceService datasources.DataSourceService, store object.ObjectStoreServer) ExportService { + datasourceService datasources.DataSourceService, store entity.EntityStoreServer) ExportService { if !features.IsEnabled(featuremgmt.FlagExport) { return &StubExport{} } @@ -233,8 +233,8 @@ func (ex *StandardExport) HandleRequestExport(c *models.ReqContext) response.Res switch cfg.Format { case "dummy": job, err = startDummyExportJob(cfg, broadcast) - case "objectStore": - job, err = startObjectStoreJob(ctx, cfg, broadcast, ex.db, ex.playlistService, ex.store, ex.dashboardsnapshotsService) + case "entityStore": + job, err = startEntityStoreJob(ctx, cfg, broadcast, ex.db, ex.playlistService, ex.store, ex.dashboardsnapshotsService) case "git": dir := filepath.Join(ex.dataDir, "export_git", fmt.Sprintf("git_%d", time.Now().Unix())) if err := os.MkdirAll(dir, os.ModePerm); err != nil { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b08a0bc4a34..6528068dcfd 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -16,7 +16,7 @@ var ( }, { Name: "promQueryBuilder", - Description: "Show prometheus query builder", + Description: "Show Prometheus query builder", State: FeatureStateStable, Expression: "true", // on by default FrontendOnly: true, @@ -33,7 +33,7 @@ var ( }, { Name: "database_metrics", - Description: "Add prometheus metrics for database tables", + Description: "Add Prometheus metrics for database tables", State: FeatureStateStable, }, { @@ -49,12 +49,12 @@ var ( }, { Name: "live-config", - Description: "Save grafana live configuration in SQL tables", + Description: "Save Grafana Live configuration in SQL tables", State: FeatureStateAlpha, }, { Name: "live-pipeline", - Description: "enable a generic live processing pipeline", + Description: "Enable a generic live processing pipeline", State: FeatureStateAlpha, }, { @@ -65,14 +65,14 @@ var ( }, { Name: "queryOverLive", - Description: "Use grafana live websocket to execute backend queries", + Description: "Use Grafana Live WebSocket to execute backend queries", State: FeatureStateAlpha, FrontendOnly: true, }, { Name: "panelTitleSearch", Description: "Search for dashboards using panel title", - State: FeatureStateAlpha, + State: FeatureStateBeta, }, { Name: "tempoApmTable", @@ -99,23 +99,23 @@ var ( }, { Name: "publicDashboards", - Description: "enables public access to dashboards", + Description: "Enables public access to dashboards", State: FeatureStateAlpha, }, { Name: "lokiLive", - Description: "support websocket streaming for loki (early prototype)", + Description: "Support WebSocket streaming for loki (early prototype)", State: FeatureStateAlpha, }, { Name: "lokiDataframeApi", - Description: "use experimental loki api for websocket streaming (early prototype)", + Description: "Use experimental loki api for WebSocket streaming (early prototype)", State: FeatureStateAlpha, }, { Name: "lokiMonacoEditor", Description: "Access to Monaco query editor for Loki", - State: FeatureStateAlpha, + State: FeatureStateStable, Expression: "true", FrontendOnly: true, }, @@ -126,7 +126,7 @@ var ( }, { Name: "featureHighlights", - Description: "Highlight Enterprise features", + Description: "Highlight Grafana Enterprise features", State: FeatureStateStable, }, { @@ -222,7 +222,7 @@ var ( { Name: "validateDashboardsOnSave", Description: "Validate dashboard JSON POSTed to api/dashboards/db", - State: FeatureStateAlpha, + State: FeatureStateBeta, RequiresRestart: true, }, { @@ -257,10 +257,11 @@ var ( { Name: "logRequestsInstrumentedAsUnknown", Description: "Logs the path for requests that are instrumented as unknown", + State: FeatureStateAlpha, }, { Name: "dataConnectionsConsole", - Description: "Enables a new top-level page called Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins.", + Description: "Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins.", State: FeatureStateAlpha, }, { @@ -280,8 +281,8 @@ var ( RequiresDevMode: true, }, { - Name: "objectStore", - Description: "SQL based object store", + Name: "entityStore", + Description: "SQL-based entity store (requires storage flag also)", State: FeatureStateAlpha, RequiresDevMode: true, }, @@ -295,6 +296,11 @@ var ( Description: "Show the flame graph", State: FeatureStateAlpha, }, + { + Name: "cloudWatchCrossAccountQuerying", + Description: "Use cross-account querying in CloudWatch datasource", + State: FeatureStateAlpha, + }, { Name: "redshiftAsyncQueryDataSupport", Description: "Enable async query data support for Redshift", @@ -310,10 +316,12 @@ var ( { Name: "increaseInMemDatabaseQueryCache", Description: "Enable more in memory caching for database queries", + State: FeatureStateAlpha, }, { Name: "interFont", Description: "Switch to inter font", + State: FeatureStateBeta, }, { Name: "newPanelChromeUI", @@ -329,16 +337,18 @@ var ( }, { Name: "showDashboardValidationWarnings", - Description: "Show warnings when Dashboards do not validate against the schema", + Description: "Show warnings when dashboards do not validate against the schema", + State: FeatureStateAlpha, }, { Name: "mysqlAnsiQuotes", - Description: "Use double quote to escape keyword in Mysql query", + Description: "Use double quotes to escape keyword in a MySQL query", State: FeatureStateAlpha, }, { Name: "datasourceLogger", Description: "Logs all datasource requests", + State: FeatureStateBeta, }, { Name: "accessControlOnCall", @@ -362,5 +372,15 @@ var ( Description: "Use Elasticsearch as backend data source", State: FeatureStateAlpha, }, + { + Name: "secureSocksDatasourceProxy", + Description: "Enable secure socks tunneling for supported core datasources", + State: FeatureStateAlpha, + }, + { + Name: "authnService", + Description: "Use new auth service to perform authentication", + State: FeatureStateAlpha, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1c503d4867f..06c778de072 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -12,7 +12,7 @@ const ( FlagAlertingBigTransactions = "alertingBigTransactions" // FlagPromQueryBuilder - // Show prometheus query builder + // Show Prometheus query builder FlagPromQueryBuilder = "promQueryBuilder" // FlagTrimDefaults @@ -24,7 +24,7 @@ const ( FlagDisableEnvelopeEncryption = "disableEnvelopeEncryption" // FlagDatabaseMetrics - // Add prometheus metrics for database tables + // Add Prometheus metrics for database tables FlagDatabaseMetrics = "database_metrics" // FlagDashboardPreviews @@ -36,11 +36,11 @@ const ( FlagDashboardPreviewsAdmin = "dashboardPreviewsAdmin" // FlagLiveConfig - // Save grafana live configuration in SQL tables + // Save Grafana Live configuration in SQL tables FlagLiveConfig = "live-config" // FlagLivePipeline - // enable a generic live processing pipeline + // Enable a generic live processing pipeline FlagLivePipeline = "live-pipeline" // FlagLiveServiceWebWorker @@ -48,7 +48,7 @@ const ( FlagLiveServiceWebWorker = "live-service-web-worker" // FlagQueryOverLive - // Use grafana live websocket to execute backend queries + // Use Grafana Live WebSocket to execute backend queries FlagQueryOverLive = "queryOverLive" // FlagPanelTitleSearch @@ -72,15 +72,15 @@ const ( FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI" // FlagPublicDashboards - // enables public access to dashboards + // Enables public access to dashboards FlagPublicDashboards = "publicDashboards" // FlagLokiLive - // support websocket streaming for loki (early prototype) + // Support WebSocket streaming for loki (early prototype) FlagLokiLive = "lokiLive" // FlagLokiDataframeApi - // use experimental loki api for websocket streaming (early prototype) + // Use experimental loki api for WebSocket streaming (early prototype) FlagLokiDataframeApi = "lokiDataframeApi" // FlagLokiMonacoEditor @@ -92,7 +92,7 @@ const ( FlagSwaggerUi = "swaggerUi" // FlagFeatureHighlights - // Highlight Enterprise features + // Highlight Grafana Enterprise features FlagFeatureHighlights = "featureHighlights" // FlagDashboardComments @@ -188,7 +188,7 @@ const ( FlagLogRequestsInstrumentedAsUnknown = "logRequestsInstrumentedAsUnknown" // FlagDataConnectionsConsole - // Enables a new top-level page called Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins. + // Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. FlagDataConnectionsConsole = "dataConnectionsConsole" // FlagInternationalization @@ -203,9 +203,9 @@ const ( // Run GRPC server FlagGrpcServer = "grpcServer" - // FlagObjectStore - // SQL based object store - FlagObjectStore = "objectStore" + // FlagEntityStore + // SQL-based entity store (requires storage flag also) + FlagEntityStore = "entityStore" // FlagTraceqlEditor // Show the TraceQL editor in the explore page @@ -215,6 +215,10 @@ const ( // Show the flame graph FlagFlameGraph = "flameGraph" + // FlagCloudWatchCrossAccountQuerying + // Use cross-account querying in CloudWatch datasource + FlagCloudWatchCrossAccountQuerying = "cloudWatchCrossAccountQuerying" + // FlagRedshiftAsyncQueryDataSupport // Enable async query data support for Redshift FlagRedshiftAsyncQueryDataSupport = "redshiftAsyncQueryDataSupport" @@ -240,11 +244,11 @@ const ( FlagQueryLibrary = "queryLibrary" // FlagShowDashboardValidationWarnings - // Show warnings when Dashboards do not validate against the schema + // Show warnings when dashboards do not validate against the schema FlagShowDashboardValidationWarnings = "showDashboardValidationWarnings" // FlagMysqlAnsiQuotes - // Use double quote to escape keyword in Mysql query + // Use double quotes to escape keyword in a MySQL query FlagMysqlAnsiQuotes = "mysqlAnsiQuotes" // FlagDatasourceLogger @@ -266,4 +270,12 @@ const ( // FlagElasticsearchBackendMigration // Use Elasticsearch as backend data source FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" + + // FlagSecureSocksDatasourceProxy + // Enable secure socks tunneling for supported core datasources + FlagSecureSocksDatasourceProxy = "secureSocksDatasourceProxy" + + // FlagAuthnService + // Use new auth service to perform authentication + FlagAuthnService = "authnService" ) diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index b5f3864749c..aceaffcb33d 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -11,6 +11,7 @@ import ( "unicode" "github.com/google/go-cmp/cmp" + "github.com/olekukonko/tablewriter" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/featuremgmt/strcase" @@ -26,6 +27,20 @@ func TestFeatureToggleFiles(t *testing.T) { "live-service-web-worker": true, } + t.Run("check registry constraints", func(t *testing.T) { + for _, flag := range standardFeatureFlags { + if flag.Expression == "true" && flag.State != FeatureStateStable { + t.Errorf("only stable features can be enabled by default. See: %s", flag.Name) + } + if flag.RequiresDevMode && flag.State != FeatureStateAlpha { + t.Errorf("only alpha features can require dev mode. See: %s", flag.Name) + } + if flag.State == FeatureStateUnknown { + t.Errorf("standard toggles should not have an unknown state. See: %s", flag.Name) + } + } + }) + t.Run("verify files", func(t *testing.T) { // Typescript files verifyAndGenerateFile(t, @@ -38,6 +53,12 @@ func TestFeatureToggleFiles(t *testing.T) { "toggles_gen.go", generateRegistry(t), ) + + // Docs files + verifyAndGenerateFile(t, + "../../../docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md", + generateDocsMD(), + ) }) t.Run("check feature naming convention", func(t *testing.T) { @@ -171,3 +192,109 @@ const (`) return buff.String() } + +func generateDocsMD() string { + hasDeprecatedFlags := false + + buf := `--- +aliases: + - /docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/ +description: Learn about toggles for experimental and beta features, which you can enable or disable. +title: Configure feature toggles +weight: 150 +--- + + + + +# Configure feature toggles + +You use feature toggles, also known as feature flags, to turn experimental or beta features on and off in Grafana. Although we do not recommend using these features in production, you can turn on feature toggles to try out new functionality in development or test environments. + +This page contains a list of available feature toggles. To learn how to turn on feature toggles, refer to our [Configure Grafana documentation]({{< relref "../_index.md/#feature_toggles" >}}). Feature toggles are also available to Grafana Cloud Advanced customers. If you use Grafana Cloud Advanced, you can open a support ticket and specify the feature toggles and stack for which you want them enabled. + +## Stable feature toggles + +Some stable features are enabled by default. You can disable a stable feature by setting the feature flag to "false" in the configuration. + +` + writeToggleDocsTable(func(flag FeatureFlag) bool { + return flag.State == FeatureStateStable + }, true) + + buf += ` +## Beta feature toggles + +` + writeToggleDocsTable(func(flag FeatureFlag) bool { + return flag.State == FeatureStateBeta + }, false) + + if hasDeprecatedFlags { + buf += ` +## Deprecated feature toggles + +When stable or beta features are slated for removal, they will be marked as Deprecated first. + + ` + writeToggleDocsTable(func(flag FeatureFlag) bool { + return flag.State == FeatureStateDeprecated + }, false) + } + + buf += ` +## Alpha feature toggles + +These features are early in their development lifecycle and so are not yet supported in Grafana Cloud. +Alpha features might be changed or removed without prior notice. + +` + writeToggleDocsTable(func(flag FeatureFlag) bool { + return flag.State == FeatureStateAlpha && !flag.RequiresDevMode + }, false) + + buf += ` +## Development feature toggles + +The following toggles require explicitly setting Grafana's [app mode]({{< relref "../_index.md/#app_mode" >}}) to 'development' before you can enable this feature toggle. These features tend to be experimental. + +` + writeToggleDocsTable(func(flag FeatureFlag) bool { + return flag.RequiresDevMode + }, false) + return buf +} + +func writeToggleDocsTable(include func(FeatureFlag) bool, showEnableByDefault bool) string { + data := [][]string{} + + for _, flag := range standardFeatureFlags { + if include(flag) { + row := []string{"`" + flag.Name + "`", flag.Description} + if showEnableByDefault { + on := "" + if flag.Expression == "true" { + on = "Yes" + } + row = append(row, on) + } + data = append(data, row) + } + } + + header := []string{"Feature toggle name", "Description"} + if showEnableByDefault { + header = append(header, "Enabled by default") + } + + sb := &strings.Builder{} + table := tablewriter.NewWriter(sb) + table.SetHeader(header) + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + table.SetAutoFormatHeaders(false) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAutoWrapText(false) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.AppendBulk(data) // Add Bulk Data + table.Render() + + // Markdown table formatting (from prittier) + v := strings.ReplaceAll(sb.String(), "|--", "| -") + return strings.ReplaceAll(v, "--|", "- |") +} diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index f16aaa1abbc..92a9d0c0501 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -3,6 +3,7 @@ package folderimpl import ( "context" "errors" + "fmt" "strings" "github.com/grafana/grafana/pkg/bus" @@ -298,8 +299,10 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( // (legacy) folder. logger.Error("error saving folder to nested folder store", "error", err) // do not shallow create error if the legacy folder delete fails - deleteErr := s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.UID, OrgID: cmd.OrgID, ForceDeleteRules: true, SignedInUser: user}) - if deleteErr != nil { + if deleteErr := s.dashboardStore.DeleteDashboard(ctx, &models.DeleteDashboardCommand{ + Id: createdFolder.ID, + OrgId: createdFolder.OrgID, + }); deleteErr != nil { logger.Error("error deleting folder after failed save to nested folder store", "error", err) } return folder.FromDashboard(dash), err @@ -414,6 +417,7 @@ func (s *Service) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderComm err := s.Delete(ctx, cmd) if err != nil { logger.Error("the delete folder on folder table failed with err: ", "error", err) + return err } } @@ -459,6 +463,7 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol } func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) error { + logger := s.log.FromContext(ctx) if cmd.SignedInUser == nil { return folder.ErrBadRequest.Errorf("missing signed in user") } @@ -477,13 +482,17 @@ func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) e return err } for _, f := range folders { - err := s.Delete(ctx, &folder.DeleteFolderCommand{UID: f.UID, OrgID: f.OrgID, ForceDeleteRules: cmd.ForceDeleteRules}) + logger.Info("deleting subfolder", "org_id", f.OrgID, "uid", f.UID) + err := s.Delete(ctx, &folder.DeleteFolderCommand{UID: f.UID, OrgID: f.OrgID, ForceDeleteRules: cmd.ForceDeleteRules, SignedInUser: cmd.SignedInUser}) if err != nil { + logger.Error("failed deleting subfolder", "org_id", f.OrgID, "uid", f.UID, "error", err) return err } } + logger.Info("deleting folder", "org_id", cmd.OrgID, "uid", cmd.UID) err = s.store.Delete(ctx, cmd.UID, cmd.OrgID) if err != nil { + logger.Info("failed deleting folder", "org_id", cmd.OrgID, "uid", cmd.UID, "err", err) return err } return nil @@ -517,7 +526,7 @@ func (s *Service) nestedFolderCreate(ctx context.Context, cmd *folder.CreateFold func (s *Service) validateParent(ctx context.Context, orgID int64, parentUID string) error { ancestors, err := s.store.GetParents(ctx, folder.GetParentsQuery{UID: parentUID, OrgID: orgID}) if err != nil { - return err + return fmt.Errorf("failed to get parents: %w", err) } if len(ancestors) == folder.MaxNestedFolderDepth { diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index bfe18a5f2d0..eb3d3ab0d2f 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -476,6 +476,10 @@ func TestNestedFolderService(t *testing.T) { dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&folder.Folder{}, nil) + var actualCmd *models.DeleteDashboardCommand + dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + actualCmd = args.Get(1).(*models.DeleteDashboardCommand) + }).Return(nil).Once() // return an error from the folder store store.ExpectedError = errors.New("FAILED") @@ -491,6 +495,7 @@ func TestNestedFolderService(t *testing.T) { // CreateFolder should also call the folder store's create method. require.True(t, store.CreateCalled) + require.NotNil(t, actualCmd) t.Cleanup(func() { guardian.New = g diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index b8dbfbe9351..7b5ab9518a6 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -10,6 +10,7 @@ import ( "github.com/go-sql-driver/mysql" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -42,11 +43,13 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) updatedBy := cmd.SignedInUser.UserID createdBy := cmd.SignedInUser.UserID */ + var lastInsertedID int64 err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var sqlOrArgs []interface{} + var sql string + var args []interface{} if cmd.ParentUID == "" { - sql := "INSERT INTO folder(org_id, uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?)" - sqlOrArgs = []interface{}{sql, cmd.OrgID, cmd.UID, cmd.Title, cmd.Description, time.Now(), time.Now()} + sql = "INSERT INTO folder(org_id, uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?)" + args = []interface{}{cmd.OrgID, cmd.UID, cmd.Title, cmd.Description, time.Now(), time.Now()} } else { if cmd.ParentUID != folder.GeneralFolderUID { if _, err := ss.Get(ctx, folder.GetFolderQuery{ @@ -56,20 +59,18 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) return folder.ErrFolderNotFound.Errorf("parent folder does not exist") } } - sql := "INSERT INTO folder(org_id, uid, parent_uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?, ?)" - sqlOrArgs = []interface{}{sql, cmd.OrgID, cmd.UID, cmd.ParentUID, cmd.Title, cmd.Description, time.Now(), time.Now()} + sql = "INSERT INTO folder(org_id, uid, parent_uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?, ?)" + args = []interface{}{cmd.OrgID, cmd.UID, cmd.ParentUID, cmd.Title, cmd.Description, time.Now(), time.Now()} } - res, err := sess.Exec(sqlOrArgs...) + + var err error + lastInsertedID, err = sess.WithReturningID(ss.db.GetDialect().DriverName(), sql, args) if err != nil { - return folder.ErrDatabaseError.Errorf("failed to insert folder: %w", err) - } - id, err := res.LastInsertId() - if err != nil { - return folder.ErrDatabaseError.Errorf("failed to get last inserted id: %w", err) + return err } foldr, err = ss.Get(ctx, folder.GetFolderQuery{ - ID: &id, + ID: &lastInsertedID, }) if err != nil { return err @@ -170,7 +171,7 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F } return nil }) - foldr.Url = models.GetFolderUrl(foldr.UID, models.SlugifyTitle(foldr.Title)) + foldr.Url = models.GetFolderUrl(foldr.UID, slugify.Slugify(foldr.Title)) return foldr, err } @@ -201,6 +202,13 @@ func (ss *sqlStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([ } return nil, err } + + if len(folders) < 1 { + // the query is expected to return at least the same folder + // if it's empty it means that the folder does not exist + return nil, folder.ErrFolderNotFound + } + return util.Reverse(folders[1:]), nil } @@ -209,7 +217,7 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetTreeQuery) ([]* err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { sql := strings.Builder{} - sql.Write([]byte("SELECT * FROM folder WHERE parent_uid=? AND org_id=?")) + sql.Write([]byte("SELECT * FROM folder WHERE parent_uid=? AND org_id=? ORDER BY id")) if q.Limit != 0 { var offset int64 = 1 @@ -254,5 +262,5 @@ func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQu } return nil }) - return folders, err + return util.Reverse(folders), err } diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 4824d934d38..ee1104b078d 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -50,7 +50,7 @@ func TestIntegrationCreate(t *testing.T) { require.Error(t, err) }) - t.Run("creating a folder without providing a parent should default to the general folder", func(t *testing.T) { + t.Run("creating a folder without providing a parent should default to the empty parent folder", func(t *testing.T) { uid := util.GenerateShortUID() f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ Title: "folder1", @@ -69,7 +69,7 @@ func TestIntegrationCreate(t *testing.T) { assert.Equal(t, "folder desc", f.Description) assert.NotEmpty(t, f.ID) assert.Equal(t, uid, f.UID) - assert.Equal(t, folder.GeneralFolderUID, f.ParentUID) + assert.Empty(t, f.ParentUID) ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ UID: &f.UID, @@ -78,7 +78,7 @@ func TestIntegrationCreate(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "folder1", ff.Title) assert.Equal(t, "folder desc", ff.Description) - assert.Equal(t, accesscontrol.GeneralFolderUID, ff.ParentUID) + assert.Empty(t, ff.ParentUID) assertAncestorUIDs(t, folderStore, f, []string{folder.GeneralFolderUID}) }) @@ -154,7 +154,7 @@ func TestIntegrationDelete(t *testing.T) { */ ancestorUIDs := CreateSubTree(t, folderStore, orgID, accesscontrol.GeneralFolderUID, folder.MaxNestedFolderDepth, "") - require.Len(t, ancestorUIDs, folder.MaxNestedFolderDepth+1) + require.Len(t, ancestorUIDs, folder.MaxNestedFolderDepth) t.Cleanup(func() { for _, uid := range ancestorUIDs[1:] { @@ -243,7 +243,7 @@ func TestIntegrationUpdate(t *testing.T) { t.Run("updating a folder should succeed", func(t *testing.T) { newTitle := "new title" newDesc := "new desc" - existingUpdated := f.Updated + // existingUpdated := f.Updated updated, err := folderStore.Update(context.Background(), folder.UpdateFolderCommand{ Folder: f, NewTitle: &newTitle, @@ -254,7 +254,7 @@ func TestIntegrationUpdate(t *testing.T) { assert.Equal(t, f.UID, updated.UID) assert.Equal(t, newTitle, updated.Title) assert.Equal(t, newDesc, updated.Description) - assert.Greater(t, updated.Updated.UnixNano(), existingUpdated.UnixNano()) + // assert.GreaterOrEqual(t, updated.Updated.UnixNano(), existingUpdated.UnixNano()) updated, err = folderStore.Get(context.Background(), folder.GetFolderQuery{ UID: &updated.UID, @@ -397,6 +397,11 @@ func TestIntegrationGetParents(t *testing.T) { require.NoError(t, err) }) + t.Run("get parents of unknown folder should return an error", func(t *testing.T) { + _, err := folderStore.GetParents(context.Background(), folder.GetParentsQuery{}) + require.ErrorIs(t, err, folder.ErrFolderNotFound) + }) + t.Run("get parents of 1-st level folder should be empty", func(t *testing.T) { parents, err := folderStore.GetParents(context.Background(), folder.GetParentsQuery{ UID: f.UID, @@ -578,16 +583,19 @@ func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { func CreateSubTree(t *testing.T, store *sqlStore, orgID int64, parentUID string, depth int, prefix string) []string { t.Helper() - ancestorUIDs := []string{parentUID} + ancestorUIDs := []string{} for i := 0; i < depth; i++ { - parentUID := ancestorUIDs[len(ancestorUIDs)-1] title := fmt.Sprintf("%sfolder-%d", prefix, i) - f, err := store.Create(context.Background(), folder.CreateFolderCommand{ + cmd := folder.CreateFolderCommand{ Title: title, OrgID: orgID, ParentUID: parentUID, UID: util.GenerateShortUID(), - }) + } + if len(ancestorUIDs) > 0 { + cmd.ParentUID = ancestorUIDs[len(ancestorUIDs)-1] + } + f, err := store.Create(context.Background(), cmd) require.NoError(t, err) require.Equal(t, title, f.Title) require.NotEmpty(t, f.ID) @@ -598,7 +606,7 @@ func CreateSubTree(t *testing.T, store *sqlStore, orgID int64, parentUID string, OrgID: orgID, }) require.NoError(t, err) - parentUIDs := []string{folder.GeneralFolderUID} + parentUIDs := []string{} for _, p := range parents { parentUIDs = append(parentUIDs, p.UID) } diff --git a/pkg/services/folder/folderimpl/test.db b/pkg/services/folder/folderimpl/test.db deleted file mode 100644 index e00490e1512..00000000000 Binary files a/pkg/services/folder/folderimpl/test.db and /dev/null differ diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 87b592c5d97..12143abef99 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -3,6 +3,7 @@ package folder import ( "time" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util/errutil" @@ -66,7 +67,7 @@ type CreateFolderCommand struct { OrgID int64 `json:"-"` Title string `json:"title"` Description string `json:"description"` - ParentUID string `json:"parent_uid"` + ParentUID string `json:"parentUid"` SignedInUser *user.SignedInUser `json:"-"` } @@ -86,7 +87,7 @@ type UpdateFolderCommand struct { // to move a folder. type MoveFolderCommand struct { UID string `json:"uid"` - NewParentUID string `json:"new_parent_uid"` + NewParentUID string `json:"newParentUid"` OrgID int64 `json:"-"` SignedInUser *user.SignedInUser `json:"-"` @@ -141,7 +142,7 @@ func (f *Folder) ToLegacyModel() *models.Folder { Id: f.ID, Uid: f.UID, Title: f.Title, - Url: models.GetFolderUrl(f.UID, models.SlugifyTitle(f.Title)), + Url: models.GetFolderUrl(f.UID, slugify.Slugify(f.Title)), Version: 0, Created: f.Created, Updated: f.Updated, diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index e8484c781ba..7ef0e559cbc 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -10,6 +10,7 @@ import ( type AuthInfoService interface { LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*user.User, error) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error + GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index a7fdf2fd65c..77540f475ce 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -108,6 +108,30 @@ func (s *AuthInfoStore) GetAuthInfo(ctx context.Context, query *models.GetAuthIn return nil } +func (s *AuthInfoStore) GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) { + userAuths := []models.UserAuth{} + params := make([]interface{}, 0, len(query.UserIDs)) + for _, id := range query.UserIDs { + params = append(params, id) + } + + err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + return sess.Table("user_auth").In("user_id", params).OrderBy("created").Find(&userAuths) + }) + + if err != nil { + return nil, err + } + + labelMap := make(map[int64]string, len(userAuths)) + + for i := range userAuths { + labelMap[userAuths[i].UserId] = userAuths[i].AuthModule + } + + return labelMap, nil +} + func (s *AuthInfoStore) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { authUser := &models.UserAuth{ UserId: cmd.UserId, diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index 3a6025bd491..76efa7ca8b0 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -185,6 +185,13 @@ func (s *Implementation) GetAuthInfo(ctx context.Context, query *models.GetAuthI return s.authInfoStore.GetAuthInfo(ctx, query) } +func (s *Implementation) GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) { + if len(query.UserIDs) == 0 { + return map[int64]string{}, nil + } + return s.authInfoStore.GetUserLabels(ctx, query) +} + func (s *Implementation) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { return s.authInfoStore.UpdateAuthInfo(ctx, cmd) } diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go index 525e6167ac0..5e8181e9528 100644 --- a/pkg/services/login/authinfoservice/user_auth_test.go +++ b/pkg/services/login/authinfoservice/user_auth_test.go @@ -478,6 +478,7 @@ func TestUserAuth(t *testing.T) { } type FakeAuthInfoStore struct { + login.AuthInfoService ExpectedError error ExpectedUser *user.User ExpectedOAuth *models.UserAuth diff --git a/pkg/services/login/login.go b/pkg/services/login/login.go index c2ee63df7c3..bb1ff508e4a 100644 --- a/pkg/services/login/login.go +++ b/pkg/services/login/login.go @@ -18,7 +18,6 @@ var ( type TeamSyncFunc func(user *user.User, externalUser *models.ExternalUserInfo) error type Service interface { - CreateUser(cmd user.CreateUserCommand) (*user.User, error) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error DisableExternalUser(ctx context.Context, username string) error SetTeamSyncFunc(TeamSyncFunc) diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 44323ead976..b32129b0578 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -43,11 +43,6 @@ type Implementation struct { orgService org.Service } -// CreateUser creates inserts a new one. -func (ls *Implementation) CreateUser(cmd user.CreateUserCommand) (*user.User, error) { - return ls.userService.Create(context.Background(), &cmd) -} - // UpsertUser updates an existing user, or if it doesn't exist, inserts a new one. func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { extUser := cmd.ExternalUser @@ -80,7 +75,12 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser } } - result, errCreateUser := ls.createUser(extUser) + result, errCreateUser := ls.userService.Create(ctx, &user.CreateUserCommand{ + Login: extUser.Login, + Email: extUser.Email, + Name: extUser.Name, + SkipOrgSetup: len(extUser.OrgRoles) > 0, + }) if errCreateUser != nil { return errCreateUser } @@ -207,16 +207,6 @@ func (ls *Implementation) SetTeamSyncFunc(teamSyncFunc login.TeamSyncFunc) { ls.TeamSync = teamSyncFunc } -func (ls *Implementation) createUser(extUser *models.ExternalUserInfo) (*user.User, error) { - cmd := user.CreateUserCommand{ - Login: extUser.Login, - Email: extUser.Email, - Name: extUser.Name, - SkipOrgSetup: len(extUser.OrgRoles) > 0, - } - return ls.CreateUser(cmd) -} - func (ls *Implementation) updateUser(ctx context.Context, usr *user.User, extUser *models.ExternalUserInfo) error { // sync user info updateCmd := &user.UpdateUserCommand{ diff --git a/pkg/services/login/loginservice/loginservice_mock.go b/pkg/services/login/loginservice/loginservice_mock.go index 99348babc41..b77ac1b4caa 100644 --- a/pkg/services/login/loginservice/loginservice_mock.go +++ b/pkg/services/login/loginservice/loginservice_mock.go @@ -2,9 +2,7 @@ package loginservice import ( "context" - "errors" - "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" @@ -12,30 +10,9 @@ import ( type LoginServiceMock struct { login.Service - ExpectedUserForm dtos.AdminCreateUserForm - NoExistingOrgId int64 - AlreadyExitingLogin string - GeneratedUserId int64 - ExpectedUser *user.User - ExpectedUserFunc func(cmd *models.UpsertUserCommand) *user.User - ExpectedError error -} - -func (s LoginServiceMock) CreateUser(cmd user.CreateUserCommand) (*user.User, error) { - if cmd.OrgID == s.NoExistingOrgId { - return nil, models.ErrOrgNotFound - } - - if cmd.Login == s.AlreadyExitingLogin { - return nil, user.ErrUserAlreadyExists - } - - if s.ExpectedUserForm.Login == cmd.Login && s.ExpectedUserForm.Email == cmd.Email && - s.ExpectedUserForm.Password == cmd.Password && s.ExpectedUserForm.Name == cmd.Name && s.ExpectedUserForm.OrgId == cmd.OrgID { - return &user.User{ID: s.GeneratedUserId}, nil - } - - return nil, errors.New("unexpected cmd") + ExpectedUser *user.User + ExpectedUserFunc func(cmd *models.UpsertUserCommand) *user.User + ExpectedError error } func (s LoginServiceMock) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go index 023ae063f13..c83795ea18d 100644 --- a/pkg/services/login/logintest/logintest.go +++ b/pkg/services/login/logintest/logintest.go @@ -10,9 +10,6 @@ import ( type LoginServiceFake struct{} -func (l *LoginServiceFake) CreateUser(cmd user.CreateUserCommand) (*user.User, error) { - return nil, nil -} func (l *LoginServiceFake) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { return nil } @@ -22,11 +19,13 @@ func (l *LoginServiceFake) DisableExternalUser(ctx context.Context, username str func (l *LoginServiceFake) SetTeamSyncFunc(login.TeamSyncFunc) {} type AuthInfoServiceFake struct { + login.AuthInfoService LatestUserID int64 ExpectedUserAuth *models.UserAuth ExpectedUser *user.User ExpectedExternalUser *models.ExternalUserInfo ExpectedError error + ExpectedLabels map[int64]string } func (a *AuthInfoServiceFake) LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*user.User, error) { @@ -44,6 +43,10 @@ func (a *AuthInfoServiceFake) GetAuthInfo(ctx context.Context, query *models.Get return a.ExpectedError } +func (a *AuthInfoServiceFake) GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) { + return a.ExpectedLabels, a.ExpectedError +} + func (a *AuthInfoServiceFake) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { return a.ExpectedError } diff --git a/pkg/services/login/userprotection.go b/pkg/services/login/userprotection.go index 83146ce2bdf..f77b6401244 100644 --- a/pkg/services/login/userprotection.go +++ b/pkg/services/login/userprotection.go @@ -14,6 +14,7 @@ type UserProtectionService interface { type Store interface { GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error + GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error UpdateAuthInfoDate(ctx context.Context, authInfo *models.UserAuth) error diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index e73929d123a..276318892c2 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -198,9 +198,8 @@ func ApplyAdminIA(root *NavTreeRoot) { pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app")) pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries")) // enterprise only - accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("users")) if globalUsers := root.FindById("global-users"); globalUsers != nil { - globalUsers.Text = "Users (All orgs)" + globalUsers.Text = "Users" accessNodeLinks = append(accessNodeLinks, globalUsers) } accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams")) diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 7bf7a95ec46..0429cd01276 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -36,14 +36,16 @@ func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, e }) } - if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) { - configNodes = append(configNodes, &navtree.NavLink{ - Text: "Users", - Id: "users", - SubTitle: "Invite and assign roles to users", - Icon: "user", - Url: s.cfg.AppSubURL + "/org/users", - }) + if !s.features.IsEnabled(featuremgmt.FlagTopnav) { + if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Users", + Id: "users", + SubTitle: "Invite and assign roles to users", + Icon: "user", + Url: s.cfg.AppSubURL + "/org/users", + }) + } } if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) { @@ -123,10 +125,18 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead) adminNavLinks := []*navtree.NavLink{} - if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { - adminNavLinks = append(adminNavLinks, &navtree.NavLink{ - Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", - }) + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", + }) + } + } else { + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", + }) + } } if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) { diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index e59a4d6bd09..1f63cdde59f 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -198,7 +198,7 @@ func (s *ServiceImpl) addPluginToSection(c *models.ReqContext, treeRoot *navtree case navtree.NavIDApps: treeRoot.AddSection(&navtree.NavLink{ Text: "Apps", - Icon: "apps", + Icon: "layer-group", SubTitle: "App plugins that extend the Grafana experience", Id: navtree.NavIDApps, Children: []*navtree.NavLink{appLink}, @@ -263,6 +263,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightSavedItems + 1, Text: "Connections"}, + "grafana-k6-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAlertsAndIncidents + 1, Text: "Performance testing"}, } s.navigationAppPathConfig = map[string]NavigationAppConfig{ diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 16f0a5e231d..602080c53d9 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -554,8 +554,8 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree // Datasources Children: []*navtree.NavLink{{ Id: "connections-your-connections-datasources", - Text: "Datasources", - SubTitle: "Manage your existing datasource connections", + Text: "Data sources", + SubTitle: "View and manage your connected data source connections", Url: baseUrl + "/your-connections/datasources", }}, }) diff --git a/pkg/services/ngalert/notifier/channels/pushover.go b/pkg/services/ngalert/notifier/channels/pushover.go index b795f7c6d18..293d537e783 100644 --- a/pkg/services/ngalert/notifier/channels/pushover.go +++ b/pkg/services/ngalert/notifier/channels/pushover.go @@ -248,6 +248,40 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al return nil, b, fmt.Errorf("failed write the message: %w", err) } + pn.writeImageParts(ctx, w, as...) + + var sound string + if status == model.AlertResolved { + sound = tmpl(pn.settings.okSound) + } else { + sound = tmpl(pn.settings.alertingSound) + } + if sound != "default" { + if err := w.WriteField("sound", sound); err != nil { + return nil, b, fmt.Errorf("failed to write the sound: %w", err) + } + } + + // Mark the message as HTML + if err := w.WriteField("html", "1"); err != nil { + return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) + } + if err := w.Close(); err != nil { + return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) + } + + if tmplErr != nil { + pn.log.Warn("failed to template pushover message", "error", tmplErr.Error()) + } + + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + } + + return headers, b, nil +} + +func (pn *PushoverNotifier) writeImageParts(ctx context.Context, w *multipart.Writer, as ...*types.Alert) { // Pushover supports at most one image attachment with a maximum size of pushoverMaxFileSize. // If the image is larger than pushoverMaxFileSize then return an error. _ = withStoredImages(ctx, pn.log, pn.images, func(index int, image ngmodels.Image) error { @@ -281,34 +315,4 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al return ErrImagesDone }, as...) - - var sound string - if status == model.AlertResolved { - sound = tmpl(pn.settings.okSound) - } else { - sound = tmpl(pn.settings.alertingSound) - } - if sound != "default" { - if err := w.WriteField("sound", sound); err != nil { - return nil, b, fmt.Errorf("failed to write the sound: %w", err) - } - } - - // Mark the message as HTML - if err := w.WriteField("html", "1"); err != nil { - return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) - } - if err := w.Close(); err != nil { - return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) - } - - if tmplErr != nil { - pn.log.Warn("failed to template pushover message", "error", tmplErr.Error()) - } - - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - } - - return headers, b, nil } diff --git a/pkg/services/ngalert/notifier/channels/util.go b/pkg/services/ngalert/notifier/channels/util.go index 409fa98901a..244418df1c9 100644 --- a/pkg/services/ngalert/notifier/channels/util.go +++ b/pkg/services/ngalert/notifier/channels/util.go @@ -82,7 +82,8 @@ func getImage(ctx context.Context, l log.Logger, imageStore ImageStore, alert ty // images have been found. func withStoredImages(ctx context.Context, l log.Logger, imageStore ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { for index, alert := range alerts { - img, err := getImage(ctx, l, imageStore, *alert) + logger := l.New("alert", alert.String()) + img, err := getImage(ctx, logger, imageStore, *alert) if err != nil { return err } else if img != nil { @@ -90,6 +91,7 @@ func withStoredImages(ctx context.Context, l log.Logger, imageStore ImageStore, if errors.Is(err, ErrImagesDone) { return nil } + logger.Error("Failed to attach image to notification", "error", err) return err } } diff --git a/pkg/services/oauthtoken/oauth_token_test.go b/pkg/services/oauthtoken/oauth_token_test.go index ae761f0580f..063de3ce3f2 100644 --- a/pkg/services/oauthtoken/oauth_token_test.go +++ b/pkg/services/oauthtoken/oauth_token_test.go @@ -304,6 +304,7 @@ func (m *MockSocialConnector) TokenSource(ctx context.Context, t *oauth2.Token) } type FakeAuthInfoStore struct { + login.Store ExpectedError error ExpectedUser *user.User ExpectedOAuth *models.UserAuth diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index d6956ce2384..5e20ee43c7e 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -148,6 +148,7 @@ type OrgUserDTO struct { LastSeenAtAge string `json:"lastSeenAtAge"` AccessControl map[string]bool `json:"accessControl,omitempty"` IsDisabled bool `json:"isDisabled"` + AuthLabels []string `json:"authLabels" xorm:"-"` } type RemoveOrgUserCommand struct { diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index e14d60369f2..60461b8698c 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -173,21 +173,21 @@ func TestIntegrationOrgDataAccess(t *testing.T) { }) t.Run("Testing Account DB Access", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + ss := db.InitTestDB(t) + orgStore = sqlStore{ + db: ss, + dialect: ss.GetDialect(), + } + ids := []int64{} + + for i := 1; i < 4; i++ { + cmd := &org.CreateOrgCommand{Name: fmt.Sprint("Org #", i)} + res, err := orgStore.CreateWithMember(context.Background(), cmd) + require.NoError(t, err) + ids = append(ids, res.ID) + } t.Run("Given we have organizations, we can query them by IDs", func(t *testing.T) { - var err error - var cmd *models.CreateOrgCommand - ids := []int64{} - - for i := 1; i < 4; i++ { - cmd = &models.CreateOrgCommand{Name: fmt.Sprint("Org #", i)} - err = sqlStore.CreateOrg(context.Background(), cmd) - require.NoError(t, err) - - ids = append(ids, cmd.Result.Id) - } - query := &org.SearchOrgsQuery{IDs: ids} queryResult, err := orgStore.Search(context.Background(), query) @@ -195,37 +195,28 @@ func TestIntegrationOrgDataAccess(t *testing.T) { require.Equal(t, len(queryResult), 3) }) - t.Run("Given we have organizations, we can limit and paginate search", func(t *testing.T) { - sqlStore = db.InitTestDB(t) - for i := 1; i < 4; i++ { - cmd := &models.CreateOrgCommand{Name: fmt.Sprint("Org #", i)} - err := sqlStore.CreateOrg(context.Background(), cmd) - require.NoError(t, err) - } + t.Run("Should be able to search with defaults", func(t *testing.T) { + query := &org.SearchOrgsQuery{} + queryResult, err := orgStore.Search(context.Background(), query) - t.Run("Should be able to search with defaults", func(t *testing.T) { - query := &org.SearchOrgsQuery{} - queryResult, err := orgStore.Search(context.Background(), query) + require.NoError(t, err) + require.Equal(t, len(queryResult), 3) + }) - require.NoError(t, err) - require.Equal(t, len(queryResult), 3) - }) + t.Run("Should be able to limit search", func(t *testing.T) { + query := &org.SearchOrgsQuery{Limit: 1} + queryResult, err := orgStore.Search(context.Background(), query) - t.Run("Should be able to limit search", func(t *testing.T) { - query := &org.SearchOrgsQuery{Limit: 1} - queryResult, err := orgStore.Search(context.Background(), query) + require.NoError(t, err) + require.Equal(t, len(queryResult), 1) + }) - require.NoError(t, err) - require.Equal(t, len(queryResult), 1) - }) + t.Run("Should be able to limit and paginate search", func(t *testing.T) { + query := &org.SearchOrgsQuery{Limit: 2, Page: 1} + queryResult, err := orgStore.Search(context.Background(), query) - t.Run("Should be able to limit and paginate search", func(t *testing.T) { - query := &org.SearchOrgsQuery{Limit: 2, Page: 1} - queryResult, err := orgStore.Search(context.Background(), query) - - require.NoError(t, err) - require.Equal(t, len(queryResult), 1) - }) + require.NoError(t, err) + require.Equal(t, len(queryResult), 1) }) }) } @@ -410,9 +401,6 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { err = orgUserStore.RemoveOrgUser(context.Background(), &remCmd) require.NoError(t, err) require.True(t, remCmd.UserWasDeleted) - - err = ss.GetSignedInUser(context.Background(), &models.GetSignedInUserQuery{UserId: ac2.ID}) - require.Equal(t, err, user.ErrUserNotFound) }) t.Run("Cannot delete last admin org user", func(t *testing.T) { diff --git a/pkg/services/playlist/playlistimpl/object_store.go b/pkg/services/playlist/playlistimpl/entity_store.go similarity index 69% rename from pkg/services/playlist/playlistimpl/object_store.go rename to pkg/services/playlist/playlistimpl/entity_store.go index ad7b867c0a0..43d0848f89b 100644 --- a/pkg/services/playlist/playlistimpl/object_store.go +++ b/pkg/services/playlist/playlistimpl/entity_store.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/sqlstore/session" objectstore "github.com/grafana/grafana/pkg/services/store" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/user" ) @@ -18,15 +18,15 @@ import ( // 2. CREATE/UPDATE/DELETE same items to the object store // 3. Use the object store for all read operations // This givs us a safe test bed to work with the store but still roll back without any lost work -type objectStoreImpl struct { - sess *session.SessionDB - sqlimpl *Service - objectstore object.ObjectStoreServer +type entityStoreImpl struct { + sess *session.SessionDB + sqlimpl *Service + store entity.EntityStoreServer } -var _ playlist.Service = &objectStoreImpl{} +var _ playlist.Service = &entityStoreImpl{} -func (s *objectStoreImpl) sync() { +func (s *entityStoreImpl) sync() { type Info struct { OrgID int64 `db:"org_id"` UID string `db:"uid"` @@ -55,30 +55,28 @@ func (s *objectStoreImpl) sync() { return } body, _ := json.Marshal(dto) - _, _ = s.objectstore.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ + _, _ = s.store.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ TenantId: info.OrgID, UID: info.UID, Kind: models.StandardKindPlaylist, - Scope: models.ObjectStoreScopeEntity, }, Body: body, }) } } -func (s *objectStoreImpl) Create(ctx context.Context, cmd *playlist.CreatePlaylistCommand) (*playlist.Playlist, error) { +func (s *entityStoreImpl) Create(ctx context.Context, cmd *playlist.CreatePlaylistCommand) (*playlist.Playlist, error) { rsp, err := s.sqlimpl.store.Insert(ctx, cmd) if err == nil && rsp != nil { body, err := json.Marshal(cmd) if err != nil { return rsp, fmt.Errorf("unable to write playlist to store") } - _, err = s.objectstore.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - Scope: models.ObjectStoreScopeEntity, - Kind: models.StandardKindPlaylist, - UID: rsp.UID, + _, err = s.store.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + Kind: models.StandardKindPlaylist, + UID: rsp.UID, }, Body: body, }) @@ -89,18 +87,17 @@ func (s *objectStoreImpl) Create(ctx context.Context, cmd *playlist.CreatePlayli return rsp, err } -func (s *objectStoreImpl) Update(ctx context.Context, cmd *playlist.UpdatePlaylistCommand) (*playlist.PlaylistDTO, error) { +func (s *entityStoreImpl) Update(ctx context.Context, cmd *playlist.UpdatePlaylistCommand) (*playlist.PlaylistDTO, error) { rsp, err := s.sqlimpl.store.Update(ctx, cmd) if err == nil { body, err := json.Marshal(cmd) if err != nil { return rsp, fmt.Errorf("unable to write playlist to store") } - _, err = s.objectstore.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - UID: rsp.Uid, - Kind: models.StandardKindPlaylist, - Scope: models.ObjectStoreScopeEntity, + _, err = s.store.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + UID: rsp.Uid, + Kind: models.StandardKindPlaylist, }, Body: body, }) @@ -111,14 +108,13 @@ func (s *objectStoreImpl) Update(ctx context.Context, cmd *playlist.UpdatePlayli return rsp, err } -func (s *objectStoreImpl) Delete(ctx context.Context, cmd *playlist.DeletePlaylistCommand) error { +func (s *entityStoreImpl) Delete(ctx context.Context, cmd *playlist.DeletePlaylistCommand) error { err := s.sqlimpl.store.Delete(ctx, cmd) if err == nil { - _, err = s.objectstore.Delete(ctx, &object.DeleteObjectRequest{ - GRN: &object.GRN{ - UID: cmd.UID, - Kind: models.StandardKindPlaylist, - Scope: models.ObjectStoreScopeEntity, + _, err = s.store.Delete(ctx, &entity.DeleteEntityRequest{ + GRN: &entity.GRN{ + UID: cmd.UID, + Kind: models.StandardKindPlaylist, }, }) if err != nil { @@ -132,7 +128,7 @@ func (s *objectStoreImpl) Delete(ctx context.Context, cmd *playlist.DeletePlayli // Read access is managed entirely by the object store //------------------------------------------------------ -func (s *objectStoreImpl) GetWithoutItems(ctx context.Context, q *playlist.GetPlaylistByUidQuery) (*playlist.Playlist, error) { +func (s *entityStoreImpl) GetWithoutItems(ctx context.Context, q *playlist.GetPlaylistByUidQuery) (*playlist.Playlist, error) { p, err := s.Get(ctx, q) // OrgID is actually picked from the user! if err != nil { return nil, err @@ -145,32 +141,31 @@ func (s *objectStoreImpl) GetWithoutItems(ctx context.Context, q *playlist.GetPl }, nil } -func (s *objectStoreImpl) Get(ctx context.Context, q *playlist.GetPlaylistByUidQuery) (*playlist.PlaylistDTO, error) { - rsp, err := s.objectstore.Read(ctx, &object.ReadObjectRequest{ - GRN: &object.GRN{ - UID: q.UID, - Kind: models.StandardKindPlaylist, - Scope: models.ObjectStoreScopeEntity, +func (s *entityStoreImpl) Get(ctx context.Context, q *playlist.GetPlaylistByUidQuery) (*playlist.PlaylistDTO, error) { + rsp, err := s.store.Read(ctx, &entity.ReadEntityRequest{ + GRN: &entity.GRN{ + UID: q.UID, + Kind: models.StandardKindPlaylist, }, WithBody: true, }) if err != nil { return nil, err } - if rsp.Object == nil || rsp.Object.Body == nil { + if rsp.Entity == nil || rsp.Entity.Body == nil { return nil, fmt.Errorf("missing object") } // Get the object from payload found := &playlist.PlaylistDTO{} - err = json.Unmarshal(rsp.Object.Body, found) + err = json.Unmarshal(rsp.Entity.Body, found) return found, err } -func (s *objectStoreImpl) Search(ctx context.Context, q *playlist.GetPlaylistsQuery) (playlist.Playlists, error) { +func (s *entityStoreImpl) Search(ctx context.Context, q *playlist.GetPlaylistsQuery) (playlist.Playlists, error) { playlists := make(playlist.Playlists, 0) - rsp, err := s.objectstore.Search(ctx, &object.ObjectSearchRequest{ + rsp, err := s.store.Search(ctx, &entity.EntitySearchRequest{ Kind: []string{models.StandardKindPlaylist}, WithBody: true, Limit: 1000, diff --git a/pkg/services/playlist/playlistimpl/playlist.go b/pkg/services/playlist/playlistimpl/playlist.go index 6524ed490a9..79f092c127c 100644 --- a/pkg/services/playlist/playlistimpl/playlist.go +++ b/pkg/services/playlist/playlistimpl/playlist.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/playlist" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" ) type Service struct { @@ -15,7 +15,7 @@ type Service struct { var _ playlist.Service = &Service{} -func ProvideService(db db.DB, toggles featuremgmt.FeatureToggles, objserver object.ObjectStoreServer) playlist.Service { +func ProvideService(db db.DB, toggles featuremgmt.FeatureToggles, objserver entity.EntityStoreServer) playlist.Service { var sqlstore store // 🐢🐢🐢 pick the store @@ -31,11 +31,11 @@ func ProvideService(db db.DB, toggles featuremgmt.FeatureToggles, objserver obje svc := &Service{store: sqlstore} // FlagObjectStore is only supported in development mode - if toggles.IsEnabled(featuremgmt.FlagObjectStore) { - impl := &objectStoreImpl{ - sqlimpl: svc, - objectstore: objserver, - sess: db.GetSqlxSession(), + if toggles.IsEnabled(featuremgmt.FlagEntityStore) { + impl := &entityStoreImpl{ + sqlimpl: svc, + store: objserver, + sess: db.GetSqlxSession(), } impl.sync() // load everythign from the existing SQL setup into the new object store return impl diff --git a/pkg/services/provisioning/alerting/rules_provisioner.go b/pkg/services/provisioning/alerting/rules_provisioner.go index e3a44cf0af2..071c549edad 100644 --- a/pkg/services/provisioning/alerting/rules_provisioner.go +++ b/pkg/services/provisioning/alerting/rules_provisioner.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" alert_models "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -99,7 +100,7 @@ func (prov *defaultAlertRuleProvisioner) provisionRule( func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( ctx context.Context, folderName string, orgID int64) (string, error) { cmd := &models.GetDashboardQuery{ - Slug: models.SlugifyTitle(folderName), + Slug: slugify.Slugify(folderName), OrgId: orgID, } err := prov.dashboardService.GetDashboard(ctx, cmd) diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index 468cff28ed6..0e5676e0542 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -3,14 +3,12 @@ package dashboards import ( "context" "errors" - "fmt" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org/orgtest" @@ -26,7 +24,7 @@ var ( func TestDashboardsAsConfig(t *testing.T) { t.Run("Dashboards as configuration", func(t *testing.T) { logger := log.New("test-logger") - store := db.InitTestDB(t) + // store := db.InitTestDB(t) orgFake := orgtest.NewOrgServiceFake() t.Run("Should fail if orgs don't exist in the database", func(t *testing.T) { @@ -38,12 +36,6 @@ func TestDashboardsAsConfig(t *testing.T) { orgFake.ExpectedError = nil }) - for i := 1; i <= 2; i++ { - orgCommand := models.CreateOrgCommand{Name: fmt.Sprintf("Main Org. %v", i)} - err := store.CreateOrg(context.Background(), &orgCommand) - require.NoError(t, err) - } - t.Run("default values should be applied", func(t *testing.T) { cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake} cfg, err := cfgProvider.readConfig(context.Background()) diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index b6c8fa73404..d87b4407e7c 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" @@ -299,7 +300,7 @@ func (fr *FileReader) getOrCreateFolderID(ctx context.Context, cfg *config, serv return 0, ErrFolderNameMissing } - cmd := &models.GetDashboardQuery{Slug: models.SlugifyTitle(folderName), OrgId: cfg.OrgID} + cmd := &models.GetDashboardQuery{Slug: slugify.Slugify(folderName), OrgId: cfg.OrgID} err := fr.dashboardStore.GetDashboard(ctx, cmd) if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go index 00d885cb7b6..db4271b6aae 100644 --- a/pkg/services/provisioning/notifiers/config_reader_test.go +++ b/pkg/services/provisioning/notifiers/config_reader_test.go @@ -14,7 +14,8 @@ import ( encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/stretchr/testify/require" @@ -35,24 +36,22 @@ var ( func TestNotificationAsConfig(t *testing.T) { var sqlStore *sqlstore.SQLStore - var orgFake org.Service + var orgService org.Service var ns *alerting.AlertNotificationService logger := log.New("fake.log") - orgService := orgtest.NewOrgServiceFake() - orgService.ExpectedOrg = &org.Org{} encryptionService := encryptionservice.SetupTestService(t) t.Run("Testing notification as configuration", func(t *testing.T) { setup := func() { sqlStore = db.InitTestDB(t) - orgFake = orgtest.NewOrgServiceFake() + orgService, _ = orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) nm := ¬ifications.NotificationService{} ns = alerting.ProvideService(sqlStore, encryptionService, nm) for i := 1; i < 5; i++ { - orgCommand := models.CreateOrgCommand{Name: fmt.Sprintf("Main Org. %v", i)} - err := sqlStore.CreateOrg(context.Background(), &orgCommand) + orgCommand := org.CreateOrgCommand{Name: fmt.Sprintf("Main Org. %v", i)} + _, err := orgService.CreateWithMember(context.Background(), &orgCommand) require.NoError(t, err) } @@ -73,7 +72,7 @@ func TestNotificationAsConfig(t *testing.T) { setup() _ = os.Setenv("TEST_VAR", "default") cfgProvider := &configReader{ - orgService: orgFake, + orgService: orgService, encryptionService: encryptionService, log: log.New("test logger"), } @@ -282,7 +281,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Broken yaml should return error", func(t *testing.T) { reader := &configReader{ - orgService: orgFake, + orgService: orgService, encryptionService: encryptionService, log: log.New("test logger"), } @@ -293,7 +292,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Skip invalid directory", func(t *testing.T) { cfgProvider := &configReader{ - orgService: orgFake, + orgService: orgService, encryptionService: encryptionService, log: log.New("test logger"), } @@ -307,7 +306,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Unknown notifier should return error", func(t *testing.T) { cfgProvider := &configReader{ - orgService: orgFake, + orgService: orgService, encryptionService: encryptionService, log: log.New("test logger"), } @@ -318,7 +317,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Read incorrect properties", func(t *testing.T) { cfgProvider := &configReader{ - orgService: orgFake, + orgService: orgService, encryptionService: encryptionService, log: log.New("test logger"), } diff --git a/pkg/services/publicdashboards/api/common_test.go b/pkg/services/publicdashboards/api/common_test.go index 2af9bf90ac3..116c952db6b 100644 --- a/pkg/services/publicdashboards/api/common_test.go +++ b/pkg/services/publicdashboards/api/common_test.go @@ -131,7 +131,7 @@ func buildQueryDataService(t *testing.T, cs datasources.CacheService, fpc *fakeP } return query.ProvideService( - nil, + setting.NewCfg(), cs, nil, &fakePluginRequestValidator{}, diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index cefd648e19e..5893f4573f5 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -3,8 +3,6 @@ package query import ( "context" "fmt" - "net/http" - "strings" "time" "github.com/grafana/grafana/pkg/api/dtos" @@ -23,10 +21,10 @@ import ( "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util/errutil" "github.com/grafana/grafana/pkg/util/proxyutil" + "golang.org/x/sync/errgroup" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "golang.org/x/sync/errgroup" ) const ( @@ -93,35 +91,60 @@ func (s *Service) QueryData(ctx context.Context, user *user.SignedInUser, skipCa return s.handleQuerySingleDatasource(ctx, user, parsedReq) } // If there are multiple datasources, handle their queries concurrently and return the aggregate result - byDataSource := parsedReq.parsedQueries - resp := backend.NewQueryDataResponse() + return s.executeConcurrentQueries(ctx, user, skipCache, reqDTO, parsedReq.parsedQueries) +} +// executeConcurrentQueries executes queries to multiple datasources concurrently and returns the aggregate result. +func (s *Service) executeConcurrentQueries(ctx context.Context, user *user.SignedInUser, skipCache bool, reqDTO dtos.MetricRequest, queriesbyDs map[string][]parsedQuery) (*backend.QueryDataResponse, error) { g, ctx := errgroup.WithContext(ctx) - results := make([]backend.Responses, len(byDataSource)) + g.SetLimit(8) // arbitrary limit to prevent too many concurrent requests + rchan := make(chan backend.Responses, len(queriesbyDs)) - for _, queries := range byDataSource { + // Create panic recovery function for loop below + recoveryFn := func(queries []*simplejson.Json) { + if r := recover(); r != nil { + var err error + s.log.Error("query datasource panic", "error", r, "stack", log.Stack(1)) + if theErr, ok := r.(error); ok { + err = theErr + } else if theErrString, ok := r.(string); ok { + err = fmt.Errorf(theErrString) + } else { + err = fmt.Errorf("unexpected error, see the server log for details") + } + // Due to the panic, there is no valid response for any query for this datasource. Append an error for each one. + rchan <- buildErrorResponses(err, queries) + } + } + + // Query each datasource concurrently + for _, queries := range queriesbyDs { rawQueries := make([]*simplejson.Json, len(queries)) for i := 0; i < len(queries); i++ { rawQueries[i] = queries[i].rawQuery } g.Go(func() error { subDTO := reqDTO.CloneWithQueries(rawQueries) + // Handle panics in the datasource qery + defer recoveryFn(subDTO.Queries) subResp, err := s.QueryData(ctx, user, skipCache, subDTO) - if err == nil { - results = append(results, subResp.Responses) + rchan <- subResp.Responses + } else { + // If there was an error, return an error response for each query for this datasource + rchan <- buildErrorResponses(err, subDTO.Queries) } - - return err + return nil }) } if err := g.Wait(); err != nil { return nil, err } - - for _, result := range results { + close(rchan) + resp := backend.NewQueryDataResponse() + for result := range rchan { for refId, dataResponse := range result { resp.Responses[refId] = dataResponse } @@ -130,13 +153,31 @@ func (s *Service) QueryData(ctx context.Context, user *user.SignedInUser, skipCa return resp, nil } +// buildErrorResponses applies the provided error to each query response in the list. These queries should all belong to the same datasource. +func buildErrorResponses(err error, queries []*simplejson.Json) backend.Responses { + er := backend.Responses{} + for _, query := range queries { + er[query.Get("refId").MustString("A")] = backend.DataResponse{ + Error: err, + } + } + return er +} + // handleExpressions handles POST /api/ds/query when there is an expression. func (s *Service) handleExpressions(ctx context.Context, user *user.SignedInUser, parsedReq *parsedRequest) (*backend.QueryDataResponse, error) { exprReq := expr.Request{ - OrgId: user.OrgID, Queries: []expr.Query{}, } + if user != nil { // for passthrough authentication, SSE does not authenticate + exprReq.User = adapters.BackendUserFromSignedInUser(user) + exprReq.OrgId = user.OrgID + } + + disallowedCookies := []string{s.cfg.LoginCookieName} + queryEnrichers := parsedReq.createDataSourceQueryEnrichers(ctx, user, s.oAuthTokenService, disallowedCookies) + for _, pq := range parsedReq.getFlattenedQueries() { if pq.datasource == nil { return nil, ErrMissingDataSourceInfo.Build(errutil.TemplateData{ @@ -157,6 +198,7 @@ func (s *Service) handleExpressions(ctx context.Context, user *user.SignedInUser From: pq.query.TimeRange.From, To: pq.query.TimeRange.To, }, + QueryEnricher: queryEnrichers[pq.datasource.Uid], }) } @@ -198,10 +240,11 @@ func (s *Service) handleQuerySingleDatasource(ctx context.Context, user *user.Si Queries: []backend.DataQuery{}, } + disallowedCookies := []string{s.cfg.LoginCookieName} middlewares := []httpclient.Middleware{} if parsedReq.httpRequest != nil { middlewares = append(middlewares, - httpclientprovider.ForwardedCookiesMiddleware(parsedReq.httpRequest.Cookies(), ds.AllowedCookies(), []string{s.cfg.LoginCookieName}), + httpclientprovider.ForwardedCookiesMiddleware(parsedReq.httpRequest.Cookies(), ds.AllowedCookies(), disallowedCookies), ) } @@ -218,7 +261,7 @@ func (s *Service) handleQuerySingleDatasource(ctx context.Context, user *user.Si } if parsedReq.httpRequest != nil { - proxyutil.ClearCookieHeader(parsedReq.httpRequest, ds.AllowedCookies(), []string{s.cfg.LoginCookieName}) + proxyutil.ClearCookieHeader(parsedReq.httpRequest, ds.AllowedCookies(), disallowedCookies) if cookieStr := parsedReq.httpRequest.Header.Get("Cookie"); cookieStr != "" { req.Headers["Cookie"] = cookieStr } @@ -233,82 +276,6 @@ func (s *Service) handleQuerySingleDatasource(ctx context.Context, user *user.Si return s.pluginClient.QueryData(ctx, req) } -type parsedQuery struct { - datasource *datasources.DataSource - query backend.DataQuery - rawQuery *simplejson.Json -} - -type parsedRequest struct { - hasExpression bool - parsedQueries map[string][]parsedQuery - dsTypes map[string]bool - httpRequest *http.Request -} - -func (pr parsedRequest) getFlattenedQueries() []parsedQuery { - queries := make([]parsedQuery, 0) - for _, pq := range pr.parsedQueries { - queries = append(queries, pq...) - } - return queries -} - -func (pr parsedRequest) validateRequest() error { - if pr.httpRequest == nil { - return nil - } - - if pr.hasExpression { - hasExpr := pr.httpRequest.URL.Query().Get("expression") - if hasExpr == "" || hasExpr == "true" { - return nil - } - return ErrQueryParamMismatch - } - - vals := splitHeaders(pr.httpRequest.Header.Values(HeaderDatasourceUID)) - count := len(vals) - if count > 0 { // header exists - if count != len(pr.parsedQueries) { - return ErrQueryParamMismatch - } - for _, t := range vals { - if pr.parsedQueries[t] == nil { - return ErrQueryParamMismatch - } - } - } - - vals = splitHeaders(pr.httpRequest.Header.Values(HeaderPluginID)) - count = len(vals) - if count > 0 { // header exists - if count != len(pr.dsTypes) { - return ErrQueryParamMismatch - } - for _, t := range vals { - if !pr.dsTypes[t] { - return ErrQueryParamMismatch - } - } - } - return nil -} - -func splitHeaders(headers []string) []string { - out := []string{} - for _, v := range headers { - if strings.Contains(v, ",") { - for _, sub := range strings.Split(v, ",") { - out = append(out, strings.TrimSpace(sub)) - } - } else { - out = append(out, v) - } - } - return out -} - // parseRequest parses a request into parsed queries grouped by datasource uid func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUser, skipCache bool, reqDTO dtos.MetricRequest) (*parsedRequest, error) { if len(reqDTO.Queries) == 0 { diff --git a/pkg/services/query/query_parsing.go b/pkg/services/query/query_parsing.go new file mode 100644 index 00000000000..9021d6a658c --- /dev/null +++ b/pkg/services/query/query_parsing.go @@ -0,0 +1,157 @@ +package query + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/oauthtoken" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util/proxyutil" + "golang.org/x/oauth2" +) + +type parsedQuery struct { + datasource *datasources.DataSource + query backend.DataQuery + rawQuery *simplejson.Json +} + +type parsedRequest struct { + hasExpression bool + parsedQueries map[string][]parsedQuery + dsTypes map[string]bool + httpRequest *http.Request +} + +func (pr parsedRequest) getFlattenedQueries() []parsedQuery { + queries := make([]parsedQuery, 0) + for _, pq := range pr.parsedQueries { + queries = append(queries, pq...) + } + return queries +} + +func (pr parsedRequest) validateRequest() error { + if pr.httpRequest == nil { + return nil + } + + if pr.hasExpression { + hasExpr := pr.httpRequest.URL.Query().Get("expression") + if hasExpr == "" || hasExpr == "true" { + return nil + } + return ErrQueryParamMismatch + } + + vals := splitHeaders(pr.httpRequest.Header.Values(HeaderDatasourceUID)) + count := len(vals) + if count > 0 { // header exists + if count != len(pr.parsedQueries) { + return ErrQueryParamMismatch + } + for _, t := range vals { + if pr.parsedQueries[t] == nil { + return ErrQueryParamMismatch + } + } + } + + vals = splitHeaders(pr.httpRequest.Header.Values(HeaderPluginID)) + count = len(vals) + if count > 0 { // header exists + if count != len(pr.dsTypes) { + return ErrQueryParamMismatch + } + for _, t := range vals { + if !pr.dsTypes[t] { + return ErrQueryParamMismatch + } + } + } + return nil +} + +func (pr parsedRequest) createDataSourceQueryEnrichers(ctx context.Context, signedInUser *user.SignedInUser, oAuthTokenService oauthtoken.OAuthTokenService, disallowedCookies []string) map[string]expr.QueryDataRequestEnricher { + datasourcesHeaderProvider := map[string]expr.QueryDataRequestEnricher{} + + if pr.httpRequest == nil { + return datasourcesHeaderProvider + } + + for uid, queries := range pr.parsedQueries { + if expr.IsDataSource(uid) { + continue + } + + if len(queries) == 0 || queries[0].datasource == nil { + continue + } + + if _, exists := datasourcesHeaderProvider[uid]; exists { + continue + } + + ds := queries[0].datasource + allowedCookies := ds.AllowedCookies() + clonedReq := pr.httpRequest.Clone(pr.httpRequest.Context()) + + var token *oauth2.Token + + if oAuthTokenService.IsOAuthPassThruEnabled(ds) { + token = oAuthTokenService.GetCurrentOAuthToken(ctx, signedInUser) + } + + datasourcesHeaderProvider[uid] = func(ctx context.Context, req *backend.QueryDataRequest) context.Context { + if len(req.Headers) == 0 { + req.Headers = map[string]string{} + } + + if len(allowedCookies) > 0 { + proxyutil.ClearCookieHeader(clonedReq, allowedCookies, disallowedCookies) + if cookieStr := clonedReq.Header.Get("Cookie"); cookieStr != "" { + req.Headers["Cookie"] = cookieStr + } + + ctx = httpclient.WithContextualMiddleware(ctx, httpclientprovider.ForwardedCookiesMiddleware(clonedReq.Cookies(), allowedCookies, disallowedCookies)) + } + + if token != nil { + req.Headers["Authorization"] = fmt.Sprintf("%s %s", token.Type(), token.AccessToken) + + idToken, ok := token.Extra("id_token").(string) + if ok && idToken != "" { + req.Headers["X-ID-Token"] = idToken + } + + ctx = httpclient.WithContextualMiddleware(ctx, httpclientprovider.ForwardedOAuthIdentityMiddleware(token)) + } + + return ctx + } + } + + return datasourcesHeaderProvider +} + +func splitHeaders(headers []string) []string { + out := []string{} + for _, v := range headers { + if strings.Contains(v, ",") { + for _, sub := range strings.Split(v, ",") { + out = append(out, strings.TrimSpace(sub)) + } + } else { + out = append(out, v) + } + } + return out +} diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index af81ea8f8b4..2dd56fde9cd 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -5,9 +5,11 @@ import ( "context" "errors" "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -16,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -33,9 +36,32 @@ import ( ) func TestParseMetricRequest(t *testing.T) { - tc := setup(t) - t.Run("Test a simple single datasource query", func(t *testing.T) { + tc := setup(t) + json, err := simplejson.NewJson([]byte(`{ + "keepCookies": [ "cookie1", "cookie3", "login" ] + }`)) + require.NoError(t, err) + tc.dataSourceCache.dsByUid = func(ctx context.Context, datasourceUID string, user *user.SignedInUser, skipCache bool) (*datasources.DataSource, error) { + if datasourceUID == "gIEkMvIVz" { + return &datasources.DataSource{ + Uid: "gIEkMvIVz", + JsonData: json, + }, nil + } + + return nil, nil + } + + token := &oauth2.Token{ + TokenType: "bearer", + AccessToken: "access-token", + } + token = token.WithExtra(map[string]interface{}{"id_token": "id-token"}) + + tc.oauthTokenService.passThruEnabled = true + tc.oauthTokenService.token = token + mr := metricRequestWithQueries(t, `{ "refId": "A", "datasource": { @@ -56,9 +82,61 @@ func TestParseMetricRequest(t *testing.T) { assert.Len(t, parsedReq.parsedQueries, 1) assert.Contains(t, parsedReq.parsedQueries, "gIEkMvIVz") assert.Len(t, parsedReq.getFlattenedQueries(), 2) + + t.Run("createDataSourceQueryEnrichers should return 0 enrichers when no HTTP request", func(t *testing.T) { + enrichers := parsedReq.createDataSourceQueryEnrichers(context.Background(), nil, tc.oauthTokenService, []string{}) + require.Empty(t, enrichers) + }) + + t.Run("createDataSourceQueryEnrichers should return 1 enricher", func(t *testing.T) { + parsedReq.httpRequest = httptest.NewRequest(http.MethodGet, "/", nil) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie1"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie2"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie3"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "login"}) + + enrichers := parsedReq.createDataSourceQueryEnrichers(context.Background(), nil, tc.oauthTokenService, []string{"login"}) + require.Len(t, enrichers, 1) + require.NotNil(t, enrichers["gIEkMvIVz"]) + req := &backend.QueryDataRequest{} + ctx := enrichers["gIEkMvIVz"](context.Background(), req) + require.Len(t, req.Headers, 3) + require.Equal(t, "Bearer access-token", req.Headers["Authorization"]) + require.Equal(t, "id-token", req.Headers["X-ID-Token"]) + require.Equal(t, "cookie1=; cookie3=", req.Headers["Cookie"]) + middlewares := httpclient.ContextualMiddlewareFromContext(ctx) + require.Len(t, middlewares, 2) + require.Equal(t, httpclientprovider.ForwardedCookiesMiddlewareName, middlewares[0].(httpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, httpclientprovider.ForwardedOAuthIdentityMiddlewareName, middlewares[1].(httpclient.MiddlewareName).MiddlewareName()) + }) }) t.Run("Test a single datasource query with expressions", func(t *testing.T) { + tc := setup(t) + json, err := simplejson.NewJson([]byte(`{ + "keepCookies": [ "cookie1", "cookie3", "login" ] + }`)) + require.NoError(t, err) + tc.dataSourceCache.dsByUid = func(ctx context.Context, datasourceUID string, user *user.SignedInUser, skipCache bool) (*datasources.DataSource, error) { + if datasourceUID == "gIEkMvIVz" { + return &datasources.DataSource{ + Uid: "gIEkMvIVz", + JsonData: json, + }, nil + } + + return nil, nil + } + + token := &oauth2.Token{ + TokenType: "bearer", + AccessToken: "access-token", + } + token = token.WithExtra(map[string]interface{}{"id_token": "id-token"}) + + tc.oauthTokenService.passThruEnabled = true + tc.oauthTokenService.token = token + mr := metricRequestWithQueries(t, `{ "refId": "A", "datasource": { @@ -85,9 +163,68 @@ func TestParseMetricRequest(t *testing.T) { // Make sure we end up with something valid _, err = tc.queryService.handleExpressions(context.Background(), tc.signedInUser, parsedReq) assert.NoError(t, err) + + t.Run("createDataSourceQueryEnrichers should return 1 enricher", func(t *testing.T) { + parsedReq.httpRequest = httptest.NewRequest(http.MethodGet, "/", nil) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie1"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie2"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie3"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "login"}) + + enrichers := parsedReq.createDataSourceQueryEnrichers(context.Background(), nil, tc.oauthTokenService, []string{"login"}) + require.Len(t, enrichers, 1) + require.NotNil(t, enrichers["gIEkMvIVz"]) + + req := &backend.QueryDataRequest{} + ctx := enrichers["gIEkMvIVz"](context.Background(), req) + require.Len(t, req.Headers, 3) + require.Equal(t, "Bearer access-token", req.Headers["Authorization"]) + require.Equal(t, "id-token", req.Headers["X-ID-Token"]) + require.Equal(t, "cookie1=; cookie3=", req.Headers["Cookie"]) + middlewares := httpclient.ContextualMiddlewareFromContext(ctx) + require.Len(t, middlewares, 2) + require.Equal(t, httpclientprovider.ForwardedCookiesMiddlewareName, middlewares[0].(httpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, httpclientprovider.ForwardedOAuthIdentityMiddlewareName, middlewares[1].(httpclient.MiddlewareName).MiddlewareName()) + }) }) t.Run("Test a simple mixed datasource query", func(t *testing.T) { + tc := setup(t) + json, err := simplejson.NewJson([]byte(`{ + "keepCookies": [ "cookie1", "cookie3", "login" ] + }`)) + require.NoError(t, err) + json2, err := simplejson.NewJson([]byte(`{ + "keepCookies": [ "cookie2" ] + }`)) + require.NoError(t, err) + tc.dataSourceCache.dsByUid = func(ctx context.Context, datasourceUID string, user *user.SignedInUser, skipCache bool) (*datasources.DataSource, error) { + if datasourceUID == "gIEkMvIVz" { + return &datasources.DataSource{ + Uid: "gIEkMvIVz", + JsonData: json, + }, nil + } + + if datasourceUID == "sEx6ZvSVk" { + return &datasources.DataSource{ + Uid: "sEx6ZvSVk", + JsonData: json2, + }, nil + } + + return nil, nil + } + + token := &oauth2.Token{ + TokenType: "bearer", + AccessToken: "access-token", + } + token = token.WithExtra(map[string]interface{}{"id_token": "id-token"}) + + tc.oauthTokenService.passThruEnabled = true + tc.oauthTokenService.token = token + mr := metricRequestWithQueries(t, `{ "refId": "A", "datasource": { @@ -100,6 +237,12 @@ func TestParseMetricRequest(t *testing.T) { "uid": "sEx6ZvSVk", "type": "testdata" } + }`, `{ + "refId": "C", + "datasource": { + "uid": "sEx6ZvSVk", + "type": "testdata" + } }`) parsedReq, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) require.NoError(t, err) @@ -107,11 +250,51 @@ func TestParseMetricRequest(t *testing.T) { assert.False(t, parsedReq.hasExpression) assert.Len(t, parsedReq.parsedQueries, 2) assert.Contains(t, parsedReq.parsedQueries, "gIEkMvIVz") + assert.Len(t, parsedReq.parsedQueries["gIEkMvIVz"], 1) assert.Contains(t, parsedReq.parsedQueries, "sEx6ZvSVk") - assert.Len(t, parsedReq.getFlattenedQueries(), 2) + assert.Len(t, parsedReq.parsedQueries["sEx6ZvSVk"], 2) + assert.Len(t, parsedReq.getFlattenedQueries(), 3) + + t.Run("createDataSourceQueryEnrichers should return 2 enrichers", func(t *testing.T) { + parsedReq.httpRequest = httptest.NewRequest(http.MethodGet, "/", nil) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie1"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie2"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "cookie3"}) + parsedReq.httpRequest.AddCookie(&http.Cookie{Name: "login"}) + + enrichers := parsedReq.createDataSourceQueryEnrichers(context.Background(), nil, tc.oauthTokenService, []string{"login"}) + require.Len(t, enrichers, 2) + + enricherOne := enrichers["gIEkMvIVz"] + require.NotNil(t, enricherOne) + reqOne := &backend.QueryDataRequest{} + ctx := enricherOne(context.Background(), reqOne) + require.Len(t, reqOne.Headers, 3) + require.Equal(t, "Bearer access-token", reqOne.Headers["Authorization"]) + require.Equal(t, "id-token", reqOne.Headers["X-ID-Token"]) + require.Equal(t, "cookie1=; cookie3=", reqOne.Headers["Cookie"]) + middlewaresOne := httpclient.ContextualMiddlewareFromContext(ctx) + require.Len(t, middlewaresOne, 2) + require.Equal(t, httpclientprovider.ForwardedCookiesMiddlewareName, middlewaresOne[0].(httpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, httpclientprovider.ForwardedOAuthIdentityMiddlewareName, middlewaresOne[1].(httpclient.MiddlewareName).MiddlewareName()) + + enricherTwo := enrichers["sEx6ZvSVk"] + require.NotNil(t, enricherTwo) + reqTwo := &backend.QueryDataRequest{} + ctx = enricherTwo(context.Background(), reqTwo) + require.Len(t, reqTwo.Headers, 3) + require.Equal(t, "Bearer access-token", reqTwo.Headers["Authorization"]) + require.Equal(t, "id-token", reqTwo.Headers["X-ID-Token"]) + require.Equal(t, "cookie2=", reqTwo.Headers["Cookie"]) + middlewaresTwo := httpclient.ContextualMiddlewareFromContext(ctx) + require.Len(t, middlewaresTwo, 2) + require.Equal(t, httpclientprovider.ForwardedCookiesMiddlewareName, middlewaresTwo[0].(httpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, httpclientprovider.ForwardedOAuthIdentityMiddlewareName, middlewaresTwo[1].(httpclient.MiddlewareName).MiddlewareName()) + }) }) t.Run("Test a mixed datasource query with expressions", func(t *testing.T) { + tc := setup(t) mr := metricRequestWithQueries(t, `{ "refId": "A", "datasource": { @@ -169,9 +352,18 @@ func TestParseMetricRequest(t *testing.T) { // Make sure we end up with something valid _, err = tc.queryService.handleExpressions(context.Background(), tc.signedInUser, parsedReq) assert.NoError(t, err) + + t.Run("createDataSourceQueryEnrichers should return 2 enrichers", func(t *testing.T) { + parsedReq.httpRequest = &http.Request{} + enrichers := parsedReq.createDataSourceQueryEnrichers(context.Background(), nil, tc.oauthTokenService, []string{}) + require.Len(t, enrichers, 2) + require.NotNil(t, enrichers["gIEkMvIVz"]) + require.NotNil(t, enrichers["sEx6ZvSVk"]) + }) }) t.Run("Header validation", func(t *testing.T) { + tc := setup(t) mr := metricRequestWithQueries(t, `{ "refId": "A", "datasource": { @@ -214,7 +406,8 @@ func TestQueryDataMultipleSources(t *testing.T) { "datasource": { "type": "mysql", "uid": "ds1" - } + }, + "refId": "A" } `)) require.NoError(t, err) @@ -223,7 +416,8 @@ func TestQueryDataMultipleSources(t *testing.T) { "datasource": { "type": "mysql", "uid": "ds2" - } + }, + "refId": "B" } `)) require.NoError(t, err) @@ -244,7 +438,6 @@ func TestQueryDataMultipleSources(t *testing.T) { t.Run("can query multiple datasources with an expression present", func(t *testing.T) { tc := setup(t) - // refId does get set if not included, but better to include it explicitly here query1, err := simplejson.NewJson([]byte(` { "datasource": { @@ -260,7 +453,8 @@ func TestQueryDataMultipleSources(t *testing.T) { "datasource": { "type": "mysql", "uid": "ds2" - } + }, + "refId": "B" } `)) require.NoError(t, err) @@ -301,7 +495,7 @@ func TestQueryDataMultipleSources(t *testing.T) { require.NoError(t, err) }) - t.Run("error is returned when one of the queries fails", func(t *testing.T) { + t.Run("error is returned in query when one of the queries fails", func(t *testing.T) { tc := setup(t) query1, _ := simplejson.NewJson([]byte(` @@ -309,7 +503,8 @@ func TestQueryDataMultipleSources(t *testing.T) { "datasource": { "type": "mysql", "uid": "ds1" - } + }, + "refId": "A" } `)) query2, _ := simplejson.NewJson([]byte(` @@ -318,6 +513,7 @@ func TestQueryDataMultipleSources(t *testing.T) { "type": "prometheus", "uid": "ds2" }, + "refId": "B", "queryType": "FAIL" } `)) @@ -333,9 +529,12 @@ func TestQueryDataMultipleSources(t *testing.T) { HTTPRequest: nil, } - _, err := tc.queryService.QueryData(context.Background(), tc.signedInUser, true, reqDTO) + res, err := tc.queryService.QueryData(context.Background(), tc.signedInUser, true, reqDTO) - require.Error(t, err) + require.NoError(t, err) + require.Error(t, res.Responses["B"].Error) + // Responses aren't mocked, so a "healthy" query will just return an empty response + require.NotContains(t, res.Responses, "A") }) } @@ -351,7 +550,12 @@ func TestQueryData(t *testing.T) { tc.oauthTokenService.passThruEnabled = true tc.oauthTokenService.token = token - _, err := tc.queryService.QueryData(context.Background(), nil, true, metricRequest()) + metricReq := metricRequest() + httpReq, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + metricReq.HTTPRequest = httpReq + + _, err = tc.queryService.QueryData(context.Background(), nil, true, metricReq) require.Nil(t, err) expected := map[string]string{ @@ -523,7 +727,8 @@ func (ts *fakeOAuthTokenService) InvalidateOAuthTokens(context.Context, *models. } type fakeDataSourceCache struct { - ds *datasources.DataSource + ds *datasources.DataSource + dsByUid func(ctx context.Context, datasourceUID string, user *user.SignedInUser, skipCache bool) (*datasources.DataSource, error) } func (c *fakeDataSourceCache) GetDatasource(ctx context.Context, datasourceID int64, user *user.SignedInUser, skipCache bool) (*datasources.DataSource, error) { @@ -531,6 +736,10 @@ func (c *fakeDataSourceCache) GetDatasource(ctx context.Context, datasourceID in } func (c *fakeDataSourceCache) GetDatasourceByUID(ctx context.Context, datasourceUID string, user *user.SignedInUser, skipCache bool) (*datasources.DataSource, error) { + if c.dsByUid != nil { + return c.dsByUid(ctx, datasourceUID, user, skipCache) + } + return &datasources.DataSource{ Uid: datasourceUID, }, nil diff --git a/pkg/services/querylibrary/querylibraryimpl/service.go b/pkg/services/querylibrary/querylibraryimpl/service.go index cf3cc193491..6078c41af82 100644 --- a/pkg/services/querylibrary/querylibraryimpl/service.go +++ b/pkg/services/querylibrary/querylibraryimpl/service.go @@ -262,8 +262,16 @@ func (s *service) GetBatch(ctx context.Context, user *user.SignedInUser, uids [] func (s *service) Update(ctx context.Context, user *user.SignedInUser, query *querylibrary.Query) error { if query.UID == "" { - query.UID = util.GenerateShortUID() + queriesWithTheSameTitle, err := s.Search(ctx, user, querylibrary.QuerySearchOptions{Query: query.Title}) + if err != nil { + return err + } + if len(queriesWithTheSameTitle) != 0 { + return fmt.Errorf("can't create query with title '%s'. existing query with similar name: '%s'", query.Title, queriesWithTheSameTitle[0].Title) + } + + query.UID = util.GenerateShortUID() return s.collection.Insert(ctx, namespaceFromUser(user), query) } diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 6614bcbdaf9..54d5c127cd1 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -479,6 +479,6 @@ func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, annotationstest.NewFakeAnnotationsRepo(), ) require.NoError(t, err) - _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService) + _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService, storesrv.ProvideSystemUsersService()) require.NoError(t, err) } diff --git a/pkg/services/searchV2/index.go b/pkg/services/searchV2/index.go index 7657bd9f115..15632368411 100644 --- a/pkg/services/searchV2/index.go +++ b/pkg/services/searchV2/index.go @@ -54,7 +54,7 @@ type dashboard struct { updated time.Time // Use generic structure - summary *models.ObjectSummary + summary *models.EntitySummary } // buildSignal is sent when search index is accessed in organization for which @@ -913,7 +913,7 @@ func (l sqlDashboardLoader) LoadDashboards(ctx context.Context, orgID int64, das slug: "", created: time.Now(), updated: time.Now(), - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ //ID: 0, Name: "General", }, diff --git a/pkg/services/searchV2/index_test.go b/pkg/services/searchV2/index_test.go index 1ba992de27f..1e56e77ff7c 100644 --- a/pkg/services/searchV2/index_test.go +++ b/pkg/services/searchV2/index_test.go @@ -113,14 +113,14 @@ var testDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "test", }, }, { id: 2, uid: "2", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "boom", }, }, @@ -162,7 +162,7 @@ func TestDashboardIndexUpdates(t *testing.T) { err := index.updateDashboard(context.Background(), testOrgID, orgIdx, dashboard{ id: 3, uid: "3", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "created", }, }) @@ -181,7 +181,7 @@ func TestDashboardIndexUpdates(t *testing.T) { err := index.updateDashboard(context.Background(), testOrgID, orgIdx, dashboard{ id: 2, uid: "2", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "nginx", }, }) @@ -197,14 +197,14 @@ var testSortDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "a-test", }, }, { id: 2, uid: "2", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "z-test", }, }, @@ -288,14 +288,14 @@ var testPrefixDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "Archer Data System", }, }, { id: 2, uid: "2", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "Document Sync repo", }, }, @@ -366,7 +366,7 @@ var longPrefixDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "Eyjafjallajökull Eruption data", }, }, @@ -385,14 +385,14 @@ var scatteredTokensDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "Three can keep a secret, if two of them are dead (Benjamin Franklin)", }, }, { id: 3, uid: "2", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "A secret is powerful when it is empty (Umberto Eco)", }, }, @@ -418,7 +418,7 @@ var dashboardsWithFolders = []dashboard{ id: 1, uid: "1", isFolder: true, - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "My folder", }, }, @@ -426,9 +426,9 @@ var dashboardsWithFolders = []dashboard{ id: 2, uid: "2", folderID: 1, - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "Dashboard in folder 1", - Nested: []*models.ObjectSummary{ + Nested: []*models.EntitySummary{ newNestedPanel(1, "Panel 1"), newNestedPanel(2, "Panel 2"), }, @@ -438,9 +438,9 @@ var dashboardsWithFolders = []dashboard{ id: 3, uid: "3", folderID: 1, - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "Dashboard in folder 2", - Nested: []*models.ObjectSummary{ + Nested: []*models.EntitySummary{ newNestedPanel(3, "Panel 3"), }, }, @@ -448,9 +448,9 @@ var dashboardsWithFolders = []dashboard{ { id: 4, uid: "4", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "One more dash", - Nested: []*models.ObjectSummary{ + Nested: []*models.EntitySummary{ newNestedPanel(4, "Panel 4"), }, }, @@ -505,9 +505,9 @@ var dashboardsWithPanels = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "My Dash", - Nested: []*models.ObjectSummary{ + Nested: []*models.EntitySummary{ newNestedPanel(1, "Panel 1"), newNestedPanel(2, "Panel 2"), }, @@ -515,8 +515,8 @@ var dashboardsWithPanels = []dashboard{ }, } -func newNestedPanel(id int64, name string) *models.ObjectSummary { - summary := &models.ObjectSummary{ +func newNestedPanel(id int64, name string) *models.EntitySummary { + summary := &models.EntitySummary{ Kind: "panel", UID: fmt.Sprintf("???#%d", id), } @@ -553,14 +553,14 @@ var punctuationSplitNgramDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "heat-torkel", }, }, { id: 2, uid: "2", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "topology heatmap", }, }, @@ -586,7 +586,7 @@ var camelCaseNgramDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: "heatTorkel", }, }, @@ -608,7 +608,7 @@ func dashboardsWithTitles(names ...string) []dashboard { out = append(out, dashboard{ id: no, uid: fmt.Sprintf("%d", no), - summary: &models.ObjectSummary{ + summary: &models.EntitySummary{ Name: name, }, }) diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 745209c09ed..549e5001d6c 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -21,31 +21,34 @@ import ( ) type ServiceAccountsAPI struct { - cfg *setting.Cfg - service serviceaccounts.Service - accesscontrol accesscontrol.AccessControl - RouterRegister routing.RouteRegister - store serviceaccounts.Store - log log.Logger - permissionService accesscontrol.ServiceAccountPermissionsService + cfg *setting.Cfg + service serviceaccounts.Service + accesscontrol accesscontrol.AccessControl + accesscontrolService accesscontrol.Service + RouterRegister routing.RouteRegister + store serviceaccounts.Store + log log.Logger + permissionService accesscontrol.ServiceAccountPermissionsService } func NewServiceAccountsAPI( cfg *setting.Cfg, service serviceaccounts.Service, accesscontrol accesscontrol.AccessControl, + accesscontrolService accesscontrol.Service, routerRegister routing.RouteRegister, store serviceaccounts.Store, permissionService accesscontrol.ServiceAccountPermissionsService, ) *ServiceAccountsAPI { return &ServiceAccountsAPI{ - cfg: cfg, - service: service, - accesscontrol: accesscontrol, - RouterRegister: routerRegister, - store: store, - log: log.New("serviceaccounts.api"), - permissionService: permissionService, + cfg: cfg, + service: service, + accesscontrol: accesscontrol, + accesscontrolService: accesscontrolService, + RouterRegister: routerRegister, + store: store, + log: log.New("serviceaccounts.api"), + permissionService: permissionService, } } @@ -127,6 +130,10 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon return response.Error(http.StatusInternalServerError, "Failed to set permissions for service account creator", err) } } + + // Clear permission cache for the user who's created the service account, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly created object + api.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) } return response.JSON(http.StatusCreated, serviceAccount) diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 8abeeb43789..92130e02611 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" @@ -60,8 +61,8 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { store.Cfg.AutoAssignOrg = autoAssignOrg }() - orgCmd := &models.CreateOrgCommand{Name: "Some Test Org"} - err = store.CreateOrg(context.Background(), orgCmd) + orgCmd := &org.CreateOrgCommand{Name: "Some Test Org"} + _, err = orgService.CreateWithMember(context.Background(), orgCmd) require.Nil(t, err) type testCreateSATestCase struct { @@ -296,8 +297,9 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, saPermissionService, err := ossaccesscontrol.ProvideServiceAccountPermissions( cfg, routing.NewRouteRegister(), sqlStore, acmock, &licensing.OSSLicensingService{}, saStore, acmock, teamSvc, userSvc) require.NoError(t, err) + acService := actest.FakeService{} - a := NewServiceAccountsAPI(cfg, svc, acmock, routerRegister, saStore, saPermissionService) + a := NewServiceAccountsAPI(cfg, svc, acmock, acService, routerRegister, saStore, saPermissionService) a.RegisterAPIEndpoints() a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index be9011ed9bc..49be2c2c536 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -43,13 +42,13 @@ func TestStore_CreateServiceAccountOrgNonExistant(t *testing.T) { func TestStore_CreateServiceAccount(t *testing.T) { _, store := setupTestDatabase(t) - orgQuery := &models.CreateOrgCommand{Name: sqlstore.MainOrgName} - err := store.sqlStore.CreateOrg(context.Background(), orgQuery) + orgQuery := &org.CreateOrgCommand{Name: sqlstore.MainOrgName} + orgResult, err := store.orgService.CreateWithMember(context.Background(), orgQuery) require.NoError(t, err) t.Run("create service account", func(t *testing.T) { serviceAccountName := "new Service Account" - serviceAccountOrgId := orgQuery.Result.Id + serviceAccountOrgId := orgResult.ID serviceAccountRole := org.RoleAdmin isDisabled := true saForm := serviceaccounts.CreateServiceAccountForm{ @@ -175,7 +174,7 @@ func TestStore_MigrateApiKeys(t *testing.T) { store.sqlStore.Cfg.AutoAssignOrg = true store.sqlStore.Cfg.AutoAssignOrgId = 1 store.sqlStore.Cfg.AutoAssignOrgRole = "Viewer" - err := store.sqlStore.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "main"}) + _, err := store.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "main"}) require.NoError(t, err) key := tests.SetupApiKey(t, db, c.key) err = store.MigrateApiKey(context.Background(), key.OrgId, key.Id) @@ -252,7 +251,7 @@ func TestStore_MigrateAllApiKeys(t *testing.T) { store.sqlStore.Cfg.AutoAssignOrg = true store.sqlStore.Cfg.AutoAssignOrgId = 1 store.sqlStore.Cfg.AutoAssignOrgRole = "Viewer" - err := store.sqlStore.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "main"}) + _, err := store.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "main"}) require.NoError(t, err) for _, key := range c.keys { @@ -314,7 +313,7 @@ func TestStore_RevertApiKey(t *testing.T) { store.sqlStore.Cfg.AutoAssignOrg = true store.sqlStore.Cfg.AutoAssignOrgId = 1 store.sqlStore.Cfg.AutoAssignOrgRole = "Viewer" - err := store.sqlStore.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "main"}) + _, err := store.orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "main"}) require.NoError(t, err) key := tests.SetupApiKey(t, db, c.key) diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 1301bc90659..4365fcd52af 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -51,7 +51,7 @@ func ProvideServiceAccountsService( usageStats.RegisterMetricsFunc(s.getUsageMetrics) - serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store, permissionService) + serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, s.store, permissionService) serviceaccountsAPI.RegisterAPIEndpoints() s.secretScanEnabled = cfg.SectionWithEnvOverrides("secretscan").Key("enabled").MustBool(false) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go index aa20a878aeb..16c12e48f33 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go @@ -119,9 +119,10 @@ func (m dashboardPermissionsMigrator) migratePermissions(dashboards []dashboard, m.mapPermission(d.ID, models.PERMISSION_VIEW, d.IsFolder)..., ) } else { - for _, a := range acls { - permissionMap[d.OrgID][getRoleName(a)] = append( - permissionMap[d.OrgID][getRoleName(a)], + for _, a := range deduplicateAcl(acls) { + roleName := getRoleName(a) + permissionMap[d.OrgID][roleName] = append( + permissionMap[d.OrgID][roleName], m.mapPermission(d.ID, a.Permission, d.IsFolder)..., ) } @@ -224,6 +225,39 @@ func getRoleName(p models.DashboardACL) string { return fmt.Sprintf("managed:builtins:%s:permissions", strings.ToLower(string(*p.Role))) } +func deduplicateAcl(acl []models.DashboardACL) []models.DashboardACL { + output := make([]models.DashboardACL, 0, len(acl)) + uniqueACL := map[string]models.DashboardACL{} + for _, item := range acl { + // acl items with userID or teamID is enforced to be unique by sql constraint, so we can skip those + if item.UserID > 0 || item.TeamID > 0 { + output = append(output, item) + continue + } + + // better to make sure so we don't panic + if item.Role == nil { + continue + } + + current, ok := uniqueACL[string(*item.Role)] + if !ok { + uniqueACL[string(*item.Role)] = item + continue + } + + if current.Permission < item.Permission { + uniqueACL[string(*item.Role)] = item + } + } + + for _, item := range uniqueACL { + output = append(output, item) + } + + return output +} + var _ migrator.CodeMigration = new(dashboardUidPermissionMigrator) type dashboardUidPermissionMigrator struct { diff --git a/pkg/services/sqlstore/migrations/object_store_mig.go b/pkg/services/sqlstore/migrations/entity_store_mig.go similarity index 59% rename from pkg/services/sqlstore/migrations/object_store_mig.go rename to pkg/services/sqlstore/migrations/entity_store_mig.go index 6f87b1d6265..baa40ee41d0 100644 --- a/pkg/services/sqlstore/migrations/object_store_mig.go +++ b/pkg/services/sqlstore/migrations/entity_store_mig.go @@ -8,33 +8,34 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func getKeyColumn(name string, isPrimaryKey bool) *migrator.Column { +func getLatinPathColumn(name string) *migrator.Column { return &migrator.Column{ - Name: name, - Type: migrator.DB_NVarchar, - Length: 1024, - Nullable: false, - IsPrimaryKey: isPrimaryKey, - IsLatin: true, // only used in MySQL + Name: name, + Type: migrator.DB_NVarchar, + Length: 1024, + Nullable: false, + IsLatin: true, // only used in MySQL } } -func addObjectStorageMigrations(mg *migrator.Migrator) { +func addEntityStoreMigrations(mg *migrator.Migrator) { + grnLength := 256 // len(tenant)~8 + len(kind)!16 + len(kind)~128 = 256 tables := []migrator.Table{} tables = append(tables, migrator.Table{ - Name: "object", + Name: "entity", Columns: []*migrator.Column{ - // Object path contains everything required to make it unique across all instances - // orgId + scope + kind + uid - getKeyColumn("path", true), + // Object ID (OID) will be unique across all objects/instances + // uuid5( tenant_id, kind + uid ) + {Name: "grn", Type: migrator.DB_NVarchar, Length: grnLength, Nullable: false, IsPrimaryKey: true}, - // This is an optimization for listing everything at the same level in the object store - getKeyColumn("parent_folder_path", false), - - // The object type + // The entity identifier + {Name: "tenant_id", Type: migrator.DB_BigInt, Nullable: false}, {Name: "kind", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, + {Name: "uid", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, + {Name: "folder", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, + {Name: "slug", Type: migrator.DB_NVarchar, Length: 189, Nullable: false}, // from title - // The raw object body (any byte array) + // The raw entity body (any byte array) {Name: "body", Type: migrator.DB_LongBlob, Nullable: false}, {Name: "size", Type: migrator.DB_BigInt, Nullable: false}, {Name: "etag", Type: migrator.DB_NVarchar, Length: 32, Nullable: false, IsLatin: true}, // md5(body) @@ -47,7 +48,8 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { {Name: "created_by", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, // Mark objects with origin metadata - {Name: "origin", Type: migrator.DB_Text, Nullable: true}, + {Name: "origin", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, + getLatinPathColumn("origin_key"), // index with length 1024 {Name: "origin_ts", Type: migrator.DB_BigInt, Nullable: false}, // Summary data (always extracted from the `body` column) @@ -58,28 +60,46 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { {Name: "errors", Type: migrator.DB_Text, Nullable: true}, // JSON object }, Indices: []*migrator.Index{ - {Cols: []string{"parent_folder_path"}}, // list in folder - {Cols: []string{"kind"}}, // filter by type + {Cols: []string{"kind"}}, + {Cols: []string{"folder"}}, + {Cols: []string{"uid"}}, + + {Cols: []string{"tenant_id", "kind", "uid"}, Type: migrator.UniqueIndex}, + // {Cols: []string{"tenant_id", "folder", "slug"}, Type: migrator.UniqueIndex}, + }, + }) + + // when saving a folder, keep a path version cached + tables = append(tables, migrator.Table{ + Name: "entity_folder", + Columns: []*migrator.Column{ + {Name: "grn", Type: migrator.DB_NVarchar, Length: grnLength, Nullable: false}, + getLatinPathColumn("path"), // slug/slug/slug/... + {Name: "depth", Type: migrator.DB_Int, Nullable: false}, + {Name: "tree", Type: migrator.DB_Text, Nullable: false}, // JSON array from root + }, + Indices: []*migrator.Index{ + {Cols: []string{"path"}, Type: migrator.UniqueIndex}, }, }) tables = append(tables, migrator.Table{ - Name: "object_labels", + Name: "entity_labels", Columns: []*migrator.Column{ - getKeyColumn("path", false), + {Name: "grn", Type: migrator.DB_NVarchar, Length: grnLength, Nullable: false}, {Name: "label", Type: migrator.DB_NVarchar, Length: 191, Nullable: false}, {Name: "value", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, }, Indices: []*migrator.Index{ - {Cols: []string{"path", "label"}, Type: migrator.UniqueIndex}, + {Cols: []string{"grn", "label"}, Type: migrator.UniqueIndex}, }, }) tables = append(tables, migrator.Table{ - Name: "object_ref", + Name: "entity_ref", Columns: []*migrator.Column{ // Source: - getKeyColumn("path", false), + {Name: "grn", Type: migrator.DB_NVarchar, Length: grnLength, Nullable: false}, // Address (defined in the body, not resolved, may be invalid and change) {Name: "kind", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, @@ -88,21 +108,21 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { // Runtime calcs (will depend on the system state) {Name: "resolved_ok", Type: migrator.DB_Bool, Nullable: false}, - getKeyColumn("resolved_to", false), + {Name: "resolved_to", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, {Name: "resolved_warning", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, {Name: "resolved_time", Type: migrator.DB_DateTime, Nullable: false}, // resolution cache timestamp }, Indices: []*migrator.Index{ - {Cols: []string{"path"}, Type: migrator.IndexType}, + {Cols: []string{"grn"}, Type: migrator.IndexType}, {Cols: []string{"kind"}, Type: migrator.IndexType}, {Cols: []string{"resolved_to"}, Type: migrator.IndexType}, }, }) tables = append(tables, migrator.Table{ - Name: "object_history", + Name: "entity_history", Columns: []*migrator.Column{ - getKeyColumn("path", false), + {Name: "grn", Type: migrator.DB_NVarchar, Length: grnLength, Nullable: false}, {Name: "version", Type: migrator.DB_NVarchar, Length: 128, Nullable: false}, // Raw bytes @@ -118,7 +138,7 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { {Name: "message", Type: migrator.DB_Text, Nullable: false}, // defaults to empty string }, Indices: []*migrator.Index{ - {Cols: []string{"path", "version"}, Type: migrator.UniqueIndex}, + {Cols: []string{"grn", "version"}, Type: migrator.UniqueIndex}, {Cols: []string{"updated_by"}, Type: migrator.IndexType}, }, }) @@ -134,25 +154,28 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { // Migration cleanups: given that this is a complex setup // that requires a lot of testing before we are ready to push out of dev // this script lets us easy wipe previous changes and initialize clean tables - suffix := " (v5)" // change this when we want to wipe and reset the object tables - mg.AddMigration("ObjectStore init: cleanup"+suffix, migrator.NewRawSQLMigration(strings.TrimSpace(` + suffix := " (v8)" // change this when we want to wipe and reset the object tables + mg.AddMigration("EntityStore init: cleanup"+suffix, migrator.NewRawSQLMigration(strings.TrimSpace(` + DELETE FROM migration_log WHERE migration_id LIKE 'EntityStore init%'; + `))) + // for a while this was called "ObjectStore"... this can be removed before we remove the dev only flags + mg.AddMigration("EntityStore init: object cleanup"+suffix, migrator.NewRawSQLMigration(strings.TrimSpace(` DELETE FROM migration_log WHERE migration_id LIKE 'ObjectStore init%'; `))) // Initialize all tables for t := range tables { - mg.AddMigration("ObjectStore init: drop "+tables[t].Name+suffix, migrator.NewRawSQLMigration( + mg.AddMigration("EntityStore init: drop "+tables[t].Name+suffix, migrator.NewRawSQLMigration( fmt.Sprintf("DROP TABLE IF EXISTS %s", tables[t].Name), )) - mg.AddMigration("ObjectStore init: table "+tables[t].Name+suffix, migrator.NewAddTableMigration(tables[t])) + mg.AddMigration("EntityStore init: table "+tables[t].Name+suffix, migrator.NewAddTableMigration(tables[t])) for i := range tables[t].Indices { - mg.AddMigration(fmt.Sprintf("ObjectStore init: index %s[%d]"+suffix, tables[t].Name, i), migrator.NewAddIndexMigration(tables[t], tables[t].Indices[i])) + mg.AddMigration(fmt.Sprintf("EntityStore init: index %s[%d]"+suffix, tables[t].Name, i), migrator.NewAddIndexMigration(tables[t], tables[t].Indices[i])) } } - // TODO: add collation support to `migrator.Column` - mg.AddMigration("ObjectStore init: set path collation in object tables"+suffix, migrator.NewRawSQLMigration(""). + mg.AddMigration("EntityStore init: set path collation in entity tables"+suffix, migrator.NewRawSQLMigration(""). // MySQL `utf8mb4_unicode_ci` collation is set in `mysql_dialect.go` // SQLite uses a `BINARY` collation by default - Postgres("ALTER TABLE object ALTER COLUMN path TYPE VARCHAR(1024) COLLATE \"C\";")) // Collate C - sorting done based on character code byte values + Postgres("ALTER TABLE entity_folder ALTER COLUMN path TYPE VARCHAR(1024) COLLATE \"C\";")) // Collate C - sorting done based on character code byte values } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index f0689a581c0..30b08f96067 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -83,8 +83,8 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { addCommentMigrations(mg) } - if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagObjectStore) { - addObjectStorageMigrations(mg) + if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagEntityStore) { + addEntityStoreMigrations(mg) } } diff --git a/pkg/services/sqlstore/migrations/ualert/dashboard.go b/pkg/services/sqlstore/migrations/ualert/dashboard.go index 917858ae788..aa6ff8be3ba 100644 --- a/pkg/services/sqlstore/migrations/ualert/dashboard.go +++ b/pkg/services/sqlstore/migrations/ualert/dashboard.go @@ -1,13 +1,10 @@ package ualert import ( - "encoding/base64" - "strings" "time" "github.com/grafana/grafana/pkg/components/simplejson" - - "github.com/gosimple/slug" + "github.com/grafana/grafana/pkg/infra/slugify" ) type dashboard struct { @@ -45,22 +42,7 @@ func (d *dashboard) setVersion(version int) { // UpdateSlug updates the slug func (d *dashboard) updateSlug() { title := d.Data.Get("title").MustString() - d.Slug = slugifyTitle(title) -} - -func slugifyTitle(title string) string { - s := slug.Make(strings.ToLower(title)) - if s == "" { - // If the dashboard name is only characters outside of the - // sluggable characters, the slug creation will return an - // empty string which will mess up URLs. This failsafe picks - // that up and creates the slug as a base64 identifier instead. - s = base64.RawURLEncoding.EncodeToString([]byte(title)) - if slug.MaxLength != 0 && len(s) > slug.MaxLength { - s = s[:slug.MaxLength] - } - } - return s + d.Slug = slugify.Slugify(title) } func newDashboardFromJson(data *simplejson.Json) *dashboard { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 9ce5d0990ea..6f6de30c37a 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -75,11 +75,6 @@ func (m *SQLStoreMock) GetUserProfile(ctx context.Context, query *models.GetUser return m.ExpectedError } -func (m *SQLStoreMock) GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = m.ExpectedSignedInUser - return m.ExpectedError -} - func (m *SQLStoreMock) CreateTeam(name string, email string, orgID int64) (models.Team, error) { return models.Team{ Name: name, diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index fdf4b1bae97..7ce730012f2 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -1,86 +1,17 @@ +// DO NOT ADD METHODS TO THIS FILES. SQLSTORE IS DEPRECATED AND WILL BE REMOVED. package sqlstore import ( - "context" "fmt" "time" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/org" - "xorm.io/xorm" ) // MainOrgName is the name of the main organization. const MainOrgName = "Main Org." -func isOrgNameTaken(name string, existingId int64, sess *DBSession) (bool, error) { - // check if org name is taken - var org models.Org - exists, err := sess.Where("name=?", name).Get(&org) - - if err != nil { - return false, nil - } - - if exists && existingId != org.Id { - return true, nil - } - - return false, nil -} - -func (ss *SQLStore) createOrg(ctx context.Context, name string, userID int64, engine *xorm.Engine) (models.Org, error) { - orga := models.Org{ - Name: name, - Created: time.Now(), - Updated: time.Now(), - } - if err := ss.inTransactionWithRetryCtx(ctx, engine, ss.bus, func(sess *DBSession) error { - if isNameTaken, err := isOrgNameTaken(name, 0, sess); err != nil { - return err - } else if isNameTaken { - return models.ErrOrgNameTaken - } - - if _, err := sess.Insert(&orga); err != nil { - return err - } - - user := models.OrgUser{ - OrgId: orga.Id, - UserId: userID, - Role: org.RoleAdmin, - Created: time.Now(), - Updated: time.Now(), - } - - _, err := sess.Insert(&user) - - sess.publishAfterCommit(&events.OrgCreated{ - Timestamp: orga.Created, - Id: orga.Id, - Name: orga.Name, - }) - - return err - }, 0); err != nil { - return orga, err - } - - return orga, nil -} - -func (ss *SQLStore) CreateOrg(ctx context.Context, cmd *models.CreateOrgCommand) error { - org, err := ss.createOrg(ctx, cmd.Name, cmd.UserId, ss.engine) - if err != nil { - return err - } - - cmd.Result = org - return nil -} - func verifyExistingOrg(sess *DBSession, orgId int64) error { var org models.Org has, err := sess.Where("id=?", orgId).Get(&org) diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 4a085f55e7d..2f79f4e4548 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -78,20 +78,6 @@ func TestIntegrationAccountDataAccess(t *testing.T) { require.NoError(t, err) }) - t.Run("Can get logged in user projection", func(t *testing.T) { - query := models.GetSignedInUserQuery{UserId: ac2.ID} - err := sqlStore.GetSignedInUser(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, query.Result.Email, "ac2@test.com") - require.Equal(t, query.Result.OrgID, ac2.OrgID) - require.Equal(t, query.Result.Name, "ac2 name") - require.Equal(t, query.Result.Login, "ac2") - require.EqualValues(t, query.Result.OrgRole, "Admin") - require.Equal(t, query.Result.OrgName, "ac2@test.com") - require.Equal(t, query.Result.IsGrafanaAdmin, true) - }) - t.Run("Can get user organizations", func(t *testing.T) { query := models.GetUserOrgListQuery{UserId: ac2.ID} err := sqlStore.GetUserOrgList(context.Background(), &query) @@ -100,37 +86,6 @@ func TestIntegrationAccountDataAccess(t *testing.T) { require.Equal(t, len(query.Result), 2) }) - t.Run("Can set using org", func(t *testing.T) { - cmd := models.SetUsingOrgCommand{UserId: ac2.ID, OrgId: ac1.OrgID} - err := sqlStore.SetUsingOrg(context.Background(), &cmd) - require.NoError(t, err) - - t.Run("SignedInUserQuery with a different org", func(t *testing.T) { - query := models.GetSignedInUserQuery{UserId: ac2.ID} - err := sqlStore.GetSignedInUser(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, query.Result.OrgID, ac1.OrgID) - require.Equal(t, query.Result.Email, "ac2@test.com") - require.Equal(t, query.Result.Name, "ac2 name") - require.Equal(t, query.Result.Login, "ac2") - require.Equal(t, query.Result.OrgName, "ac1@test.com") - }) - - // TODO: This test should be moved to user store - // t.Run("Should set last org as current when removing user from current", func(t *testing.T) { - // remCmd := models.RemoveOrgUserCommand{OrgId: ac1.OrgID, UserId: ac2.ID} - // err := sqlStore.RemoveOrgUser(context.Background(), &remCmd) - // require.NoError(t, err) - - // query := models.GetSignedInUserQuery{UserId: ac2.ID} - // err = sqlStore.GetSignedInUser(context.Background(), &query) - - // require.NoError(t, err) - // require.Equal(t, query.Result.OrgID, ac2.OrgID) - // }) - }) - t.Run("Given an org user with dashboard permissions", func(t *testing.T) { ac3cmd := user.CreateUserCommand{Login: "ac3", Email: "ac3@test.com", Name: "ac3 name", IsAdmin: false} ac3, err := sqlStore.CreateUser(context.Background(), ac3cmd) @@ -157,33 +112,6 @@ func TestIntegrationAccountDataAccess(t *testing.T) { DashboardID: dash2.Id, OrgID: ac3.OrgID, UserID: ac3.ID, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) - - // TODO: should be moved to dashboard service - // t.Run("When org user is deleted", func(t *testing.T) { - // cmdRemove := models.RemoveOrgUserCommand{OrgId: ac1.OrgID, UserId: ac3.ID} - // err := sqlStore.RemoveOrgUser(context.Background(), &cmdRemove) - // require.NoError(t, err) - - // t.Run("Should remove dependent permissions for deleted org user", func(t *testing.T) { - // permQuery := &models.GetDashboardACLInfoListQuery{DashboardID: dash1.Id, OrgID: ac1.OrgID} - - // err = getDashboardACLInfoList(sqlStore, permQuery) - // require.NoError(t, err) - - // require.Equal(t, len(permQuery.Result), 0) - // }) - - // t.Run("Should not remove dashboard permissions for same user in another org", func(t *testing.T) { - // permQuery := &models.GetDashboardACLInfoListQuery{DashboardID: dash2.Id, OrgID: ac3.OrgID} - - // err = getDashboardACLInfoList(sqlStore, permQuery) - // require.NoError(t, err) - - // require.Equal(t, len(permQuery.Result), 1) - // require.Equal(t, permQuery.Result[0].OrgId, ac3.OrgID) - // require.Equal(t, permQuery.Result[0].UserId, ac3.ID) - // }) - // }) }) }) }) diff --git a/pkg/services/sqlstore/session.go b/pkg/services/sqlstore/session.go index 8f9e2a63d37..9cd004f7e19 100644 --- a/pkg/services/sqlstore/session.go +++ b/pkg/services/sqlstore/session.go @@ -3,12 +3,14 @@ package sqlstore import ( "context" "errors" + "fmt" "reflect" "time" "xorm.io/xorm" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/util/errutil" "github.com/grafana/grafana/pkg/util/retryer" "github.com/mattn/go-sqlite3" @@ -131,6 +133,28 @@ func (sess *DBSession) InsertId(bean interface{}) (int64, error) { return id, nil } +func (sess *DBSession) WithReturningID(driverName string, query string, args []interface{}) (int64, error) { + supported := driverName != migrator.Postgres + var id int64 + if !supported { + query = fmt.Sprintf("%s RETURNING id", query) + if _, err := sess.SQL(query, args...).Get(&id); err != nil { + return id, err + } + } else { + sqlOrArgs := append([]interface{}{query}, args...) + res, err := sess.Exec(sqlOrArgs...) + if err != nil { + return id, err + } + id, err = res.LastInsertId() + if err != nil { + return id, err + } + } + return id, nil +} + func getTypeName(bean interface{}) (res string) { t := reflect.TypeOf(bean) for t.Kind() == reflect.Ptr { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 411c30d038b..be59253c184 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -489,7 +489,7 @@ var featuresEnabledDuringTests = []string{ featuremgmt.FlagDashboardPreviews, featuremgmt.FlagDashboardComments, featuremgmt.FlagPanelTitleSearch, - featuremgmt.FlagObjectStore, + featuremgmt.FlagEntityStore, } // InitTestDBWithMigration initializes the test DB given custom migrations. diff --git a/pkg/services/sqlstore/stats_integration_test.go b/pkg/services/sqlstore/stats_integration_test.go deleted file mode 100644 index 674c2674e91..00000000000 --- a/pkg/services/sqlstore/stats_integration_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package sqlstore - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/models" - "github.com/stretchr/testify/require" -) - -func TestIntegration_GetAdminStats(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - sqlStore := InitTestDB(t) - - query := models.GetAdminStatsQuery{} - err := sqlStore.GetAdminStats(context.Background(), &query) - require.NoError(t, err) -} diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 16ca3e885aa..77dfe576391 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -12,15 +12,9 @@ import ( ) type Store interface { - GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error - GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error - GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error - GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error GetDialect() migrator.Dialect GetDBType() core.DbType - GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error CreateUser(ctx context.Context, cmd user.CreateUserCommand) (*user.User, error) - GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error WithDbSession(ctx context.Context, callback DBTransactionFunc) error WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error WithTransactionalDbSession(ctx context.Context, callback DBTransactionFunc) error diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 9b97b5fda9c..fdeede15c91 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -1,17 +1,14 @@ +// DO NOT ADD METHODS TO THIS FILES. SQLSTORE IS DEPRECATED AND WILL BE REMOVED. package sqlstore import ( - "bytes" "context" "fmt" "sort" - "strconv" "strings" - "time" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" - ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -160,28 +157,6 @@ func NotServiceAccountFilter(ss *SQLStore) string { ss.Dialect.BooleanStr(false)) } -// deprecated method, use only for tests -func (ss *SQLStore) SetUsingOrg(ctx context.Context, cmd *models.SetUsingOrgCommand) error { - getOrgsForUserCmd := &models.GetUserOrgListQuery{UserId: cmd.UserId} - if err := ss.GetUserOrgList(ctx, getOrgsForUserCmd); err != nil { - return err - } - - valid := false - for _, other := range getOrgsForUserCmd.Result { - if other.OrgId == cmd.OrgId { - valid = true - } - } - if !valid { - return fmt.Errorf("user does not belong to org") - } - - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - return setUsingOrgInTransaction(sess, cmd.UserId, cmd.OrgId) - }) -} - func setUsingOrgInTransaction(sess *DBSession, userID int64, orgID int64) error { user := user.User{ ID: userID, @@ -229,235 +204,6 @@ func (ss *SQLStore) GetUserOrgList(ctx context.Context, query *models.GetUserOrg }) } -func newSignedInUserCacheKey(orgID, userID int64) string { - return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID) -} - -// deprecated method, use only for tests -func (ss *SQLStore) GetSignedInUserWithCacheCtx(ctx context.Context, query *models.GetSignedInUserQuery) error { - cacheKey := newSignedInUserCacheKey(query.OrgId, query.UserId) - if cached, found := ss.CacheService.Get(cacheKey); found { - cachedUser := cached.(user.SignedInUser) - query.Result = &cachedUser - return nil - } - - err := ss.GetSignedInUser(ctx, query) - if err != nil { - return err - } - - cacheKey = newSignedInUserCacheKey(query.Result.OrgID, query.UserId) - ss.CacheService.Set(cacheKey, *query.Result, time.Second*5) - return nil -} - -func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error { - return ss.WithDbSession(ctx, func(dbSess *DBSession) error { - orgId := "u.org_id" - if query.OrgId > 0 { - orgId = strconv.FormatInt(query.OrgId, 10) - } - - var rawSQL = `SELECT - u.id as user_id, - u.is_admin as is_grafana_admin, - u.email as email, - u.login as login, - u.name as name, - u.is_disabled as is_disabled, - u.help_flags1 as help_flags1, - u.last_seen_at as last_seen_at, - (SELECT COUNT(*) FROM org_user where org_user.user_id = u.id) as org_count, - user_auth.auth_module as external_auth_module, - user_auth.auth_id as external_auth_id, - org.name as org_name, - org_user.role as org_role, - org.id as org_id - FROM ` + dialect.Quote("user") + ` as u - LEFT OUTER JOIN user_auth on user_auth.user_id = u.id - LEFT OUTER JOIN org_user on org_user.org_id = ` + orgId + ` and org_user.user_id = u.id - LEFT OUTER JOIN org on org.id = org_user.org_id ` - - sess := dbSess.Table("user") - sess = sess.Context(ctx) - switch { - case query.UserId > 0: - sess.SQL(rawSQL+"WHERE u.id=?", query.UserId) - case query.Login != "": - if ss.Cfg.CaseInsensitiveLogin { - sess.SQL(rawSQL+"WHERE LOWER(u.login)=LOWER(?)", query.Login) - } else { - sess.SQL(rawSQL+"WHERE u.login=?", query.Login) - } - case query.Email != "": - if ss.Cfg.CaseInsensitiveLogin { - sess.SQL(rawSQL+"WHERE LOWER(u.email)=LOWER(?)", query.Email) - } else { - sess.SQL(rawSQL+"WHERE u.email=?", query.Email) - } - } - - var usr user.SignedInUser - has, err := sess.Get(&usr) - if err != nil { - return err - } else if !has { - return user.ErrUserNotFound - } - - if usr.OrgRole == "" { - usr.OrgID = -1 - usr.OrgName = "Org missing" - } - - if usr.ExternalAuthModule != "oauth_grafana_com" { - usr.ExternalAuthID = "" - } - - // tempUser is used to retrieve the teams for the signed in user for internal use. - tempUser := &user.SignedInUser{ - OrgID: usr.OrgID, - Permissions: map[int64]map[string][]string{ - usr.OrgID: { - ac.ActionTeamsRead: {ac.ScopeTeamsAll}, - }, - }, - } - getTeamsByUserQuery := &models.GetTeamsByUserQuery{ - OrgId: usr.OrgID, - UserId: usr.UserID, - SignedInUser: tempUser, - } - err = ss.GetTeamsByUser(ctx, getTeamsByUserQuery) - if err != nil { - return err - } - - usr.Teams = make([]int64, len(getTeamsByUserQuery.Result)) - for i, t := range getTeamsByUserQuery.Result { - usr.Teams[i] = t.Id - } - - query.Result = &usr - return err - }) -} - -// GetTeamsByUser is used by the Guardian when checking a users' permissions -// TODO: use team.Service after user service is split -func (ss *SQLStore) GetTeamsByUser(ctx context.Context, query *models.GetTeamsByUserQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - query.Result = make([]*models.TeamDTO, 0) - - var sql bytes.Buffer - var params []interface{} - params = append(params, query.OrgId, query.UserId) - - sql.WriteString(getTeamSelectSQLBase([]string{})) - sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`) - sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`) - - if !ac.IsDisabled(ss.Cfg) { - acFilter, err := ac.Filter(query.SignedInUser, "team.id", "teams:id:", ac.ActionTeamsRead) - if err != nil { - return err - } - sql.WriteString(` and` + acFilter.Where) - params = append(params, acFilter.Args...) - } - - err := sess.SQL(sql.String(), params...).Find(&query.Result) - return err - }) -} - -func getTeamMemberCount(filteredUsers []string) string { - if len(filteredUsers) > 0 { - return `(SELECT COUNT(*) FROM team_member - INNER JOIN ` + dialect.Quote("user") + ` ON team_member.user_id = ` + dialect.Quote("user") + `.id - WHERE team_member.team_id = team.id AND ` + dialect.Quote("user") + `.login NOT IN (?` + - strings.Repeat(",?", len(filteredUsers)-1) + ")" + - `) AS member_count ` - } - - return "(SELECT COUNT(*) FROM team_member WHERE team_member.team_id = team.id) AS member_count " -} - -func getTeamSelectSQLBase(filteredUsers []string) string { - return `SELECT - team.id as id, - team.org_id, - team.name as name, - team.email as email, ` + - getTeamMemberCount(filteredUsers) + - ` FROM team as team ` -} - -func (ss *SQLStore) DeleteUserInSession(ctx context.Context, sess *DBSession, cmd *models.DeleteUserCommand) error { - return deleteUserInTransaction(ss, sess, cmd) -} - -func deleteUserInTransaction(ss *SQLStore, sess *DBSession, cmd *models.DeleteUserCommand) error { - // Check if user exists - usr := user.User{ID: cmd.UserId} - has, err := sess.Where(NotServiceAccountFilter(ss)).Get(&usr) - if err != nil { - return err - } - if !has { - return user.ErrUserNotFound - } - for _, sql := range UserDeletions() { - _, err := sess.Exec(sql, cmd.UserId) - if err != nil { - return err - } - } - - return deleteUserAccessControl(sess, cmd.UserId) -} - -func deleteUserAccessControl(sess *DBSession, userID int64) error { - // Delete user role assignments - if _, err := sess.Exec("DELETE FROM user_role WHERE user_id = ?", userID); err != nil { - return err - } - - // Delete permissions that are scoped to user - if _, err := sess.Exec("DELETE FROM permission WHERE scope = ?", ac.Scope("users", "id", strconv.FormatInt(userID, 10))); err != nil { - return err - } - - var roleIDs []int64 - if err := sess.SQL("SELECT id FROM role WHERE name = ?", ac.ManagedUserRoleName(userID)).Find(&roleIDs); err != nil { - return err - } - - if len(roleIDs) == 0 { - return nil - } - - query := "DELETE FROM permission WHERE role_id IN(? " + strings.Repeat(",?", len(roleIDs)-1) + ")" - args := make([]interface{}, 0, len(roleIDs)+1) - args = append(args, query) - for _, id := range roleIDs { - args = append(args, id) - } - - // Delete managed user permissions - if _, err := sess.Exec(args...); err != nil { - return err - } - - // Delete managed user roles - if _, err := sess.Exec("DELETE FROM role WHERE name = ?", ac.ManagedUserRoleName(userID)); err != nil { - return err - } - - return nil -} - func UserDeletions() []string { deletes := []string{ "DELETE FROM star WHERE user_id = ?", diff --git a/pkg/services/stats/stats.go b/pkg/services/stats/stats.go new file mode 100644 index 00000000000..da2b9a49bf2 --- /dev/null +++ b/pkg/services/stats/stats.go @@ -0,0 +1,16 @@ +package stats + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +type Service interface { + GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error + GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error + GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error + GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error + GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error + GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error +} diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/stats/statsimpl/stats.go similarity index 77% rename from pkg/services/sqlstore/stats.go rename to pkg/services/stats/statsimpl/stats.go index f0a635e68f7..f5a0f79e91b 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -1,39 +1,48 @@ -package sqlstore +package statsimpl import ( "context" "strconv" "time" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/stats" ) const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 -func (ss *SQLStore) GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error { - return ss.WithDbSession(ctx, func(dbSession *DBSession) error { - var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type` +func ProvideService(db db.DB) stats.Service { + return &sqlStatsService{db: db} +} + +type sqlStatsService struct{ db db.DB } + +func (ss *sqlStatsService) GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error { + return ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + ss.db.GetDialect().Quote("alert_notification") + ` GROUP BY type` query.Result = make([]*models.NotifierUsageStats, 0) err := dbSession.SQL(rawSQL).Find(&query.Result) return err }) } -func (ss *SQLStore) GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error { - return ss.WithDbSession(ctx, func(dbSession *DBSession) error { - var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type` +func (ss *sqlStatsService) GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error { + return ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + ss.db.GetDialect().Quote("data_source") + ` GROUP BY type` query.Result = make([]*models.DataSourceStats, 0) err := dbSession.SQL(rawSQL).Find(&query.Result) return err }) } -func (ss *SQLStore) GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error { - return ss.WithDbSession(ctx, func(dbSession *DBSession) error { - var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access` +func (ss *sqlStatsService) GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error { + return ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + ss.db.GetDialect().Quote("data_source") + ` GROUP BY type, access` query.Result = make([]*models.DataSourceAccessStats, 0) err := dbSession.SQL(rawSQL).Find(&query.Result) return err @@ -45,10 +54,11 @@ func notServiceAccount(dialect migrator.Dialect) string { dialect.BooleanStr(false) } -func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error { - return ss.WithDbSession(ctx, func(dbSession *DBSession) error { - sb := &SQLBuilder{} +func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error { + return ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + sb := &sqlstore.SQLBuilder{} sb.Write("SELECT ") + dialect := ss.db.GetDialect() sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE ` + notServiceAccount(dialect) + `) AS users,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) @@ -88,10 +98,10 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS WHERE d.is_folder = ? ) AS folder_permissions,`, dialect.BooleanStr(true)) - sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) - sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_edit", true, models.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) @@ -112,7 +122,7 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS sb.Write(ss.roleCounterSQL(ctx)) var stats models.SystemStats - _, err := dbSession.SQL(sb.GetSQLString(), sb.params...).Get(&stats) + _, err := dbSession.SQL(sb.GetSQLString(), sb.GetParams()...).Get(&stats) if err != nil { return err } @@ -123,7 +133,7 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS }) } -func (ss *SQLStore) roleCounterSQL(ctx context.Context) string { +func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string { const roleCounterTimeout = 20 * time.Second ctx, cancel := context.WithTimeout(ctx, roleCounterTimeout) defer cancel() @@ -142,7 +152,8 @@ func (ss *SQLStore) roleCounterSQL(ctx context.Context) string { return sqlQuery } -func viewersPermissionsCounterSQL(statName string, isFolder bool, permission models.PermissionType) string { +func viewersPermissionsCounterSQL(db db.DB, statName string, isFolder bool, permission models.PermissionType) string { + dialect := db.GetDialect() return `( SELECT COUNT(*) FROM ` + dialect.Quote("dashboard_acl") + ` AS acl @@ -154,8 +165,9 @@ func viewersPermissionsCounterSQL(statName string, isFolder bool, permission mod ) AS ` + statName + `, ` } -func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error { - return ss.WithDbSession(ctx, func(dbSession *DBSession) error { +func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error { + return ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.GetDialect() now := time.Now() activeEndDate := now.Add(-activeUserTimeLimit) dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) @@ -231,9 +243,9 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta }) } -func (ss *SQLStore) GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - var rawSQL = `SELECT COUNT(id) AS Count FROM ` + dialect.Quote("user") +func (ss *sqlStatsService) GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { + return ss.db.WithDbSession(ctx, func(sess *db.Session) error { + var rawSQL = `SELECT COUNT(id) AS Count FROM ` + ss.db.GetDialect().Quote("user") var stats models.SystemUserCountStats _, err := sess.SQL(rawSQL).Get(&stats) if err != nil { @@ -246,7 +258,7 @@ func (ss *SQLStore) GetSystemUserCountStats(ctx context.Context, query *models.G }) } -func (ss *SQLStore) updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error { +func (ss *sqlStatsService) updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error { memoizationPeriod := time.Now().Add(-userStatsCacheLimetime) if forced || userStatsCache.memoized.Before(memoizationPeriod) { err := ss.updateUserRoleCounts(ctx) @@ -270,8 +282,8 @@ var ( userStatsCacheLimetime = 5 * time.Minute ) -func (ss *SQLStore) updateUserRoleCounts(ctx context.Context) error { - return ss.WithDbSession(ctx, func(dbSession *DBSession) error { +func (ss *sqlStatsService) updateUserRoleCounts(ctx context.Context) error { + return ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { query := ` SELECT role AS bitrole, active, COUNT(role) AS count FROM (SELECT last_seen_at>? AS active, last_seen_at>? AS daily_active, SUM(role) AS role @@ -283,7 +295,7 @@ SELECT role AS bitrole, active, COUNT(role) AS count FROM ELSE 1 END AS role, u.last_seen_at - FROM ` + dialect.Quote("user") + ` AS u INNER JOIN org_user ON org_user.user_id = u.id + FROM ` + ss.db.GetDialect().Quote("user") + ` AS u INNER JOIN org_user ON org_user.user_id = u.id GROUP BY u.id, u.last_seen_at, org_user.role) AS t2 GROUP BY id, last_seen_at) AS t1 GROUP BY active, daily_active, role;` diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go similarity index 60% rename from pkg/services/sqlstore/stats_test.go rename to pkg/services/stats/statsimpl/stats_test.go index 1b0eca543e9..1017f870b35 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -1,28 +1,31 @@ -package sqlstore +package statsimpl import ( "context" "fmt" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIntegrationStatsDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore := InitTestDB(t) - populateDB(t, sqlStore) + db := sqlstore.InitTestDB(t) + statsService := &sqlStatsService{db: db} + populateDB(t, db) t.Run("Get system stats should not results in error", func(t *testing.T) { query := models.GetSystemStatsQuery{} - err := sqlStore.GetSystemStats(context.Background(), &query) + err := statsService.GetSystemStats(context.Background(), &query) require.NoError(t, err) assert.Equal(t, int64(3), query.Result.Users) assert.Equal(t, int64(0), query.Result.Editors) @@ -35,38 +38,40 @@ func TestIntegrationStatsDataAccess(t *testing.T) { t.Run("Get system user count stats should not results in error", func(t *testing.T) { query := models.GetSystemUserCountStatsQuery{} - err := sqlStore.GetSystemUserCountStats(context.Background(), &query) + err := statsService.GetSystemUserCountStats(context.Background(), &query) assert.NoError(t, err) }) t.Run("Get datasource stats should not results in error", func(t *testing.T) { query := models.GetDataSourceStatsQuery{} - err := sqlStore.GetDataSourceStats(context.Background(), &query) + err := statsService.GetDataSourceStats(context.Background(), &query) assert.NoError(t, err) }) t.Run("Get datasource access stats should not results in error", func(t *testing.T) { query := models.GetDataSourceAccessStatsQuery{} - err := sqlStore.GetDataSourceAccessStats(context.Background(), &query) + err := statsService.GetDataSourceAccessStats(context.Background(), &query) assert.NoError(t, err) }) t.Run("Get alert notifier stats should not results in error", func(t *testing.T) { query := models.GetAlertNotifierUsageStatsQuery{} - err := sqlStore.GetAlertNotifiersUsageStats(context.Background(), &query) + err := statsService.GetAlertNotifiersUsageStats(context.Background(), &query) assert.NoError(t, err) }) t.Run("Get admin stats should not result in error", func(t *testing.T) { query := models.GetAdminStatsQuery{} - err := sqlStore.GetAdminStats(context.Background(), &query) + err := statsService.GetAdminStats(context.Background(), &query) assert.NoError(t, err) }) } -func populateDB(t *testing.T, sqlStore *SQLStore) { +func populateDB(t *testing.T, sqlStore *sqlstore.SQLStore) { t.Helper() + orgService, _ := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) + users := make([]user.User, 3) for i := range users { cmd := user.CreateUserCommand{ @@ -81,33 +86,41 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { } // add 2nd user as editor - cmd := &models.AddOrgUserCommand{ - OrgId: users[0].OrgID, - UserId: users[1].ID, + cmd := &org.AddOrgUserCommand{ + OrgID: users[0].OrgID, + UserID: users[1].ID, Role: org.RoleEditor, } - err := sqlStore.addOrgUser(context.Background(), cmd) + err := orgService.AddOrgUser(context.Background(), cmd) require.NoError(t, err) // add 3rd user as viewer - cmd = &models.AddOrgUserCommand{ - OrgId: users[0].OrgID, - UserId: users[2].ID, + cmd = &org.AddOrgUserCommand{ + OrgID: users[0].OrgID, + UserID: users[2].ID, Role: org.RoleViewer, } - err = sqlStore.addOrgUser(context.Background(), cmd) + err = orgService.AddOrgUser(context.Background(), cmd) require.NoError(t, err) // add 1st user as admin - cmd = &models.AddOrgUserCommand{ - OrgId: users[1].OrgID, - UserId: users[0].ID, + cmd = &org.AddOrgUserCommand{ + OrgID: users[1].OrgID, + UserID: users[0].ID, Role: org.RoleAdmin, } - err = sqlStore.addOrgUser(context.Background(), cmd) + err = orgService.AddOrgUser(context.Background(), cmd) require.NoError(t, err) +} - // force renewal of user stats - err = sqlStore.updateUserRoleCountsIfNecessary(context.Background(), true) +func TestIntegration_GetAdminStats(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + db := sqlstore.InitTestDB(t) + statsService := ProvideService(db) + + query := models.GetAdminStatsQuery{} + err := statsService.GetAdminStats(context.Background(), &query) require.NoError(t, err) } diff --git a/pkg/services/stats/statstest/stats.go b/pkg/services/stats/statstest/stats.go new file mode 100644 index 00000000000..77822bbfffb --- /dev/null +++ b/pkg/services/stats/statstest/stats.go @@ -0,0 +1,48 @@ +package statstest + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +type FakeService struct { + ExpectedSystemStats *models.SystemStats + ExpectedDataSourceStats []*models.DataSourceStats + ExpectedDataSourcesAccessStats []*models.DataSourceAccessStats + ExpectedNotifierUsageStats []*models.NotifierUsageStats + + ExpectedError error +} + +func NewFakeService() *FakeService { + return &FakeService{} +} + +func (s *FakeService) GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error { + return s.ExpectedError +} + +func (s *FakeService) GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error { + query.Result = s.ExpectedNotifierUsageStats + return s.ExpectedError +} + +func (s *FakeService) GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error { + query.Result = s.ExpectedDataSourceStats + return s.ExpectedError +} + +func (s *FakeService) GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error { + query.Result = s.ExpectedDataSourcesAccessStats + return s.ExpectedError +} + +func (s *FakeService) GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error { + query.Result = s.ExpectedSystemStats + return s.ExpectedError +} + +func (s *FakeService) GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { + return s.ExpectedError +} diff --git a/pkg/services/store/object/dummy/dummy_server.go b/pkg/services/store/entity/dummy/dummy_server.go similarity index 51% rename from pkg/services/store/object/dummy/dummy_server.go rename to pkg/services/store/entity/dummy/dummy_server.go index 4e5fbe6f721..84a6e8544f3 100644 --- a/pkg/services/store/object/dummy/dummy_server.go +++ b/pkg/services/store/entity/dummy/dummy_server.go @@ -1,4 +1,4 @@ -package objectdummyserver +package dummy import ( "context" @@ -14,59 +14,59 @@ import ( "github.com/grafana/grafana/pkg/infra/x/persistentcollection" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind" - "github.com/grafana/grafana/pkg/services/store/object" "github.com/grafana/grafana/pkg/setting" ) -type ObjectVersionWithBody struct { - *object.ObjectVersionInfo `json:"info,omitempty"` +type EntityVersionWithBody struct { + *entity.EntityVersionInfo `json:"info,omitempty"` Body []byte `json:"body,omitempty"` } -type RawObjectWithHistory struct { - Object *object.RawObject `json:"object,omitempty"` - History []*ObjectVersionWithBody `json:"history,omitempty"` +type EntityWithHistory struct { + Entity *entity.Entity `json:"entity,omitempty"` + History []*EntityVersionWithBody `json:"history,omitempty"` } var ( - // increment when RawObject changes - rawObjectVersion = 9 + // increment when Entity changes + rawEntityVersion = 10 ) // Make sure we implement both store + admin -var _ object.ObjectStoreServer = &dummyObjectServer{} -var _ object.ObjectStoreAdminServer = &dummyObjectServer{} +var _ entity.EntityStoreServer = &dummyEntityServer{} +var _ entity.EntityStoreAdminServer = &dummyEntityServer{} -func ProvideDummyObjectServer(cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry) object.ObjectStoreServer { - objectServer := &dummyObjectServer{ - collection: persistentcollection.NewLocalFSPersistentCollection[*RawObjectWithHistory]("raw-object", cfg.DataPath, rawObjectVersion), +func ProvideDummyEntityServer(cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry) entity.EntityStoreServer { + objectServer := &dummyEntityServer{ + collection: persistentcollection.NewLocalFSPersistentCollection[*EntityWithHistory]("raw-object", cfg.DataPath, rawEntityVersion), log: log.New("in-memory-object-server"), kinds: kinds, } - object.RegisterObjectStoreServer(grpcServerProvider.GetServer(), objectServer) + entity.RegisterEntityStoreServer(grpcServerProvider.GetServer(), objectServer) return objectServer } -type dummyObjectServer struct { +type dummyEntityServer struct { log log.Logger - collection persistentcollection.PersistentCollection[*RawObjectWithHistory] + collection persistentcollection.PersistentCollection[*EntityWithHistory] kinds kind.KindRegistry } -func namespaceFromUID(grn *object.GRN) string { +func namespaceFromUID(grn *entity.GRN) string { // TODO return "orgId-1" } -func (i *dummyObjectServer) findObject(ctx context.Context, grn *object.GRN, version string) (*RawObjectWithHistory, *object.RawObject, error) { +func (i *dummyEntityServer) findEntity(ctx context.Context, grn *entity.GRN, version string) (*EntityWithHistory, *entity.Entity, error) { if grn == nil { return nil, nil, errors.New("GRN must not be nil") } - obj, err := i.collection.FindFirst(ctx, namespaceFromUID(grn), func(i *RawObjectWithHistory) (bool, error) { - return grn.Equals(i.Object.GRN), nil + obj, err := i.collection.FindFirst(ctx, namespaceFromUID(grn), func(i *EntityWithHistory) (bool, error) { + return grn.Equals(i.Entity.GRN), nil }) if err != nil { @@ -79,21 +79,21 @@ func (i *dummyObjectServer) findObject(ctx context.Context, grn *object.GRN, ver getLatestVersion := version == "" if getLatestVersion { - return obj, obj.Object, nil + return obj, obj.Entity, nil } for _, objVersion := range obj.History { if objVersion.Version == version { - copy := &object.RawObject{ - GRN: obj.Object.GRN, - CreatedAt: obj.Object.CreatedAt, - CreatedBy: obj.Object.CreatedBy, + copy := &entity.Entity{ + GRN: obj.Entity.GRN, + CreatedAt: obj.Entity.CreatedAt, + CreatedBy: obj.Entity.CreatedBy, UpdatedAt: objVersion.UpdatedAt, UpdatedBy: objVersion.UpdatedBy, ETag: objVersion.ETag, Version: objVersion.Version, - // Body is added from the dummy server cache (it does not exist in ObjectVersionInfo) + // Body is added from the dummy server cache (it does not exist in EntityVersionInfo) Body: objVersion.Body, } @@ -104,22 +104,22 @@ func (i *dummyObjectServer) findObject(ctx context.Context, grn *object.GRN, ver return obj, nil, nil } -func (i *dummyObjectServer) Read(ctx context.Context, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { +func (i *dummyEntityServer) Read(ctx context.Context, r *entity.ReadEntityRequest) (*entity.ReadEntityResponse, error) { grn := getFullGRN(ctx, r.GRN) - _, objVersion, err := i.findObject(ctx, grn, r.Version) + _, objVersion, err := i.findEntity(ctx, grn, r.Version) if err != nil { return nil, err } if objVersion == nil { - return &object.ReadObjectResponse{ - Object: nil, + return &entity.ReadEntityResponse{ + Entity: nil, SummaryJson: nil, }, nil } - rsp := &object.ReadObjectResponse{ - Object: objVersion, + rsp := &entity.ReadEntityResponse{ + Entity: objVersion, } if r.WithSummary { // Since we do not store the summary, we can just recreate on demand @@ -135,8 +135,8 @@ func (i *dummyObjectServer) Read(ctx context.Context, r *object.ReadObjectReques return rsp, err } -func (i *dummyObjectServer) BatchRead(ctx context.Context, batchR *object.BatchReadObjectRequest) (*object.BatchReadObjectResponse, error) { - results := make([]*object.ReadObjectResponse, 0) +func (i *dummyEntityServer) BatchRead(ctx context.Context, batchR *entity.BatchReadEntityRequest) (*entity.BatchReadEntityResponse, error) { + results := make([]*entity.ReadEntityResponse, 0) for _, r := range batchR.Batch { resp, err := i.Read(ctx, r) if err != nil { @@ -145,7 +145,7 @@ func (i *dummyObjectServer) BatchRead(ctx context.Context, batchR *object.BatchR results = append(results, resp) } - return &object.BatchReadObjectResponse{Results: results}, nil + return &entity.BatchReadEntityResponse{Results: results}, nil } func createContentsHash(contents []byte) string { @@ -153,33 +153,33 @@ func createContentsHash(contents []byte) string { return hex.EncodeToString(hash[:]) } -func (i *dummyObjectServer) update(ctx context.Context, r *object.AdminWriteObjectRequest, namespace string) (*object.WriteObjectResponse, error) { +func (i *dummyEntityServer) update(ctx context.Context, r *entity.AdminWriteEntityRequest, namespace string) (*entity.WriteEntityResponse, error) { builder := i.kinds.GetSummaryBuilder(r.GRN.Kind) if builder == nil { return nil, fmt.Errorf("unsupported kind: " + r.GRN.Kind) } - rsp := &object.WriteObjectResponse{} + rsp := &entity.WriteEntityResponse{} - updatedCount, err := i.collection.Update(ctx, namespace, func(i *RawObjectWithHistory) (bool, *RawObjectWithHistory, error) { - if !r.GRN.Equals(i.Object.GRN) { + updatedCount, err := i.collection.Update(ctx, namespace, func(i *EntityWithHistory) (bool, *EntityWithHistory, error) { + if !r.GRN.Equals(i.Entity.GRN) { return false, nil, nil } - if r.PreviousVersion != "" && i.Object.Version != r.PreviousVersion { - return false, nil, fmt.Errorf("expected the previous version to be %s, but was %s", r.PreviousVersion, i.Object.Version) + if r.PreviousVersion != "" && i.Entity.Version != r.PreviousVersion { + return false, nil, fmt.Errorf("expected the previous version to be %s, but was %s", r.PreviousVersion, i.Entity.Version) } - prevVersion, err := strconv.Atoi(i.Object.Version) + prevVersion, err := strconv.Atoi(i.Entity.Version) if err != nil { return false, nil, err } modifier := store.UserFromContext(ctx) - updated := &object.RawObject{ + updated := &entity.Entity{ GRN: r.GRN, - CreatedAt: i.Object.CreatedAt, - CreatedBy: i.Object.CreatedBy, + CreatedAt: i.Entity.CreatedAt, + CreatedBy: i.Entity.CreatedBy, UpdatedAt: time.Now().UnixMilli(), UpdatedBy: store.GetUserIDString(modifier), Size: int64(len(r.Body)), @@ -188,9 +188,9 @@ func (i *dummyObjectServer) update(ctx context.Context, r *object.AdminWriteObje Version: fmt.Sprintf("%d", prevVersion+1), } - versionInfo := &ObjectVersionWithBody{ + versionInfo := &EntityVersionWithBody{ Body: r.Body, - ObjectVersionInfo: &object.ObjectVersionInfo{ + EntityVersionInfo: &entity.EntityVersionInfo{ Version: updated.Version, UpdatedAt: updated.UpdatedAt, UpdatedBy: updated.UpdatedBy, @@ -199,18 +199,18 @@ func (i *dummyObjectServer) update(ctx context.Context, r *object.AdminWriteObje Comment: r.Comment, }, } - rsp.Object = versionInfo.ObjectVersionInfo - rsp.Status = object.WriteObjectResponse_UPDATED + rsp.Entity = versionInfo.EntityVersionInfo + rsp.Status = entity.WriteEntityResponse_UPDATED // When saving, it must be different than the head version - if i.Object.ETag == updated.ETag { - versionInfo.ObjectVersionInfo.Version = i.Object.Version - rsp.Status = object.WriteObjectResponse_UNCHANGED + if i.Entity.ETag == updated.ETag { + versionInfo.EntityVersionInfo.Version = i.Entity.Version + rsp.Status = entity.WriteEntityResponse_UNCHANGED return false, nil, nil } - return true, &RawObjectWithHistory{ - Object: updated, + return true, &EntityWithHistory{ + Entity: updated, History: append(i.History, versionInfo), }, nil }) @@ -219,16 +219,16 @@ func (i *dummyObjectServer) update(ctx context.Context, r *object.AdminWriteObje return nil, err } - if updatedCount == 0 && rsp.Object == nil { + if updatedCount == 0 && rsp.Entity == nil { return nil, fmt.Errorf("could not find object: %v", r.GRN) } return rsp, nil } -func (i *dummyObjectServer) insert(ctx context.Context, r *object.AdminWriteObjectRequest, namespace string) (*object.WriteObjectResponse, error) { +func (i *dummyEntityServer) insert(ctx context.Context, r *entity.AdminWriteEntityRequest, namespace string) (*entity.WriteEntityResponse, error) { modifier := store.GetUserIDString(store.UserFromContext(ctx)) - rawObj := &object.RawObject{ + rawObj := &entity.Entity{ GRN: r.GRN, UpdatedAt: time.Now().UnixMilli(), CreatedAt: time.Now().UnixMilli(), @@ -240,7 +240,7 @@ func (i *dummyObjectServer) insert(ctx context.Context, r *object.AdminWriteObje Version: fmt.Sprintf("%d", 1), } - info := &object.ObjectVersionInfo{ + info := &entity.EntityVersionInfo{ Version: rawObj.Version, UpdatedAt: rawObj.UpdatedAt, UpdatedBy: rawObj.UpdatedBy, @@ -249,10 +249,10 @@ func (i *dummyObjectServer) insert(ctx context.Context, r *object.AdminWriteObje Comment: r.Comment, } - newObj := &RawObjectWithHistory{ - Object: rawObj, - History: []*ObjectVersionWithBody{{ - ObjectVersionInfo: info, + newObj := &EntityWithHistory{ + Entity: rawObj, + History: []*EntityVersionWithBody{{ + EntityVersionInfo: info, Body: r.Body, }}, } @@ -262,30 +262,30 @@ func (i *dummyObjectServer) insert(ctx context.Context, r *object.AdminWriteObje return nil, err } - return &object.WriteObjectResponse{ + return &entity.WriteEntityResponse{ Error: nil, - Object: info, - Status: object.WriteObjectResponse_CREATED, + Entity: info, + Status: entity.WriteEntityResponse_CREATED, }, nil } -func (i *dummyObjectServer) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { - return i.doWrite(ctx, object.ToAdminWriteObjectRequest(r)) +func (i *dummyEntityServer) Write(ctx context.Context, r *entity.WriteEntityRequest) (*entity.WriteEntityResponse, error) { + return i.doWrite(ctx, entity.ToAdminWriteEntityRequest(r)) } -func (i *dummyObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { +func (i *dummyEntityServer) AdminWrite(ctx context.Context, r *entity.AdminWriteEntityRequest) (*entity.WriteEntityResponse, error) { // Check permissions? return i.doWrite(ctx, r) } -func (i *dummyObjectServer) doWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { +func (i *dummyEntityServer) doWrite(ctx context.Context, r *entity.AdminWriteEntityRequest) (*entity.WriteEntityResponse, error) { grn := getFullGRN(ctx, r.GRN) namespace := namespaceFromUID(grn) - obj, err := i.collection.FindFirst(ctx, namespace, func(i *RawObjectWithHistory) (bool, error) { + obj, err := i.collection.FindFirst(ctx, namespace, func(i *EntityWithHistory) (bool, error) { if i == nil || r == nil { return false, nil } - return grn.Equals(i.Object.GRN), nil + return grn.Equals(i.Entity.GRN), nil }) if err != nil { return nil, err @@ -298,12 +298,12 @@ func (i *dummyObjectServer) doWrite(ctx context.Context, r *object.AdminWriteObj return i.update(ctx, r, namespace) } -func (i *dummyObjectServer) Delete(ctx context.Context, r *object.DeleteObjectRequest) (*object.DeleteObjectResponse, error) { +func (i *dummyEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) { grn := getFullGRN(ctx, r.GRN) - _, err := i.collection.Delete(ctx, namespaceFromUID(grn), func(i *RawObjectWithHistory) (bool, error) { - if grn.Equals(i.Object.GRN) { - if r.PreviousVersion != "" && i.Object.Version != r.PreviousVersion { - return false, fmt.Errorf("expected the previous version to be %s, but was %s", r.PreviousVersion, i.Object.Version) + _, err := i.collection.Delete(ctx, namespaceFromUID(grn), func(i *EntityWithHistory) (bool, error) { + if grn.Equals(i.Entity.GRN) { + if r.PreviousVersion != "" && i.Entity.Version != r.PreviousVersion { + return false, fmt.Errorf("expected the previous version to be %s, but was %s", r.PreviousVersion, i.Entity.Version) } return true, nil @@ -316,30 +316,30 @@ func (i *dummyObjectServer) Delete(ctx context.Context, r *object.DeleteObjectRe return nil, err } - return &object.DeleteObjectResponse{ + return &entity.DeleteEntityResponse{ OK: true, }, nil } -func (i *dummyObjectServer) History(ctx context.Context, r *object.ObjectHistoryRequest) (*object.ObjectHistoryResponse, error) { +func (i *dummyEntityServer) History(ctx context.Context, r *entity.EntityHistoryRequest) (*entity.EntityHistoryResponse, error) { grn := getFullGRN(ctx, r.GRN) - obj, _, err := i.findObject(ctx, grn, "") + obj, _, err := i.findEntity(ctx, grn, "") if err != nil { return nil, err } - rsp := &object.ObjectHistoryResponse{} + rsp := &entity.EntityHistoryResponse{} if obj != nil { // Return the most recent versions first // Better? save them in this order? for i := len(obj.History) - 1; i >= 0; i-- { - rsp.Versions = append(rsp.Versions, obj.History[i].ObjectVersionInfo) + rsp.Versions = append(rsp.Versions, obj.History[i].EntityVersionInfo) } } return rsp, nil } -func (i *dummyObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequest) (*object.ObjectSearchResponse, error) { +func (i *dummyEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequest) (*entity.EntitySearchResponse, error) { var kindMap map[string]bool if len(r.Kind) != 0 { kindMap = make(map[string]bool) @@ -349,9 +349,9 @@ func (i *dummyObjectServer) Search(ctx context.Context, r *object.ObjectSearchRe } // TODO more filters - objects, err := i.collection.Find(ctx, namespaceFromUID(&object.GRN{}), func(i *RawObjectWithHistory) (bool, error) { + objects, err := i.collection.Find(ctx, namespaceFromUID(&entity.GRN{}), func(i *EntityWithHistory) (bool, error) { if len(r.Kind) != 0 { - if _, ok := kindMap[i.Object.GRN.Kind]; !ok { + if _, ok := kindMap[i.Entity.GRN.Kind]; !ok { return false, nil } } @@ -361,35 +361,35 @@ func (i *dummyObjectServer) Search(ctx context.Context, r *object.ObjectSearchRe return nil, err } - searchResults := make([]*object.ObjectSearchResult, 0) + searchResults := make([]*entity.EntitySearchResult, 0) for _, o := range objects { - builder := i.kinds.GetSummaryBuilder(o.Object.GRN.Kind) + builder := i.kinds.GetSummaryBuilder(o.Entity.GRN.Kind) if builder == nil { continue } - summary, clean, e2 := builder(ctx, o.Object.GRN.UID, o.Object.Body) + summary, clean, e2 := builder(ctx, o.Entity.GRN.UID, o.Entity.Body) if e2 != nil { continue } - searchResults = append(searchResults, &object.ObjectSearchResult{ - GRN: o.Object.GRN, - Version: o.Object.Version, - UpdatedAt: o.Object.UpdatedAt, - UpdatedBy: o.Object.UpdatedBy, + searchResults = append(searchResults, &entity.EntitySearchResult{ + GRN: o.Entity.GRN, + Version: o.Entity.Version, + UpdatedAt: o.Entity.UpdatedAt, + UpdatedBy: o.Entity.UpdatedBy, Name: summary.Name, Description: summary.Description, Body: clean, }) } - return &object.ObjectSearchResponse{ + return &entity.EntitySearchResponse{ Results: searchResults, }, nil } // This sets the TenantId on the request GRN -func getFullGRN(ctx context.Context, grn *object.GRN) *object.GRN { +func getFullGRN(ctx context.Context, grn *entity.GRN) *entity.GRN { if grn.TenantId == 0 { modifier := store.UserFromContext(ctx) grn.TenantId = modifier.OrgID diff --git a/pkg/services/store/object/dummy/dummy_server_test.go b/pkg/services/store/entity/dummy/dummy_server_test.go similarity index 70% rename from pkg/services/store/object/dummy/dummy_server_test.go rename to pkg/services/store/entity/dummy/dummy_server_test.go index 49dcb785363..7566affdf2f 100644 --- a/pkg/services/store/object/dummy/dummy_server_test.go +++ b/pkg/services/store/entity/dummy/dummy_server_test.go @@ -1,11 +1,11 @@ -package objectdummyserver +package dummy import ( "encoding/json" "fmt" "testing" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/stretchr/testify/require" ) @@ -16,8 +16,8 @@ func TestRawEncoders(t *testing.T) { }) require.NoError(t, err) - raw := &ObjectVersionWithBody{ - &object.ObjectVersionInfo{ + raw := &EntityVersionWithBody{ + &entity.EntityVersionInfo{ Version: "A", }, body, @@ -30,28 +30,28 @@ func TestRawEncoders(t *testing.T) { fmt.Printf("expect: %s", str) require.JSONEq(t, `{"info":{"version":"A"},"body":"eyJmaWVsZCI6MS4yMywiaGVsbG8iOiJ3b3JsZCJ9"}`, str) - copy := &ObjectVersionWithBody{} + copy := &EntityVersionWithBody{} err = json.Unmarshal(b, copy) require.NoError(t, err) } -func TestRawObjectWithHistory(t *testing.T) { +func TestRawEntityWithHistory(t *testing.T) { body, err := json.Marshal(map[string]interface{}{ "hello": "world", "field": 1.23, }) require.NoError(t, err) - raw := &RawObjectWithHistory{ - Object: &object.RawObject{ - GRN: &object.GRN{UID: "x"}, + raw := &EntityWithHistory{ + Entity: &entity.Entity{ + GRN: &entity.GRN{UID: "x"}, Version: "A", Body: body, }, - History: make([]*ObjectVersionWithBody, 0), + History: make([]*EntityVersionWithBody, 0), } - raw.History = append(raw.History, &ObjectVersionWithBody{ - &object.ObjectVersionInfo{ + raw.History = append(raw.History, &EntityVersionWithBody{ + &entity.EntityVersionInfo{ Version: "B", }, body, @@ -63,7 +63,7 @@ func TestRawObjectWithHistory(t *testing.T) { str := string(b) //fmt.Printf("expect: %s", str) require.JSONEq(t, `{ - "object": { + "entity": { "GRN": { "UID": "x" }, @@ -83,7 +83,7 @@ func TestRawObjectWithHistory(t *testing.T) { ] }`, str) - copy := &ObjectVersionWithBody{} + copy := &EntityVersionWithBody{} err = json.Unmarshal(b, copy) require.NoError(t, err) } diff --git a/pkg/services/store/entity/dummy/fake_store.go b/pkg/services/store/entity/dummy/fake_store.go new file mode 100644 index 00000000000..59d2687b495 --- /dev/null +++ b/pkg/services/store/entity/dummy/fake_store.go @@ -0,0 +1,46 @@ +package dummy + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/services/store/entity" +) + +// Make sure we implement both store + admin +var _ entity.EntityStoreServer = &fakeEntityStore{} +var _ entity.EntityStoreAdminServer = &fakeEntityStore{} + +func ProvideFakeEntityServer() entity.EntityStoreServer { + return &fakeEntityStore{} +} + +type fakeEntityStore struct{} + +func (i fakeEntityStore) AdminWrite(ctx context.Context, r *entity.AdminWriteEntityRequest) (*entity.WriteEntityResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + +func (i fakeEntityStore) Write(ctx context.Context, r *entity.WriteEntityRequest) (*entity.WriteEntityResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + +func (i fakeEntityStore) Read(ctx context.Context, r *entity.ReadEntityRequest) (*entity.ReadEntityResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + +func (i fakeEntityStore) BatchRead(ctx context.Context, batchR *entity.BatchReadEntityRequest) (*entity.BatchReadEntityResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + +func (i fakeEntityStore) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + +func (i fakeEntityStore) History(ctx context.Context, r *entity.EntityHistoryRequest) (*entity.EntityHistoryResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + +func (i fakeEntityStore) Search(ctx context.Context, r *entity.EntitySearchRequest) (*entity.EntitySearchResponse, error) { + return nil, fmt.Errorf("unimplemented") +} diff --git a/pkg/services/store/entity/entity.pb.go b/pkg/services/store/entity/entity.pb.go new file mode 100644 index 00000000000..e9a72463ed3 --- /dev/null +++ b/pkg/services/store/entity/entity.pb.go @@ -0,0 +1,2251 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v3.21.9 +// source: entity.proto + +package entity + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Status enumeration +type WriteEntityResponse_Status int32 + +const ( + WriteEntityResponse_ERROR WriteEntityResponse_Status = 0 + WriteEntityResponse_CREATED WriteEntityResponse_Status = 1 + WriteEntityResponse_UPDATED WriteEntityResponse_Status = 2 + WriteEntityResponse_UNCHANGED WriteEntityResponse_Status = 3 +) + +// Enum value maps for WriteEntityResponse_Status. +var ( + WriteEntityResponse_Status_name = map[int32]string{ + 0: "ERROR", + 1: "CREATED", + 2: "UPDATED", + 3: "UNCHANGED", + } + WriteEntityResponse_Status_value = map[string]int32{ + "ERROR": 0, + "CREATED": 1, + "UPDATED": 2, + "UNCHANGED": 3, + } +) + +func (x WriteEntityResponse_Status) Enum() *WriteEntityResponse_Status { + p := new(WriteEntityResponse_Status) + *p = x + return p +} + +func (x WriteEntityResponse_Status) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WriteEntityResponse_Status) Descriptor() protoreflect.EnumDescriptor { + return file_entity_proto_enumTypes[0].Descriptor() +} + +func (WriteEntityResponse_Status) Type() protoreflect.EnumType { + return &file_entity_proto_enumTypes[0] +} + +func (x WriteEntityResponse_Status) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WriteEntityResponse_Status.Descriptor instead. +func (WriteEntityResponse_Status) EnumDescriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{11, 0} +} + +type GRN struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // the tenant/org id + TenantId int64 `protobuf:"varint,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + // Identify the entity kind. This kind will be used to apply a schema to the body and + // will trigger additional indexing behavior. + Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` + // Unique ID + // 40 characters or less, no slashes or other special characters + UID string `protobuf:"bytes,4,opt,name=UID,proto3" json:"UID,omitempty"` +} + +func (x *GRN) Reset() { + *x = GRN{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GRN) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GRN) ProtoMessage() {} + +func (x *GRN) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GRN.ProtoReflect.Descriptor instead. +func (*GRN) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{0} +} + +func (x *GRN) GetTenantId() int64 { + if x != nil { + return x.TenantId + } + return 0 +} + +func (x *GRN) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *GRN) GetUID() string { + if x != nil { + return x.UID + } + return "" +} + +// The canonical entity/document data -- this represents the raw bytes and storage level metadata +type Entity struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Time in epoch milliseconds that the entity was created + CreatedAt int64 `protobuf:"varint,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Time in epoch milliseconds that the entity was updated + UpdatedAt int64 `protobuf:"varint,3,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Who created the entity + CreatedBy string `protobuf:"bytes,4,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` + // Who updated the entity + UpdatedBy string `protobuf:"bytes,5,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` + // Content Length + Size int64 `protobuf:"varint,6,opt,name=size,proto3" json:"size,omitempty"` + // MD5 digest of the body + ETag string `protobuf:"bytes,7,opt,name=ETag,proto3" json:"ETag,omitempty"` + // Raw bytes of the storage entity. The kind will determine what is a valid payload + Body []byte `protobuf:"bytes,8,opt,name=body,proto3" json:"body,omitempty"` + // Folder UID + Folder string `protobuf:"bytes,9,opt,name=folder,proto3" json:"folder,omitempty"` + // Unique slug within folder (may be UID) + Slug string `protobuf:"bytes,10,opt,name=slug,proto3" json:"slug,omitempty"` + // The version will change when the entity is saved. It is not necessarily sortable + // + // NOTE: currently managed by the dashboard+dashboard_version tables + Version string `protobuf:"bytes,11,opt,name=version,proto3" json:"version,omitempty"` + // External location info + Origin *EntityOriginInfo `protobuf:"bytes,12,opt,name=origin,proto3" json:"origin,omitempty"` +} + +func (x *Entity) Reset() { + *x = Entity{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Entity) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Entity) ProtoMessage() {} + +func (x *Entity) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Entity.ProtoReflect.Descriptor instead. +func (*Entity) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{1} +} + +func (x *Entity) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *Entity) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *Entity) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 +} + +func (x *Entity) GetCreatedBy() string { + if x != nil { + return x.CreatedBy + } + return "" +} + +func (x *Entity) GetUpdatedBy() string { + if x != nil { + return x.UpdatedBy + } + return "" +} + +func (x *Entity) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *Entity) GetETag() string { + if x != nil { + return x.ETag + } + return "" +} + +func (x *Entity) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *Entity) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +func (x *Entity) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *Entity) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Entity) GetOrigin() *EntityOriginInfo { + if x != nil { + return x.Origin + } + return nil +} + +type EntityOriginInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // NOTE: currently managed by the dashboard_provisioning table + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + // Key in the upstream system + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + // Time in epoch milliseconds that the entity was last synced with an external system (provisioning/git) + Time int64 `protobuf:"varint,3,opt,name=time,proto3" json:"time,omitempty"` +} + +func (x *EntityOriginInfo) Reset() { + *x = EntityOriginInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntityOriginInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntityOriginInfo) ProtoMessage() {} + +func (x *EntityOriginInfo) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntityOriginInfo.ProtoReflect.Descriptor instead. +func (*EntityOriginInfo) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{2} +} + +func (x *EntityOriginInfo) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *EntityOriginInfo) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *EntityOriginInfo) GetTime() int64 { + if x != nil { + return x.Time + } + return 0 +} + +// Report error while working with entitys +// NOTE: real systems at scale will contain errors. +type EntityErrorInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Match an error code registry? + Code int64 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` + // Simple error display + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // Details encoded in JSON + DetailsJson []byte `protobuf:"bytes,3,opt,name=details_json,json=detailsJson,proto3" json:"details_json,omitempty"` +} + +func (x *EntityErrorInfo) Reset() { + *x = EntityErrorInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntityErrorInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntityErrorInfo) ProtoMessage() {} + +func (x *EntityErrorInfo) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntityErrorInfo.ProtoReflect.Descriptor instead. +func (*EntityErrorInfo) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{3} +} + +func (x *EntityErrorInfo) GetCode() int64 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *EntityErrorInfo) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *EntityErrorInfo) GetDetailsJson() []byte { + if x != nil { + return x.DetailsJson + } + return nil +} + +// This is a subset of Entity that does not include body or sync info +type EntityVersionInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The version will change when the entity is saved. It is not necessarily sortable + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + // Time in epoch milliseconds that the entity was updated + UpdatedAt int64 `protobuf:"varint,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Who updated the entity + UpdatedBy string `protobuf:"bytes,3,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` + // Content Length + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + // MD5 digest of the body + ETag string `protobuf:"bytes,5,opt,name=ETag,proto3" json:"ETag,omitempty"` + // optional "save" or "commit" message + // + // NOTE: currently managed by the dashboard_version table, and will be returned from a "history" command + Comment string `protobuf:"bytes,6,opt,name=comment,proto3" json:"comment,omitempty"` +} + +func (x *EntityVersionInfo) Reset() { + *x = EntityVersionInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntityVersionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntityVersionInfo) ProtoMessage() {} + +func (x *EntityVersionInfo) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntityVersionInfo.ProtoReflect.Descriptor instead. +func (*EntityVersionInfo) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{4} +} + +func (x *EntityVersionInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *EntityVersionInfo) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 +} + +func (x *EntityVersionInfo) GetUpdatedBy() string { + if x != nil { + return x.UpdatedBy + } + return "" +} + +func (x *EntityVersionInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *EntityVersionInfo) GetETag() string { + if x != nil { + return x.ETag + } + return "" +} + +func (x *EntityVersionInfo) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +type ReadEntityRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Fetch an explicit version + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // Include the full body bytes + WithBody bool `protobuf:"varint,3,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` + // Include derived summary metadata + WithSummary bool `protobuf:"varint,4,opt,name=with_summary,json=withSummary,proto3" json:"with_summary,omitempty"` +} + +func (x *ReadEntityRequest) Reset() { + *x = ReadEntityRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReadEntityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadEntityRequest) ProtoMessage() {} + +func (x *ReadEntityRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadEntityRequest.ProtoReflect.Descriptor instead. +func (*ReadEntityRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{5} +} + +func (x *ReadEntityRequest) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *ReadEntityRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *ReadEntityRequest) GetWithBody() bool { + if x != nil { + return x.WithBody + } + return false +} + +func (x *ReadEntityRequest) GetWithSummary() bool { + if x != nil { + return x.WithSummary + } + return false +} + +type ReadEntityResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity details with the body removed + Entity *Entity `protobuf:"bytes,1,opt,name=entity,proto3" json:"entity,omitempty"` + // Entity summary as JSON + SummaryJson []byte `protobuf:"bytes,2,opt,name=summary_json,json=summaryJson,proto3" json:"summary_json,omitempty"` +} + +func (x *ReadEntityResponse) Reset() { + *x = ReadEntityResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReadEntityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadEntityResponse) ProtoMessage() {} + +func (x *ReadEntityResponse) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadEntityResponse.ProtoReflect.Descriptor instead. +func (*ReadEntityResponse) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{6} +} + +func (x *ReadEntityResponse) GetEntity() *Entity { + if x != nil { + return x.Entity + } + return nil +} + +func (x *ReadEntityResponse) GetSummaryJson() []byte { + if x != nil { + return x.SummaryJson + } + return nil +} + +type BatchReadEntityRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Batch []*ReadEntityRequest `protobuf:"bytes,1,rep,name=batch,proto3" json:"batch,omitempty"` +} + +func (x *BatchReadEntityRequest) Reset() { + *x = BatchReadEntityRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchReadEntityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchReadEntityRequest) ProtoMessage() {} + +func (x *BatchReadEntityRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchReadEntityRequest.ProtoReflect.Descriptor instead. +func (*BatchReadEntityRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{7} +} + +func (x *BatchReadEntityRequest) GetBatch() []*ReadEntityRequest { + if x != nil { + return x.Batch + } + return nil +} + +type BatchReadEntityResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Results []*ReadEntityResponse `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` +} + +func (x *BatchReadEntityResponse) Reset() { + *x = BatchReadEntityResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchReadEntityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchReadEntityResponse) ProtoMessage() {} + +func (x *BatchReadEntityResponse) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchReadEntityResponse.ProtoReflect.Descriptor instead. +func (*BatchReadEntityResponse) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{8} +} + +func (x *BatchReadEntityResponse) GetResults() []*ReadEntityResponse { + if x != nil { + return x.Results + } + return nil +} + +type WriteEntityRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Where to save the entity (empty will leave it unchanged) + Folder string `protobuf:"bytes,2,opt,name=folder,proto3" json:"folder,omitempty"` + // The raw entity body + Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + // Message that can be seen when exploring entity history + Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"` + // Used for optimistic locking. If missing, the previous version will be replaced regardless + PreviousVersion string `protobuf:"bytes,5,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` +} + +func (x *WriteEntityRequest) Reset() { + *x = WriteEntityRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WriteEntityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteEntityRequest) ProtoMessage() {} + +func (x *WriteEntityRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteEntityRequest.ProtoReflect.Descriptor instead. +func (*WriteEntityRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{9} +} + +func (x *WriteEntityRequest) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *WriteEntityRequest) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +func (x *WriteEntityRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *WriteEntityRequest) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +func (x *WriteEntityRequest) GetPreviousVersion() string { + if x != nil { + return x.PreviousVersion + } + return "" +} + +// This operation is useful when syncing a resource from external sources +// that have more accurate metadata information (git, or an archive). +// This process can bypass the forced checks that +type AdminWriteEntityRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Where to save the entity (empty will leave it unchanged) + Folder string `protobuf:"bytes,2,opt,name=folder,proto3" json:"folder,omitempty"` + // The raw entity body + Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + // Message that can be seen when exploring entity history + Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"` + // Time in epoch milliseconds that the entity was created + // Optional, if 0 it will use the current time + CreatedAt int64 `protobuf:"varint,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Time in epoch milliseconds that the entity was updated + // Optional, if empty it will use the current user + UpdatedAt int64 `protobuf:"varint,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Who created the entity + // Optional, if 0 it will use the current time + CreatedBy string `protobuf:"bytes,7,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` + // Who updated the entity + // Optional, if empty it will use the current user + UpdatedBy string `protobuf:"bytes,8,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` + // An explicit version identifier + // Optional, if set, this will overwrite/define an explicit version + Version string `protobuf:"bytes,9,opt,name=version,proto3" json:"version,omitempty"` + // Used for optimistic locking. If missing, the previous version will be replaced regardless + // This may not be used along with an explicit version in the request + PreviousVersion string `protobuf:"bytes,10,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` + // Request that all previous versions are removed from the history + // This will make sense for systems that manage history explicitly externallay + ClearHistory bool `protobuf:"varint,11,opt,name=clear_history,json=clearHistory,proto3" json:"clear_history,omitempty"` + // Optionally define where the entity came from + Origin *EntityOriginInfo `protobuf:"bytes,12,opt,name=origin,proto3" json:"origin,omitempty"` +} + +func (x *AdminWriteEntityRequest) Reset() { + *x = AdminWriteEntityRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AdminWriteEntityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminWriteEntityRequest) ProtoMessage() {} + +func (x *AdminWriteEntityRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminWriteEntityRequest.ProtoReflect.Descriptor instead. +func (*AdminWriteEntityRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{10} +} + +func (x *AdminWriteEntityRequest) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *AdminWriteEntityRequest) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +func (x *AdminWriteEntityRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *AdminWriteEntityRequest) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +func (x *AdminWriteEntityRequest) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *AdminWriteEntityRequest) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 +} + +func (x *AdminWriteEntityRequest) GetCreatedBy() string { + if x != nil { + return x.CreatedBy + } + return "" +} + +func (x *AdminWriteEntityRequest) GetUpdatedBy() string { + if x != nil { + return x.UpdatedBy + } + return "" +} + +func (x *AdminWriteEntityRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AdminWriteEntityRequest) GetPreviousVersion() string { + if x != nil { + return x.PreviousVersion + } + return "" +} + +func (x *AdminWriteEntityRequest) GetClearHistory() bool { + if x != nil { + return x.ClearHistory + } + return false +} + +func (x *AdminWriteEntityRequest) GetOrigin() *EntityOriginInfo { + if x != nil { + return x.Origin + } + return nil +} + +type WriteEntityResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Error info -- if exists, the save did not happen + Error *EntityErrorInfo `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + // Entity identifier + GRN *GRN `protobuf:"bytes,2,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Entity details with the body removed + Entity *EntityVersionInfo `protobuf:"bytes,3,opt,name=entity,proto3" json:"entity,omitempty"` + // Entity summary as JSON + SummaryJson []byte `protobuf:"bytes,4,opt,name=summary_json,json=summaryJson,proto3" json:"summary_json,omitempty"` + // Status code + Status WriteEntityResponse_Status `protobuf:"varint,5,opt,name=status,proto3,enum=entity.WriteEntityResponse_Status" json:"status,omitempty"` +} + +func (x *WriteEntityResponse) Reset() { + *x = WriteEntityResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WriteEntityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteEntityResponse) ProtoMessage() {} + +func (x *WriteEntityResponse) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteEntityResponse.ProtoReflect.Descriptor instead. +func (*WriteEntityResponse) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{11} +} + +func (x *WriteEntityResponse) GetError() *EntityErrorInfo { + if x != nil { + return x.Error + } + return nil +} + +func (x *WriteEntityResponse) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *WriteEntityResponse) GetEntity() *EntityVersionInfo { + if x != nil { + return x.Entity + } + return nil +} + +func (x *WriteEntityResponse) GetSummaryJson() []byte { + if x != nil { + return x.SummaryJson + } + return nil +} + +func (x *WriteEntityResponse) GetStatus() WriteEntityResponse_Status { + if x != nil { + return x.Status + } + return WriteEntityResponse_ERROR +} + +type DeleteEntityRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Used for optimistic locking. If missing, the previous version will be replaced regardless + PreviousVersion string `protobuf:"bytes,3,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` +} + +func (x *DeleteEntityRequest) Reset() { + *x = DeleteEntityRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteEntityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteEntityRequest) ProtoMessage() {} + +func (x *DeleteEntityRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteEntityRequest.ProtoReflect.Descriptor instead. +func (*DeleteEntityRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{12} +} + +func (x *DeleteEntityRequest) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *DeleteEntityRequest) GetPreviousVersion() string { + if x != nil { + return x.PreviousVersion + } + return "" +} + +type DeleteEntityResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OK bool `protobuf:"varint,1,opt,name=OK,proto3" json:"OK,omitempty"` +} + +func (x *DeleteEntityResponse) Reset() { + *x = DeleteEntityResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteEntityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteEntityResponse) ProtoMessage() {} + +func (x *DeleteEntityResponse) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteEntityResponse.ProtoReflect.Descriptor instead. +func (*DeleteEntityResponse) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{13} +} + +func (x *DeleteEntityResponse) GetOK() bool { + if x != nil { + return x.OK + } + return false +} + +type EntityHistoryRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Maximum number of items to return + Limit int64 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + // Starting from the requested page + NextPageToken string `protobuf:"bytes,5,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` +} + +func (x *EntityHistoryRequest) Reset() { + *x = EntityHistoryRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntityHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntityHistoryRequest) ProtoMessage() {} + +func (x *EntityHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntityHistoryRequest.ProtoReflect.Descriptor instead. +func (*EntityHistoryRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{14} +} + +func (x *EntityHistoryRequest) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *EntityHistoryRequest) GetLimit() int64 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *EntityHistoryRequest) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +type EntityHistoryResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // Entity metadata without the raw bytes + Versions []*EntityVersionInfo `protobuf:"bytes,2,rep,name=versions,proto3" json:"versions,omitempty"` + // More results exist... pass this in the next request + NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` +} + +func (x *EntityHistoryResponse) Reset() { + *x = EntityHistoryResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntityHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntityHistoryResponse) ProtoMessage() {} + +func (x *EntityHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntityHistoryResponse.ProtoReflect.Descriptor instead. +func (*EntityHistoryResponse) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{15} +} + +func (x *EntityHistoryResponse) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *EntityHistoryResponse) GetVersions() []*EntityVersionInfo { + if x != nil { + return x.Versions + } + return nil +} + +func (x *EntityHistoryResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +type EntitySearchRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Starting from the requested page (other query parameters must match!) + NextPageToken string `protobuf:"bytes,1,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // Maximum number of items to return + Limit int64 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + // Free text query string -- mileage may vary :) + Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` + // limit to a specific kind (empty is all) + Kind []string `protobuf:"bytes,4,rep,name=kind,proto3" json:"kind,omitempty"` + // Limit results to items in a specific folder + Folder string `protobuf:"bytes,5,opt,name=folder,proto3" json:"folder,omitempty"` + // Must match all labels + Labels map[string]string `protobuf:"bytes,6,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Sorting instructions `field ASC/DESC` + Sort []string `protobuf:"bytes,7,rep,name=sort,proto3" json:"sort,omitempty"` + // Return the full body in each payload + WithBody bool `protobuf:"varint,8,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` + // Return the full body in each payload + WithLabels bool `protobuf:"varint,9,opt,name=with_labels,json=withLabels,proto3" json:"with_labels,omitempty"` + // Return the full body in each payload + WithFields bool `protobuf:"varint,10,opt,name=with_fields,json=withFields,proto3" json:"with_fields,omitempty"` +} + +func (x *EntitySearchRequest) Reset() { + *x = EntitySearchRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntitySearchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntitySearchRequest) ProtoMessage() {} + +func (x *EntitySearchRequest) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntitySearchRequest.ProtoReflect.Descriptor instead. +func (*EntitySearchRequest) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{16} +} + +func (x *EntitySearchRequest) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +func (x *EntitySearchRequest) GetLimit() int64 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *EntitySearchRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +func (x *EntitySearchRequest) GetKind() []string { + if x != nil { + return x.Kind + } + return nil +} + +func (x *EntitySearchRequest) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +func (x *EntitySearchRequest) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *EntitySearchRequest) GetSort() []string { + if x != nil { + return x.Sort + } + return nil +} + +func (x *EntitySearchRequest) GetWithBody() bool { + if x != nil { + return x.WithBody + } + return false +} + +func (x *EntitySearchRequest) GetWithLabels() bool { + if x != nil { + return x.WithLabels + } + return false +} + +func (x *EntitySearchRequest) GetWithFields() bool { + if x != nil { + return x.WithFields + } + return false +} + +// Search result metadata for each entity +type EntitySearchResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Entity identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // The current veresion of this entity + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // Content Length + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` + // Time in epoch milliseconds that the entity was updated + UpdatedAt int64 `protobuf:"varint,4,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Who updated the entity + UpdatedBy string `protobuf:"bytes,5,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` + // Optionally include the full entity body + Body []byte `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"` + // Always included + Name string `protobuf:"bytes,7,opt,name=name,proto3" json:"name,omitempty"` + // Always included + Description string `protobuf:"bytes,8,opt,name=description,proto3" json:"description,omitempty"` + // The structured labels + Labels map[string]string `protobuf:"bytes,9,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Folder UID + Folder string `protobuf:"bytes,10,opt,name=folder,proto3" json:"folder,omitempty"` + // Slugified name + Slug string `protobuf:"bytes,11,opt,name=slug,proto3" json:"slug,omitempty"` + // Optionally include extracted JSON + FieldsJson []byte `protobuf:"bytes,12,opt,name=fields_json,json=fieldsJson,proto3" json:"fields_json,omitempty"` + // EntityErrorInfo in json + ErrorJson []byte `protobuf:"bytes,13,opt,name=error_json,json=errorJson,proto3" json:"error_json,omitempty"` +} + +func (x *EntitySearchResult) Reset() { + *x = EntitySearchResult{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntitySearchResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntitySearchResult) ProtoMessage() {} + +func (x *EntitySearchResult) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntitySearchResult.ProtoReflect.Descriptor instead. +func (*EntitySearchResult) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{17} +} + +func (x *EntitySearchResult) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *EntitySearchResult) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *EntitySearchResult) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *EntitySearchResult) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 +} + +func (x *EntitySearchResult) GetUpdatedBy() string { + if x != nil { + return x.UpdatedBy + } + return "" +} + +func (x *EntitySearchResult) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *EntitySearchResult) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EntitySearchResult) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *EntitySearchResult) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *EntitySearchResult) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +func (x *EntitySearchResult) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *EntitySearchResult) GetFieldsJson() []byte { + if x != nil { + return x.FieldsJson + } + return nil +} + +func (x *EntitySearchResult) GetErrorJson() []byte { + if x != nil { + return x.ErrorJson + } + return nil +} + +type EntitySearchResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Results []*EntitySearchResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + // More results exist... pass this in the next request + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` +} + +func (x *EntitySearchResponse) Reset() { + *x = EntitySearchResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_entity_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EntitySearchResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntitySearchResponse) ProtoMessage() {} + +func (x *EntitySearchResponse) ProtoReflect() protoreflect.Message { + mi := &file_entity_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntitySearchResponse.ProtoReflect.Descriptor instead. +func (*EntitySearchResponse) Descriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{18} +} + +func (x *EntitySearchResponse) GetResults() []*EntitySearchResult { + if x != nil { + return x.Results + } + return nil +} + +func (x *EntitySearchResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +var File_entity_proto protoreflect.FileDescriptor + +var file_entity_proto_rawDesc = []byte{ + 0x0a, 0x0c, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x48, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x1b, 0x0a, + 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, + 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x10, + 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, + 0x22, 0xd7, 0x02, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, 0x03, 0x47, + 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, 0x54, + 0x61, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, 0x12, + 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, + 0x64, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, + 0x75, 0x67, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, + 0x69, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, + 0x66, 0x6f, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x22, 0x50, 0x0a, 0x10, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, + 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, + 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, + 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, + 0x0c, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, + 0x22, 0xad, 0x01, 0x0a, 0x11, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, + 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, + 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, + 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, 0x54, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, + 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, + 0x22, 0x8c, 0x01, 0x0a, 0x11, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x47, 0x52, 0x4e, + 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x21, 0x0a, 0x0c, + 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, + 0x5f, 0x0a, 0x12, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x21, 0x0a, + 0x0c, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, + 0x22, 0x49, 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, + 0x74, 0x63, 0x68, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x4f, 0x0a, 0x17, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0xa4, 0x01, 0x0a, + 0x12, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, + 0x52, 0x4e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, + 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, + 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, + 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x22, 0x96, 0x03, 0x0a, 0x17, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, + 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x16, + 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, + 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, + 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x42, + 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, + 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, + 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x68, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6c, + 0x65, 0x61, 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x30, 0x0a, 0x06, 0x6f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, + 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x22, 0xb3, 0x02, 0x0a, + 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, + 0x52, 0x4e, 0x12, 0x31, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x06, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, + 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, + 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x22, 0x3c, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, + 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, + 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, + 0x44, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, 0x44, + 0x10, 0x03, 0x22, 0x5f, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, + 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x22, 0x26, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x4f, + 0x4b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x4f, 0x4b, 0x22, 0x73, 0x0a, 0x14, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, + 0x52, 0x4e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, + 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x22, 0x95, 0x01, 0x0a, 0x15, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, + 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, + 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x35, 0x0a, 0x08, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x84, 0x03, 0x0a, 0x13, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, + 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, + 0x75, 0x65, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, + 0x12, 0x3f, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x27, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, + 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, + 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, + 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x66, 0x69, 0x65, 0x6c, + 0x64, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x46, 0x69, + 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0xd0, 0x03, 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x47, 0x52, 0x4e, + 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, + 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, + 0x79, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, 0x06, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, + 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, + 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x66, 0x69, + 0x65, 0x6c, 0x64, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x4a, 0x73, 0x6f, 0x6e, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x22, 0x74, 0x0a, 0x14, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, + 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0xfa, 0x03, 0x0a, 0x0b, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3d, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, + 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x42, 0x61, + 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x42, 0x61, + 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1a, + 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, + 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, + 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1b, + 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x0a, 0x41, 0x64, 0x6d, + 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x5e, 0x0a, 0x10, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, + 0x74, 0x6f, 0x72, 0x65, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x12, 0x4a, 0x0a, 0x0a, 0x41, 0x64, 0x6d, + 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x3b, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_entity_proto_rawDescOnce sync.Once + file_entity_proto_rawDescData = file_entity_proto_rawDesc +) + +func file_entity_proto_rawDescGZIP() []byte { + file_entity_proto_rawDescOnce.Do(func() { + file_entity_proto_rawDescData = protoimpl.X.CompressGZIP(file_entity_proto_rawDescData) + }) + return file_entity_proto_rawDescData +} + +var file_entity_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_entity_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_entity_proto_goTypes = []interface{}{ + (WriteEntityResponse_Status)(0), // 0: entity.WriteEntityResponse.Status + (*GRN)(nil), // 1: entity.GRN + (*Entity)(nil), // 2: entity.Entity + (*EntityOriginInfo)(nil), // 3: entity.EntityOriginInfo + (*EntityErrorInfo)(nil), // 4: entity.EntityErrorInfo + (*EntityVersionInfo)(nil), // 5: entity.EntityVersionInfo + (*ReadEntityRequest)(nil), // 6: entity.ReadEntityRequest + (*ReadEntityResponse)(nil), // 7: entity.ReadEntityResponse + (*BatchReadEntityRequest)(nil), // 8: entity.BatchReadEntityRequest + (*BatchReadEntityResponse)(nil), // 9: entity.BatchReadEntityResponse + (*WriteEntityRequest)(nil), // 10: entity.WriteEntityRequest + (*AdminWriteEntityRequest)(nil), // 11: entity.AdminWriteEntityRequest + (*WriteEntityResponse)(nil), // 12: entity.WriteEntityResponse + (*DeleteEntityRequest)(nil), // 13: entity.DeleteEntityRequest + (*DeleteEntityResponse)(nil), // 14: entity.DeleteEntityResponse + (*EntityHistoryRequest)(nil), // 15: entity.EntityHistoryRequest + (*EntityHistoryResponse)(nil), // 16: entity.EntityHistoryResponse + (*EntitySearchRequest)(nil), // 17: entity.EntitySearchRequest + (*EntitySearchResult)(nil), // 18: entity.EntitySearchResult + (*EntitySearchResponse)(nil), // 19: entity.EntitySearchResponse + nil, // 20: entity.EntitySearchRequest.LabelsEntry + nil, // 21: entity.EntitySearchResult.LabelsEntry +} +var file_entity_proto_depIdxs = []int32{ + 1, // 0: entity.Entity.GRN:type_name -> entity.GRN + 3, // 1: entity.Entity.origin:type_name -> entity.EntityOriginInfo + 1, // 2: entity.ReadEntityRequest.GRN:type_name -> entity.GRN + 2, // 3: entity.ReadEntityResponse.entity:type_name -> entity.Entity + 6, // 4: entity.BatchReadEntityRequest.batch:type_name -> entity.ReadEntityRequest + 7, // 5: entity.BatchReadEntityResponse.results:type_name -> entity.ReadEntityResponse + 1, // 6: entity.WriteEntityRequest.GRN:type_name -> entity.GRN + 1, // 7: entity.AdminWriteEntityRequest.GRN:type_name -> entity.GRN + 3, // 8: entity.AdminWriteEntityRequest.origin:type_name -> entity.EntityOriginInfo + 4, // 9: entity.WriteEntityResponse.error:type_name -> entity.EntityErrorInfo + 1, // 10: entity.WriteEntityResponse.GRN:type_name -> entity.GRN + 5, // 11: entity.WriteEntityResponse.entity:type_name -> entity.EntityVersionInfo + 0, // 12: entity.WriteEntityResponse.status:type_name -> entity.WriteEntityResponse.Status + 1, // 13: entity.DeleteEntityRequest.GRN:type_name -> entity.GRN + 1, // 14: entity.EntityHistoryRequest.GRN:type_name -> entity.GRN + 1, // 15: entity.EntityHistoryResponse.GRN:type_name -> entity.GRN + 5, // 16: entity.EntityHistoryResponse.versions:type_name -> entity.EntityVersionInfo + 20, // 17: entity.EntitySearchRequest.labels:type_name -> entity.EntitySearchRequest.LabelsEntry + 1, // 18: entity.EntitySearchResult.GRN:type_name -> entity.GRN + 21, // 19: entity.EntitySearchResult.labels:type_name -> entity.EntitySearchResult.LabelsEntry + 18, // 20: entity.EntitySearchResponse.results:type_name -> entity.EntitySearchResult + 6, // 21: entity.EntityStore.Read:input_type -> entity.ReadEntityRequest + 8, // 22: entity.EntityStore.BatchRead:input_type -> entity.BatchReadEntityRequest + 10, // 23: entity.EntityStore.Write:input_type -> entity.WriteEntityRequest + 13, // 24: entity.EntityStore.Delete:input_type -> entity.DeleteEntityRequest + 15, // 25: entity.EntityStore.History:input_type -> entity.EntityHistoryRequest + 17, // 26: entity.EntityStore.Search:input_type -> entity.EntitySearchRequest + 11, // 27: entity.EntityStore.AdminWrite:input_type -> entity.AdminWriteEntityRequest + 11, // 28: entity.EntityStoreAdmin.AdminWrite:input_type -> entity.AdminWriteEntityRequest + 7, // 29: entity.EntityStore.Read:output_type -> entity.ReadEntityResponse + 9, // 30: entity.EntityStore.BatchRead:output_type -> entity.BatchReadEntityResponse + 12, // 31: entity.EntityStore.Write:output_type -> entity.WriteEntityResponse + 14, // 32: entity.EntityStore.Delete:output_type -> entity.DeleteEntityResponse + 16, // 33: entity.EntityStore.History:output_type -> entity.EntityHistoryResponse + 19, // 34: entity.EntityStore.Search:output_type -> entity.EntitySearchResponse + 12, // 35: entity.EntityStore.AdminWrite:output_type -> entity.WriteEntityResponse + 12, // 36: entity.EntityStoreAdmin.AdminWrite:output_type -> entity.WriteEntityResponse + 29, // [29:37] is the sub-list for method output_type + 21, // [21:29] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name +} + +func init() { file_entity_proto_init() } +func file_entity_proto_init() { + if File_entity_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_entity_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GRN); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Entity); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntityOriginInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntityErrorInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntityVersionInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReadEntityRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReadEntityResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchReadEntityRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchReadEntityResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WriteEntityRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AdminWriteEntityRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WriteEntityResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteEntityRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteEntityResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntityHistoryRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntityHistoryResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntitySearchRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntitySearchResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_entity_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EntitySearchResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_entity_proto_rawDesc, + NumEnums: 1, + NumMessages: 21, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_entity_proto_goTypes, + DependencyIndexes: file_entity_proto_depIdxs, + EnumInfos: file_entity_proto_enumTypes, + MessageInfos: file_entity_proto_msgTypes, + }.Build() + File_entity_proto = out.File + file_entity_proto_rawDesc = nil + file_entity_proto_goTypes = nil + file_entity_proto_depIdxs = nil +} diff --git a/pkg/services/store/object/object.proto b/pkg/services/store/entity/entity.proto similarity index 57% rename from pkg/services/store/object/object.proto rename to pkg/services/store/entity/entity.proto index 152681bafec..b0b57d4e1cd 100644 --- a/pkg/services/store/object/object.proto +++ b/pkg/services/store/entity/entity.proto @@ -1,41 +1,36 @@ syntax = "proto3"; -package object; +package entity; -option go_package = "./;object"; +option go_package = "./;entity"; message GRN { // the tenant/org id int64 tenant_id = 1; - // mabybe "namespace"? valid values include - // * entity - // * drive - // * service/xyz - string scope = 2; - - // Identify the object kind. This kind will be used to apply a schema to the body and + // Identify the entity kind. This kind will be used to apply a schema to the body and // will trigger additional indexing behavior. string kind = 3; // Unique ID + // 40 characters or less, no slashes or other special characters string UID = 4; } -// The canonical object/document data -- this represents the raw bytes and storage level metadata -message RawObject { - // Object identifier +// The canonical entity/document data -- this represents the raw bytes and storage level metadata +message Entity { + // Entity identifier GRN GRN = 1; - // Time in epoch milliseconds that the object was created + // Time in epoch milliseconds that the entity was created int64 created_at = 2; - // Time in epoch milliseconds that the object was updated + // Time in epoch milliseconds that the entity was updated int64 updated_at = 3; - // Who created the object + // Who created the entity string created_by = 4; - // Who updated the object + // Who updated the entity string updated_by = 5; // Content Length @@ -44,29 +39,38 @@ message RawObject { // MD5 digest of the body string ETag = 7; - // Raw bytes of the storage object. The kind will determine what is a valid payload + // Raw bytes of the storage entity. The kind will determine what is a valid payload bytes body = 8; - // The version will change when the object is saved. It is not necessarily sortable + // Folder UID + string folder = 9; + + // Unique slug within folder (may be UID) + string slug = 10; + + // The version will change when the entity is saved. It is not necessarily sortable // // NOTE: currently managed by the dashboard+dashboard_version tables - string version = 9; + string version = 11; // External location info - ObjectOriginInfo origin = 10; + EntityOriginInfo origin = 12; } -message ObjectOriginInfo { +message EntityOriginInfo { // NOTE: currently managed by the dashboard_provisioning table string source = 1; - // Time in epoch milliseconds that the object was last synced with an external system (provisioning/git) - int64 time = 2; + // Key in the upstream system + string key = 2; + + // Time in epoch milliseconds that the entity was last synced with an external system (provisioning/git) + int64 time = 3; } -// Report error while working with objects +// Report error while working with entitys // NOTE: real systems at scale will contain errors. -message ObjectErrorInfo { +message EntityErrorInfo { // Match an error code registry? int64 code = 1; @@ -77,15 +81,15 @@ message ObjectErrorInfo { bytes details_json = 3; } -// This is a subset of RawObject that does not include body or sync info -message ObjectVersionInfo { - // The version will change when the object is saved. It is not necessarily sortable +// This is a subset of Entity that does not include body or sync info +message EntityVersionInfo { + // The version will change when the entity is saved. It is not necessarily sortable string version = 1; - // Time in epoch milliseconds that the object was updated + // Time in epoch milliseconds that the entity was updated int64 updated_at = 2; - // Who updated the object + // Who updated the entity string updated_by = 3; // Content Length @@ -104,8 +108,8 @@ message ObjectVersionInfo { // Get request/response //----------------------------------------------- -message ReadObjectRequest { - // Object identifier +message ReadEntityRequest { + // Entity identifier GRN GRN = 1; // Fetch an explicit version @@ -118,11 +122,11 @@ message ReadObjectRequest { bool with_summary = 4; } -message ReadObjectResponse { - // Object details with the body removed - RawObject object = 1; +message ReadEntityResponse { + // Entity details with the body removed + Entity entity = 1; - // Object summary as JSON + // Entity summary as JSON bytes summary_json = 2; } @@ -130,88 +134,94 @@ message ReadObjectResponse { // Make many read requests at once (by Kind+ID+version) //------------------------------------------------------ -message BatchReadObjectRequest { - repeated ReadObjectRequest batch = 1; +message BatchReadEntityRequest { + repeated ReadEntityRequest batch = 1; } -message BatchReadObjectResponse { - repeated ReadObjectResponse results = 1; +message BatchReadEntityResponse { + repeated ReadEntityResponse results = 1; } //----------------------------------------------- // Write request/response //----------------------------------------------- -message WriteObjectRequest { - // Object identifier +message WriteEntityRequest { + // Entity identifier GRN GRN = 1; - // The raw object body - bytes body = 2; + // Where to save the entity (empty will leave it unchanged) + string folder = 2; - // Message that can be seen when exploring object history - string comment = 3; + // The raw entity body + bytes body = 3; + + // Message that can be seen when exploring entity history + string comment = 4; // Used for optimistic locking. If missing, the previous version will be replaced regardless - string previous_version = 4; + string previous_version = 5; } // This operation is useful when syncing a resource from external sources // that have more accurate metadata information (git, or an archive). // This process can bypass the forced checks that -message AdminWriteObjectRequest { - // Object identifier +message AdminWriteEntityRequest { + // Entity identifier GRN GRN = 1; - // The raw object body - bytes body = 2; + // Where to save the entity (empty will leave it unchanged) + string folder = 2; - // Message that can be seen when exploring object history - string comment = 3; + // The raw entity body + bytes body = 3; - // Time in epoch milliseconds that the object was created + // Message that can be seen when exploring entity history + string comment = 4; + + // Time in epoch milliseconds that the entity was created // Optional, if 0 it will use the current time - int64 created_at = 4; + int64 created_at = 5; - // Time in epoch milliseconds that the object was updated + // Time in epoch milliseconds that the entity was updated // Optional, if empty it will use the current user - int64 updated_at = 5; + int64 updated_at = 6; - // Who created the object + // Who created the entity // Optional, if 0 it will use the current time - string created_by = 6; + string created_by = 7; - // Who updated the object + // Who updated the entity // Optional, if empty it will use the current user - string updated_by = 7; + string updated_by = 8; // An explicit version identifier // Optional, if set, this will overwrite/define an explicit version - string version = 8; + string version = 9; // Used for optimistic locking. If missing, the previous version will be replaced regardless // This may not be used along with an explicit version in the request - string previous_version = 9; + string previous_version = 10; // Request that all previous versions are removed from the history // This will make sense for systems that manage history explicitly externallay - bool clear_history = 10; + bool clear_history = 11; - // Optionally define where the object came from - string origin = 11; + // Optionally define where the entity came from + EntityOriginInfo origin = 12; } -message WriteObjectResponse { +message WriteEntityResponse { // Error info -- if exists, the save did not happen - ObjectErrorInfo error = 1; + EntityErrorInfo error = 1; - // Object identifier + // Entity identifier GRN GRN = 2; - // Object details with the body removed - ObjectVersionInfo object = 3; + // Entity details with the body removed + EntityVersionInfo entity = 3; - // Object summary as JSON + // Entity summary as JSON bytes summary_json = 4; // Status code @@ -230,15 +240,15 @@ message WriteObjectResponse { // Delete request/response //----------------------------------------------- -message DeleteObjectRequest { - // Object identifier +message DeleteEntityRequest { + // Entity identifier GRN GRN = 1; // Used for optimistic locking. If missing, the previous version will be replaced regardless string previous_version = 3; } -message DeleteObjectResponse { +message DeleteEntityResponse { bool OK = 1; } @@ -246,8 +256,8 @@ message DeleteObjectResponse { // History request/response //----------------------------------------------- -message ObjectHistoryRequest { - // Object identifier +message EntityHistoryRequest { + // Entity identifier GRN GRN = 1; // Maximum number of items to return @@ -257,12 +267,12 @@ message ObjectHistoryRequest { string next_page_token = 5; } -message ObjectHistoryResponse { - // Object identifier +message EntityHistoryResponse { + // Entity identifier GRN GRN = 1; - // Object metadata without the raw bytes - repeated ObjectVersionInfo versions = 2; + // Entity metadata without the raw bytes + repeated EntityVersionInfo versions = 2; // More results exist... pass this in the next request string next_page_token = 3; @@ -273,7 +283,7 @@ message ObjectHistoryResponse { // List request/response //----------------------------------------------- -message ObjectSearchRequest { +message EntitySearchRequest { // Starting from the requested page (other query parameters must match!) string next_page_token = 1; @@ -305,24 +315,24 @@ message ObjectSearchRequest { bool with_fields = 10; } -// Search result metadata for each object -message ObjectSearchResult { - // Object identifier +// Search result metadata for each entity +message EntitySearchResult { + // Entity identifier GRN GRN = 1; - // The current veresion of this object + // The current veresion of this entity string version = 2; // Content Length int64 size = 3; - // Time in epoch milliseconds that the object was updated + // Time in epoch milliseconds that the entity was updated int64 updated_at = 4; - // Who updated the object + // Who updated the entity string updated_by = 5; - // Optionally include the full object body + // Optionally include the full entity body bytes body = 6; //---------------------------------------- @@ -338,15 +348,21 @@ message ObjectSearchResult { // The structured labels map labels = 9; - // Optionally include extracted JSON - bytes fields_json = 10; + // Folder UID + string folder = 10; - // ObjectErrorInfo in json - bytes error_json = 11; + // Slugified name + string slug = 11; + + // Optionally include extracted JSON + bytes fields_json = 12; + + // EntityErrorInfo in json + bytes error_json = 13; } -message ObjectSearchResponse { - repeated ObjectSearchResult results = 1; +message EntitySearchResponse { + repeated EntitySearchResult results = 1; // More results exist... pass this in the next request string next_page_token = 2; @@ -357,25 +373,25 @@ message ObjectSearchResponse { // Storage interface //----------------------------------------------- -// The object store provides a basic CRUD (+watch eventually) interface for generic objects -service ObjectStore { - rpc Read(ReadObjectRequest) returns (ReadObjectResponse); - rpc BatchRead(BatchReadObjectRequest) returns (BatchReadObjectResponse); - rpc Write(WriteObjectRequest) returns (WriteObjectResponse); - rpc Delete(DeleteObjectRequest) returns (DeleteObjectResponse); - rpc History(ObjectHistoryRequest) returns (ObjectHistoryResponse); - rpc Search(ObjectSearchRequest) returns (ObjectSearchResponse); +// The entity store provides a basic CRUD (+watch eventually) interface for generic entitys +service EntityStore { + rpc Read(ReadEntityRequest) returns (ReadEntityResponse); + rpc BatchRead(BatchReadEntityRequest) returns (BatchReadEntityResponse); + rpc Write(WriteEntityRequest) returns (WriteEntityResponse); + rpc Delete(DeleteEntityRequest) returns (DeleteEntityResponse); + rpc History(EntityHistoryRequest) returns (EntityHistoryResponse); + rpc Search(EntitySearchRequest) returns (EntitySearchResponse); // Ideally an additional search endpoint with more flexibility to limit what you actually care about // https://github.com/grafana/grafana-plugin-sdk-go/blob/main/proto/backend.proto#L129 -// rpc SearchEX(ObjectSearchRequest) returns (DataResponse); +// rpc SearchEX(EntitySearchRequest) returns (DataResponse); // TEMPORARY... while we split this into a new service (see below) - rpc AdminWrite(AdminWriteObjectRequest) returns (WriteObjectResponse); + rpc AdminWrite(AdminWriteEntityRequest) returns (WriteEntityResponse); } -// The admin service extends the basic object store interface, but provides +// The admin service extends the basic entity store interface, but provides // more explicit control that can support bulk operations like efficient git sync -service ObjectStoreAdmin { - rpc AdminWrite(AdminWriteObjectRequest) returns (WriteObjectResponse); +service EntityStoreAdmin { + rpc AdminWrite(AdminWriteEntityRequest) returns (WriteEntityResponse); } diff --git a/pkg/services/store/entity/entity_grpc.pb.go b/pkg/services/store/entity/entity_grpc.pb.go new file mode 100644 index 00000000000..acc7cc694f8 --- /dev/null +++ b/pkg/services/store/entity/entity_grpc.pb.go @@ -0,0 +1,405 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.21.9 +// source: entity.proto + +package entity + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// EntityStoreClient is the client API for EntityStore service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type EntityStoreClient interface { + Read(ctx context.Context, in *ReadEntityRequest, opts ...grpc.CallOption) (*ReadEntityResponse, error) + BatchRead(ctx context.Context, in *BatchReadEntityRequest, opts ...grpc.CallOption) (*BatchReadEntityResponse, error) + Write(ctx context.Context, in *WriteEntityRequest, opts ...grpc.CallOption) (*WriteEntityResponse, error) + Delete(ctx context.Context, in *DeleteEntityRequest, opts ...grpc.CallOption) (*DeleteEntityResponse, error) + History(ctx context.Context, in *EntityHistoryRequest, opts ...grpc.CallOption) (*EntityHistoryResponse, error) + Search(ctx context.Context, in *EntitySearchRequest, opts ...grpc.CallOption) (*EntitySearchResponse, error) + // TEMPORARY... while we split this into a new service (see below) + AdminWrite(ctx context.Context, in *AdminWriteEntityRequest, opts ...grpc.CallOption) (*WriteEntityResponse, error) +} + +type entityStoreClient struct { + cc grpc.ClientConnInterface +} + +func NewEntityStoreClient(cc grpc.ClientConnInterface) EntityStoreClient { + return &entityStoreClient{cc} +} + +func (c *entityStoreClient) Read(ctx context.Context, in *ReadEntityRequest, opts ...grpc.CallOption) (*ReadEntityResponse, error) { + out := new(ReadEntityResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/Read", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *entityStoreClient) BatchRead(ctx context.Context, in *BatchReadEntityRequest, opts ...grpc.CallOption) (*BatchReadEntityResponse, error) { + out := new(BatchReadEntityResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/BatchRead", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *entityStoreClient) Write(ctx context.Context, in *WriteEntityRequest, opts ...grpc.CallOption) (*WriteEntityResponse, error) { + out := new(WriteEntityResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/Write", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *entityStoreClient) Delete(ctx context.Context, in *DeleteEntityRequest, opts ...grpc.CallOption) (*DeleteEntityResponse, error) { + out := new(DeleteEntityResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/Delete", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *entityStoreClient) History(ctx context.Context, in *EntityHistoryRequest, opts ...grpc.CallOption) (*EntityHistoryResponse, error) { + out := new(EntityHistoryResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/History", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *entityStoreClient) Search(ctx context.Context, in *EntitySearchRequest, opts ...grpc.CallOption) (*EntitySearchResponse, error) { + out := new(EntitySearchResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/Search", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *entityStoreClient) AdminWrite(ctx context.Context, in *AdminWriteEntityRequest, opts ...grpc.CallOption) (*WriteEntityResponse, error) { + out := new(WriteEntityResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStore/AdminWrite", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// EntityStoreServer is the server API for EntityStore service. +// All implementations should embed UnimplementedEntityStoreServer +// for forward compatibility +type EntityStoreServer interface { + Read(context.Context, *ReadEntityRequest) (*ReadEntityResponse, error) + BatchRead(context.Context, *BatchReadEntityRequest) (*BatchReadEntityResponse, error) + Write(context.Context, *WriteEntityRequest) (*WriteEntityResponse, error) + Delete(context.Context, *DeleteEntityRequest) (*DeleteEntityResponse, error) + History(context.Context, *EntityHistoryRequest) (*EntityHistoryResponse, error) + Search(context.Context, *EntitySearchRequest) (*EntitySearchResponse, error) + // TEMPORARY... while we split this into a new service (see below) + AdminWrite(context.Context, *AdminWriteEntityRequest) (*WriteEntityResponse, error) +} + +// UnimplementedEntityStoreServer should be embedded to have forward compatible implementations. +type UnimplementedEntityStoreServer struct { +} + +func (UnimplementedEntityStoreServer) Read(context.Context, *ReadEntityRequest) (*ReadEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Read not implemented") +} +func (UnimplementedEntityStoreServer) BatchRead(context.Context, *BatchReadEntityRequest) (*BatchReadEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method BatchRead not implemented") +} +func (UnimplementedEntityStoreServer) Write(context.Context, *WriteEntityRequest) (*WriteEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Write not implemented") +} +func (UnimplementedEntityStoreServer) Delete(context.Context, *DeleteEntityRequest) (*DeleteEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented") +} +func (UnimplementedEntityStoreServer) History(context.Context, *EntityHistoryRequest) (*EntityHistoryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method History not implemented") +} +func (UnimplementedEntityStoreServer) Search(context.Context, *EntitySearchRequest) (*EntitySearchResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Search not implemented") +} +func (UnimplementedEntityStoreServer) AdminWrite(context.Context, *AdminWriteEntityRequest) (*WriteEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AdminWrite not implemented") +} + +// UnsafeEntityStoreServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to EntityStoreServer will +// result in compilation errors. +type UnsafeEntityStoreServer interface { + mustEmbedUnimplementedEntityStoreServer() +} + +func RegisterEntityStoreServer(s grpc.ServiceRegistrar, srv EntityStoreServer) { + s.RegisterService(&EntityStore_ServiceDesc, srv) +} + +func _EntityStore_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadEntityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).Read(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/Read", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).Read(ctx, req.(*ReadEntityRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EntityStore_BatchRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BatchReadEntityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).BatchRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/BatchRead", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).BatchRead(ctx, req.(*BatchReadEntityRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EntityStore_Write_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WriteEntityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).Write(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/Write", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).Write(ctx, req.(*WriteEntityRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EntityStore_Delete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteEntityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).Delete(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/Delete", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).Delete(ctx, req.(*DeleteEntityRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EntityStore_History_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EntityHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).History(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/History", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).History(ctx, req.(*EntityHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EntityStore_Search_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EntitySearchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).Search(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/Search", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).Search(ctx, req.(*EntitySearchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EntityStore_AdminWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdminWriteEntityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreServer).AdminWrite(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStore/AdminWrite", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreServer).AdminWrite(ctx, req.(*AdminWriteEntityRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// EntityStore_ServiceDesc is the grpc.ServiceDesc for EntityStore service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var EntityStore_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "entity.EntityStore", + HandlerType: (*EntityStoreServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Read", + Handler: _EntityStore_Read_Handler, + }, + { + MethodName: "BatchRead", + Handler: _EntityStore_BatchRead_Handler, + }, + { + MethodName: "Write", + Handler: _EntityStore_Write_Handler, + }, + { + MethodName: "Delete", + Handler: _EntityStore_Delete_Handler, + }, + { + MethodName: "History", + Handler: _EntityStore_History_Handler, + }, + { + MethodName: "Search", + Handler: _EntityStore_Search_Handler, + }, + { + MethodName: "AdminWrite", + Handler: _EntityStore_AdminWrite_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "entity.proto", +} + +// EntityStoreAdminClient is the client API for EntityStoreAdmin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type EntityStoreAdminClient interface { + AdminWrite(ctx context.Context, in *AdminWriteEntityRequest, opts ...grpc.CallOption) (*WriteEntityResponse, error) +} + +type entityStoreAdminClient struct { + cc grpc.ClientConnInterface +} + +func NewEntityStoreAdminClient(cc grpc.ClientConnInterface) EntityStoreAdminClient { + return &entityStoreAdminClient{cc} +} + +func (c *entityStoreAdminClient) AdminWrite(ctx context.Context, in *AdminWriteEntityRequest, opts ...grpc.CallOption) (*WriteEntityResponse, error) { + out := new(WriteEntityResponse) + err := c.cc.Invoke(ctx, "/entity.EntityStoreAdmin/AdminWrite", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// EntityStoreAdminServer is the server API for EntityStoreAdmin service. +// All implementations should embed UnimplementedEntityStoreAdminServer +// for forward compatibility +type EntityStoreAdminServer interface { + AdminWrite(context.Context, *AdminWriteEntityRequest) (*WriteEntityResponse, error) +} + +// UnimplementedEntityStoreAdminServer should be embedded to have forward compatible implementations. +type UnimplementedEntityStoreAdminServer struct { +} + +func (UnimplementedEntityStoreAdminServer) AdminWrite(context.Context, *AdminWriteEntityRequest) (*WriteEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AdminWrite not implemented") +} + +// UnsafeEntityStoreAdminServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to EntityStoreAdminServer will +// result in compilation errors. +type UnsafeEntityStoreAdminServer interface { + mustEmbedUnimplementedEntityStoreAdminServer() +} + +func RegisterEntityStoreAdminServer(s grpc.ServiceRegistrar, srv EntityStoreAdminServer) { + s.RegisterService(&EntityStoreAdmin_ServiceDesc, srv) +} + +func _EntityStoreAdmin_AdminWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdminWriteEntityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EntityStoreAdminServer).AdminWrite(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/entity.EntityStoreAdmin/AdminWrite", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EntityStoreAdminServer).AdminWrite(ctx, req.(*AdminWriteEntityRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// EntityStoreAdmin_ServiceDesc is the grpc.ServiceDesc for EntityStoreAdmin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var EntityStoreAdmin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "entity.EntityStoreAdmin", + HandlerType: (*EntityStoreAdminServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AdminWrite", + Handler: _EntityStoreAdmin_AdminWrite_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "entity.proto", +} diff --git a/pkg/services/store/object/generate.sh b/pkg/services/store/entity/generate.sh similarity index 96% rename from pkg/services/store/object/generate.sh rename to pkg/services/store/entity/generate.sh index dac1819eed3..67b865ac9c2 100755 --- a/pkg/services/store/object/generate.sh +++ b/pkg/services/store/entity/generate.sh @@ -17,5 +17,5 @@ cd "$DIR" protoc -I ./ \ --go_out=${DST_DIR} \ --go-grpc_out=${DST_DIR} --go-grpc_opt=require_unimplemented_servers=false \ - object.proto + entity.proto \ No newline at end of file diff --git a/pkg/services/store/object/grn.go b/pkg/services/store/entity/grn.go similarity index 62% rename from pkg/services/store/object/grn.go rename to pkg/services/store/entity/grn.go index 28d48fc3a12..8b336cd3c21 100644 --- a/pkg/services/store/object/grn.go +++ b/pkg/services/store/entity/grn.go @@ -1,4 +1,8 @@ -package object +package entity + +import ( + "fmt" +) // Check if the two GRNs reference to the same object // we can not use simple `*x == *b` because of the internal settings @@ -7,9 +11,11 @@ func (x *GRN) Equals(b *GRN) bool { return false } return x == b || (x.TenantId == b.TenantId && - x.Scope == b.Scope && x.Kind == b.Kind && x.UID == b.UID) } -// TODO: this should interpoerate with the GRN string flavor +// Set an OID based on the GRN +func (x *GRN) ToGRNString() string { + return fmt.Sprintf("grn:%d/%s/%s", x.TenantId, x.Kind, x.UID) +} diff --git a/pkg/services/store/object/httpobjectstore/service.go b/pkg/services/store/entity/httpentitystore/service.go similarity index 69% rename from pkg/services/store/object/httpobjectstore/service.go rename to pkg/services/store/entity/httpentitystore/service.go index 29bb7c6734f..4897241ddb4 100644 --- a/pkg/services/store/object/httpobjectstore/service.go +++ b/pkg/services/store/entity/httpentitystore/service.go @@ -1,4 +1,4 @@ -package httpobjectstore +package httpentitystore import ( "fmt" @@ -10,9 +10,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind" - "github.com/grafana/grafana/pkg/services/store/object" - "github.com/grafana/grafana/pkg/services/store/router" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -21,56 +20,48 @@ import ( "github.com/grafana/grafana/pkg/models" ) -type HTTPObjectStore interface { +type HTTPEntityStore interface { // Register HTTP Access to the store RegisterHTTPRoutes(routing.RouteRegister) } -type httpObjectStore struct { - store object.ObjectStoreServer - log log.Logger - kinds kind.KindRegistry - router router.ObjectStoreRouter +type httpEntityStore struct { + store entity.EntityStoreServer + log log.Logger + kinds kind.KindRegistry } -func ProvideHTTPObjectStore(store object.ObjectStoreServer, kinds kind.KindRegistry) HTTPObjectStore { - return &httpObjectStore{ - store: store, - log: log.New("http-object-store"), - kinds: kinds, - router: router.NewObjectStoreRouter(kinds), +func ProvideHTTPEntityStore(store entity.EntityStoreServer, kinds kind.KindRegistry) HTTPEntityStore { + return &httpEntityStore{ + store: store, + log: log.New("http-entity-store"), + kinds: kinds, } } -// All registered under "api/object" -func (s *httpObjectStore) RegisterHTTPRoutes(route routing.RouteRegister) { +// All registered under "api/entity" +func (s *httpEntityStore) RegisterHTTPRoutes(route routing.RouteRegister) { // For now, require admin for everything reqGrafanaAdmin := middleware.ReqSignedIn //.ReqGrafanaAdmin // Every * must parse to a GRN (uid+kind) - route.Get("/store/*", reqGrafanaAdmin, routing.Wrap(s.doGetObject)) - route.Post("/store/*", reqGrafanaAdmin, routing.Wrap(s.doWriteObject)) - route.Delete("/store/*", reqGrafanaAdmin, routing.Wrap(s.doDeleteObject)) - route.Get("/raw/*", reqGrafanaAdmin, routing.Wrap(s.doGetRawObject)) - route.Get("/history/*", reqGrafanaAdmin, routing.Wrap(s.doGetHistory)) - route.Get("/list/*", reqGrafanaAdmin, routing.Wrap(s.doListFolder)) // Simplified version of search -- path is prefix + route.Get("/store/:kind/:uid", reqGrafanaAdmin, routing.Wrap(s.doGetEntity)) + route.Post("/store/:kind/:uid", reqGrafanaAdmin, routing.Wrap(s.doWriteEntity)) + route.Delete("/store/:kind/:uid", reqGrafanaAdmin, routing.Wrap(s.doDeleteEntity)) + route.Get("/raw/:kind/:uid", reqGrafanaAdmin, routing.Wrap(s.doGetRawEntity)) + route.Get("/history/:kind/:uid", reqGrafanaAdmin, routing.Wrap(s.doGetHistory)) + route.Get("/list/:uid", reqGrafanaAdmin, routing.Wrap(s.doListFolder)) // Simplified version of search -- path is prefix route.Get("/search", reqGrafanaAdmin, routing.Wrap(s.doSearch)) // File upload - route.Post("/upload/:scope", reqGrafanaAdmin, routing.Wrap(s.doUpload)) + route.Post("/upload", reqGrafanaAdmin, routing.Wrap(s.doUpload)) } // This function will extract UID+Kind from the requested path "*" in our router // This is far from ideal! but is at least consistent for these endpoints. // This will quickly be revisited as we explore how to encode UID+Kind in a "GRN" format -func (s *httpObjectStore) getGRNFromRequest(c *models.ReqContext) (*object.GRN, map[string]string, error) { +func (s *httpEntityStore) getGRNFromRequest(c *models.ReqContext) (*entity.GRN, map[string]string, error) { params := web.Params(c.Req) - info, err := s.router.RouteFromKey(c.Req.Context(), - fmt.Sprintf("%d/%s", c.OrgID, params["*"])) - if err != nil { - return nil, params, err - } - // Read parameters that are encoded in the URL vals := c.Req.URL.Query() for k, v := range vals { @@ -78,34 +69,38 @@ func (s *httpObjectStore) getGRNFromRequest(c *models.ReqContext) (*object.GRN, params[k] = v[0] } } - return info.GRN, params, nil + return &entity.GRN{ + TenantId: c.OrgID, + Kind: params[":kind"], + UID: params[":uid"], + }, params, nil } -func (s *httpObjectStore) doGetObject(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doGetEntity(c *models.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) } - rsp, err := s.store.Read(c.Req.Context(), &object.ReadObjectRequest{ + rsp, err := s.store.Read(c.Req.Context(), &entity.ReadEntityRequest{ GRN: grn, Version: params["version"], // ?version = XYZ WithBody: params["body"] != "false", // default to true WithSummary: params["summary"] == "true", // default to false }) if err != nil { - return response.Error(500, "error fetching object", err) + return response.Error(500, "error fetching entity", err) } - if rsp.Object == nil { + if rsp.Entity == nil { return response.Error(404, "not found", nil) } // Configure etag support - currentEtag := rsp.Object.ETag + currentEtag := rsp.Entity.ETag previousEtag := c.Req.Header.Get("If-None-Match") if previousEtag == currentEtag { return response.CreateNormalResponse( http.Header{ - "ETag": []string{rsp.Object.ETag}, + "ETag": []string{rsp.Entity.ETag}, }, []byte{}, // nothing http.StatusNotModified, // 304 @@ -116,12 +111,12 @@ func (s *httpObjectStore) doGetObject(c *models.ReqContext) response.Response { return response.JSON(200, rsp) } -func (s *httpObjectStore) doGetRawObject(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doGetRawEntity(c *models.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) } - rsp, err := s.store.Read(c.Req.Context(), &object.ReadObjectRequest{ + rsp, err := s.store.Read(c.Req.Context(), &entity.ReadEntityRequest{ GRN: grn, Version: params["version"], // ?version = XYZ WithBody: true, @@ -135,14 +130,14 @@ func (s *httpObjectStore) doGetRawObject(c *models.ReqContext) response.Response return response.Error(400, "Unsupported kind", err) } - if rsp.Object != nil && rsp.Object.Body != nil { + if rsp.Entity != nil && rsp.Entity.Body != nil { // Configure etag support - currentEtag := rsp.Object.ETag + currentEtag := rsp.Entity.ETag previousEtag := c.Req.Header.Get("If-None-Match") if previousEtag == currentEtag { return response.CreateNormalResponse( http.Header{ - "ETag": []string{rsp.Object.ETag}, + "ETag": []string{rsp.Entity.ETag}, }, []byte{}, // nothing http.StatusNotModified, // 304 @@ -157,7 +152,7 @@ func (s *httpObjectStore) doGetRawObject(c *models.ReqContext) response.Response "Content-Type": []string{mime}, "ETag": []string{currentEtag}, }, - rsp.Object.Body, + rsp.Entity.Body, 200, ) } @@ -166,7 +161,7 @@ func (s *httpObjectStore) doGetRawObject(c *models.ReqContext) response.Response const MAX_UPLOAD_SIZE = 5 * 1024 * 1024 // 5MB -func (s *httpObjectStore) doWriteObject(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doWriteEntity(c *models.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) @@ -179,9 +174,10 @@ func (s *httpObjectStore) doWriteObject(c *models.ReqContext) response.Response return response.Error(400, "error reading body", err) } - rsp, err := s.store.Write(c.Req.Context(), &object.WriteObjectRequest{ + rsp, err := s.store.Write(c.Req.Context(), &entity.WriteEntityRequest{ GRN: grn, Body: b, + Folder: params["folder"], Comment: params["comment"], PreviousVersion: params["previousVersion"], }) @@ -191,12 +187,12 @@ func (s *httpObjectStore) doWriteObject(c *models.ReqContext) response.Response return response.JSON(200, rsp) } -func (s *httpObjectStore) doDeleteObject(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doDeleteEntity(c *models.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) } - rsp, err := s.store.Delete(c.Req.Context(), &object.DeleteObjectRequest{ + rsp, err := s.store.Delete(c.Req.Context(), &entity.DeleteEntityRequest{ GRN: grn, PreviousVersion: params["previousVersion"], }) @@ -206,13 +202,13 @@ func (s *httpObjectStore) doDeleteObject(c *models.ReqContext) response.Response return response.JSON(200, rsp) } -func (s *httpObjectStore) doGetHistory(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doGetHistory(c *models.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) } limit := int64(20) // params - rsp, err := s.store.History(c.Req.Context(), &object.ObjectHistoryRequest{ + rsp, err := s.store.History(c.Req.Context(), &entity.EntityHistoryRequest{ GRN: grn, Limit: limit, NextPageToken: params["nextPageToken"], @@ -223,7 +219,7 @@ func (s *httpObjectStore) doGetHistory(c *models.ReqContext) response.Response { return response.JSON(200, rsp) } -func (s *httpObjectStore) doUpload(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doUpload(c *models.ReqContext) response.Response { c.Req.Body = http.MaxBytesReader(c.Resp, c.Req.Body, MAX_UPLOAD_SIZE) if err := c.Req.ParseMultipartForm(MAX_UPLOAD_SIZE); err != nil { msg := fmt.Sprintf("Please limit file uploaded under %s", util.ByteCountSI(MAX_UPLOAD_SIZE)) @@ -233,12 +229,8 @@ func (s *httpObjectStore) doUpload(c *models.ReqContext) response.Response { if len(fileinfo) < 1 { return response.Error(400, "missing files", nil) } - scope := web.Params(c.Req)[":scope"] - if scope == "" { - return response.Error(400, "invalid scope", nil) - } - var rsp []*object.WriteObjectResponse + var rsp []*entity.WriteEntityResponse message := getMultipartFormValue(c.Req, "message") overwriteExistingFile := getMultipartFormValue(c.Req, "overwriteExistingFile") != "false" // must explicitly overwrite @@ -257,12 +249,7 @@ func (s *httpObjectStore) doUpload(c *models.ReqContext) response.Response { if err != nil || kind.ID == "" { return response.Error(400, "Unsupported kind: "+fileHeader.Filename, err) } - uid := folder - if uid != "" { - uid = uid + "/" + fileHeader.Filename[:idx] - } else { - uid = fileHeader.Filename[:idx] - } + uid := fileHeader.Filename[:idx] file, err := fileHeader.Open() if err != nil { @@ -277,14 +264,14 @@ func (s *httpObjectStore) doUpload(c *models.ReqContext) response.Response { return response.Error(500, "Internal Server Error", err) } - grn := &object.GRN{ - Scope: scope, - UID: uid, - Kind: kind.ID, + grn := &entity.GRN{ + UID: uid, + Kind: kind.ID, + TenantId: c.OrgID, } if !overwriteExistingFile { - result, err := s.store.Read(ctx, &object.ReadObjectRequest{ + result, err := s.store.Read(ctx, &entity.ReadEntityRequest{ GRN: grn, WithBody: false, WithSummary: false, @@ -292,15 +279,16 @@ func (s *httpObjectStore) doUpload(c *models.ReqContext) response.Response { if err != nil { return response.Error(500, "Internal Server Error", err) } - if result.Object != nil { + if result.Entity != nil { return response.Error(400, "File name already in use", err) } } - result, err := s.store.Write(ctx, &object.WriteObjectRequest{ + result, err := s.store.Write(ctx, &entity.WriteEntityRequest{ GRN: grn, Body: data, Comment: message, + Folder: folder, // PreviousVersion: params["previousVersion"], }) @@ -314,14 +302,14 @@ func (s *httpObjectStore) doUpload(c *models.ReqContext) response.Response { return response.JSON(200, rsp) } -func (s *httpObjectStore) doListFolder(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doListFolder(c *models.ReqContext) response.Response { return response.JSON(501, "Not implemented yet") } -func (s *httpObjectStore) doSearch(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doSearch(c *models.ReqContext) response.Response { vals := c.Req.URL.Query() - req := &object.ObjectSearchRequest{ + req := &entity.EntitySearchRequest{ WithBody: asBoolean("body", vals, false), WithLabels: asBoolean("labels", vals, true), WithFields: asBoolean("fields", vals, true), diff --git a/pkg/services/store/object/json.go b/pkg/services/store/entity/json.go similarity index 76% rename from pkg/services/store/object/json.go rename to pkg/services/store/entity/json.go index b3c4e20d839..34933e7dec9 100644 --- a/pkg/services/store/object/json.go +++ b/pkg/services/store/entity/json.go @@ -1,4 +1,4 @@ -package object +package entity import ( "encoding/base64" @@ -10,12 +10,12 @@ import ( ) func init() { //nolint:gochecknoinits - jsoniter.RegisterTypeEncoder("object.ObjectSearchResult", &searchResultCodec{}) - jsoniter.RegisterTypeEncoder("object.WriteObjectResponse", &writeResponseCodec{}) - jsoniter.RegisterTypeEncoder("object.ReadObjectResponse", &readResponseCodec{}) + jsoniter.RegisterTypeEncoder("entity.EntitySearchResult", &searchResultCodec{}) + jsoniter.RegisterTypeEncoder("entity.WriteEntityResponse", &writeResponseCodec{}) + jsoniter.RegisterTypeEncoder("entity.ReadEntityResponse", &readResponseCodec{}) - jsoniter.RegisterTypeEncoder("object.RawObject", &rawObjectCodec{}) - jsoniter.RegisterTypeDecoder("object.RawObject", &rawObjectCodec{}) + jsoniter.RegisterTypeEncoder("entity.Entity", &rawEntityCodec{}) + jsoniter.RegisterTypeDecoder("entity.Entity", &rawEntityCodec{}) } func writeRawJson(stream *jsoniter.Stream, val []byte) { @@ -27,30 +27,30 @@ func writeRawJson(stream *jsoniter.Stream, val []byte) { } // Unlike the standard JSON marshal, this will write bytes as JSON when it can -type rawObjectCodec struct{} +type rawEntityCodec struct{} -func (obj *RawObject) MarshalJSON() ([]byte, error) { +func (obj *Entity) MarshalJSON() ([]byte, error) { var json = jsoniter.ConfigCompatibleWithStandardLibrary return json.Marshal(obj) } -// UnmarshalJSON will read JSON into a RawObject -func (obj *RawObject) UnmarshalJSON(b []byte) error { +// UnmarshalJSON will read JSON into a Entity +func (obj *Entity) UnmarshalJSON(b []byte) error { if obj == nil { return fmt.Errorf("unexpected nil for raw objcet") } iter := jsoniter.ParseBytes(jsoniter.ConfigDefault, b) - readRawObject(iter, obj) + readEntity(iter, obj) return iter.Error } -func (codec *rawObjectCodec) IsEmpty(ptr unsafe.Pointer) bool { - f := (*RawObject)(ptr) +func (codec *rawEntityCodec) IsEmpty(ptr unsafe.Pointer) bool { + f := (*Entity)(ptr) return f.GRN == nil && f.Body == nil } -func (codec *rawObjectCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { - obj := (*RawObject)(ptr) +func (codec *rawEntityCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + obj := (*Entity)(ptr) stream.WriteObjectStart() stream.WriteObjectField("GRN") stream.WriteVal(obj.GRN) @@ -96,6 +96,16 @@ func (codec *rawObjectCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) stream.WriteObjectField("etag") stream.WriteString(obj.ETag) } + if obj.Folder != "" { + stream.WriteMore() + stream.WriteObjectField("folder") + stream.WriteString(obj.Folder) + } + if obj.Slug != "" { + stream.WriteMore() + stream.WriteObjectField("slug") + stream.WriteString(obj.Slug) + } if obj.Size > 0 { stream.WriteMore() stream.WriteObjectField("size") @@ -111,13 +121,13 @@ func (codec *rawObjectCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) stream.WriteObjectEnd() } -func (codec *rawObjectCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { - *(*RawObject)(ptr) = RawObject{} - raw := (*RawObject)(ptr) - readRawObject(iter, raw) +func (codec *rawEntityCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + *(*Entity)(ptr) = Entity{} + raw := (*Entity)(ptr) + readEntity(iter, raw) } -func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { +func readEntity(iter *jsoniter.Iterator, raw *Entity) { for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { switch l1Field { case "GRN": @@ -135,10 +145,14 @@ func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { raw.Size = iter.ReadInt64() case "etag": raw.ETag = iter.ReadString() + case "folder": + raw.Folder = iter.ReadString() + case "slug": + raw.Slug = iter.ReadString() case "version": raw.Version = iter.ReadString() case "origin": - raw.Origin = &ObjectOriginInfo{} + raw.Origin = &EntityOriginInfo{} iter.ReadVal(raw.Origin) case "body": @@ -146,7 +160,7 @@ func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { iter.ReadVal(&val) // ??? is there a smarter way to just keep the underlying bytes without read+marshal body, err := json.Marshal(val) if err != nil { - iter.ReportError("raw object", "error creating json from body") + iter.ReportError("raw entity", "error creating json from body") return } raw.Body = body @@ -155,7 +169,7 @@ func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { val := iter.ReadString() body, err := base64.StdEncoding.DecodeString(val) if err != nil { - iter.ReportError("raw object", "error decoding base64 body") + iter.ReportError("raw entity", "error decoding base64 body") return } raw.Body = body @@ -170,21 +184,21 @@ func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { // Unlike the standard JSON marshal, this will write bytes as JSON when it can type readResponseCodec struct{} -func (obj *ReadObjectResponse) MarshalJSON() ([]byte, error) { +func (obj *ReadEntityResponse) MarshalJSON() ([]byte, error) { var json = jsoniter.ConfigCompatibleWithStandardLibrary return json.Marshal(obj) } func (codec *readResponseCodec) IsEmpty(ptr unsafe.Pointer) bool { - f := (*ReadObjectResponse)(ptr) + f := (*ReadEntityResponse)(ptr) return f == nil } func (codec *readResponseCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { - obj := (*ReadObjectResponse)(ptr) + obj := (*ReadEntityResponse)(ptr) stream.WriteObjectStart() - stream.WriteObjectField("object") - stream.WriteVal(obj.Object) + stream.WriteObjectField("entity") + stream.WriteVal(obj.Entity) if len(obj.SummaryJson) > 0 { stream.WriteMore() @@ -198,18 +212,18 @@ func (codec *readResponseCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stre // Unlike the standard JSON marshal, this will write bytes as JSON when it can type searchResultCodec struct{} -func (obj *ObjectSearchResult) MarshalJSON() ([]byte, error) { +func (obj *EntitySearchResult) MarshalJSON() ([]byte, error) { var json = jsoniter.ConfigCompatibleWithStandardLibrary return json.Marshal(obj) } func (codec *searchResultCodec) IsEmpty(ptr unsafe.Pointer) bool { - f := (*ObjectSearchResult)(ptr) + f := (*EntitySearchResult)(ptr) return f.GRN == nil && f.Body == nil } func (codec *searchResultCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { - obj := (*ObjectSearchResult)(ptr) + obj := (*EntitySearchResult)(ptr) stream.WriteObjectStart() stream.WriteObjectField("GRN") stream.WriteVal(obj.GRN) @@ -271,18 +285,18 @@ func (codec *searchResultCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stre // Unlike the standard JSON marshal, this will write bytes as JSON when it can type writeResponseCodec struct{} -func (obj *WriteObjectResponse) MarshalJSON() ([]byte, error) { +func (obj *WriteEntityResponse) MarshalJSON() ([]byte, error) { var json = jsoniter.ConfigCompatibleWithStandardLibrary return json.Marshal(obj) } func (codec *writeResponseCodec) IsEmpty(ptr unsafe.Pointer) bool { - f := (*WriteObjectResponse)(ptr) + f := (*WriteEntityResponse)(ptr) return f == nil } func (codec *writeResponseCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { - obj := (*WriteObjectResponse)(ptr) + obj := (*WriteEntityResponse)(ptr) stream.WriteObjectStart() stream.WriteObjectField("status") stream.WriteString(obj.Status.String()) @@ -297,10 +311,10 @@ func (codec *writeResponseCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Str stream.WriteObjectField("GRN") stream.WriteVal(obj.GRN) } - if obj.Object != nil { + if obj.Entity != nil { stream.WriteMore() - stream.WriteObjectField("object") - stream.WriteVal(obj.Object) + stream.WriteObjectField("entity") + stream.WriteVal(obj.Entity) } if len(obj.SummaryJson) > 0 { stream.WriteMore() diff --git a/pkg/services/store/object/json_test.go b/pkg/services/store/entity/json_test.go similarity index 91% rename from pkg/services/store/object/json_test.go rename to pkg/services/store/entity/json_test.go index 61b6c5e5a2d..01486b88258 100644 --- a/pkg/services/store/object/json_test.go +++ b/pkg/services/store/entity/json_test.go @@ -1,4 +1,4 @@ -package object +package entity import ( "encoding/json" @@ -14,7 +14,7 @@ func TestRawEncoders(t *testing.T) { }) require.NoError(t, err) - raw := &RawObject{ + raw := &Entity{ GRN: &GRN{ UID: "a", Kind: "b", @@ -42,7 +42,7 @@ func TestRawEncoders(t *testing.T) { "etag": "d" }`, str) - copy := &RawObject{} + copy := &Entity{} err = json.Unmarshal(b, copy) require.NoError(t, err) } diff --git a/pkg/services/store/object/sqlstash/querybuilder.go b/pkg/services/store/entity/sqlstash/querybuilder.go similarity index 87% rename from pkg/services/store/object/sqlstash/querybuilder.go rename to pkg/services/store/entity/sqlstash/querybuilder.go index 73e13a66c70..ac13956f2af 100644 --- a/pkg/services/store/object/sqlstash/querybuilder.go +++ b/pkg/services/store/entity/sqlstash/querybuilder.go @@ -12,7 +12,7 @@ type selectQuery struct { args []interface{} } -func (q *selectQuery) addWhere(f string, val string) { +func (q *selectQuery) addWhere(f string, val interface{}) { q.args = append(q.args, val) q.where = append(q.where, f+"=?") } @@ -37,11 +37,6 @@ func (q *selectQuery) addWhereIn(f string, vals []string) { } } -func (q *selectQuery) addWherePrefix(f string, v string) { - q.args = append(q.args, v+"%") - q.where = append(q.where, f+" LIKE ?") -} - func (q *selectQuery) toQuery() (string, []interface{}) { args := q.args sb := strings.Builder{} diff --git a/pkg/services/store/object/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go similarity index 55% rename from pkg/services/store/object/sqlstash/sql_storage_server.go rename to pkg/services/store/entity/sqlstash/sql_storage_server.go index 29e23cec334..9b88660c3a1 100644 --- a/pkg/services/store/object/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -11,49 +11,46 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind" - "github.com/grafana/grafana/pkg/services/store/kind/folder" - "github.com/grafana/grafana/pkg/services/store/object" "github.com/grafana/grafana/pkg/services/store/resolver" - "github.com/grafana/grafana/pkg/services/store/router" "github.com/grafana/grafana/pkg/setting" ) // Make sure we implement both store + admin -var _ object.ObjectStoreServer = &sqlObjectServer{} -var _ object.ObjectStoreAdminServer = &sqlObjectServer{} +var _ entity.EntityStoreServer = &sqlEntityServer{} +var _ entity.EntityStoreAdminServer = &sqlEntityServer{} -func ProvideSQLObjectServer(db db.DB, cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry, resolver resolver.ObjectReferenceResolver) object.ObjectStoreServer { - objectServer := &sqlObjectServer{ +func ProvideSQLEntityServer(db db.DB, cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry, resolver resolver.EntityReferenceResolver) entity.EntityStoreServer { + entityServer := &sqlEntityServer{ sess: db.GetSqlxSession(), - log: log.New("sql-object-server"), + log: log.New("sql-entity-server"), kinds: kinds, resolver: resolver, - router: router.NewObjectStoreRouter(kinds), } - object.RegisterObjectStoreServer(grpcServerProvider.GetServer(), objectServer) - return objectServer + entity.RegisterEntityStoreServer(grpcServerProvider.GetServer(), entityServer) + return entityServer } -type sqlObjectServer struct { +type sqlEntityServer struct { log log.Logger sess *session.SessionDB kinds kind.KindRegistry - resolver resolver.ObjectReferenceResolver - router router.ObjectStoreRouter + resolver resolver.EntityReferenceResolver } -func getReadSelect(r *object.ReadObjectRequest) string { +func getReadSelect(r *entity.ReadEntityRequest) string { fields := []string{ - "path", "kind", "version", + "tenant_id", "kind", "uid", // The PK + "version", "slug", "folder", "size", "etag", "errors", // errors are always returned "created_at", "created_by", "updated_at", "updated_by", - "origin", "origin_ts"} + "origin", "origin_key", "origin_ts"} if r.WithBody { fields = append(fields, `body`) @@ -61,24 +58,24 @@ func getReadSelect(r *object.ReadObjectRequest) string { if r.WithSummary { fields = append(fields, `name`, `description`, `labels`, `fields`) } - return "SELECT " + strings.Join(fields, ",") + " FROM object WHERE " + return "SELECT " + strings.Join(fields, ",") + " FROM entity WHERE " } -func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql.Rows, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { - path := "" // string (extract UID?) - var origin sql.NullString - originTime := int64(0) - raw := &object.RawObject{ - GRN: &object.GRN{}, +func (s *sqlEntityServer) rowToReadEntityResponse(ctx context.Context, rows *sql.Rows, r *entity.ReadEntityRequest) (*entity.ReadEntityResponse, error) { + raw := &entity.Entity{ + GRN: &entity.GRN{}, + Origin: &entity.EntityOriginInfo{}, } + slug := "" summaryjson := &summarySupport{} args := []interface{}{ - &path, &raw.GRN.Kind, &raw.Version, + &raw.GRN.TenantId, &raw.GRN.Kind, &raw.GRN.UID, + &raw.Version, &slug, &raw.Folder, &raw.Size, &raw.ETag, &summaryjson.errors, &raw.CreatedAt, &raw.CreatedBy, &raw.UpdatedAt, &raw.UpdatedBy, - &origin, &originTime, + &raw.Origin.Source, &raw.Origin.Key, &raw.Origin.Time, } if r.WithBody { args = append(args, &raw.Body) @@ -92,25 +89,16 @@ func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql return nil, err } - if origin.Valid { - raw.Origin = &object.ObjectOriginInfo{ - Source: origin.String, - Time: originTime, - } + if raw.Origin.Source == "" { + raw.Origin = nil } - // Get the GRN from key. TODO? save each part as a column? - info, _ := s.router.RouteFromKey(ctx, path) - if info.GRN != nil { - raw.GRN = info.GRN - } - - rsp := &object.ReadObjectResponse{ - Object: raw, + rsp := &entity.ReadEntityResponse{ + Entity: raw, } if r.WithSummary || summaryjson.errors != nil { - summary, err := summaryjson.toObjectSummary() + summary, err := summaryjson.toEntitySummary() if err != nil { return nil, err } @@ -124,35 +112,43 @@ func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql return rsp, nil } -func (s *sqlObjectServer) getObjectKey(ctx context.Context, grn *object.GRN) (router.ResourceRouteInfo, error) { +func (s *sqlEntityServer) validateGRN(ctx context.Context, grn *entity.GRN) (*entity.GRN, error) { if grn == nil { - return router.ResourceRouteInfo{}, fmt.Errorf("missing grn") + return nil, fmt.Errorf("missing GRN") } user := store.UserFromContext(ctx) - if user == nil { - return router.ResourceRouteInfo{}, fmt.Errorf("can not find user in context") - } - if user.OrgID != grn.TenantId { - if grn.TenantId > 0 { - return router.ResourceRouteInfo{}, fmt.Errorf("invalid user (wrong tenant id)") - } + if grn.TenantId == 0 { grn.TenantId = user.OrgID + } else if grn.TenantId != user.OrgID { + return nil, fmt.Errorf("tenant ID does not match userID") } - return s.router.Route(ctx, grn) + + if grn.Kind == "" { + return nil, fmt.Errorf("GRN missing kind") + } + if grn.UID == "" { + return nil, fmt.Errorf("GRN missing UID") + } + if len(grn.UID) > 40 { + return nil, fmt.Errorf("GRN UID is too long (>40)") + } + if strings.ContainsAny(grn.UID, "/#$@?") { + return nil, fmt.Errorf("invalid character in GRN") + } + return grn, nil } -func (s *sqlObjectServer) Read(ctx context.Context, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { +func (s *sqlEntityServer) Read(ctx context.Context, r *entity.ReadEntityRequest) (*entity.ReadEntityResponse, error) { if r.Version != "" { return s.readFromHistory(ctx, r) } - - route, err := s.getObjectKey(ctx, r.GRN) + grn, err := s.validateGRN(ctx, r.GRN) if err != nil { return nil, err } - args := []interface{}{route.Key} - where := "path=?" + args := []interface{}{grn.ToGRNString()} + where := "grn=?" rows, err := s.sess.Query(ctx, getReadSelect(r)+where, args...) if err != nil { @@ -161,17 +157,18 @@ func (s *sqlObjectServer) Read(ctx context.Context, r *object.ReadObjectRequest) defer func() { _ = rows.Close() }() if !rows.Next() { - return &object.ReadObjectResponse{}, nil + return &entity.ReadEntityResponse{}, nil } - return s.rowToReadObjectResponse(ctx, rows, r) + return s.rowToReadEntityResponse(ctx, rows, r) } -func (s *sqlObjectServer) readFromHistory(ctx context.Context, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { - route, err := s.getObjectKey(ctx, r.GRN) +func (s *sqlEntityServer) readFromHistory(ctx context.Context, r *entity.ReadEntityRequest) (*entity.ReadEntityResponse, error) { + grn, err := s.validateGRN(ctx, r.GRN) if err != nil { return nil, err } + oid := grn.ToGRNString() fields := []string{ "body", "size", "etag", @@ -180,7 +177,7 @@ func (s *sqlObjectServer) readFromHistory(ctx context.Context, r *object.ReadObj rows, err := s.sess.Query(ctx, "SELECT "+strings.Join(fields, ",")+ - " FROM object_history WHERE path=? AND version=?", route.Key, r.Version) + " FROM entity_history WHERE grn=? AND version=?", oid, r.Version) if err != nil { return nil, err } @@ -188,14 +185,14 @@ func (s *sqlObjectServer) readFromHistory(ctx context.Context, r *object.ReadObj // Version or key not found if !rows.Next() { - return &object.ReadObjectResponse{}, nil + return &entity.ReadEntityResponse{}, nil } - raw := &object.RawObject{ + raw := &entity.Entity{ GRN: r.GRN, } - rsp := &object.ReadObjectResponse{ - Object: raw, + rsp := &entity.ReadEntityResponse{ + Entity: raw, } err = rows.Scan(&raw.Body, &raw.Size, &raw.ETag, &raw.UpdatedAt, &raw.UpdatedBy) if err != nil { @@ -223,13 +220,13 @@ func (s *sqlObjectServer) readFromHistory(ctx context.Context, r *object.ReadObj // Clear the body if not requested if !r.WithBody { - rsp.Object.Body = nil + rsp.Entity.Body = nil } return rsp, err } -func (s *sqlObjectServer) BatchRead(ctx context.Context, b *object.BatchReadObjectRequest) (*object.BatchReadObjectResponse, error) { +func (s *sqlEntityServer) BatchRead(ctx context.Context, b *entity.BatchReadEntityRequest) (*entity.BatchReadEntityResponse, error) { if len(b.Batch) < 1 { return nil, fmt.Errorf("missing querires") } @@ -243,13 +240,13 @@ func (s *sqlObjectServer) BatchRead(ctx context.Context, b *object.BatchReadObje return nil, fmt.Errorf("requests must want the same things") } - route, err := s.getObjectKey(ctx, r.GRN) + grn, err := s.validateGRN(ctx, r.GRN) if err != nil { return nil, err } - where := "path=?" - args = append(args, route.Key) + where := "grn=?" + args = append(args, grn.ToGRNString()) if r.Version != "" { return nil, fmt.Errorf("version not supported for batch read (yet?)") } @@ -265,9 +262,9 @@ func (s *sqlObjectServer) BatchRead(ctx context.Context, b *object.BatchReadObje defer func() { _ = rows.Close() }() // TODO? make sure the results are in order? - rsp := &object.BatchReadObjectResponse{} + rsp := &entity.BatchReadEntityResponse{} for rows.Next() { - r, err := s.rowToReadObjectResponse(ctx, rows, req) + r, err := s.rowToReadEntityResponse(ctx, rows, req) if err != nil { return nil, err } @@ -276,20 +273,17 @@ func (s *sqlObjectServer) BatchRead(ctx context.Context, b *object.BatchReadObje return rsp, nil } -func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { - return s.AdminWrite(ctx, object.ToAdminWriteObjectRequest(r)) +func (s *sqlEntityServer) Write(ctx context.Context, r *entity.WriteEntityRequest) (*entity.WriteEntityResponse, error) { + return s.AdminWrite(ctx, entity.ToAdminWriteEntityRequest(r)) } //nolint:gocyclo -func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { - route, err := s.getObjectKey(ctx, r.GRN) +func (s *sqlEntityServer) AdminWrite(ctx context.Context, r *entity.AdminWriteEntityRequest) (*entity.WriteEntityResponse, error) { + grn, err := s.validateGRN(ctx, r.GRN) if err != nil { return nil, err } - grn := route.GRN - if grn == nil { - return nil, fmt.Errorf("invalid grn") - } + oid := grn.ToGRNString() timestamp := time.Now().UnixMilli() createdAt := r.CreatedAt @@ -312,49 +306,49 @@ func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteOb return nil, err } - etag := createContentsHash(body) - path := route.Key - - rsp := &object.WriteObjectResponse{ - GRN: grn, - Status: object.WriteObjectResponse_CREATED, // Will be changed if not true + t := summary.name + if t == "" { + t = r.GRN.UID } - // Make sure all parent folders exist - if grn.Scope == models.ObjectStoreScopeDrive { - err = s.ensureFolders(ctx, grn) - if err != nil { - return nil, err - } + slug := slugify.Slugify(t) + etag := createContentsHash(body) + rsp := &entity.WriteEntityResponse{ + GRN: grn, + Status: entity.WriteEntityResponse_CREATED, // Will be changed if not true + } + origin := r.Origin + if origin == nil { + origin = &entity.EntityOriginInfo{} } err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { - var versionInfo *object.ObjectVersionInfo + var versionInfo *entity.EntityVersionInfo isUpdate := false if r.ClearHistory { // Optionally keep the original creation time information if createdAt < 1000 || createdBy == "" { - err = s.fillCreationInfo(ctx, tx, path, &createdAt, &createdBy) + err = s.fillCreationInfo(ctx, tx, oid, &createdAt, &createdBy) if err != nil { return err } } - _, err = doDelete(ctx, tx, path) + _, err = doDelete(ctx, tx, oid) if err != nil { return err } - versionInfo = &object.ObjectVersionInfo{} + versionInfo = &entity.EntityVersionInfo{} } else { - versionInfo, err = s.selectForUpdate(ctx, tx, path) + versionInfo, err = s.selectForUpdate(ctx, tx, oid) if err != nil { return err } } - // Same object + // Same entity if versionInfo.ETag == etag { - rsp.Object = versionInfo - rsp.Status = object.WriteObjectResponse_UNCHANGED + rsp.Entity = versionInfo + rsp.Status = entity.WriteEntityResponse_UNCHANGED return nil } @@ -385,25 +379,25 @@ func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteOb if isUpdate { // Clear the labels+refs - if _, err := tx.Exec(ctx, "DELETE FROM object_labels WHERE path=?", path); err != nil { + if _, err := tx.Exec(ctx, "DELETE FROM entity_labels WHERE grn=?", oid); err != nil { return err } - if _, err := tx.Exec(ctx, "DELETE FROM object_ref WHERE path=?", path); err != nil { + if _, err := tx.Exec(ctx, "DELETE FROM entity_ref WHERE grn=?", oid); err != nil { return err } } - // 1. Add the `object_history` values + // 1. Add the `entity_history` values versionInfo.Size = int64(len(body)) versionInfo.ETag = etag versionInfo.UpdatedAt = updatedAt versionInfo.UpdatedBy = updatedBy - _, err = tx.Exec(ctx, `INSERT INTO object_history (`+ - "path, version, message, "+ + _, err = tx.Exec(ctx, `INSERT INTO entity_history (`+ + "grn, version, message, "+ "size, body, etag, "+ "updated_at, updated_by) "+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - path, versionInfo.Version, versionInfo.Comment, + oid, versionInfo.Version, versionInfo.Comment, versionInfo.Size, body, versionInfo.ETag, updatedAt, versionInfo.UpdatedBy, ) @@ -414,10 +408,10 @@ func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteOb // 2. Add the labels rows for k, v := range summary.model.Labels { _, err = tx.Exec(ctx, - `INSERT INTO object_labels `+ - "(path, label, value) "+ + `INSERT INTO entity_labels `+ + "(grn, label, value) "+ `VALUES (?, ?, ?)`, - path, k, v, + oid, k, v, ) if err != nil { return err @@ -430,11 +424,11 @@ func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteOb if err != nil { return err } - _, err = tx.Exec(ctx, `INSERT INTO object_ref (`+ - "path, kind, type, uid, "+ + _, err = tx.Exec(ctx, `INSERT INTO entity_ref (`+ + "grn, kind, type, uid, "+ "resolved_ok, resolved_to, resolved_warning, resolved_time) "+ `VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - path, ref.Kind, ref.Type, ref.UID, + oid, ref.Kind, ref.Type, ref.UID, resolved.OK, resolved.Key, resolved.Warning, resolved.Timestamp, ) if err != nil { @@ -442,23 +436,23 @@ func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteOb } } - // 5. Add/update the main `object` table - rsp.Object = versionInfo + // 5. Add/update the main `entity` table + rsp.Entity = versionInfo if isUpdate { - rsp.Status = object.WriteObjectResponse_UPDATED - _, err = tx.Exec(ctx, "UPDATE object SET "+ + rsp.Status = entity.WriteEntityResponse_UPDATED + _, err = tx.Exec(ctx, "UPDATE entity SET "+ "body=?, size=?, etag=?, version=?, "+ "updated_at=?, updated_by=?,"+ "name=?, description=?,"+ "labels=?, fields=?, errors=?, "+ - "origin=?, origin_ts=? "+ - "WHERE path=?", + "origin=?, origin_key=?, origin_ts=? "+ + "WHERE grn=?", body, versionInfo.Size, etag, versionInfo.Version, updatedAt, versionInfo.UpdatedBy, summary.model.Name, summary.model.Description, summary.labels, summary.fields, summary.errors, - r.Origin, timestamp, - path, + origin.Source, origin.Key, timestamp, + oid, ) return err } @@ -470,27 +464,36 @@ func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteOb createdBy = updatedBy } - _, err = tx.Exec(ctx, "INSERT INTO object ("+ - "path, parent_folder_path, kind, size, body, etag, version, "+ + _, err = tx.Exec(ctx, "INSERT INTO entity ("+ + "grn, tenant_id, kind, uid, folder, "+ + "size, body, etag, version, "+ "updated_at, updated_by, created_at, created_by, "+ - "name, description, origin, origin_ts, "+ - "labels, fields, errors) "+ - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - path, getParentFolderPath(grn.Kind, path), grn.Kind, versionInfo.Size, body, etag, versionInfo.Version, + "name, description, slug, "+ + "labels, fields, errors, "+ + "origin, origin_key, origin_ts) "+ + "VALUES (?, ?, ?, ?, ?, "+ + " ?, ?, ?, ?, "+ + " ?, ?, ?, ?, "+ + " ?, ?, ?, "+ + " ?, ?, ?, "+ + " ?, ?, ?)", + oid, grn.TenantId, grn.Kind, grn.UID, r.Folder, + versionInfo.Size, body, etag, versionInfo.Version, updatedAt, createdBy, createdAt, createdBy, // created + updated are the same - summary.model.Name, summary.model.Description, r.Origin, timestamp, + summary.model.Name, summary.model.Description, slug, summary.labels, summary.fields, summary.errors, + origin.Source, origin.Key, origin.Time, ) return err }) rsp.SummaryJson = summary.marshaled if err != nil { - rsp.Status = object.WriteObjectResponse_ERROR + rsp.Status = entity.WriteEntityResponse_ERROR } return rsp, err } -func (s *sqlObjectServer) fillCreationInfo(ctx context.Context, tx *session.SessionTx, path string, createdAt *int64, createdBy *string) error { +func (s *sqlEntityServer) fillCreationInfo(ctx context.Context, tx *session.SessionTx, grn string, createdAt *int64, createdBy *string) error { if *createdAt > 1000 { ignore := int64(0) createdAt = &ignore @@ -500,7 +503,7 @@ func (s *sqlObjectServer) fillCreationInfo(ctx context.Context, tx *session.Sess createdBy = &ignore } - rows, err := tx.Query(ctx, "SELECT created_at,created_by FROM object WHERE path=?", path) + rows, err := tx.Query(ctx, "SELECT created_at,created_by FROM entity WHERE grn=?", grn) if err == nil { if rows.Next() { err = rows.Scan(&createdAt, &createdBy) @@ -512,16 +515,16 @@ func (s *sqlObjectServer) fillCreationInfo(ctx context.Context, tx *session.Sess return err } -func (s *sqlObjectServer) selectForUpdate(ctx context.Context, tx *session.SessionTx, path string) (*object.ObjectVersionInfo, error) { - q := "SELECT etag,version,updated_at,size FROM object WHERE path=?" +func (s *sqlEntityServer) selectForUpdate(ctx context.Context, tx *session.SessionTx, grn string) (*entity.EntityVersionInfo, error) { + q := "SELECT etag,version,updated_at,size FROM entity WHERE grn=?" if false { // TODO, MYSQL/PosgreSQL can lock the row " FOR UPDATE" q += " FOR UPDATE" } - rows, err := tx.Query(ctx, q, path) + rows, err := tx.Query(ctx, q, grn) if err != nil { return nil, err } - current := &object.ObjectVersionInfo{} + current := &entity.EntityVersionInfo{} if rows.Next() { err = rows.Scan(¤t.ETag, ¤t.Version, ¤t.UpdatedAt, ¤t.Size) } @@ -531,7 +534,7 @@ func (s *sqlObjectServer) selectForUpdate(ctx context.Context, tx *session.Sessi return current, err } -func (s *sqlObjectServer) prepare(ctx context.Context, r *object.AdminWriteObjectRequest) (*summarySupport, []byte, error) { +func (s *sqlEntityServer) prepare(ctx context.Context, r *entity.AdminWriteEntityRequest) (*summarySupport, []byte, error) { grn := r.GRN builder := s.kinds.GetSummaryBuilder(grn.Kind) if builder == nil { @@ -550,23 +553,22 @@ func (s *sqlObjectServer) prepare(ctx context.Context, r *object.AdminWriteObjec return summaryjson, body, nil } -func (s *sqlObjectServer) Delete(ctx context.Context, r *object.DeleteObjectRequest) (*object.DeleteObjectResponse, error) { - route, err := s.getObjectKey(ctx, r.GRN) +func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) { + grn, err := s.validateGRN(ctx, r.GRN) if err != nil { return nil, err } - path := route.Key - rsp := &object.DeleteObjectResponse{} + rsp := &entity.DeleteEntityResponse{} err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { - rsp.OK, err = doDelete(ctx, tx, path) + rsp.OK, err = doDelete(ctx, tx, grn.ToGRNString()) return err }) return rsp, err } -func doDelete(ctx context.Context, tx *session.SessionTx, path string) (bool, error) { - results, err := tx.Exec(ctx, "DELETE FROM object WHERE path=?", path) +func doDelete(ctx context.Context, tx *session.SessionTx, grn string) (bool, error) { + results, err := tx.Exec(ctx, "DELETE FROM entity WHERE grn=?", grn) if err != nil { return false, err } @@ -576,21 +578,21 @@ func doDelete(ctx context.Context, tx *session.SessionTx, path string) (bool, er } // TODO: keep history? would need current version bump, and the "write" would have to get from history - _, _ = tx.Exec(ctx, "DELETE FROM object_history WHERE path=?", path) - _, _ = tx.Exec(ctx, "DELETE FROM object_labels WHERE path=?", path) - _, _ = tx.Exec(ctx, "DELETE FROM object_ref WHERE path=?", path) + _, _ = tx.Exec(ctx, "DELETE FROM entity_history WHERE grn=?", grn) + _, _ = tx.Exec(ctx, "DELETE FROM entity_labels WHERE grn=?", grn) + _, _ = tx.Exec(ctx, "DELETE FROM entity_ref WHERE grn=?", grn) return rows > 0, err } -func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRequest) (*object.ObjectHistoryResponse, error) { - route, err := s.getObjectKey(ctx, r.GRN) +func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRequest) (*entity.EntityHistoryResponse, error) { + grn, err := s.validateGRN(ctx, r.GRN) if err != nil { return nil, err } - path := route.Key + oid := grn.ToGRNString() page := "" - args := []interface{}{path} + args := []interface{}{oid} if r.NextPageToken != "" { // args = append(args, r.NextPageToken) // TODO, need to get time from the version // page = "AND updated <= ?" @@ -598,8 +600,8 @@ func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRe } query := "SELECT version,size,etag,updated_at,updated_by,message \n" + - " FROM object_history \n" + - " WHERE path=? " + page + "\n" + + " FROM entity_history \n" + + " WHERE grn=? " + page + "\n" + " ORDER BY updated_at DESC LIMIT 100" rows, err := s.sess.Query(ctx, query, args...) @@ -607,11 +609,11 @@ func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRe return nil, err } defer func() { _ = rows.Close() }() - rsp := &object.ObjectHistoryResponse{ - GRN: route.GRN, + rsp := &entity.EntityHistoryResponse{ + GRN: r.GRN, } for rows.Next() { - v := &object.ObjectVersionInfo{} + v := &entity.EntityVersionInfo{} err := rows.Scan(&v.Version, &v.Size, &v.ETag, &v.UpdatedAt, &v.UpdatedBy, &v.Comment) if err != nil { return nil, err @@ -621,7 +623,7 @@ func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRe return rsp, err } -func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequest) (*object.ObjectSearchResponse, error) { +func (s *sqlEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequest) (*entity.EntitySearchResponse, error) { user := store.UserFromContext(ctx) if user == nil { return nil, fmt.Errorf("missing user in context") @@ -632,7 +634,8 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ } fields := []string{ - "path", "kind", "version", "errors", // errors are always returned + "grn", "tenant_id", "kind", "uid", + "version", "folder", "slug", "errors", // errors are always returned "size", "updated_at", "updated_by", "name", "description", // basic summary } @@ -649,31 +652,20 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ selectQuery := selectQuery{ fields: fields, - from: "object", // the table + from: "entity", // the table args: []interface{}{}, limit: int(r.Limit), oneExtra: true, // request one more than the limit (and show next token if it exists) } + selectQuery.addWhere("tenant_id", user.OrgID) if len(r.Kind) > 0 { selectQuery.addWhereIn("kind", r.Kind) } - // Locked to a folder or prefix + // Folder UID or OID? if r.Folder != "" { - if strings.HasSuffix(r.Folder, "/") { - return nil, fmt.Errorf("folder should not end with slash") - } - if strings.HasSuffix(r.Folder, "*") { - keyPrefix := fmt.Sprintf("%d/%s", user.OrgID, strings.ReplaceAll(r.Folder, "*", "")) - selectQuery.addWherePrefix("path", keyPrefix) - } else { - keyPrefix := fmt.Sprintf("%d/%s", user.OrgID, r.Folder) - selectQuery.addWhere("parent_folder_path", keyPrefix) - } - } else { - keyPrefix := fmt.Sprintf("%d/", user.OrgID) - selectQuery.addWherePrefix("path", keyPrefix) + selectQuery.addWhere("folder", r.Folder) } query, args := selectQuery.toQuery() @@ -688,16 +680,17 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ return nil, err } defer func() { _ = rows.Close() }() - key := "" - rsp := &object.ObjectSearchResponse{} + oid := "" + rsp := &entity.EntitySearchResponse{} for rows.Next() { - result := &object.ObjectSearchResult{ - GRN: &object.GRN{}, + result := &entity.EntitySearchResult{ + GRN: &entity.GRN{}, } summaryjson := summarySupport{} args := []interface{}{ - &key, &result.GRN.Kind, &result.Version, &summaryjson.errors, + &oid, &result.GRN.TenantId, &result.GRN.Kind, &result.GRN.UID, + &result.Version, &result.Folder, &result.Slug, &summaryjson.errors, &result.Size, &result.UpdatedAt, &result.UpdatedBy, &result.Name, &summaryjson.description, } @@ -716,16 +709,10 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ return rsp, err } - info, err := s.router.RouteFromKey(ctx, key) - if err != nil { - return rsp, err - } - result.GRN = info.GRN - // found one more than requested if len(rsp.Results) >= selectQuery.limit { // TODO? should this encode start+offset? - rsp.NextPageToken = key + rsp.NextPageToken = oid break } @@ -753,54 +740,3 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ } return rsp, err } - -func (s *sqlObjectServer) ensureFolders(ctx context.Context, objectgrn *object.GRN) error { - uid := objectgrn.UID - idx := strings.LastIndex(uid, "/") - var missing []*object.GRN - - for idx > 0 { - parent := uid[:idx] - grn := &object.GRN{ - TenantId: objectgrn.TenantId, - Scope: objectgrn.Scope, - Kind: models.StandardKindFolder, - UID: parent, - } - fr, err := s.router.Route(ctx, grn) - if err != nil { - return err - } - - // Not super efficient, but maybe it is OK? - results := []int64{} - err = s.sess.Select(ctx, &results, "SELECT 1 from object WHERE path=?", fr.Key) - if err != nil { - return err - } - if len(results) == 0 { - missing = append([]*object.GRN{grn}, missing...) - } - idx = strings.LastIndex(parent, "/") - } - - // walk though each missing element - for _, grn := range missing { - f := &folder.Model{ - Name: store.GuessNameFromUID(grn.UID), - } - fmt.Printf("CREATE Folder: %s\n", grn.UID) - body, err := json.Marshal(f) - if err != nil { - return err - } - _, err = s.Write(ctx, &object.WriteObjectRequest{ - GRN: grn, - Body: body, - }) - if err != nil { - return err - } - } - return nil -} diff --git a/pkg/services/store/object/sqlstash/summary_handler.go b/pkg/services/store/entity/sqlstash/summary_handler.go similarity index 89% rename from pkg/services/store/object/sqlstash/summary_handler.go rename to pkg/services/store/entity/sqlstash/summary_handler.go index 7e2a65ab957..fa8aaa84e64 100644 --- a/pkg/services/store/object/sqlstash/summary_handler.go +++ b/pkg/services/store/entity/sqlstash/summary_handler.go @@ -7,7 +7,7 @@ import ( ) type summarySupport struct { - model *models.ObjectSummary + model *models.EntitySummary name string description *string // null or empty labels *string @@ -16,7 +16,7 @@ type summarySupport struct { marshaled []byte } -func newSummarySupport(summary *models.ObjectSummary) (*summarySupport, error) { +func newSummarySupport(summary *models.EntitySummary) (*summarySupport, error) { var err error var js []byte s := &summarySupport{ @@ -63,9 +63,9 @@ func newSummarySupport(summary *models.ObjectSummary) (*summarySupport, error) { return s, err } -func (s summarySupport) toObjectSummary() (*models.ObjectSummary, error) { +func (s summarySupport) toEntitySummary() (*models.EntitySummary, error) { var err error - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Name: s.name, } if s.description != nil { diff --git a/pkg/services/store/entity/sqlstash/utils.go b/pkg/services/store/entity/sqlstash/utils.go new file mode 100644 index 00000000000..b400a983e70 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/utils.go @@ -0,0 +1,11 @@ +package sqlstash + +import ( + "crypto/md5" + "encoding/hex" +) + +func createContentsHash(contents []byte) string { + hash := md5.Sum(contents) + return hex.EncodeToString(hash[:]) +} diff --git a/pkg/services/store/object/tests/common.go b/pkg/services/store/entity/tests/common.go similarity index 92% rename from pkg/services/store/object/tests/common.go rename to pkg/services/store/entity/tests/common.go index 29dfd27d752..78628eccbaf 100644 --- a/pkg/services/store/object/tests/common.go +++ b/pkg/services/store/entity/tests/common.go @@ -1,4 +1,4 @@ -package object_server_tests +package entity_server_tests import ( "context" @@ -11,7 +11,7 @@ import ( saAPI "github.com/grafana/grafana/pkg/services/serviceaccounts/api" saTests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/store" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/stretchr/testify/require" @@ -53,7 +53,7 @@ func createServiceAccountAdminToken(t *testing.T, env *server.TestEnv) (string, type testContext struct { authToken string - client object.ObjectStoreClient + client entity.EntityStoreClient user *user.SignedInUser ctx context.Context } @@ -64,7 +64,7 @@ func createTestContext(t *testing.T) testContext { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{ featuremgmt.FlagGrpcServer, - featuremgmt.FlagObjectStore, + featuremgmt.FlagEntityStore, }, AppModeProduction: false, // required for migrations to run GRPCServerAddress: "127.0.0.1:0", // :0 for choosing the port automatically @@ -80,7 +80,7 @@ func createTestContext(t *testing.T) testContext { ) require.NoError(t, err) - client := object.NewObjectStoreClient(conn) + client := entity.NewEntityStoreClient(conn) return testContext{ authToken: authToken, diff --git a/pkg/services/store/object/tests/server_integration_test.go b/pkg/services/store/entity/tests/server_integration_test.go similarity index 70% rename from pkg/services/store/object/tests/server_integration_test.go rename to pkg/services/store/entity/tests/server_integration_test.go index 46607d1186f..4d6f689a44c 100644 --- a/pkg/services/store/object/tests/server_integration_test.go +++ b/pkg/services/store/entity/tests/server_integration_test.go @@ -1,4 +1,4 @@ -package object_server_tests +package entity_server_tests import ( "encoding/json" @@ -9,14 +9,14 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store" - "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" ) -type rawObjectMatcher struct { - grn *object.GRN +type rawEntityMatcher struct { + grn *entity.GRN createdRange []time.Time updatedRange []time.Time createdBy string @@ -39,7 +39,7 @@ func timestampInRange(ts int64, tsRange []time.Time) bool { return ts >= low && ts <= high } -func requireObjectMatch(t *testing.T, obj *object.RawObject, m rawObjectMatcher) { +func requireEntityMatch(t *testing.T, obj *entity.Entity, m rawEntityMatcher) { t.Helper() require.NotNil(t, obj) @@ -48,9 +48,6 @@ func requireObjectMatch(t *testing.T, obj *object.RawObject, m rawObjectMatcher) if m.grn.TenantId > 0 && m.grn.TenantId != obj.GRN.TenantId { mismatches += fmt.Sprintf("expected tenant: %d, actual: %d\n", m.grn.TenantId, obj.GRN.TenantId) } - if m.grn.Scope != "" && m.grn.Scope != obj.GRN.Scope { - mismatches += fmt.Sprintf("expected Scope: %s, actual: %s\n", m.grn.Scope, obj.GRN.Scope) - } if m.grn.Kind != "" && m.grn.Kind != obj.GRN.Kind { mismatches += fmt.Sprintf("expected Kind: %s, actual: %s\n", m.grn.Kind, obj.GRN.Kind) } @@ -90,7 +87,7 @@ func requireObjectMatch(t *testing.T, obj *object.RawObject, m rawObjectMatcher) require.True(t, len(mismatches) == 0, mismatches) } -func requireVersionMatch(t *testing.T, obj *object.ObjectVersionInfo, m objectVersionMatcher) { +func requireVersionMatch(t *testing.T, obj *entity.EntityVersionInfo, m objectVersionMatcher) { t.Helper() mismatches := "" @@ -113,7 +110,7 @@ func requireVersionMatch(t *testing.T, obj *object.ObjectVersionInfo, m objectVe require.True(t, len(mismatches) == 0, mismatches) } -func TestIntegrationObjectServer(t *testing.T) { +func TestIntegrationEntityServer(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -124,26 +121,25 @@ func TestIntegrationObjectServer(t *testing.T) { fakeUser := store.GetUserIDString(testCtx.user) firstVersion := "1" kind := models.StandardKindJSONObj - grn := &object.GRN{ - Kind: kind, - UID: "my-test-entity", - Scope: models.ObjectStoreScopeEntity, + grn := &entity.GRN{ + Kind: kind, + UID: "my-test-entity", } body := []byte("{\"name\":\"John\"}") t.Run("should not retrieve non-existent objects", func(t *testing.T) { - resp, err := testCtx.client.Read(ctx, &object.ReadObjectRequest{ + resp, err := testCtx.client.Read(ctx, &entity.ReadEntityRequest{ GRN: grn, }) require.NoError(t, err) require.NotNil(t, resp) - require.Nil(t, resp.Object) + require.Nil(t, resp.Entity) }) t.Run("should be able to read persisted objects", func(t *testing.T) { before := time.Now() - writeReq := &object.WriteObjectRequest{ + writeReq := &entity.WriteEntityRequest{ GRN: grn, Body: body, Comment: "first entity!", @@ -157,25 +153,24 @@ func TestIntegrationObjectServer(t *testing.T) { version: &firstVersion, comment: &writeReq.Comment, } - requireVersionMatch(t, writeResp.Object, versionMatcher) + requireVersionMatch(t, writeResp.Entity, versionMatcher) - readResp, err := testCtx.client.Read(ctx, &object.ReadObjectRequest{ + readResp, err := testCtx.client.Read(ctx, &entity.ReadEntityRequest{ GRN: grn, Version: "", WithBody: true, }) require.NoError(t, err) require.Nil(t, readResp.SummaryJson) - require.NotNil(t, readResp.Object) + require.NotNil(t, readResp.Entity) - foundGRN := readResp.Object.GRN + foundGRN := readResp.Entity.GRN require.NotNil(t, foundGRN) require.Equal(t, testCtx.user.OrgID, foundGRN.TenantId) // orgId becomes the tenant id when not set - require.Equal(t, grn.Scope, foundGRN.Scope) require.Equal(t, grn.Kind, foundGRN.Kind) require.Equal(t, grn.UID, foundGRN.UID) - objectMatcher := rawObjectMatcher{ + objectMatcher := rawEntityMatcher{ grn: grn, createdRange: []time.Time{before, time.Now()}, updatedRange: []time.Time{before, time.Now()}, @@ -184,98 +179,97 @@ func TestIntegrationObjectServer(t *testing.T) { body: body, version: &firstVersion, } - requireObjectMatch(t, readResp.Object, objectMatcher) + requireEntityMatch(t, readResp.Entity, objectMatcher) - deleteResp, err := testCtx.client.Delete(ctx, &object.DeleteObjectRequest{ + deleteResp, err := testCtx.client.Delete(ctx, &entity.DeleteEntityRequest{ GRN: grn, - PreviousVersion: writeResp.Object.Version, + PreviousVersion: writeResp.Entity.Version, }) require.NoError(t, err) require.True(t, deleteResp.OK) - readRespAfterDelete, err := testCtx.client.Read(ctx, &object.ReadObjectRequest{ + readRespAfterDelete, err := testCtx.client.Read(ctx, &entity.ReadEntityRequest{ GRN: grn, Version: "", WithBody: true, }) require.NoError(t, err) - require.Nil(t, readRespAfterDelete.Object) + require.Nil(t, readRespAfterDelete.Entity) }) t.Run("should be able to update an object", func(t *testing.T) { before := time.Now() - grn := &object.GRN{ - Kind: kind, - UID: util.GenerateShortUID(), - Scope: models.ObjectStoreScopeEntity, + grn := &entity.GRN{ + Kind: kind, + UID: util.GenerateShortUID(), } - writeReq1 := &object.WriteObjectRequest{ + writeReq1 := &entity.WriteEntityRequest{ GRN: grn, Body: body, Comment: "first entity!", } writeResp1, err := testCtx.client.Write(ctx, writeReq1) require.NoError(t, err) - require.Equal(t, object.WriteObjectResponse_CREATED, writeResp1.Status) + require.Equal(t, entity.WriteEntityResponse_CREATED, writeResp1.Status) body2 := []byte("{\"name\":\"John2\"}") - writeReq2 := &object.WriteObjectRequest{ + writeReq2 := &entity.WriteEntityRequest{ GRN: grn, Body: body2, Comment: "update1", } writeResp2, err := testCtx.client.Write(ctx, writeReq2) require.NoError(t, err) - require.NotEqual(t, writeResp1.Object.Version, writeResp2.Object.Version) + require.NotEqual(t, writeResp1.Entity.Version, writeResp2.Entity.Version) // Duplicate write (no change) writeDupRsp, err := testCtx.client.Write(ctx, writeReq2) require.NoError(t, err) require.Nil(t, writeDupRsp.Error) - require.Equal(t, object.WriteObjectResponse_UNCHANGED, writeDupRsp.Status) - require.Equal(t, writeResp2.Object.Version, writeDupRsp.Object.Version) - require.Equal(t, writeResp2.Object.ETag, writeDupRsp.Object.ETag) + require.Equal(t, entity.WriteEntityResponse_UNCHANGED, writeDupRsp.Status) + require.Equal(t, writeResp2.Entity.Version, writeDupRsp.Entity.Version) + require.Equal(t, writeResp2.Entity.ETag, writeDupRsp.Entity.ETag) body3 := []byte("{\"name\":\"John3\"}") - writeReq3 := &object.WriteObjectRequest{ + writeReq3 := &entity.WriteEntityRequest{ GRN: grn, Body: body3, Comment: "update3", } writeResp3, err := testCtx.client.Write(ctx, writeReq3) require.NoError(t, err) - require.NotEqual(t, writeResp3.Object.Version, writeResp2.Object.Version) + require.NotEqual(t, writeResp3.Entity.Version, writeResp2.Entity.Version) - latestMatcher := rawObjectMatcher{ + latestMatcher := rawEntityMatcher{ grn: grn, createdRange: []time.Time{before, time.Now()}, updatedRange: []time.Time{before, time.Now()}, createdBy: fakeUser, updatedBy: fakeUser, body: body3, - version: &writeResp3.Object.Version, + version: &writeResp3.Entity.Version, } - readRespLatest, err := testCtx.client.Read(ctx, &object.ReadObjectRequest{ + readRespLatest, err := testCtx.client.Read(ctx, &entity.ReadEntityRequest{ GRN: grn, Version: "", // latest WithBody: true, }) require.NoError(t, err) require.Nil(t, readRespLatest.SummaryJson) - requireObjectMatch(t, readRespLatest.Object, latestMatcher) + requireEntityMatch(t, readRespLatest.Entity, latestMatcher) - readRespFirstVer, err := testCtx.client.Read(ctx, &object.ReadObjectRequest{ + readRespFirstVer, err := testCtx.client.Read(ctx, &entity.ReadEntityRequest{ GRN: grn, - Version: writeResp1.Object.Version, + Version: writeResp1.Entity.Version, WithBody: true, }) require.NoError(t, err) require.Nil(t, readRespFirstVer.SummaryJson) - require.NotNil(t, readRespFirstVer.Object) - requireObjectMatch(t, readRespFirstVer.Object, rawObjectMatcher{ + require.NotNil(t, readRespFirstVer.Entity) + requireEntityMatch(t, readRespFirstVer.Entity, rawEntityMatcher{ grn: grn, createdRange: []time.Time{before, time.Now()}, updatedRange: []time.Time{before, time.Now()}, @@ -285,19 +279,19 @@ func TestIntegrationObjectServer(t *testing.T) { version: &firstVersion, }) - history, err := testCtx.client.History(ctx, &object.ObjectHistoryRequest{ + history, err := testCtx.client.History(ctx, &entity.EntityHistoryRequest{ GRN: grn, }) require.NoError(t, err) - require.Equal(t, []*object.ObjectVersionInfo{ - writeResp3.Object, - writeResp2.Object, - writeResp1.Object, + require.Equal(t, []*entity.EntityVersionInfo{ + writeResp3.Entity, + writeResp2.Entity, + writeResp1.Entity, }, history.Versions) - deleteResp, err := testCtx.client.Delete(ctx, &object.DeleteObjectRequest{ + deleteResp, err := testCtx.client.Delete(ctx, &entity.DeleteEntityRequest{ GRN: grn, - PreviousVersion: writeResp3.Object.Version, + PreviousVersion: writeResp3.Entity.Version, }) require.NoError(t, err) require.True(t, deleteResp.OK) @@ -308,43 +302,40 @@ func TestIntegrationObjectServer(t *testing.T) { uid3 := "uid3" uid4 := "uid4" kind2 := models.StandardKindPlaylist - w1, err := testCtx.client.Write(ctx, &object.WriteObjectRequest{ + w1, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ GRN: grn, Body: body, }) require.NoError(t, err) - w2, err := testCtx.client.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - UID: uid2, - Kind: kind, - Scope: grn.Scope, + w2, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + UID: uid2, + Kind: kind, }, Body: body, }) require.NoError(t, err) - w3, err := testCtx.client.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - UID: uid3, - Kind: kind2, - Scope: grn.Scope, + w3, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + UID: uid3, + Kind: kind2, }, Body: body, }) require.NoError(t, err) - w4, err := testCtx.client.Write(ctx, &object.WriteObjectRequest{ - GRN: &object.GRN{ - UID: uid4, - Kind: kind2, - Scope: grn.Scope, + w4, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + UID: uid4, + Kind: kind2, }, Body: body, }) require.NoError(t, err) - search, err := testCtx.client.Search(ctx, &object.ObjectSearchRequest{ + search, err := testCtx.client.Search(ctx, &entity.EntitySearchRequest{ Kind: []string{kind, kind2}, WithBody: false, }) @@ -362,14 +353,14 @@ func TestIntegrationObjectServer(t *testing.T) { require.Equal(t, []string{"my-test-entity", "uid2", "uid3", "uid4"}, uids) require.Equal(t, []string{"jsonobj", "jsonobj", "playlist", "playlist"}, kinds) require.Equal(t, []string{ - w1.Object.Version, - w2.Object.Version, - w3.Object.Version, - w4.Object.Version, + w1.Entity.Version, + w2.Entity.Version, + w3.Entity.Version, + w4.Entity.Version, }, version) // Again with only one kind - searchKind1, err := testCtx.client.Search(ctx, &object.ObjectSearchRequest{ + searchKind1, err := testCtx.client.Search(ctx, &entity.EntitySearchRequest{ Kind: []string{kind}, }) require.NoError(t, err) @@ -384,8 +375,8 @@ func TestIntegrationObjectServer(t *testing.T) { require.Equal(t, []string{"my-test-entity", "uid2"}, uids) require.Equal(t, []string{"jsonobj", "jsonobj"}, kinds) require.Equal(t, []string{ - w1.Object.Version, - w2.Object.Version, + w1.Entity.Version, + w2.Entity.Version, }, version) }) } diff --git a/pkg/services/store/object/utils.go b/pkg/services/store/entity/utils.go similarity index 54% rename from pkg/services/store/object/utils.go rename to pkg/services/store/entity/utils.go index 9aac9eb8e29..4da36286906 100644 --- a/pkg/services/store/object/utils.go +++ b/pkg/services/store/entity/utils.go @@ -1,10 +1,11 @@ -package object +package entity // The admin request is a superset of write request features -func ToAdminWriteObjectRequest(req *WriteObjectRequest) *AdminWriteObjectRequest { - return &AdminWriteObjectRequest{ +func ToAdminWriteEntityRequest(req *WriteEntityRequest) *AdminWriteEntityRequest { + return &AdminWriteEntityRequest{ GRN: req.GRN, Body: req.Body, + Folder: req.Folder, Comment: req.Comment, PreviousVersion: req.PreviousVersion, } diff --git a/pkg/services/store/kind/dashboard/reference.go b/pkg/services/store/kind/dashboard/reference.go index d039fba737a..25b78c35f2f 100644 --- a/pkg/services/store/kind/dashboard/reference.go +++ b/pkg/services/store/kind/dashboard/reference.go @@ -13,24 +13,24 @@ type ReferenceAccumulator interface { Add(kind string, subtype string, uid string) // Returns the set of distinct references in a sorted order - Get() []*models.ObjectExternalReference + Get() []*models.EntityExternalReference } func NewReferenceAccumulator() ReferenceAccumulator { return &referenceAccumulator{ - refs: make(map[string]*models.ObjectExternalReference), + refs: make(map[string]*models.EntityExternalReference), } } type referenceAccumulator struct { - refs map[string]*models.ObjectExternalReference + refs map[string]*models.EntityExternalReference } func (x *referenceAccumulator) Add(kind string, sub string, uid string) { key := fmt.Sprintf("%s/%s/%s", kind, sub, uid) _, ok := x.refs[key] if !ok { - x.refs[key] = &models.ObjectExternalReference{ + x.refs[key] = &models.EntityExternalReference{ Kind: kind, Type: sub, UID: uid, @@ -38,14 +38,14 @@ func (x *referenceAccumulator) Add(kind string, sub string, uid string) { } } -func (x *referenceAccumulator) Get() []*models.ObjectExternalReference { +func (x *referenceAccumulator) Get() []*models.EntityExternalReference { keys := make([]string, 0, len(x.refs)) for k := range x.refs { keys = append(keys, k) } sort.Strings(keys) - refs := make([]*models.ObjectExternalReference, len(keys)) + refs := make([]*models.EntityExternalReference, len(keys)) for i, key := range keys { refs[i] = x.refs[key] } diff --git a/pkg/services/store/kind/dashboard/summary.go b/pkg/services/store/kind/dashboard/summary.go index 4aa6780e86f..881189b2d1e 100644 --- a/pkg/services/store/kind/dashboard/summary.go +++ b/pkg/services/store/kind/dashboard/summary.go @@ -7,12 +7,13 @@ import ( "fmt" "strconv" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindDashboard, Name: "Dashboard", Description: "Define a grafana dashboard layout", @@ -20,16 +21,16 @@ func GetObjectKindInfo() models.ObjectKindInfo { } // This summary does not resolve old name as UID -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { builder := NewStaticDashboardSummaryBuilder(&directLookup{}, true) - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { return builder(ctx, uid, body) } } // This implementation moves datasources referenced by internal ID or name to UID -func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { var parsed map[string]interface{} if sanitize { @@ -43,21 +44,21 @@ func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) mo // slug? (derived from title) } - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Labels: make(map[string]string), Fields: make(map[string]interface{}), } stream := bytes.NewBuffer(body) dash, err := readDashboard(stream, lookup) if err != nil { - summary.Error = &models.ObjectErrorInfo{ + summary.Error = &models.EntityErrorInfo{ Message: err.Error(), } return summary, body, err } dashboardRefs := NewReferenceAccumulator() - url := fmt.Sprintf("/d/%s/%s", uid, models.SlugifyTitle(dash.Title)) + url := fmt.Sprintf("/d/%s/%s", uid, slugify.Slugify(dash.Title)) summary.Name = dash.Title summary.Description = dash.Description summary.URL = url @@ -71,7 +72,7 @@ func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) mo for _, panel := range dash.Panels { panelRefs := NewReferenceAccumulator() - p := &models.ObjectSummary{ + p := &models.EntitySummary{ UID: uid + "#" + strconv.FormatInt(panel.ID, 10), Kind: "panel", } diff --git a/pkg/services/store/kind/dashboard/summary_test.go b/pkg/services/store/kind/dashboard/summary_test.go index 9ec1382165b..47685fe8093 100644 --- a/pkg/services/store/kind/dashboard/summary_test.go +++ b/pkg/services/store/kind/dashboard/summary_test.go @@ -16,7 +16,7 @@ func TestReadSummaries(t *testing.T) { devdash := "../../../../../devenv/dev-dashboards/panel-graph/" ctx := context.Background() - reader := GetObjectSummaryBuilder() + reader := GetEntitySummaryBuilder() failed := make([]string, 0, 10) err := filepath.Walk(devdash, diff --git a/pkg/services/store/kind/dataframe/summary.go b/pkg/services/store/kind/dataframe/summary.go index deb5f6d87db..88c5df709b9 100644 --- a/pkg/services/store/kind/dataframe/summary.go +++ b/pkg/services/store/kind/dataframe/summary.go @@ -9,16 +9,16 @@ import ( "github.com/grafana/grafana/pkg/services/store" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindDataFrame, Name: "Data frame", Description: "Data frame", } } -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { df := &data.Frame{} err := json.Unmarshal(body, df) if err != nil { @@ -33,7 +33,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { if err != nil { return nil, nil, err } - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Kind: models.StandardKindDataFrame, Name: df.Name, UID: uid, diff --git a/pkg/services/store/kind/dataframe/summary_test.go b/pkg/services/store/kind/dataframe/summary_test.go index cc329e65d9e..9efe2c94c96 100644 --- a/pkg/services/store/kind/dataframe/summary_test.go +++ b/pkg/services/store/kind/dataframe/summary_test.go @@ -23,7 +23,7 @@ func TestDataFrameSummary(t *testing.T) { in, err := data.FrameToJSON(df, data.IncludeAll) require.NoError(t, err) - summary, out, err := GetObjectSummaryBuilder()(context.Background(), "somthing", in) + summary, out, err := GetEntitySummaryBuilder()(context.Background(), "somthing", in) require.NoError(t, err) require.Equal(t, in, out) // same json diff --git a/pkg/services/store/kind/dummy/summary.go b/pkg/services/store/kind/dummy/summary.go index 6fdb14f5858..cdf88f97530 100644 --- a/pkg/services/store/kind/dummy/summary.go +++ b/pkg/services/store/kind/dummy/summary.go @@ -8,8 +8,8 @@ import ( "github.com/grafana/grafana/pkg/models" ) -func GetObjectKindInfo(kind string) models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo(kind string) models.EntityKindInfo { + return models.EntityKindInfo{ ID: kind, Name: kind, Description: "Dummy kind used for testing.", @@ -17,9 +17,9 @@ func GetObjectKindInfo(kind string) models.ObjectKindInfo { } } -func GetObjectSummaryBuilder(kind string) models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { - summary := &models.ObjectSummary{ +func GetEntitySummaryBuilder(kind string) models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { + summary := &models.EntitySummary{ Name: fmt.Sprintf("Dummy: %s", kind), Kind: kind, Description: fmt.Sprintf("Wrote at %s", time.Now().Local().String()), @@ -35,7 +35,7 @@ func GetObjectSummaryBuilder(kind string) models.ObjectSummaryBuilder { }, Error: nil, // ignore for now Nested: nil, // ignore for now - References: []*models.ObjectExternalReference{ + References: []*models.EntityExternalReference{ { Kind: "ds", Type: "influx", diff --git a/pkg/services/store/kind/folder/summary.go b/pkg/services/store/kind/folder/summary.go index 9509ab8d8de..0e24a95fa38 100644 --- a/pkg/services/store/kind/folder/summary.go +++ b/pkg/services/store/kind/folder/summary.go @@ -13,15 +13,15 @@ type Model struct { Description string `json:"description,omitempty"` } -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindFolder, Name: "Folder", } } -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { obj := &Model{} err := json.Unmarshal(body, obj) if err != nil { @@ -32,7 +32,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { obj.Name = store.GuessNameFromUID(uid) } - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Kind: models.StandardKindFolder, Name: obj.Name, Description: obj.Description, diff --git a/pkg/services/store/kind/geojson/summary.go b/pkg/services/store/kind/geojson/summary.go index 171d8a4ee47..55c7c115929 100644 --- a/pkg/services/store/kind/geojson/summary.go +++ b/pkg/services/store/kind/geojson/summary.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/services/store" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindGeoJSON, Name: "GeoJSON", Description: "JSON formatted spatial data", @@ -20,8 +20,8 @@ func GetObjectKindInfo() models.ObjectKindInfo { } // Very basic geojson validator -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { var geojson map[string]interface{} err := json.Unmarshal(body, &geojson) if err != nil { @@ -38,7 +38,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { return nil, nil, err } - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Kind: models.StandardKindGeoJSON, Name: store.GuessNameFromUID(uid), UID: uid, diff --git a/pkg/services/store/kind/geojson/summary_test.go b/pkg/services/store/kind/geojson/summary_test.go index 154b525c52f..b3dc846b67b 100644 --- a/pkg/services/store/kind/geojson/summary_test.go +++ b/pkg/services/store/kind/geojson/summary_test.go @@ -10,7 +10,7 @@ import ( ) func TestGeoJSONSummary(t *testing.T) { - builder := GetObjectSummaryBuilder() + builder := GetEntitySummaryBuilder() geo := []byte(`{"type":"FeatureCo`) // invalid _, _, err := builder(context.Background(), "hello", geo) require.Error(t, err) diff --git a/pkg/services/store/kind/jsonobj/summary.go b/pkg/services/store/kind/jsonobj/summary.go index a489c599f62..7bbe5051778 100644 --- a/pkg/services/store/kind/jsonobj/summary.go +++ b/pkg/services/store/kind/jsonobj/summary.go @@ -8,16 +8,16 @@ import ( "github.com/grafana/grafana/pkg/services/store" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindJSONObj, Name: "JSON Object", Description: "JSON Object", } } -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { v := make(map[string]interface{}) err := json.Unmarshal(body, &v) if err != nil { @@ -28,7 +28,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { if err != nil { return nil, nil, err } - return &models.ObjectSummary{ + return &models.EntitySummary{ Kind: models.StandardKindJSONObj, Name: store.GuessNameFromUID(uid), UID: uid, diff --git a/pkg/services/store/kind/jsonobj/summary_test.go b/pkg/services/store/kind/jsonobj/summary_test.go index 321409d9a35..9866352b6fe 100644 --- a/pkg/services/store/kind/jsonobj/summary_test.go +++ b/pkg/services/store/kind/jsonobj/summary_test.go @@ -23,7 +23,7 @@ func TestDataFrameSummary(t *testing.T) { in, err := data.FrameToJSON(df, data.IncludeAll) require.NoError(t, err) - summary, out, err := GetObjectSummaryBuilder()(context.Background(), "path/to/item", in) + summary, out, err := GetEntitySummaryBuilder()(context.Background(), "path/to/item", in) require.NoError(t, err) require.JSONEq(t, string(in), string(out)) // same json diff --git a/pkg/services/store/kind/playlist/summary.go b/pkg/services/store/kind/playlist/summary.go index d5ca2b896ab..356aafa9268 100644 --- a/pkg/services/store/kind/playlist/summary.go +++ b/pkg/services/store/kind/playlist/summary.go @@ -9,19 +9,19 @@ import ( "github.com/grafana/grafana/pkg/models" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindPlaylist, Name: "Playlist", Description: "Cycle though a collection of dashboards automatically", } } -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { return summaryBuilder } -func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { obj := &playlist.Playlist{} err := json.Unmarshal(body, obj) if err != nil { @@ -35,7 +35,7 @@ func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.Objec } obj.Uid = uid // make sure they are consistent - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ UID: uid, Name: obj.Name, Description: fmt.Sprintf("%d items, refreshed every %s", len(*obj.Items), obj.Interval), @@ -44,7 +44,7 @@ func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.Objec for _, item := range *obj.Items { switch item.Type { case playlist.PlaylistItemTypeDashboardByUid: - summary.References = append(summary.References, &models.ObjectExternalReference{ + summary.References = append(summary.References, &models.EntityExternalReference{ Kind: "dashboard", UID: item.Value, }) @@ -57,7 +57,7 @@ func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.Objec case playlist.PlaylistItemTypeDashboardById: // obviously insufficient long term... but good to have an example :) - summary.Error = &models.ObjectErrorInfo{ + summary.Error = &models.EntityErrorInfo{ Message: "Playlist uses deprecated internal id system", } } diff --git a/pkg/services/store/kind/playlist/summary_test.go b/pkg/services/store/kind/playlist/summary_test.go index f6d346ea0ae..b3cf459ca3e 100644 --- a/pkg/services/store/kind/playlist/summary_test.go +++ b/pkg/services/store/kind/playlist/summary_test.go @@ -10,7 +10,7 @@ import ( ) func TestPlaylistSummary(t *testing.T) { - builder := GetObjectSummaryBuilder() + builder := GetEntitySummaryBuilder() // Do not parse invalid input _, _, err := builder(context.Background(), "abc", []byte("{invalid json")) diff --git a/pkg/services/store/kind/png/summary.go b/pkg/services/store/kind/png/summary.go index 722a0093eef..cfa325b5096 100644 --- a/pkg/services/store/kind/png/summary.go +++ b/pkg/services/store/kind/png/summary.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/services/store" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindPNG, Name: "PNG", Description: "PNG Image file", @@ -21,15 +21,15 @@ func GetObjectKindInfo() models.ObjectKindInfo { } // SVG sanitizer based on the rendering service -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { img, err := png.Decode(bytes.NewReader(body)) if err != nil { return nil, nil, err } size := img.Bounds().Size() - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Kind: models.StandardKindSVG, Name: store.GuessNameFromUID(uid), UID: uid, diff --git a/pkg/services/store/kind/png/summary_test.go b/pkg/services/store/kind/png/summary_test.go index 09e7158ea76..00bf4627136 100644 --- a/pkg/services/store/kind/png/summary_test.go +++ b/pkg/services/store/kind/png/summary_test.go @@ -14,7 +14,7 @@ func TestPNGSummary(t *testing.T) { img, err := base64.StdEncoding.DecodeString(gopher) require.NoError(t, err) - summary, out, err := GetObjectSummaryBuilder()(context.Background(), "hello.png", img) + summary, out, err := GetEntitySummaryBuilder()(context.Background(), "hello.png", img) require.NoError(t, err) require.Equal(t, img, out) // same image diff --git a/pkg/services/store/kind/registry.go b/pkg/services/store/kind/registry.go index 18988d5e073..7c76f106e3e 100644 --- a/pkg/services/store/kind/registry.go +++ b/pkg/services/store/kind/registry.go @@ -20,46 +20,46 @@ import ( ) type KindRegistry interface { - Register(info models.ObjectKindInfo, builder models.ObjectSummaryBuilder) error - GetSummaryBuilder(kind string) models.ObjectSummaryBuilder - GetInfo(kind string) (models.ObjectKindInfo, error) - GetFromExtension(suffix string) (models.ObjectKindInfo, error) - GetKinds() []models.ObjectKindInfo + Register(info models.EntityKindInfo, builder models.EntitySummaryBuilder) error + GetSummaryBuilder(kind string) models.EntitySummaryBuilder + GetInfo(kind string) (models.EntityKindInfo, error) + GetFromExtension(suffix string) (models.EntityKindInfo, error) + GetKinds() []models.EntityKindInfo } func NewKindRegistry() KindRegistry { kinds := make(map[string]*kindValues) kinds[models.StandardKindPlaylist] = &kindValues{ - info: playlist.GetObjectKindInfo(), - builder: playlist.GetObjectSummaryBuilder(), + info: playlist.GetEntityKindInfo(), + builder: playlist.GetEntitySummaryBuilder(), } kinds[models.StandardKindDashboard] = &kindValues{ - info: dashboard.GetObjectKindInfo(), - builder: dashboard.GetObjectSummaryBuilder(), + info: dashboard.GetEntityKindInfo(), + builder: dashboard.GetEntitySummaryBuilder(), } kinds[models.StandardKindSnapshot] = &kindValues{ - info: snapshot.GetObjectKindInfo(), - builder: snapshot.GetObjectSummaryBuilder(), + info: snapshot.GetEntityKindInfo(), + builder: snapshot.GetEntitySummaryBuilder(), } kinds[models.StandardKindFolder] = &kindValues{ - info: folder.GetObjectKindInfo(), - builder: folder.GetObjectSummaryBuilder(), + info: folder.GetEntityKindInfo(), + builder: folder.GetEntitySummaryBuilder(), } kinds[models.StandardKindPNG] = &kindValues{ - info: png.GetObjectKindInfo(), - builder: png.GetObjectSummaryBuilder(), + info: png.GetEntityKindInfo(), + builder: png.GetEntitySummaryBuilder(), } kinds[models.StandardKindGeoJSON] = &kindValues{ - info: geojson.GetObjectKindInfo(), - builder: geojson.GetObjectSummaryBuilder(), + info: geojson.GetEntityKindInfo(), + builder: geojson.GetEntitySummaryBuilder(), } kinds[models.StandardKindDataFrame] = &kindValues{ - info: dataframe.GetObjectKindInfo(), - builder: dataframe.GetObjectSummaryBuilder(), + info: dataframe.GetEntityKindInfo(), + builder: dataframe.GetEntitySummaryBuilder(), } kinds[models.StandardKindJSONObj] = &kindValues{ - info: jsonobj.GetObjectKindInfo(), - builder: jsonobj.GetObjectSummaryBuilder(), + info: jsonobj.GetEntityKindInfo(), + builder: jsonobj.GetEntitySummaryBuilder(), } // create a registry @@ -77,29 +77,29 @@ func ProvideService(cfg *setting.Cfg, renderer rendering.Service) KindRegistry { // Register SVG support //----------------------- - info := svg.GetObjectKindInfo() + info := svg.GetEntityKindInfo() allowUnsanitizedSvgUpload := cfg != nil && cfg.Storage.AllowUnsanitizedSvgUpload - support := svg.GetObjectSummaryBuilder(allowUnsanitizedSvgUpload, renderer) + support := svg.GetEntitySummaryBuilder(allowUnsanitizedSvgUpload, renderer) _ = reg.Register(info, support) return reg } type kindValues struct { - info models.ObjectKindInfo - builder models.ObjectSummaryBuilder + info models.EntityKindInfo + builder models.EntitySummaryBuilder } type registry struct { mutex sync.RWMutex kinds map[string]*kindValues - info []models.ObjectKindInfo - suffix map[string]models.ObjectKindInfo + info []models.EntityKindInfo + suffix map[string]models.EntityKindInfo } func (r *registry) updateInfoArray() { - suffix := make(map[string]models.ObjectKindInfo) - info := make([]models.ObjectKindInfo, 0, len(r.kinds)) + suffix := make(map[string]models.EntityKindInfo) + info := make([]models.EntityKindInfo, 0, len(r.kinds)) for _, v := range r.kinds { info = append(info, v.info) if v.info.FileExtension != "" { @@ -113,7 +113,7 @@ func (r *registry) updateInfoArray() { r.suffix = suffix } -func (r *registry) Register(info models.ObjectKindInfo, builder models.ObjectSummaryBuilder) error { +func (r *registry) Register(info models.EntityKindInfo, builder models.EntitySummaryBuilder) error { if info.ID == "" || builder == nil { return fmt.Errorf("invalid kind") } @@ -134,7 +134,7 @@ func (r *registry) Register(info models.ObjectKindInfo, builder models.ObjectSum } // GetSummaryBuilder returns a builder or nil if not found -func (r *registry) GetSummaryBuilder(kind string) models.ObjectSummaryBuilder { +func (r *registry) GetSummaryBuilder(kind string) models.EntitySummaryBuilder { r.mutex.RLock() defer r.mutex.RUnlock() @@ -146,7 +146,7 @@ func (r *registry) GetSummaryBuilder(kind string) models.ObjectSummaryBuilder { } // GetInfo returns the registered info -func (r *registry) GetInfo(kind string) (models.ObjectKindInfo, error) { +func (r *registry) GetInfo(kind string) (models.EntityKindInfo, error) { r.mutex.RLock() defer r.mutex.RUnlock() @@ -154,11 +154,11 @@ func (r *registry) GetInfo(kind string) (models.ObjectKindInfo, error) { if ok { return v.info, nil } - return models.ObjectKindInfo{}, fmt.Errorf("not found") + return models.EntityKindInfo{}, fmt.Errorf("not found") } // GetInfo returns the registered info -func (r *registry) GetFromExtension(suffix string) (models.ObjectKindInfo, error) { +func (r *registry) GetFromExtension(suffix string) (models.EntityKindInfo, error) { r.mutex.RLock() defer r.mutex.RUnlock() @@ -166,11 +166,11 @@ func (r *registry) GetFromExtension(suffix string) (models.ObjectKindInfo, error if ok { return v, nil } - return models.ObjectKindInfo{}, fmt.Errorf("not found") + return models.EntityKindInfo{}, fmt.Errorf("not found") } // GetSummaryBuilder returns a builder or nil if not found -func (r *registry) GetKinds() []models.ObjectKindInfo { +func (r *registry) GetKinds() []models.EntityKindInfo { r.mutex.RLock() defer r.mutex.RUnlock() diff --git a/pkg/services/store/kind/registry_test.go b/pkg/services/store/kind/registry_test.go index 526dcdf814f..3b92361cfff 100644 --- a/pkg/services/store/kind/registry_test.go +++ b/pkg/services/store/kind/registry_test.go @@ -11,7 +11,7 @@ import ( func TestKindRegistry(t *testing.T) { registry := NewKindRegistry() - err := registry.Register(dummy.GetObjectKindInfo("test"), dummy.GetObjectSummaryBuilder("test")) + err := registry.Register(dummy.GetEntityKindInfo("test"), dummy.GetEntitySummaryBuilder("test")) require.NoError(t, err) ids := []string{} diff --git a/pkg/services/store/kind/snapshot/summary.go b/pkg/services/store/kind/snapshot/summary.go index 863de17244e..7994851fe49 100644 --- a/pkg/services/store/kind/snapshot/summary.go +++ b/pkg/services/store/kind/snapshot/summary.go @@ -19,15 +19,15 @@ type Model struct { Snapshot json.RawMessage `json:"snapshot,omitempty"` } -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindSnapshot, Name: "Snapshot", } } -func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder() models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { obj := &Model{} err := json.Unmarshal(body, obj) if err != nil { @@ -41,7 +41,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { return nil, nil, fmt.Errorf("expected delete key") } - summary := &models.ObjectSummary{ + summary := &models.EntitySummary{ Kind: models.StandardKindFolder, Name: obj.Name, Description: obj.Description, @@ -51,7 +51,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { "externalURL": obj.ExternalURL, "expires": obj.Expires, }, - References: []*models.ObjectExternalReference{ + References: []*models.EntityExternalReference{ {Kind: models.StandardKindDashboard, UID: obj.DashboardUID}, }, } diff --git a/pkg/services/store/kind/svg/summary.go b/pkg/services/store/kind/svg/summary.go index 2e5fab85ad4..a13c51ca2ad 100644 --- a/pkg/services/store/kind/svg/summary.go +++ b/pkg/services/store/kind/svg/summary.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/services/rendering" ) -func GetObjectKindInfo() models.ObjectKindInfo { - return models.ObjectKindInfo{ +func GetEntityKindInfo() models.EntityKindInfo { + return models.EntityKindInfo{ ID: models.StandardKindSVG, Name: "SVG", Description: "Scalable Vector Graphics", @@ -21,8 +21,8 @@ func GetObjectKindInfo() models.ObjectKindInfo { } // SVG sanitizer based on the rendering service -func GetObjectSummaryBuilder(allowUnsanitizedSvgUpload bool, renderer rendering.Service) models.ObjectSummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { +func GetEntitySummaryBuilder(allowUnsanitizedSvgUpload bool, renderer rendering.Service) models.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { if !IsSVG(body) { return nil, nil, fmt.Errorf("invalid svg") } @@ -45,7 +45,7 @@ func GetObjectSummaryBuilder(allowUnsanitizedSvgUpload bool, renderer rendering. sanitized = body } - return &models.ObjectSummary{ + return &models.EntitySummary{ Kind: models.StandardKindSVG, Name: guessNameFromUID(uid), UID: uid, diff --git a/pkg/services/store/object/dummy/fake_store.go b/pkg/services/store/object/dummy/fake_store.go deleted file mode 100644 index c85c56ebd68..00000000000 --- a/pkg/services/store/object/dummy/fake_store.go +++ /dev/null @@ -1,46 +0,0 @@ -package objectdummyserver - -import ( - "context" - "fmt" - - "github.com/grafana/grafana/pkg/services/store/object" -) - -// Make sure we implement both store + admin -var _ object.ObjectStoreServer = &fakeObjectStore{} -var _ object.ObjectStoreAdminServer = &fakeObjectStore{} - -func ProvideFakeObjectServer() object.ObjectStoreServer { - return &fakeObjectStore{} -} - -type fakeObjectStore struct{} - -func (i fakeObjectStore) AdminWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { - return nil, fmt.Errorf("unimplemented") -} - -func (i fakeObjectStore) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { - return nil, fmt.Errorf("unimplemented") -} - -func (i fakeObjectStore) Read(ctx context.Context, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { - return nil, fmt.Errorf("unimplemented") -} - -func (i fakeObjectStore) BatchRead(ctx context.Context, batchR *object.BatchReadObjectRequest) (*object.BatchReadObjectResponse, error) { - return nil, fmt.Errorf("unimplemented") -} - -func (i fakeObjectStore) Delete(ctx context.Context, r *object.DeleteObjectRequest) (*object.DeleteObjectResponse, error) { - return nil, fmt.Errorf("unimplemented") -} - -func (i fakeObjectStore) History(ctx context.Context, r *object.ObjectHistoryRequest) (*object.ObjectHistoryResponse, error) { - return nil, fmt.Errorf("unimplemented") -} - -func (i fakeObjectStore) Search(ctx context.Context, r *object.ObjectSearchRequest) (*object.ObjectSearchResponse, error) { - return nil, fmt.Errorf("unimplemented") -} diff --git a/pkg/services/store/object/object.pb.go b/pkg/services/store/object/object.pb.go deleted file mode 100644 index b37ab17330d..00000000000 --- a/pkg/services/store/object/object.pb.go +++ /dev/null @@ -1,2189 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.28.1 -// protoc v3.21.9 -// source: object.proto - -package object - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Status enumeration -type WriteObjectResponse_Status int32 - -const ( - WriteObjectResponse_ERROR WriteObjectResponse_Status = 0 - WriteObjectResponse_CREATED WriteObjectResponse_Status = 1 - WriteObjectResponse_UPDATED WriteObjectResponse_Status = 2 - WriteObjectResponse_UNCHANGED WriteObjectResponse_Status = 3 -) - -// Enum value maps for WriteObjectResponse_Status. -var ( - WriteObjectResponse_Status_name = map[int32]string{ - 0: "ERROR", - 1: "CREATED", - 2: "UPDATED", - 3: "UNCHANGED", - } - WriteObjectResponse_Status_value = map[string]int32{ - "ERROR": 0, - "CREATED": 1, - "UPDATED": 2, - "UNCHANGED": 3, - } -) - -func (x WriteObjectResponse_Status) Enum() *WriteObjectResponse_Status { - p := new(WriteObjectResponse_Status) - *p = x - return p -} - -func (x WriteObjectResponse_Status) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (WriteObjectResponse_Status) Descriptor() protoreflect.EnumDescriptor { - return file_object_proto_enumTypes[0].Descriptor() -} - -func (WriteObjectResponse_Status) Type() protoreflect.EnumType { - return &file_object_proto_enumTypes[0] -} - -func (x WriteObjectResponse_Status) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use WriteObjectResponse_Status.Descriptor instead. -func (WriteObjectResponse_Status) EnumDescriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{11, 0} -} - -type GRN struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // the tenant/org id - TenantId int64 `protobuf:"varint,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` - // mabybe "namespace"? valid values include - // * entity - // * drive - // * service/xyz - Scope string `protobuf:"bytes,2,opt,name=scope,proto3" json:"scope,omitempty"` - // Identify the object kind. This kind will be used to apply a schema to the body and - // will trigger additional indexing behavior. - Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` - // Unique ID - UID string `protobuf:"bytes,4,opt,name=UID,proto3" json:"UID,omitempty"` -} - -func (x *GRN) Reset() { - *x = GRN{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GRN) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GRN) ProtoMessage() {} - -func (x *GRN) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GRN.ProtoReflect.Descriptor instead. -func (*GRN) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{0} -} - -func (x *GRN) GetTenantId() int64 { - if x != nil { - return x.TenantId - } - return 0 -} - -func (x *GRN) GetScope() string { - if x != nil { - return x.Scope - } - return "" -} - -func (x *GRN) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -func (x *GRN) GetUID() string { - if x != nil { - return x.UID - } - return "" -} - -// The canonical object/document data -- this represents the raw bytes and storage level metadata -type RawObject struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // Time in epoch milliseconds that the object was created - CreatedAt int64 `protobuf:"varint,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - // Time in epoch milliseconds that the object was updated - UpdatedAt int64 `protobuf:"varint,3,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - // Who created the object - CreatedBy string `protobuf:"bytes,4,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` - // Who updated the object - UpdatedBy string `protobuf:"bytes,5,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` - // Content Length - Size int64 `protobuf:"varint,6,opt,name=size,proto3" json:"size,omitempty"` - // MD5 digest of the body - ETag string `protobuf:"bytes,7,opt,name=ETag,proto3" json:"ETag,omitempty"` - // Raw bytes of the storage object. The kind will determine what is a valid payload - Body []byte `protobuf:"bytes,8,opt,name=body,proto3" json:"body,omitempty"` - // The version will change when the object is saved. It is not necessarily sortable - // - // NOTE: currently managed by the dashboard+dashboard_version tables - Version string `protobuf:"bytes,9,opt,name=version,proto3" json:"version,omitempty"` - // External location info - Origin *ObjectOriginInfo `protobuf:"bytes,10,opt,name=origin,proto3" json:"origin,omitempty"` -} - -func (x *RawObject) Reset() { - *x = RawObject{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *RawObject) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RawObject) ProtoMessage() {} - -func (x *RawObject) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RawObject.ProtoReflect.Descriptor instead. -func (*RawObject) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{1} -} - -func (x *RawObject) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *RawObject) GetCreatedAt() int64 { - if x != nil { - return x.CreatedAt - } - return 0 -} - -func (x *RawObject) GetUpdatedAt() int64 { - if x != nil { - return x.UpdatedAt - } - return 0 -} - -func (x *RawObject) GetCreatedBy() string { - if x != nil { - return x.CreatedBy - } - return "" -} - -func (x *RawObject) GetUpdatedBy() string { - if x != nil { - return x.UpdatedBy - } - return "" -} - -func (x *RawObject) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -func (x *RawObject) GetETag() string { - if x != nil { - return x.ETag - } - return "" -} - -func (x *RawObject) GetBody() []byte { - if x != nil { - return x.Body - } - return nil -} - -func (x *RawObject) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -func (x *RawObject) GetOrigin() *ObjectOriginInfo { - if x != nil { - return x.Origin - } - return nil -} - -type ObjectOriginInfo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // NOTE: currently managed by the dashboard_provisioning table - Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` - // Time in epoch milliseconds that the object was last synced with an external system (provisioning/git) - Time int64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` -} - -func (x *ObjectOriginInfo) Reset() { - *x = ObjectOriginInfo{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectOriginInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectOriginInfo) ProtoMessage() {} - -func (x *ObjectOriginInfo) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectOriginInfo.ProtoReflect.Descriptor instead. -func (*ObjectOriginInfo) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{2} -} - -func (x *ObjectOriginInfo) GetSource() string { - if x != nil { - return x.Source - } - return "" -} - -func (x *ObjectOriginInfo) GetTime() int64 { - if x != nil { - return x.Time - } - return 0 -} - -// Report error while working with objects -// NOTE: real systems at scale will contain errors. -type ObjectErrorInfo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Match an error code registry? - Code int64 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` - // Simple error display - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - // Details encoded in JSON - DetailsJson []byte `protobuf:"bytes,3,opt,name=details_json,json=detailsJson,proto3" json:"details_json,omitempty"` -} - -func (x *ObjectErrorInfo) Reset() { - *x = ObjectErrorInfo{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectErrorInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectErrorInfo) ProtoMessage() {} - -func (x *ObjectErrorInfo) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectErrorInfo.ProtoReflect.Descriptor instead. -func (*ObjectErrorInfo) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{3} -} - -func (x *ObjectErrorInfo) GetCode() int64 { - if x != nil { - return x.Code - } - return 0 -} - -func (x *ObjectErrorInfo) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *ObjectErrorInfo) GetDetailsJson() []byte { - if x != nil { - return x.DetailsJson - } - return nil -} - -// This is a subset of RawObject that does not include body or sync info -type ObjectVersionInfo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // The version will change when the object is saved. It is not necessarily sortable - Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` - // Time in epoch milliseconds that the object was updated - UpdatedAt int64 `protobuf:"varint,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - // Who updated the object - UpdatedBy string `protobuf:"bytes,3,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` - // Content Length - Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` - // MD5 digest of the body - ETag string `protobuf:"bytes,5,opt,name=ETag,proto3" json:"ETag,omitempty"` - // optional "save" or "commit" message - // - // NOTE: currently managed by the dashboard_version table, and will be returned from a "history" command - Comment string `protobuf:"bytes,6,opt,name=comment,proto3" json:"comment,omitempty"` -} - -func (x *ObjectVersionInfo) Reset() { - *x = ObjectVersionInfo{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectVersionInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectVersionInfo) ProtoMessage() {} - -func (x *ObjectVersionInfo) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectVersionInfo.ProtoReflect.Descriptor instead. -func (*ObjectVersionInfo) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{4} -} - -func (x *ObjectVersionInfo) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -func (x *ObjectVersionInfo) GetUpdatedAt() int64 { - if x != nil { - return x.UpdatedAt - } - return 0 -} - -func (x *ObjectVersionInfo) GetUpdatedBy() string { - if x != nil { - return x.UpdatedBy - } - return "" -} - -func (x *ObjectVersionInfo) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -func (x *ObjectVersionInfo) GetETag() string { - if x != nil { - return x.ETag - } - return "" -} - -func (x *ObjectVersionInfo) GetComment() string { - if x != nil { - return x.Comment - } - return "" -} - -type ReadObjectRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // Fetch an explicit version - Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - // Include the full body bytes - WithBody bool `protobuf:"varint,3,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` - // Include derived summary metadata - WithSummary bool `protobuf:"varint,4,opt,name=with_summary,json=withSummary,proto3" json:"with_summary,omitempty"` -} - -func (x *ReadObjectRequest) Reset() { - *x = ReadObjectRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ReadObjectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReadObjectRequest) ProtoMessage() {} - -func (x *ReadObjectRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReadObjectRequest.ProtoReflect.Descriptor instead. -func (*ReadObjectRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{5} -} - -func (x *ReadObjectRequest) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *ReadObjectRequest) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -func (x *ReadObjectRequest) GetWithBody() bool { - if x != nil { - return x.WithBody - } - return false -} - -func (x *ReadObjectRequest) GetWithSummary() bool { - if x != nil { - return x.WithSummary - } - return false -} - -type ReadObjectResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object details with the body removed - Object *RawObject `protobuf:"bytes,1,opt,name=object,proto3" json:"object,omitempty"` - // Object summary as JSON - SummaryJson []byte `protobuf:"bytes,2,opt,name=summary_json,json=summaryJson,proto3" json:"summary_json,omitempty"` -} - -func (x *ReadObjectResponse) Reset() { - *x = ReadObjectResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ReadObjectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReadObjectResponse) ProtoMessage() {} - -func (x *ReadObjectResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReadObjectResponse.ProtoReflect.Descriptor instead. -func (*ReadObjectResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{6} -} - -func (x *ReadObjectResponse) GetObject() *RawObject { - if x != nil { - return x.Object - } - return nil -} - -func (x *ReadObjectResponse) GetSummaryJson() []byte { - if x != nil { - return x.SummaryJson - } - return nil -} - -type BatchReadObjectRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Batch []*ReadObjectRequest `protobuf:"bytes,1,rep,name=batch,proto3" json:"batch,omitempty"` -} - -func (x *BatchReadObjectRequest) Reset() { - *x = BatchReadObjectRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *BatchReadObjectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BatchReadObjectRequest) ProtoMessage() {} - -func (x *BatchReadObjectRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BatchReadObjectRequest.ProtoReflect.Descriptor instead. -func (*BatchReadObjectRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{7} -} - -func (x *BatchReadObjectRequest) GetBatch() []*ReadObjectRequest { - if x != nil { - return x.Batch - } - return nil -} - -type BatchReadObjectResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Results []*ReadObjectResponse `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` -} - -func (x *BatchReadObjectResponse) Reset() { - *x = BatchReadObjectResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *BatchReadObjectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BatchReadObjectResponse) ProtoMessage() {} - -func (x *BatchReadObjectResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BatchReadObjectResponse.ProtoReflect.Descriptor instead. -func (*BatchReadObjectResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{8} -} - -func (x *BatchReadObjectResponse) GetResults() []*ReadObjectResponse { - if x != nil { - return x.Results - } - return nil -} - -type WriteObjectRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // The raw object body - Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` - // Message that can be seen when exploring object history - Comment string `protobuf:"bytes,3,opt,name=comment,proto3" json:"comment,omitempty"` - // Used for optimistic locking. If missing, the previous version will be replaced regardless - PreviousVersion string `protobuf:"bytes,4,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` -} - -func (x *WriteObjectRequest) Reset() { - *x = WriteObjectRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *WriteObjectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WriteObjectRequest) ProtoMessage() {} - -func (x *WriteObjectRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WriteObjectRequest.ProtoReflect.Descriptor instead. -func (*WriteObjectRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{9} -} - -func (x *WriteObjectRequest) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *WriteObjectRequest) GetBody() []byte { - if x != nil { - return x.Body - } - return nil -} - -func (x *WriteObjectRequest) GetComment() string { - if x != nil { - return x.Comment - } - return "" -} - -func (x *WriteObjectRequest) GetPreviousVersion() string { - if x != nil { - return x.PreviousVersion - } - return "" -} - -// This operation is useful when syncing a resource from external sources -// that have more accurate metadata information (git, or an archive). -// This process can bypass the forced checks that -type AdminWriteObjectRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // The raw object body - Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` - // Message that can be seen when exploring object history - Comment string `protobuf:"bytes,3,opt,name=comment,proto3" json:"comment,omitempty"` - // Time in epoch milliseconds that the object was created - // Optional, if 0 it will use the current time - CreatedAt int64 `protobuf:"varint,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - // Time in epoch milliseconds that the object was updated - // Optional, if empty it will use the current user - UpdatedAt int64 `protobuf:"varint,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - // Who created the object - // Optional, if 0 it will use the current time - CreatedBy string `protobuf:"bytes,6,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` - // Who updated the object - // Optional, if empty it will use the current user - UpdatedBy string `protobuf:"bytes,7,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` - // An explicit version identifier - // Optional, if set, this will overwrite/define an explicit version - Version string `protobuf:"bytes,8,opt,name=version,proto3" json:"version,omitempty"` - // Used for optimistic locking. If missing, the previous version will be replaced regardless - // This may not be used along with an explicit version in the request - PreviousVersion string `protobuf:"bytes,9,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` - // Request that all previous versions are removed from the history - // This will make sense for systems that manage history explicitly externallay - ClearHistory bool `protobuf:"varint,10,opt,name=clear_history,json=clearHistory,proto3" json:"clear_history,omitempty"` - // Optionally define where the object came from - Origin string `protobuf:"bytes,11,opt,name=origin,proto3" json:"origin,omitempty"` -} - -func (x *AdminWriteObjectRequest) Reset() { - *x = AdminWriteObjectRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *AdminWriteObjectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminWriteObjectRequest) ProtoMessage() {} - -func (x *AdminWriteObjectRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminWriteObjectRequest.ProtoReflect.Descriptor instead. -func (*AdminWriteObjectRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{10} -} - -func (x *AdminWriteObjectRequest) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *AdminWriteObjectRequest) GetBody() []byte { - if x != nil { - return x.Body - } - return nil -} - -func (x *AdminWriteObjectRequest) GetComment() string { - if x != nil { - return x.Comment - } - return "" -} - -func (x *AdminWriteObjectRequest) GetCreatedAt() int64 { - if x != nil { - return x.CreatedAt - } - return 0 -} - -func (x *AdminWriteObjectRequest) GetUpdatedAt() int64 { - if x != nil { - return x.UpdatedAt - } - return 0 -} - -func (x *AdminWriteObjectRequest) GetCreatedBy() string { - if x != nil { - return x.CreatedBy - } - return "" -} - -func (x *AdminWriteObjectRequest) GetUpdatedBy() string { - if x != nil { - return x.UpdatedBy - } - return "" -} - -func (x *AdminWriteObjectRequest) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -func (x *AdminWriteObjectRequest) GetPreviousVersion() string { - if x != nil { - return x.PreviousVersion - } - return "" -} - -func (x *AdminWriteObjectRequest) GetClearHistory() bool { - if x != nil { - return x.ClearHistory - } - return false -} - -func (x *AdminWriteObjectRequest) GetOrigin() string { - if x != nil { - return x.Origin - } - return "" -} - -type WriteObjectResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Error info -- if exists, the save did not happen - Error *ObjectErrorInfo `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - // Object identifier - GRN *GRN `protobuf:"bytes,2,opt,name=GRN,proto3" json:"GRN,omitempty"` - // Object details with the body removed - Object *ObjectVersionInfo `protobuf:"bytes,3,opt,name=object,proto3" json:"object,omitempty"` - // Object summary as JSON - SummaryJson []byte `protobuf:"bytes,4,opt,name=summary_json,json=summaryJson,proto3" json:"summary_json,omitempty"` - // Status code - Status WriteObjectResponse_Status `protobuf:"varint,5,opt,name=status,proto3,enum=object.WriteObjectResponse_Status" json:"status,omitempty"` -} - -func (x *WriteObjectResponse) Reset() { - *x = WriteObjectResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *WriteObjectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WriteObjectResponse) ProtoMessage() {} - -func (x *WriteObjectResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WriteObjectResponse.ProtoReflect.Descriptor instead. -func (*WriteObjectResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{11} -} - -func (x *WriteObjectResponse) GetError() *ObjectErrorInfo { - if x != nil { - return x.Error - } - return nil -} - -func (x *WriteObjectResponse) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *WriteObjectResponse) GetObject() *ObjectVersionInfo { - if x != nil { - return x.Object - } - return nil -} - -func (x *WriteObjectResponse) GetSummaryJson() []byte { - if x != nil { - return x.SummaryJson - } - return nil -} - -func (x *WriteObjectResponse) GetStatus() WriteObjectResponse_Status { - if x != nil { - return x.Status - } - return WriteObjectResponse_ERROR -} - -type DeleteObjectRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // Used for optimistic locking. If missing, the previous version will be replaced regardless - PreviousVersion string `protobuf:"bytes,3,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` -} - -func (x *DeleteObjectRequest) Reset() { - *x = DeleteObjectRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *DeleteObjectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteObjectRequest) ProtoMessage() {} - -func (x *DeleteObjectRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteObjectRequest.ProtoReflect.Descriptor instead. -func (*DeleteObjectRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{12} -} - -func (x *DeleteObjectRequest) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *DeleteObjectRequest) GetPreviousVersion() string { - if x != nil { - return x.PreviousVersion - } - return "" -} - -type DeleteObjectResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - OK bool `protobuf:"varint,1,opt,name=OK,proto3" json:"OK,omitempty"` -} - -func (x *DeleteObjectResponse) Reset() { - *x = DeleteObjectResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *DeleteObjectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteObjectResponse) ProtoMessage() {} - -func (x *DeleteObjectResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteObjectResponse.ProtoReflect.Descriptor instead. -func (*DeleteObjectResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{13} -} - -func (x *DeleteObjectResponse) GetOK() bool { - if x != nil { - return x.OK - } - return false -} - -type ObjectHistoryRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // Maximum number of items to return - Limit int64 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` - // Starting from the requested page - NextPageToken string `protobuf:"bytes,5,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` -} - -func (x *ObjectHistoryRequest) Reset() { - *x = ObjectHistoryRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectHistoryRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectHistoryRequest) ProtoMessage() {} - -func (x *ObjectHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectHistoryRequest.ProtoReflect.Descriptor instead. -func (*ObjectHistoryRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{14} -} - -func (x *ObjectHistoryRequest) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *ObjectHistoryRequest) GetLimit() int64 { - if x != nil { - return x.Limit - } - return 0 -} - -func (x *ObjectHistoryRequest) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -type ObjectHistoryResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // Object metadata without the raw bytes - Versions []*ObjectVersionInfo `protobuf:"bytes,2,rep,name=versions,proto3" json:"versions,omitempty"` - // More results exist... pass this in the next request - NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` -} - -func (x *ObjectHistoryResponse) Reset() { - *x = ObjectHistoryResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectHistoryResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectHistoryResponse) ProtoMessage() {} - -func (x *ObjectHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectHistoryResponse.ProtoReflect.Descriptor instead. -func (*ObjectHistoryResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{15} -} - -func (x *ObjectHistoryResponse) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *ObjectHistoryResponse) GetVersions() []*ObjectVersionInfo { - if x != nil { - return x.Versions - } - return nil -} - -func (x *ObjectHistoryResponse) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -type ObjectSearchRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Starting from the requested page (other query parameters must match!) - NextPageToken string `protobuf:"bytes,1,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - // Maximum number of items to return - Limit int64 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` - // Free text query string -- mileage may vary :) - Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` - // limit to a specific kind (empty is all) - Kind []string `protobuf:"bytes,4,rep,name=kind,proto3" json:"kind,omitempty"` - // Limit results to items in a specific folder - Folder string `protobuf:"bytes,5,opt,name=folder,proto3" json:"folder,omitempty"` - // Must match all labels - Labels map[string]string `protobuf:"bytes,6,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` - // Sorting instructions `field ASC/DESC` - Sort []string `protobuf:"bytes,7,rep,name=sort,proto3" json:"sort,omitempty"` - // Return the full body in each payload - WithBody bool `protobuf:"varint,8,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` - // Return the full body in each payload - WithLabels bool `protobuf:"varint,9,opt,name=with_labels,json=withLabels,proto3" json:"with_labels,omitempty"` - // Return the full body in each payload - WithFields bool `protobuf:"varint,10,opt,name=with_fields,json=withFields,proto3" json:"with_fields,omitempty"` -} - -func (x *ObjectSearchRequest) Reset() { - *x = ObjectSearchRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectSearchRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectSearchRequest) ProtoMessage() {} - -func (x *ObjectSearchRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[16] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectSearchRequest.ProtoReflect.Descriptor instead. -func (*ObjectSearchRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{16} -} - -func (x *ObjectSearchRequest) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -func (x *ObjectSearchRequest) GetLimit() int64 { - if x != nil { - return x.Limit - } - return 0 -} - -func (x *ObjectSearchRequest) GetQuery() string { - if x != nil { - return x.Query - } - return "" -} - -func (x *ObjectSearchRequest) GetKind() []string { - if x != nil { - return x.Kind - } - return nil -} - -func (x *ObjectSearchRequest) GetFolder() string { - if x != nil { - return x.Folder - } - return "" -} - -func (x *ObjectSearchRequest) GetLabels() map[string]string { - if x != nil { - return x.Labels - } - return nil -} - -func (x *ObjectSearchRequest) GetSort() []string { - if x != nil { - return x.Sort - } - return nil -} - -func (x *ObjectSearchRequest) GetWithBody() bool { - if x != nil { - return x.WithBody - } - return false -} - -func (x *ObjectSearchRequest) GetWithLabels() bool { - if x != nil { - return x.WithLabels - } - return false -} - -func (x *ObjectSearchRequest) GetWithFields() bool { - if x != nil { - return x.WithFields - } - return false -} - -// Search result metadata for each object -type ObjectSearchResult struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Object identifier - GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` - // The current veresion of this object - Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - // Content Length - Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` - // Time in epoch milliseconds that the object was updated - UpdatedAt int64 `protobuf:"varint,4,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - // Who updated the object - UpdatedBy string `protobuf:"bytes,5,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` - // Optionally include the full object body - Body []byte `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"` - // Always included - Name string `protobuf:"bytes,7,opt,name=name,proto3" json:"name,omitempty"` - // Always included - Description string `protobuf:"bytes,8,opt,name=description,proto3" json:"description,omitempty"` - // The structured labels - Labels map[string]string `protobuf:"bytes,9,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` - // Optionally include extracted JSON - FieldsJson []byte `protobuf:"bytes,10,opt,name=fields_json,json=fieldsJson,proto3" json:"fields_json,omitempty"` - // ObjectErrorInfo in json - ErrorJson []byte `protobuf:"bytes,11,opt,name=error_json,json=errorJson,proto3" json:"error_json,omitempty"` -} - -func (x *ObjectSearchResult) Reset() { - *x = ObjectSearchResult{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectSearchResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectSearchResult) ProtoMessage() {} - -func (x *ObjectSearchResult) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[17] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectSearchResult.ProtoReflect.Descriptor instead. -func (*ObjectSearchResult) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{17} -} - -func (x *ObjectSearchResult) GetGRN() *GRN { - if x != nil { - return x.GRN - } - return nil -} - -func (x *ObjectSearchResult) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -func (x *ObjectSearchResult) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -func (x *ObjectSearchResult) GetUpdatedAt() int64 { - if x != nil { - return x.UpdatedAt - } - return 0 -} - -func (x *ObjectSearchResult) GetUpdatedBy() string { - if x != nil { - return x.UpdatedBy - } - return "" -} - -func (x *ObjectSearchResult) GetBody() []byte { - if x != nil { - return x.Body - } - return nil -} - -func (x *ObjectSearchResult) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ObjectSearchResult) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *ObjectSearchResult) GetLabels() map[string]string { - if x != nil { - return x.Labels - } - return nil -} - -func (x *ObjectSearchResult) GetFieldsJson() []byte { - if x != nil { - return x.FieldsJson - } - return nil -} - -func (x *ObjectSearchResult) GetErrorJson() []byte { - if x != nil { - return x.ErrorJson - } - return nil -} - -type ObjectSearchResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Results []*ObjectSearchResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` - // More results exist... pass this in the next request - NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` -} - -func (x *ObjectSearchResponse) Reset() { - *x = ObjectSearchResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ObjectSearchResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ObjectSearchResponse) ProtoMessage() {} - -func (x *ObjectSearchResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[18] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ObjectSearchResponse.ProtoReflect.Descriptor instead. -func (*ObjectSearchResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{18} -} - -func (x *ObjectSearchResponse) GetResults() []*ObjectSearchResult { - if x != nil { - return x.Results - } - return nil -} - -func (x *ObjectSearchResponse) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -var File_object_proto protoreflect.FileDescriptor - -var file_object_proto_rawDesc = []byte{ - 0x0a, 0x0c, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x5e, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x1b, 0x0a, - 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, - 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, 0x22, 0xae, 0x02, 0x0a, 0x09, 0x52, 0x61, 0x77, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, - 0x47, 0x52, 0x4e, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, - 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, - 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, - 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, - 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, - 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, 0x54, 0x61, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x18, - 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, - 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x22, 0x3e, 0x0a, 0x10, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, - 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x74, 0x61, - 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, - 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0xad, 0x01, 0x0a, 0x11, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x75, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, - 0x04, 0x45, 0x54, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, - 0x67, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x8c, 0x01, 0x0a, 0x11, - 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, - 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, - 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, - 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x69, 0x74, 0x68, 0x5f, - 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, - 0x69, 0x74, 0x68, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0x62, 0x0a, 0x12, 0x52, 0x65, - 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x29, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, - 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x49, - 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, 0x63, - 0x68, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x4f, 0x0a, 0x17, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, - 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x8c, 0x01, 0x0a, 0x12, 0x57, - 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, - 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, - 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x29, - 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, - 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xe4, 0x02, 0x0a, 0x17, 0x41, 0x64, - 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, - 0x03, 0x47, 0x52, 0x4e, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, - 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, - 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, - 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, - 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, - 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x18, - 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, - 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x68, 0x69, 0x73, - 0x74, 0x6f, 0x72, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6c, 0x65, 0x61, - 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, - 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, - 0x22, 0xb3, 0x02, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, - 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, - 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x31, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, - 0x6f, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, - 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x3c, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, - 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, - 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, - 0x4e, 0x47, 0x45, 0x44, 0x10, 0x03, 0x22, 0x5f, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, - 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x29, 0x0a, 0x10, - 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x26, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x0e, 0x0a, 0x02, 0x4f, 0x4b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x4f, 0x4b, 0x22, - 0x73, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, - 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, - 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x15, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, - 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, - 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x35, 0x0a, - 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, - 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, - 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x84, 0x03, 0x0a, - 0x13, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, - 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, - 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, - 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, - 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, - 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, - 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, - 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3f, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, - 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, - 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, - 0x68, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, - 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, - 0x74, 0x68, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, - 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x22, 0xa4, 0x03, 0x0a, 0x12, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, - 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, - 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x26, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, - 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, - 0x1f, 0x0a, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0a, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x4a, 0x73, 0x6f, 0x6e, - 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0b, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4a, 0x73, 0x6f, 0x6e, 0x1a, - 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x74, 0x0a, 0x14, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, - 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, - 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x32, 0xfa, 0x03, 0x0a, 0x0b, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, - 0x12, 0x3d, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, - 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x4c, 0x0a, 0x09, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, - 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, - 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, - 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, - 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, - 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x4a, 0x0a, 0x0a, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, - 0x1f, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, - 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x5e, 0x0a, - 0x10, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x41, 0x64, 0x6d, 0x69, - 0x6e, 0x12, 0x4a, 0x0a, 0x0a, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, - 0x1f, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, - 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, - 0x09, 0x2e, 0x2f, 0x3b, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, -} - -var ( - file_object_proto_rawDescOnce sync.Once - file_object_proto_rawDescData = file_object_proto_rawDesc -) - -func file_object_proto_rawDescGZIP() []byte { - file_object_proto_rawDescOnce.Do(func() { - file_object_proto_rawDescData = protoimpl.X.CompressGZIP(file_object_proto_rawDescData) - }) - return file_object_proto_rawDescData -} - -var file_object_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_object_proto_msgTypes = make([]protoimpl.MessageInfo, 21) -var file_object_proto_goTypes = []interface{}{ - (WriteObjectResponse_Status)(0), // 0: object.WriteObjectResponse.Status - (*GRN)(nil), // 1: object.GRN - (*RawObject)(nil), // 2: object.RawObject - (*ObjectOriginInfo)(nil), // 3: object.ObjectOriginInfo - (*ObjectErrorInfo)(nil), // 4: object.ObjectErrorInfo - (*ObjectVersionInfo)(nil), // 5: object.ObjectVersionInfo - (*ReadObjectRequest)(nil), // 6: object.ReadObjectRequest - (*ReadObjectResponse)(nil), // 7: object.ReadObjectResponse - (*BatchReadObjectRequest)(nil), // 8: object.BatchReadObjectRequest - (*BatchReadObjectResponse)(nil), // 9: object.BatchReadObjectResponse - (*WriteObjectRequest)(nil), // 10: object.WriteObjectRequest - (*AdminWriteObjectRequest)(nil), // 11: object.AdminWriteObjectRequest - (*WriteObjectResponse)(nil), // 12: object.WriteObjectResponse - (*DeleteObjectRequest)(nil), // 13: object.DeleteObjectRequest - (*DeleteObjectResponse)(nil), // 14: object.DeleteObjectResponse - (*ObjectHistoryRequest)(nil), // 15: object.ObjectHistoryRequest - (*ObjectHistoryResponse)(nil), // 16: object.ObjectHistoryResponse - (*ObjectSearchRequest)(nil), // 17: object.ObjectSearchRequest - (*ObjectSearchResult)(nil), // 18: object.ObjectSearchResult - (*ObjectSearchResponse)(nil), // 19: object.ObjectSearchResponse - nil, // 20: object.ObjectSearchRequest.LabelsEntry - nil, // 21: object.ObjectSearchResult.LabelsEntry -} -var file_object_proto_depIdxs = []int32{ - 1, // 0: object.RawObject.GRN:type_name -> object.GRN - 3, // 1: object.RawObject.origin:type_name -> object.ObjectOriginInfo - 1, // 2: object.ReadObjectRequest.GRN:type_name -> object.GRN - 2, // 3: object.ReadObjectResponse.object:type_name -> object.RawObject - 6, // 4: object.BatchReadObjectRequest.batch:type_name -> object.ReadObjectRequest - 7, // 5: object.BatchReadObjectResponse.results:type_name -> object.ReadObjectResponse - 1, // 6: object.WriteObjectRequest.GRN:type_name -> object.GRN - 1, // 7: object.AdminWriteObjectRequest.GRN:type_name -> object.GRN - 4, // 8: object.WriteObjectResponse.error:type_name -> object.ObjectErrorInfo - 1, // 9: object.WriteObjectResponse.GRN:type_name -> object.GRN - 5, // 10: object.WriteObjectResponse.object:type_name -> object.ObjectVersionInfo - 0, // 11: object.WriteObjectResponse.status:type_name -> object.WriteObjectResponse.Status - 1, // 12: object.DeleteObjectRequest.GRN:type_name -> object.GRN - 1, // 13: object.ObjectHistoryRequest.GRN:type_name -> object.GRN - 1, // 14: object.ObjectHistoryResponse.GRN:type_name -> object.GRN - 5, // 15: object.ObjectHistoryResponse.versions:type_name -> object.ObjectVersionInfo - 20, // 16: object.ObjectSearchRequest.labels:type_name -> object.ObjectSearchRequest.LabelsEntry - 1, // 17: object.ObjectSearchResult.GRN:type_name -> object.GRN - 21, // 18: object.ObjectSearchResult.labels:type_name -> object.ObjectSearchResult.LabelsEntry - 18, // 19: object.ObjectSearchResponse.results:type_name -> object.ObjectSearchResult - 6, // 20: object.ObjectStore.Read:input_type -> object.ReadObjectRequest - 8, // 21: object.ObjectStore.BatchRead:input_type -> object.BatchReadObjectRequest - 10, // 22: object.ObjectStore.Write:input_type -> object.WriteObjectRequest - 13, // 23: object.ObjectStore.Delete:input_type -> object.DeleteObjectRequest - 15, // 24: object.ObjectStore.History:input_type -> object.ObjectHistoryRequest - 17, // 25: object.ObjectStore.Search:input_type -> object.ObjectSearchRequest - 11, // 26: object.ObjectStore.AdminWrite:input_type -> object.AdminWriteObjectRequest - 11, // 27: object.ObjectStoreAdmin.AdminWrite:input_type -> object.AdminWriteObjectRequest - 7, // 28: object.ObjectStore.Read:output_type -> object.ReadObjectResponse - 9, // 29: object.ObjectStore.BatchRead:output_type -> object.BatchReadObjectResponse - 12, // 30: object.ObjectStore.Write:output_type -> object.WriteObjectResponse - 14, // 31: object.ObjectStore.Delete:output_type -> object.DeleteObjectResponse - 16, // 32: object.ObjectStore.History:output_type -> object.ObjectHistoryResponse - 19, // 33: object.ObjectStore.Search:output_type -> object.ObjectSearchResponse - 12, // 34: object.ObjectStore.AdminWrite:output_type -> object.WriteObjectResponse - 12, // 35: object.ObjectStoreAdmin.AdminWrite:output_type -> object.WriteObjectResponse - 28, // [28:36] is the sub-list for method output_type - 20, // [20:28] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name -} - -func init() { file_object_proto_init() } -func file_object_proto_init() { - if File_object_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_object_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GRN); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RawObject); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectOriginInfo); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectErrorInfo); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectVersionInfo); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ReadObjectRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ReadObjectResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BatchReadObjectRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BatchReadObjectResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WriteObjectRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AdminWriteObjectRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WriteObjectResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteObjectRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteObjectResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectHistoryRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectHistoryResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectSearchRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectSearchResult); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_object_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectSearchResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_object_proto_rawDesc, - NumEnums: 1, - NumMessages: 21, - NumExtensions: 0, - NumServices: 2, - }, - GoTypes: file_object_proto_goTypes, - DependencyIndexes: file_object_proto_depIdxs, - EnumInfos: file_object_proto_enumTypes, - MessageInfos: file_object_proto_msgTypes, - }.Build() - File_object_proto = out.File - file_object_proto_rawDesc = nil - file_object_proto_goTypes = nil - file_object_proto_depIdxs = nil -} diff --git a/pkg/services/store/object/object_grpc.pb.go b/pkg/services/store/object/object_grpc.pb.go deleted file mode 100644 index ec546c6786b..00000000000 --- a/pkg/services/store/object/object_grpc.pb.go +++ /dev/null @@ -1,405 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.2.0 -// - protoc v3.21.9 -// source: object.proto - -package object - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 - -// ObjectStoreClient is the client API for ObjectStore service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type ObjectStoreClient interface { - Read(ctx context.Context, in *ReadObjectRequest, opts ...grpc.CallOption) (*ReadObjectResponse, error) - BatchRead(ctx context.Context, in *BatchReadObjectRequest, opts ...grpc.CallOption) (*BatchReadObjectResponse, error) - Write(ctx context.Context, in *WriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) - Delete(ctx context.Context, in *DeleteObjectRequest, opts ...grpc.CallOption) (*DeleteObjectResponse, error) - History(ctx context.Context, in *ObjectHistoryRequest, opts ...grpc.CallOption) (*ObjectHistoryResponse, error) - Search(ctx context.Context, in *ObjectSearchRequest, opts ...grpc.CallOption) (*ObjectSearchResponse, error) - // TEMPORARY... while we split this into a new service (see below) - AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) -} - -type objectStoreClient struct { - cc grpc.ClientConnInterface -} - -func NewObjectStoreClient(cc grpc.ClientConnInterface) ObjectStoreClient { - return &objectStoreClient{cc} -} - -func (c *objectStoreClient) Read(ctx context.Context, in *ReadObjectRequest, opts ...grpc.CallOption) (*ReadObjectResponse, error) { - out := new(ReadObjectResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/Read", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *objectStoreClient) BatchRead(ctx context.Context, in *BatchReadObjectRequest, opts ...grpc.CallOption) (*BatchReadObjectResponse, error) { - out := new(BatchReadObjectResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/BatchRead", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *objectStoreClient) Write(ctx context.Context, in *WriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) { - out := new(WriteObjectResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/Write", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *objectStoreClient) Delete(ctx context.Context, in *DeleteObjectRequest, opts ...grpc.CallOption) (*DeleteObjectResponse, error) { - out := new(DeleteObjectResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/Delete", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *objectStoreClient) History(ctx context.Context, in *ObjectHistoryRequest, opts ...grpc.CallOption) (*ObjectHistoryResponse, error) { - out := new(ObjectHistoryResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/History", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *objectStoreClient) Search(ctx context.Context, in *ObjectSearchRequest, opts ...grpc.CallOption) (*ObjectSearchResponse, error) { - out := new(ObjectSearchResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/Search", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *objectStoreClient) AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) { - out := new(WriteObjectResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStore/AdminWrite", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// ObjectStoreServer is the server API for ObjectStore service. -// All implementations should embed UnimplementedObjectStoreServer -// for forward compatibility -type ObjectStoreServer interface { - Read(context.Context, *ReadObjectRequest) (*ReadObjectResponse, error) - BatchRead(context.Context, *BatchReadObjectRequest) (*BatchReadObjectResponse, error) - Write(context.Context, *WriteObjectRequest) (*WriteObjectResponse, error) - Delete(context.Context, *DeleteObjectRequest) (*DeleteObjectResponse, error) - History(context.Context, *ObjectHistoryRequest) (*ObjectHistoryResponse, error) - Search(context.Context, *ObjectSearchRequest) (*ObjectSearchResponse, error) - // TEMPORARY... while we split this into a new service (see below) - AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) -} - -// UnimplementedObjectStoreServer should be embedded to have forward compatible implementations. -type UnimplementedObjectStoreServer struct { -} - -func (UnimplementedObjectStoreServer) Read(context.Context, *ReadObjectRequest) (*ReadObjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Read not implemented") -} -func (UnimplementedObjectStoreServer) BatchRead(context.Context, *BatchReadObjectRequest) (*BatchReadObjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method BatchRead not implemented") -} -func (UnimplementedObjectStoreServer) Write(context.Context, *WriteObjectRequest) (*WriteObjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Write not implemented") -} -func (UnimplementedObjectStoreServer) Delete(context.Context, *DeleteObjectRequest) (*DeleteObjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented") -} -func (UnimplementedObjectStoreServer) History(context.Context, *ObjectHistoryRequest) (*ObjectHistoryResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method History not implemented") -} -func (UnimplementedObjectStoreServer) Search(context.Context, *ObjectSearchRequest) (*ObjectSearchResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Search not implemented") -} -func (UnimplementedObjectStoreServer) AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method AdminWrite not implemented") -} - -// UnsafeObjectStoreServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ObjectStoreServer will -// result in compilation errors. -type UnsafeObjectStoreServer interface { - mustEmbedUnimplementedObjectStoreServer() -} - -func RegisterObjectStoreServer(s grpc.ServiceRegistrar, srv ObjectStoreServer) { - s.RegisterService(&ObjectStore_ServiceDesc, srv) -} - -func _ObjectStore_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReadObjectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).Read(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/Read", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).Read(ctx, req.(*ReadObjectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ObjectStore_BatchRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(BatchReadObjectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).BatchRead(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/BatchRead", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).BatchRead(ctx, req.(*BatchReadObjectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ObjectStore_Write_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(WriteObjectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).Write(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/Write", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).Write(ctx, req.(*WriteObjectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ObjectStore_Delete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteObjectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).Delete(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/Delete", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).Delete(ctx, req.(*DeleteObjectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ObjectStore_History_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ObjectHistoryRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).History(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/History", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).History(ctx, req.(*ObjectHistoryRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ObjectStore_Search_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ObjectSearchRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).Search(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/Search", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).Search(ctx, req.(*ObjectSearchRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ObjectStore_AdminWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AdminWriteObjectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreServer).AdminWrite(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStore/AdminWrite", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreServer).AdminWrite(ctx, req.(*AdminWriteObjectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// ObjectStore_ServiceDesc is the grpc.ServiceDesc for ObjectStore service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ObjectStore_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "object.ObjectStore", - HandlerType: (*ObjectStoreServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Read", - Handler: _ObjectStore_Read_Handler, - }, - { - MethodName: "BatchRead", - Handler: _ObjectStore_BatchRead_Handler, - }, - { - MethodName: "Write", - Handler: _ObjectStore_Write_Handler, - }, - { - MethodName: "Delete", - Handler: _ObjectStore_Delete_Handler, - }, - { - MethodName: "History", - Handler: _ObjectStore_History_Handler, - }, - { - MethodName: "Search", - Handler: _ObjectStore_Search_Handler, - }, - { - MethodName: "AdminWrite", - Handler: _ObjectStore_AdminWrite_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "object.proto", -} - -// ObjectStoreAdminClient is the client API for ObjectStoreAdmin service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type ObjectStoreAdminClient interface { - AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) -} - -type objectStoreAdminClient struct { - cc grpc.ClientConnInterface -} - -func NewObjectStoreAdminClient(cc grpc.ClientConnInterface) ObjectStoreAdminClient { - return &objectStoreAdminClient{cc} -} - -func (c *objectStoreAdminClient) AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) { - out := new(WriteObjectResponse) - err := c.cc.Invoke(ctx, "/object.ObjectStoreAdmin/AdminWrite", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// ObjectStoreAdminServer is the server API for ObjectStoreAdmin service. -// All implementations should embed UnimplementedObjectStoreAdminServer -// for forward compatibility -type ObjectStoreAdminServer interface { - AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) -} - -// UnimplementedObjectStoreAdminServer should be embedded to have forward compatible implementations. -type UnimplementedObjectStoreAdminServer struct { -} - -func (UnimplementedObjectStoreAdminServer) AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method AdminWrite not implemented") -} - -// UnsafeObjectStoreAdminServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ObjectStoreAdminServer will -// result in compilation errors. -type UnsafeObjectStoreAdminServer interface { - mustEmbedUnimplementedObjectStoreAdminServer() -} - -func RegisterObjectStoreAdminServer(s grpc.ServiceRegistrar, srv ObjectStoreAdminServer) { - s.RegisterService(&ObjectStoreAdmin_ServiceDesc, srv) -} - -func _ObjectStoreAdmin_AdminWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AdminWriteObjectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ObjectStoreAdminServer).AdminWrite(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/object.ObjectStoreAdmin/AdminWrite", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ObjectStoreAdminServer).AdminWrite(ctx, req.(*AdminWriteObjectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// ObjectStoreAdmin_ServiceDesc is the grpc.ServiceDesc for ObjectStoreAdmin service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ObjectStoreAdmin_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "object.ObjectStoreAdmin", - HandlerType: (*ObjectStoreAdminServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "AdminWrite", - Handler: _ObjectStoreAdmin_AdminWrite_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "object.proto", -} diff --git a/pkg/services/store/object/sqlstash/utils.go b/pkg/services/store/object/sqlstash/utils.go deleted file mode 100644 index a75ad845615..00000000000 --- a/pkg/services/store/object/sqlstash/utils.go +++ /dev/null @@ -1,27 +0,0 @@ -package sqlstash - -import ( - "crypto/md5" - "encoding/hex" - "strings" - - "github.com/grafana/grafana/pkg/models" -) - -func createContentsHash(contents []byte) string { - hash := md5.Sum(contents) - return hex.EncodeToString(hash[:]) -} - -func getParentFolderPath(kind string, key string) string { - idx := strings.LastIndex(key, "/") - if idx < 0 { - return "" // ? - } - - // folder should have a parent up one directory - if kind == models.StandardKindFolder { - idx = strings.LastIndex(key[:idx], "/") - } - return key[:idx] -} diff --git a/pkg/services/store/resolver/service.go b/pkg/services/store/resolver/service.go index d4471a2412d..4160edbea5b 100644 --- a/pkg/services/store/resolver/service.go +++ b/pkg/services/store/resolver/service.go @@ -27,11 +27,11 @@ type ResolutionInfo struct { Timestamp time.Time `json:"timestamp,omitempty"` } -type ObjectReferenceResolver interface { - Resolve(ctx context.Context, ref *models.ObjectExternalReference) (ResolutionInfo, error) +type EntityReferenceResolver interface { + Resolve(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) } -func ProvideObjectReferenceResolver(ds datasources.DataSourceService, pluginStore plugins.Store) ObjectReferenceResolver { +func ProvideEntityReferenceResolver(ds datasources.DataSourceService, pluginStore plugins.Store) EntityReferenceResolver { return &standardReferenceResolver{ pluginStore: pluginStore, ds: dsCache{ @@ -46,7 +46,7 @@ type standardReferenceResolver struct { ds dsCache } -func (r *standardReferenceResolver) Resolve(ctx context.Context, ref *models.ObjectExternalReference) (ResolutionInfo, error) { +func (r *standardReferenceResolver) Resolve(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) { if ref == nil { return ResolutionInfo{OK: false, Timestamp: getNow()}, fmt.Errorf("ref is nil") } @@ -73,7 +73,7 @@ func (r *standardReferenceResolver) Resolve(ctx context.Context, ref *models.Obj }, nil } -func (r *standardReferenceResolver) resolveDatasource(ctx context.Context, ref *models.ObjectExternalReference) (ResolutionInfo, error) { +func (r *standardReferenceResolver) resolveDatasource(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) { ds, err := r.ds.getDS(ctx, ref.UID) if err != nil || ds == nil || ds.UID == "" { return ResolutionInfo{ @@ -99,7 +99,7 @@ func (r *standardReferenceResolver) resolveDatasource(ctx context.Context, ref * return res, nil } -func (r *standardReferenceResolver) resolvePlugin(ctx context.Context, ref *models.ObjectExternalReference) (ResolutionInfo, error) { +func (r *standardReferenceResolver) resolvePlugin(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) { p, ok := r.pluginStore.Plugin(ctx, ref.UID) if !ok { return ResolutionInfo{ diff --git a/pkg/services/store/resolver/service_test.go b/pkg/services/store/resolver/service_test.go index 09bd725f51c..92d305761cc 100644 --- a/pkg/services/store/resolver/service_test.go +++ b/pkg/services/store/resolver/service_test.go @@ -45,18 +45,18 @@ func TestResolver(t *testing.T) { pluginStore := plugins.FakePluginStore{ PluginList: []plugins.PluginDTO{p1, p2, p3}, } - provider := ProvideObjectReferenceResolver(ds, pluginStore) + provider := ProvideEntityReferenceResolver(ds, pluginStore) scenarios := []struct { name string - given *models.ObjectExternalReference + given *models.EntityExternalReference expect ResolutionInfo err string ctx context.Context }{ { name: "Missing datasource without type", - given: &models.ObjectExternalReference{ + given: &models.EntityExternalReference{ Kind: models.StandardKindDataSource, UID: "xyz", }, @@ -65,7 +65,7 @@ func TestResolver(t *testing.T) { }, { name: "OK datasource", - given: &models.ObjectExternalReference{ + given: &models.EntityExternalReference{ Kind: models.StandardKindDataSource, Type: "influx", UID: "influx-uid", @@ -75,7 +75,7 @@ func TestResolver(t *testing.T) { }, { name: "Get the default datasource", - given: &models.ObjectExternalReference{ + given: &models.EntityExternalReference{ Kind: models.StandardKindDataSource, }, expect: ResolutionInfo{ @@ -87,7 +87,7 @@ func TestResolver(t *testing.T) { }, { name: "Get the default datasource (with type)", - given: &models.ObjectExternalReference{ + given: &models.EntityExternalReference{ Kind: models.StandardKindDataSource, Type: "influx", }, @@ -99,7 +99,7 @@ func TestResolver(t *testing.T) { }, { name: "Lookup by name", - given: &models.ObjectExternalReference{ + given: &models.EntityExternalReference{ Kind: models.StandardKindDataSource, UID: "Influx2", }, diff --git a/pkg/services/store/router/router.go b/pkg/services/store/router/router.go deleted file mode 100644 index ec2ae9818a2..00000000000 --- a/pkg/services/store/router/router.go +++ /dev/null @@ -1,173 +0,0 @@ -package router - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/store/kind" - "github.com/grafana/grafana/pkg/services/store/object" -) - -type ResourceRouteInfo struct { - // The resource identifier - GRN *object.GRN - - // Raw key used in storage engine - Key string -} - -type ObjectStoreRouter interface { - // This will throw exceptions for unsupported - Route(ctx context.Context, grn *object.GRN) (ResourceRouteInfo, error) - - // Parse a key to get the GRN and storage information - RouteFromKey(ctx context.Context, key string) (ResourceRouteInfo, error) -} - -type standardStoreRouter struct { - kinds kind.KindRegistry -} - -func NewObjectStoreRouter(kinds kind.KindRegistry) ObjectStoreRouter { - return &standardStoreRouter{kinds: kinds} -} - -var _ ObjectStoreRouter = &standardStoreRouter{} - -func (r *standardStoreRouter) Route(ctx context.Context, grn *object.GRN) (ResourceRouteInfo, error) { - info := ResourceRouteInfo{ - GRN: grn, - } - - if grn == nil { - return info, fmt.Errorf("missing GRN") - } - - // Make sure the orgID is set - if grn.TenantId < 1 { - return info, fmt.Errorf("missing TenantId") - } - if grn.Kind == "" { - return info, fmt.Errorf("missing Kind") - } - if grn.UID == "" { - return info, fmt.Errorf("missing UID") - } - - kind, err := r.kinds.GetInfo(grn.Kind) - if err != nil { - return info, fmt.Errorf("unknown Kind: " + grn.Kind) - } - - if grn.Scope == "" { - return info, fmt.Errorf("missing Scope") - } - - switch grn.Scope { - case models.ObjectStoreScopeEntity: - { - info.Key = fmt.Sprintf("%d/%s/%s/%s", grn.TenantId, grn.Scope, grn.Kind, grn.UID) - } - case models.ObjectStoreScopeDrive: - { - // Special folder handling in drive - if grn.Kind == models.StandardKindFolder { - info.Key = fmt.Sprintf("%d/%s/%s/__folder.json", grn.TenantId, grn.Scope, grn.UID) - return info, nil - } - if kind.FileExtension != "" { - info.Key = fmt.Sprintf("%d/%s/%s.%s", grn.TenantId, grn.Scope, grn.UID, kind.FileExtension) - } else { - info.Key = fmt.Sprintf("%d/%s/%s-%s.json", grn.TenantId, grn.Scope, grn.UID, grn.Kind) - } - } - default: - return info, fmt.Errorf("unsupported scope: " + grn.Scope) - } - - return info, nil -} - -func (r *standardStoreRouter) RouteFromKey(ctx context.Context, key string) (ResourceRouteInfo, error) { - info := ResourceRouteInfo{ - Key: key, - GRN: &object.GRN{}, - } - // {orgID}/{scope}/.... - - idx := strings.Index(key, "/") - if idx <= 0 { - return info, fmt.Errorf("can not find orgID") - } - p0 := key[:idx] - key = key[idx+1:] - idx = strings.Index(key, "/") - if idx <= 0 { - return info, fmt.Errorf("can not find namespace") - } - p2 := key[:idx] - key = key[idx+1:] - - tenantID, err := strconv.ParseInt(p0, 10, 64) - if err != nil { - return info, fmt.Errorf("error parsing orgID") - } - info.GRN.TenantId = tenantID - info.GRN.Scope = p2 - - switch info.GRN.Scope { - case models.ObjectStoreScopeDrive: - { - idx := strings.LastIndex(key, ".") - if idx > 0 { - ext := key[idx+1:] - if ext == "json" { - sdx := strings.LastIndex(key, "/") - idx = strings.LastIndex(key, "-") - if idx > sdx { - ddx := strings.LastIndex(key, ".") // .json - info.GRN.UID = key[:idx] - info.GRN.Kind = key[idx+1 : ddx] - } else { - switch key[sdx+1:] { - case "__folder.json": - { - info.GRN.UID = key[:sdx] - info.GRN.Kind = models.StandardKindFolder - } - default: - return info, fmt.Errorf("unable to parse drive path") - } - } - } else { - info.GRN.UID = key[:idx] - k, err := r.kinds.GetFromExtension(ext) - if err != nil { - return info, err - } - info.GRN.Kind = k.ID - } - } else { - idx = strings.Index(key, "/") - - info.GRN.Kind = key[:idx] - info.GRN.UID = key[idx+1:] - } - } - - case models.ObjectStoreScopeEntity: - { - idx = strings.Index(key, "/") - - info.GRN.Kind = key[:idx] - info.GRN.UID = key[idx+1:] - } - - default: - return info, fmt.Errorf("unsupported scope") - } - return info, nil -} diff --git a/pkg/services/store/router/router_test.go b/pkg/services/store/router/router_test.go deleted file mode 100644 index c47d92ab575..00000000000 --- a/pkg/services/store/router/router_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package router - -import ( - "context" - "fmt" - "testing" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/store/kind" - "github.com/grafana/grafana/pkg/services/store/object" - "github.com/stretchr/testify/require" -) - -func TestSimpleRouter(t *testing.T) { - ctx := context.Background() - router := &standardStoreRouter{ - kinds: kind.NewKindRegistry(), - } - info, err := router.Route(ctx, &object.GRN{ - UID: "path/to/file", - }) - require.Error(t, err) // needs OrgID - - type routeScenario struct { - GRN *object.GRN - Error string - Key string - } - - scenarios := []routeScenario{{ - Error: "missing TenantId", - GRN: &object.GRN{Scope: "x"}, - }, { - Error: "unknown Kind: xyz", - GRN: &object.GRN{ - TenantId: 11, - Scope: models.ObjectStoreScopeDrive, - UID: "path/to/file", - Kind: "xyz", - }, - }, { - Key: "11/drive/path/to/file-dashboard.json", - GRN: &object.GRN{ - TenantId: 11, - Scope: models.ObjectStoreScopeDrive, - UID: "path/to/file", - Kind: "dashboard", - }, - }, { - Key: "11/drive/path/to/folder/__folder.json", - GRN: &object.GRN{ - TenantId: 11, - Scope: models.ObjectStoreScopeDrive, - UID: "path/to/folder", - Kind: "folder", - }, - }, { - Key: "10/drive/path/to/file.png", - GRN: &object.GRN{ - TenantId: 10, - Scope: models.ObjectStoreScopeDrive, - UID: "path/to/file", - Kind: "png", - }, - }, { - Key: "10/entity/playlist/aaaaa", // ?.json better or not? - GRN: &object.GRN{ - TenantId: 10, - Scope: models.ObjectStoreScopeEntity, - UID: "aaaaa", - Kind: "playlist", - }, - }} - - for idx, check := range scenarios { - testID := fmt.Sprintf("[%d] %s", idx, check.Key) - - // Read the key from the GRN - info, err = router.Route(ctx, check.GRN) - if check.Error == "" { - require.NoError(t, err, testID) - } else { - require.Error(t, err, testID) - require.Equal(t, check.Error, err.Error(), testID) - continue - } - // Check that the key matched - require.Equal(t, check.Key, info.Key, testID) - - // Now try to parse the same key again - out, err := router.RouteFromKey(ctx, info.Key) - require.NoError(t, err, testID) - require.Equal(t, check.GRN, out.GRN, testID) - } -} diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index cc3beab7d10..fc17c6b85b1 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -40,14 +40,6 @@ const RootContent = "content" const RootDevenv = "devenv" const RootSystem = "system" -const brandingStorage = "branding" -const SystemBrandingStorage = "system/" + brandingStorage - -var ( - SystemBrandingReader = &user.SignedInUser{OrgID: ac.GlobalOrgID} - SystemBrandingAdmin = &user.SignedInUser{OrgID: ac.GlobalOrgID} -) - const MAX_UPLOAD_SIZE = 1 * 1024 * 1024 // 3MB type DeleteFolderCmd struct { @@ -96,6 +88,7 @@ type standardStorageService struct { cfg *GlobalStorageConfig authService storageAuthService quotaService quota.Service + systemUsers SystemUsersFilterProvider } func ProvideService( @@ -103,6 +96,7 @@ func ProvideService( features featuremgmt.FeatureToggles, cfg *setting.Cfg, quotaService quota.Service, + systemUsersService SystemUsers, ) (StorageService, error) { settings, err := LoadStorageConfig(cfg, features) if err != nil { @@ -208,22 +202,17 @@ func ProvideService( } if storageName == RootSystem { - if user == SystemBrandingReader { + filter, err := systemUsersService.GetFilter(user) + if err != nil { + grafanaStorageLogger.Error("failed to create path filter for system user", "userID", user.UserID, "userLogin", user.Login, "err", err) return map[string]filestorage.PathFilter{ - ActionFilesRead: createSystemBrandingPathFilter(), + ActionFilesRead: denyAllPathFilter, ActionFilesWrite: denyAllPathFilter, ActionFilesDelete: denyAllPathFilter, } } - if user == SystemBrandingAdmin { - systemBrandingFilter := createSystemBrandingPathFilter() - return map[string]filestorage.PathFilter{ - ActionFilesRead: systemBrandingFilter, - ActionFilesWrite: systemBrandingFilter, - ActionFilesDelete: systemBrandingFilter, - } - } + return filter } if storageName == RootContent { @@ -262,7 +251,7 @@ func ProvideService( } }) - s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg) + s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg, systemUsersService) s.quotaService = quotaService s.cfg = settings @@ -298,20 +287,13 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { return limits, nil } -func createSystemBrandingPathFilter() filestorage.PathFilter { - return filestorage.NewPathFilter( - []string{filestorage.Delimiter + brandingStorage + filestorage.Delimiter}, // access to all folders and files inside `/branding/` - []string{filestorage.Delimiter + brandingStorage}, // access to the `/branding` folder itself, but not to any other sibling folder - nil, - nil) -} - func newStandardStorageService( sql db.DB, globalRoots []storageRuntime, initializeOrgStorages func(orgId int64) []storageRuntime, authService storageAuthService, cfg *setting.Cfg, + systemUsers SystemUsersFilterProvider, ) *standardStorageService { prefixes := make(map[string]bool) @@ -336,6 +318,7 @@ func newStandardStorageService( sql: sql, tree: res, authService: authService, + systemUsers: systemUsers, } } diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index c74b744af16..c5d34a0674e 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -74,7 +74,7 @@ func TestListFiles(t *testing.T) { store := newStandardStorageService(db.InitTestDB(t), roots, func(orgId int64) []storageRuntime { return make([]storageRuntime, 0) - }, allowAllAuthService, cfg) + }, allowAllAuthService, cfg, nil) frame, err := store.List(context.Background(), dummyUser, "public/testdata") require.NoError(t, err) @@ -84,6 +84,7 @@ func TestListFiles(t *testing.T) { require.NoError(t, err) require.NotNil(t, file) + t.Skip("Skipping golden JSON frame test as it is flaky") testDsFrame, err := testdatasource.LoadCsvContent(bytes.NewReader(file.Contents), file.Name) require.NoError(t, err) experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata_js_libraries.golden", testDsFrame, true) @@ -94,7 +95,7 @@ func TestListFilesWithoutPermissions(t *testing.T) { store := newStandardStorageService(db.InitTestDB(t), roots, func(orgId int64) []storageRuntime { return make([]storageRuntime, 0) - }, denyAllAuthService, cfg) + }, denyAllAuthService, cfg, nil) frame, err := store.List(context.Background(), dummyUser, "public/testdata") require.NoError(t, err) rowLen, err := frame.RowLen() @@ -114,7 +115,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ } store := newStandardStorageService(db.InitTestDB(t), []storageRuntime{sqlStorage}, func(orgId int64) []storageRuntime { return make([]storageRuntime, 0) - }, authService, cfg) + }, authService, cfg, nil) store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } @@ -268,7 +269,7 @@ func TestSetupWithNonUniqueStoragePrefixes(t *testing.T) { newStandardStorageService(db.InitTestDB(t), []storageRuntime{sqlStorage, sqlStorage2}, func(orgId int64) []storageRuntime { return make([]storageRuntime, 0) - }, allowAllAuthService, cfg) + }, allowAllAuthService, cfg, nil) } func TestContentRootWithNestedStorage(t *testing.T) { @@ -293,7 +294,7 @@ func TestContentRootWithNestedStorage(t *testing.T) { store := newStandardStorageService(db.InitTestDB(t), []storageRuntime{contentStorage, nestedStorage}, func(orgId int64) []storageRuntime { return []storageRuntime{nestedOrgedStorage, contentStorage} - }, allowAllAuthService, cfg) + }, allowAllAuthService, cfg, nil) store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } @@ -531,7 +532,7 @@ func TestShadowingExistingFolderByNestedContentRoot(t *testing.T) { }) require.NoError(t, err) - store := newStandardStorageService(db, []storageRuntime{nestedStorage, contentStorage}, func(orgId int64) []storageRuntime { return make([]storageRuntime, 0) }, allowAllAuthService, cfg) + store := newStandardStorageService(db, []storageRuntime{nestedStorage, contentStorage}, func(orgId int64) []storageRuntime { return make([]storageRuntime, 0) }, allowAllAuthService, cfg, nil) store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } diff --git a/pkg/services/store/system_users.go b/pkg/services/store/system_users.go new file mode 100644 index 00000000000..048710a949d --- /dev/null +++ b/pkg/services/store/system_users.go @@ -0,0 +1,109 @@ +package store + +import ( + "fmt" + "sync" + + "github.com/grafana/grafana/pkg/infra/filestorage" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/user" +) + +type SystemUserType string + +// SystemUsersFilterProvider interface internal to `pkg/store` service. +// Used by the Storage service to retrieve path filter for system users +type SystemUsersFilterProvider interface { + GetFilter(user *user.SignedInUser) (map[string]filestorage.PathFilter, error) +} + +// SystemUsersProvider interface used by `pkg/store` clients +// Used by Grafana services to retrieve users having access only to their own slice of storage +// For example, service 'Dashboard' could have exclusive access to paths matching `system/dashboard/*` +// by creating a system user with appropriate permissions. +type SystemUsersProvider interface { + GetUser(userType SystemUserType, orgID int64) (*user.SignedInUser, error) +} + +type SystemUsers interface { + SystemUsersFilterProvider + SystemUsersProvider + + // RegisterUser extension point - allows other Grafana services to register their own user type and assign them path-based permissions + RegisterUser(userType SystemUserType, filterFn func() map[string]filestorage.PathFilter) +} + +func ProvideSystemUsersService() SystemUsers { + return &hardcodedSystemUsers{ + mutex: sync.RWMutex{}, + users: make(map[SystemUserType]map[int64]*user.SignedInUser), + createFilterByUser: make(map[*user.SignedInUser]func() map[string]filestorage.PathFilter), + } +} + +type hardcodedSystemUsers struct { + mutex sync.RWMutex + + // map of user type -> map of user per orgID + users map[SystemUserType]map[int64]*user.SignedInUser + + // map of user -> create filter function. all users of the same type will point to the same function + createFilterByUser map[*user.SignedInUser]func() map[string]filestorage.PathFilter +} + +func (h *hardcodedSystemUsers) GetFilter(user *user.SignedInUser) (map[string]filestorage.PathFilter, error) { + h.mutex.Lock() + defer h.mutex.Unlock() + + createFn, ok := h.createFilterByUser[user] + if !ok { + return nil, fmt.Errorf("user %s with id %d has not been initialized", user.Login, user.UserID) + } + + return createFn(), nil +} + +func (h *hardcodedSystemUsers) GetUser(userType SystemUserType, orgID int64) (*user.SignedInUser, error) { + h.mutex.Lock() + defer h.mutex.Unlock() + + userPerOrgIdMap, ok := h.users[userType] + if !ok { + return nil, fmt.Errorf("user type %s is unknown", userType) + } + + orgSignedInUser, ok := userPerOrgIdMap[orgID] + if ok { + return orgSignedInUser, nil + } + + // user for the given org does not yet exist - initialize it + + globalUser, globalUserExists := userPerOrgIdMap[ac.GlobalOrgID] + if !globalUserExists { + return nil, fmt.Errorf("initialization error: user type %s should exist for global org id: %d", userType, ac.GlobalOrgID) + } + + globalUserFn, globalUserFnExists := h.createFilterByUser[globalUser] + if !globalUserFnExists { + return nil, fmt.Errorf("initialization error: user type %s should be associated with a create filter function", userType) + } + + newUser := &user.SignedInUser{ + Login: string(userType), + OrgID: orgID, + } + userPerOrgIdMap[orgID] = newUser + h.createFilterByUser[newUser] = globalUserFn + return newUser, nil +} + +func (h *hardcodedSystemUsers) RegisterUser(userType SystemUserType, filterFn func() map[string]filestorage.PathFilter) { + h.mutex.Lock() + defer h.mutex.Unlock() + + globalUser := &user.SignedInUser{OrgID: ac.GlobalOrgID, Login: string(userType)} + h.users[userType] = map[int64]*user.SignedInUser{ac.GlobalOrgID: globalUser} + + h.createFilterByUser[globalUser] = filterFn +} diff --git a/pkg/services/store/system_users_test.go b/pkg/services/store/system_users_test.go new file mode 100644 index 00000000000..422d975974f --- /dev/null +++ b/pkg/services/store/system_users_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "testing" + + "github.com/grafana/grafana/pkg/infra/filestorage" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/require" +) + +const admin SystemUserType = "storageAdmin" + +func TestRetrievalOfNotInitializedOrg(t *testing.T) { + service := setupSystemUsers() + + orgID := int64(1) + user, err := service.GetUser(admin, orgID) + require.NoError(t, err) + + require.Equal(t, string(admin), user.Login) + require.Equal(t, orgID, user.OrgID) + + userFromSubsequentCall, err := service.GetUser(admin, orgID) + require.NoError(t, err) + require.Same(t, user, userFromSubsequentCall) +} + +func TestRetrievalOfFilterForInitializedUser(t *testing.T) { + service := setupSystemUsers() + + orgID := int64(1) + reportsAdminUser, err := service.GetUser(admin, orgID) + require.NoError(t, err) + + filter, err := service.GetFilter(reportsAdminUser) + require.NoError(t, err) + require.NotNil(t, filter) +} + +func TestRetrievalOfFilterForNotInitializedUser(t *testing.T) { + service := setupSystemUsers() + + orgID := int64(1) + + filter, err := service.GetFilter(&user.SignedInUser{ + OrgID: orgID, + Login: string(admin), + }) + require.Error(t, err) + require.Nil(t, filter) +} + +func setupSystemUsers() SystemUsers { + service := ProvideSystemUsersService() + + service.RegisterUser(admin, func() map[string]filestorage.PathFilter { + return map[string]filestorage.PathFilter{ + ActionFilesRead: denyAllPathFilter, + } + }) + + return service +} diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index 169a6b53143..17ec4f6d08b 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -236,7 +236,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { Permissions: map[int64]map[string][]string{testOrgID: {ac.ActionOrgUsersRead: {ac.ScopeUsersAll}, ac.ActionTeamsRead: {ac.ScopeTeamsAll}}}, }, } - err = sqlStore.GetTeamsByUser(context.Background(), query) + err = teamSvc.GetTeamsByUser(context.Background(), query) require.NoError(t, err) require.Equal(t, len(query.Result), 1) require.Equal(t, query.Result[0].Name, "group2 name") diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index ee50e622781..48e2d2de23b 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -454,22 +454,11 @@ func TestIntegrationUserDataAccess(t *testing.T) { ss.CacheService.Flush() - query3 := &models.GetSignedInUserQuery{OrgId: users[1].OrgID, UserId: users[1].ID} - err = ss.GetSignedInUserWithCacheCtx(context.Background(), query3) + query3 := &user.GetSignedInUserQuery{OrgID: users[1].OrgID, UserID: users[1].ID} + query3Result, err := userStore.GetSignedInUser(context.Background(), query3) require.Nil(t, err) - require.NotNil(t, query3.Result) - require.Equal(t, query3.OrgId, users[1].OrgID) - err = ss.SetUsingOrg(context.Background(), &models.SetUsingOrgCommand{UserId: users[1].ID, OrgId: users[0].OrgID}) - require.Nil(t, err) - query4 := &models.GetSignedInUserQuery{OrgId: 0, UserId: users[1].ID} - err = ss.GetSignedInUserWithCacheCtx(context.Background(), query4) - require.Nil(t, err) - require.NotNil(t, query4.Result) - require.Equal(t, query4.Result.OrgID, users[0].OrgID) - - cacheKey := newSignedInUserCacheKey(query4.Result.OrgID, query4.UserId) - _, found := ss.CacheService.Get(cacheKey) - require.True(t, found) + require.NotNil(t, query3Result) + require.Equal(t, query3.OrgID, users[1].OrgID) disableCmd := user.BatchDisableUsersCommand{ UserIDs: []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID, users[4].ID}, @@ -717,6 +706,20 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Len(t, queryResult.Users, 1) require.EqualValues(t, queryResult.TotalCount, 1) }) + + t.Run("Can get logged in user projection", func(t *testing.T) { + query := user.GetSignedInUserQuery{UserID: 2} + queryResult, err := userStore.GetSignedInUser(context.Background(), &query) + + require.NoError(t, err) + assert.Equal(t, queryResult.Email, "user1@test.com") + assert.EqualValues(t, queryResult.OrgID, 2) + assert.Equal(t, queryResult.Name, "user1") + assert.Equal(t, queryResult.Login, "loginuser1") + assert.EqualValues(t, queryResult.OrgRole, "Admin") + assert.Equal(t, queryResult.OrgName, "user1@test.com") + assert.Equal(t, queryResult.IsGrafanaAdmin, false) + }) } func TestIntegrationUserUpdate(t *testing.T) { diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index aadd510e2ad..d491bd4f848 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -24,6 +24,7 @@ func TestUserService(t *testing.T) { store: userStore, orgService: orgService, cacheService: localcache.ProvideService(), + teamService: &teamtest.FakeService{}, } t.Run("create user", func(t *testing.T) { @@ -162,6 +163,33 @@ func TestUserService(t *testing.T) { require.Equal(t, roletype.RoleType(userService.cfg.AnonymousOrgRole), u.OrgRole) }) }) + + t.Run("Can set using org", func(t *testing.T) { + cmd := user.SetUsingOrgCommand{UserID: 2, OrgID: 1} + orgService.ExpectedUserOrgDTO = []*org.UserOrgDTO{{OrgID: 1}} + userStore.ExpectedError = nil + err := userService.SetUsingOrg(context.Background(), &cmd) + require.NoError(t, err) + + t.Run("SignedInUserQuery with a different org", func(t *testing.T) { + query := user.GetSignedInUserQuery{UserID: 2} + userStore.ExpectedSignedInUser = &user.SignedInUser{ + OrgID: 1, + Email: "ac2@test.com", + Name: "ac2 name", + Login: "ac2", + OrgName: "ac1@test.com", + } + queryResult, err := userService.GetSignedInUser(context.Background(), &query) + + require.NoError(t, err) + require.EqualValues(t, queryResult.OrgID, 1) + require.Equal(t, queryResult.Email, "ac2@test.com") + require.Equal(t, queryResult.Name, "ac2 name") + require.Equal(t, queryResult.Login, "ac2") + require.Equal(t, queryResult.OrgName, "ac1@test.com") + }) + }) } type FakeUserStore struct { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 4754d5c028e..e79354ccdb5 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -475,15 +475,21 @@ type Cfg struct { Search SearchSettings + SecureSocksDSProxy SecureSocksDSProxySettings + // Access Control RBACEnabled bool RBACPermissionCache bool // Enable Permission validation during role creation and provisioning RBACPermissionValidationEnabled bool + // Reset basic roles permissions on start-up + RBACResetBasicRoles bool // GRPC Server. GRPCServerNetwork string GRPCServerAddress string GRPCServerTLSConfig *tls.Config + + CustomResponseHeaders map[string]string } type CommandLineArgs struct { @@ -1080,6 +1086,13 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.Storage = readStorageSettings(iniFile) cfg.Search = readSearchSettings(iniFile) + cfg.SecureSocksDSProxy, err = readSecureSocksDSProxySettings(iniFile) + if err != nil { + // if the proxy is misconfigured, disable it rather than crashing + cfg.SecureSocksDSProxy.Enabled = false + cfg.Logger.Error("secure_socks_datasource_proxy unable to start up", "err", err.Error()) + } + if VerifyEmailEnabled && !cfg.Smtp.Enabled { cfg.Logger.Warn("require_email_validation is enabled but smtp is disabled") } @@ -1436,6 +1449,7 @@ func readAccessControlSettings(iniFile *ini.File, cfg *Cfg) { cfg.RBACEnabled = rbac.Key("enabled").MustBool(true) cfg.RBACPermissionCache = rbac.Key("permission_cache").MustBool(true) cfg.RBACPermissionValidationEnabled = rbac.Key("permission_validation_enabled").MustBool(false) + cfg.RBACResetBasicRoles = rbac.Key("reset_basic_roles").MustBool(false) } func readUserSettings(iniFile *ini.File, cfg *Cfg) error { @@ -1683,6 +1697,14 @@ func (cfg *Cfg) readServerSettings(iniFile *ini.File) error { cfg.ReadTimeout = server.Key("read_timeout").MustDuration(0) + headersSection := cfg.Raw.Section("server.custom_response_headers") + keys := headersSection.Keys() + cfg.CustomResponseHeaders = make(map[string]string, len(keys)) + + for _, key := range keys { + cfg.CustomResponseHeaders[key.Name()] = key.Value() + } + return nil } diff --git a/pkg/setting/setting_secure_socks_proxy.go b/pkg/setting/setting_secure_socks_proxy.go new file mode 100644 index 00000000000..83fbb156a9e --- /dev/null +++ b/pkg/setting/setting_secure_socks_proxy.go @@ -0,0 +1,44 @@ +package setting + +import ( + "errors" + + "gopkg.in/ini.v1" +) + +type SecureSocksDSProxySettings struct { + Enabled bool + ClientCert string + ClientKey string + RootCA string + ProxyAddress string + ServerName string +} + +func readSecureSocksDSProxySettings(iniFile *ini.File) (SecureSocksDSProxySettings, error) { + s := SecureSocksDSProxySettings{} + secureSocksProxySection := iniFile.Section("secure_socks_datasource_proxy") + s.Enabled = secureSocksProxySection.Key("enabled").MustBool(false) + s.ClientCert = secureSocksProxySection.Key("client_cert").MustString("") + s.ClientKey = secureSocksProxySection.Key("client_key").MustString("") + s.RootCA = secureSocksProxySection.Key("root_ca_cert").MustString("") + s.ProxyAddress = secureSocksProxySection.Key("proxy_address").MustString("") + s.ServerName = secureSocksProxySection.Key("server_name").MustString("") + + if !s.Enabled { + return s, nil + } + + // all fields must be specified to use the proxy + if s.RootCA == "" { + return s, errors.New("rootCA required") + } else if s.ClientCert == "" || s.ClientKey == "" { + return s, errors.New("client key pair required") + } else if s.ServerName == "" { + return s, errors.New("server name required") + } else if s.ProxyAddress == "" { + return s, errors.New("proxy address required") + } + + return s, nil +} diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go index 09d2be1a8ec..1633da33995 100644 --- a/pkg/tests/api/alerting/api_admin_configuration_test.go +++ b/pkg/tests/api/alerting/api_admin_configuration_test.go @@ -2,6 +2,7 @@ package alerting import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -18,6 +19,8 @@ import ( ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/sender" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" ) @@ -35,6 +38,9 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { grafanaListedAddr, s := testinfra.StartGrafana(t, dir, path) + orgService, err := orgimpl.ProvideService(s, s.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + // Create a user to make authenticated requests userID := createUser(t, s, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleAdmin), @@ -42,8 +48,12 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { Password: "password", }) apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + // create another organisation - orgID := createOrg(t, s, "another org", userID) + newOrg, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "another org", UserID: userID}) + require.NoError(t, err) + orgID := newOrg.ID + // ensure that the orgID is 3 (the disabled org) require.Equal(t, disableOrgID, orgID) diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index 8abf9294559..e3daa09d5ea 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -1,6 +1,7 @@ package alerting import ( + "context" "encoding/json" "fmt" "io" @@ -11,6 +12,8 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/stretchr/testify/assert" @@ -28,6 +31,9 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + // editor from main organisation requests configuration alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr) @@ -39,7 +45,9 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { }) // create another organisation - orgID := createOrg(t, store, "another org", userID) + newOrg, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "another org", UserID: userID}) + require.NoError(t, err) + orgID := newOrg.ID // create user under different organisation createUser(t, store, user.CreateUserCommand{ diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index a5464ddfdae..ca79eb54301 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -2540,14 +2539,6 @@ func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserComma return u.ID } -func createOrg(t *testing.T, store *sqlstore.SQLStore, name string, userID int64) int64 { - cmd := &models.CreateOrgCommand{Name: name, UserId: userID} - err := store.CreateOrg(context.Background(), cmd) - require.NoError(t, err) - org := cmd.Result - return org.Id -} - func getLongString(t *testing.T, n int) string { t.Helper() diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 9950b4672a8..e8337cebf39 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -201,7 +201,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/barchart/", "category": "", - "state": "beta", + "state": "", "signature": "internal", "signatureType": "", "signatureOrg": "" @@ -273,7 +273,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/candlestick/", "category": "", - "state": "beta", + "state": "", "signature": "internal", "signatureType": "", "signatureOrg": "" @@ -755,7 +755,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/histogram/", "category": "", - "state": "beta", + "state": "", "signature": "internal", "signatureType": "", "signatureOrg": "" @@ -1282,7 +1282,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/state-timeline/", "category": "", - "state": "beta", + "state": "", "signature": "internal", "signatureType": "", "signatureOrg": "" @@ -1318,7 +1318,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/status-history/", "category": "", - "state": "beta", + "state": "", "signature": "internal", "signatureType": "", "signatureOrg": "" @@ -1693,4 +1693,4 @@ "signatureType": "", "signatureOrg": "" } -] +] \ No newline at end of file diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go index a690c78dc4e..c48edd5d52e 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go @@ -7,14 +7,13 @@ import ( "io" "math" "net/http" - "net/url" - "path" "regexp" "strconv" "strings" "time" "github.com/grafana/grafana-google-sdk-go/pkg/utils" + "github.com/huandu/xstrings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" @@ -25,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/setting" ) var ( @@ -33,11 +31,9 @@ var ( ) var ( - matchAllCap = regexp.MustCompile("(.)([A-Z][a-z]*)") legendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) metricNameFormat = regexp.MustCompile(`([\w\d_]+)\.(googleapis\.com|io)/(.+)`) wildcardRegexRe = regexp.MustCompile(`[-\/^$+?.()|[\]{}]`) - alignmentPeriodRe = regexp.MustCompile("[0-9]+") cloudMonitoringUnitMappings = map[string]string{ "bit": "bits", "By": "bytes", @@ -62,7 +58,6 @@ const ( annotationQueryType = "annotation" metricQueryType = "metrics" sloQueryType = "slo" - mqlEditorMode = "mql" crossSeriesReducerDefault = "REDUCE_NONE" perSeriesAlignerDefault = "ALIGN_MEAN" ) @@ -207,6 +202,46 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst } } +func migrateMetricTypeFilter(metricTypeFilter string, prevFilters interface{}) []string { + metricTypeFilterArray := []string{"metric.type", "=", metricTypeFilter} + if prevFilters != nil { + filtersIface := prevFilters.([]interface{}) + filters := []string{} + for _, f := range filtersIface { + filters = append(filters, f.(string)) + } + metricTypeFilterArray = append([]string{"AND"}, metricTypeFilterArray...) + return append(filters, metricTypeFilterArray...) + } + return metricTypeFilterArray +} + +func migratePreprocessor(tsl *timeSeriesList, preprocessor string) { + // In case a preprocessor is defined, the preprocessor becomes the primary aggregation + // and the aggregation that is specified in the UI becomes the secondary aggregation + // Rules are specified in this issue: https://github.com/grafana/grafana/issues/30866 + t := toPreprocessorType(preprocessor) + if t != PreprocessorTypeNone { + // Move aggregation to secondaryAggregation + tsl.SecondaryAlignmentPeriod = tsl.AlignmentPeriod + tsl.SecondaryCrossSeriesReducer = tsl.CrossSeriesReducer + tsl.SecondaryPerSeriesAligner = tsl.PerSeriesAligner + tsl.SecondaryGroupBys = tsl.GroupBys + + // Set a default cross series reducer if grouped + if len(tsl.GroupBys) == 0 { + tsl.CrossSeriesReducer = crossSeriesReducerDefault + } + + // Set aligner based on preprocessor type + aligner := "ALIGN_RATE" + if t == PreprocessorTypeDelta { + aligner = "ALIGN_DELTA" + } + tsl.PerSeriesAligner = aligner + } +} + func migrateRequest(req *backend.QueryDataRequest) error { for i, q := range req.Queries { var rawQuery map[string]interface{} @@ -217,16 +252,27 @@ func migrateRequest(req *backend.QueryDataRequest) error { if rawQuery["metricQuery"] == nil { // migrate legacy query - var mq metricQuery + var mq timeSeriesList err = json.Unmarshal(q.JSON, &mq) if err != nil { return err } + q.QueryType = metricQueryType + gq := grafanaQuery{ + TimeSeriesList: &mq, + } + if rawQuery["aliasBy"] != nil { + gq.AliasBy = rawQuery["aliasBy"].(string) + } + if rawQuery["metricType"] != nil { + // metricType should be a filter + gq.TimeSeriesList.Filters = migrateMetricTypeFilter(rawQuery["metricType"].(string), rawQuery["filters"]) + } + if rawQuery["preprocessor"] != nil { + migratePreprocessor(gq.TimeSeriesList, rawQuery["preprocessor"].(string)) + } - b, err := json.Marshal(grafanaQuery{ - QueryType: metricQueryType, - MetricQuery: mq, - }) + b, err := json.Marshal(gq) if err != nil { return err } @@ -237,6 +283,51 @@ func migrateRequest(req *backend.QueryDataRequest) error { if rawQuery["type"] != nil && rawQuery["type"].(string) == "annotationQuery" { q.QueryType = annotationQueryType } + if rawQuery["queryType"] != nil { + q.QueryType = rawQuery["queryType"].(string) + } + + // Metric query was divided between timeSeriesList and timeSeriesQuery API calls + if rawQuery["metricQuery"] != nil { + metricQuery := rawQuery["metricQuery"].(map[string]interface{}) + + if metricQuery["editorMode"] != nil && toString(metricQuery["editorMode"]) == "mql" { + rawQuery["timeSeriesQuery"] = &timeSeriesQuery{ + ProjectName: toString(metricQuery["projectName"]), + Query: toString(metricQuery["query"]), + GraphPeriod: toString(metricQuery["graphPeriod"]), + } + } else { + tslb, err := json.Marshal(metricQuery) + if err != nil { + return err + } + tsl := &timeSeriesList{} + err = json.Unmarshal(tslb, tsl) + if err != nil { + return err + } + if metricQuery["metricType"] != nil { + // metricType should be a filter + tsl.Filters = migrateMetricTypeFilter(metricQuery["metricType"].(string), metricQuery["filters"]) + } + if rawQuery["preprocessor"] != nil { + migratePreprocessor(tsl, rawQuery["preprocessor"].(string)) + } + rawQuery["timeSeriesList"] = tsl + } + if metricQuery["aliasBy"] != nil { + rawQuery["aliasBy"] = metricQuery["aliasBy"] + } + b, err := json.Marshal(rawQuery) + if err != nil { + return err + } + if q.QueryType == "" { + q.QueryType = metricQueryType + } + q.JSON = b + } req.Queries[i] = q } @@ -315,61 +406,43 @@ func (s *Service) buildQueryExecutors(logger log.Logger, req *backend.QueryDataR return nil, fmt.Errorf("could not unmarshal CloudMonitoringQuery json: %w", err) } - q.MetricQuery.PreprocessorType = toPreprocessorType(q.MetricQuery.Preprocessor) - var target string - params := url.Values{} - params.Add("interval.startTime", startTime.UTC().Format(time.RFC3339)) - params.Add("interval.endTime", endTime.UTC().Format(time.RFC3339)) - var queryInterface cloudMonitoringQueryExecutor - cmtsf := &cloudMonitoringTimeSeriesFilter{ - RefID: query.RefID, - GroupBys: []string{}, - logger: logger, - } - switch q.QueryType { + switch query.QueryType { case metricQueryType, annotationQueryType: - if q.MetricQuery.EditorMode == mqlEditorMode { + if q.TimeSeriesQuery != nil { queryInterface = &cloudMonitoringTimeSeriesQuery{ - RefID: query.RefID, - ProjectName: q.MetricQuery.ProjectName, - Query: q.MetricQuery.Query, - IntervalMS: query.Interval.Milliseconds(), - AliasBy: q.MetricQuery.AliasBy, - timeRange: req.Queries[0].TimeRange, - GraphPeriod: q.MetricQuery.GraphPeriod, + refID: query.RefID, + aliasBy: q.AliasBy, + parameters: q.TimeSeriesQuery, + IntervalMS: query.Interval.Milliseconds(), + timeRange: req.Queries[0].TimeRange, } - } else { - cmtsf.AliasBy = q.MetricQuery.AliasBy - cmtsf.ProjectName = q.MetricQuery.ProjectName - cmtsf.GroupBys = append(cmtsf.GroupBys, q.MetricQuery.GroupBys...) - if q.MetricQuery.View == "" { - q.MetricQuery.View = "FULL" + } else if q.TimeSeriesList != nil { + cmtsf := &cloudMonitoringTimeSeriesList{ + refID: query.RefID, + logger: logger, + aliasBy: q.AliasBy, } - params.Add("filter", buildFilterString(q.MetricQuery.MetricType, q.MetricQuery.Filters)) - params.Add("view", q.MetricQuery.View) - setMetricAggParams(¶ms, &q.MetricQuery, durationSeconds, query.Interval.Milliseconds()) + if q.TimeSeriesList.View == "" { + q.TimeSeriesList.View = "FULL" + } + cmtsf.parameters = q.TimeSeriesList + cmtsf.setParams(startTime, endTime, durationSeconds, query.Interval.Milliseconds()) queryInterface = cmtsf + } else { + return nil, fmt.Errorf("missing query info") } case sloQueryType: - cmtsf.AliasBy = q.SloQuery.AliasBy - cmtsf.ProjectName = q.SloQuery.ProjectName - cmtsf.Selector = q.SloQuery.SelectorName - cmtsf.Service = q.SloQuery.ServiceId - cmtsf.Slo = q.SloQuery.SloId - params.Add("filter", buildSLOFilterExpression(q.SloQuery)) - setSloAggParams(¶ms, &q.SloQuery, durationSeconds, query.Interval.Milliseconds()) - queryInterface = cmtsf + cmslo := &cloudMonitoringSLO{ + refID: query.RefID, + logger: logger, + aliasBy: q.AliasBy, + parameters: q.SloQuery, + } + cmslo.setParams(startTime, endTime, durationSeconds, query.Interval.Milliseconds()) + queryInterface = cmslo default: - return nil, fmt.Errorf("unrecognized query type %q", q.QueryType) - } - - target = params.Encode() - cmtsf.Target = target - cmtsf.Params = params - - if setting.Env == setting.Dev { - logger.Debug("CloudMonitoring request", "params", params) + return nil, fmt.Errorf("unrecognized query type %q", query.QueryType) } cloudMonitoringQueryExecutors = append(cloudMonitoringQueryExecutors, queryInterface) @@ -388,7 +461,7 @@ func interpolateFilterWildcards(value string) string { value = strings.Replace(value, "*", "", 1) value = fmt.Sprintf(`ends_with("%s")`, value) case matches == 1 && strings.HasSuffix(value, "*"): - value = reverse(strings.Replace(reverse(value), "*", "", 1)) + value = xstrings.Reverse(strings.Replace(xstrings.Reverse(value), "*", "", 1)) value = fmt.Sprintf(`starts_with("%s")`, value) case matches != 0: value = string(wildcardRegexRe.ReplaceAllFunc([]byte(value), func(in []byte) []byte { @@ -402,97 +475,6 @@ func interpolateFilterWildcards(value string) string { return value } -func buildFilterString(metricType string, filterParts []string) string { - filterString := "" - for i, part := range filterParts { - mod := i % 4 - switch { - case part == "AND": - filterString += " " - case mod == 2: - operator := filterParts[i-1] - switch { - case operator == "=~" || operator == "!=~": - filterString = reverse(strings.Replace(reverse(filterString), "~", "", 1)) - filterString += fmt.Sprintf(`monitoring.regex.full_match("%s")`, part) - case strings.Contains(part, "*"): - filterString += interpolateFilterWildcards(part) - default: - filterString += fmt.Sprintf(`"%s"`, part) - } - default: - filterString += part - } - } - - return strings.Trim(fmt.Sprintf(`metric.type="%s" %s`, metricType, filterString), " ") -} - -func buildSLOFilterExpression(q sloQuery) string { - sloName := fmt.Sprintf("projects/%s/services/%s/serviceLevelObjectives/%s", q.ProjectName, q.ServiceId, q.SloId) - - if q.SelectorName == "select_slo_burn_rate" { - return fmt.Sprintf(`%s("%s", "%s")`, q.SelectorName, sloName, q.LookbackPeriod) - } else { - return fmt.Sprintf(`%s("%s")`, q.SelectorName, sloName) - } -} - -func setMetricAggParams(params *url.Values, query *metricQuery, durationSeconds int, intervalMs int64) { - if query.CrossSeriesReducer == "" { - query.CrossSeriesReducer = crossSeriesReducerDefault - } - - if query.PerSeriesAligner == "" { - query.PerSeriesAligner = perSeriesAlignerDefault - } - - alignmentPeriod := calculateAlignmentPeriod(query.AlignmentPeriod, intervalMs, durationSeconds) - - // In case a preprocessor is defined, the preprocessor becomes the primary aggregation - // and the aggregation that is specified in the UI becomes the secondary aggregation - // Rules are specified in this issue: https://github.com/grafana/grafana/issues/30866 - if query.PreprocessorType != PreprocessorTypeNone { - params.Add("secondaryAggregation.alignmentPeriod", alignmentPeriod) - params.Add("secondaryAggregation.crossSeriesReducer", query.CrossSeriesReducer) - params.Add("secondaryAggregation.perSeriesAligner", query.PerSeriesAligner) - - primaryCrossSeriesReducer := crossSeriesReducerDefault - if len(query.GroupBys) > 0 { - primaryCrossSeriesReducer = query.CrossSeriesReducer - } - params.Add("aggregation.crossSeriesReducer", primaryCrossSeriesReducer) - - aligner := "ALIGN_RATE" - if query.PreprocessorType == PreprocessorTypeDelta { - aligner = "ALIGN_DELTA" - } - params.Add("aggregation.perSeriesAligner", aligner) - - for _, groupBy := range query.GroupBys { - params.Add("secondaryAggregation.groupByFields", groupBy) - } - } else { - params.Add("aggregation.crossSeriesReducer", query.CrossSeriesReducer) - params.Add("aggregation.perSeriesAligner", query.PerSeriesAligner) - } - - params.Add("aggregation.alignmentPeriod", alignmentPeriod) - - for _, groupBy := range query.GroupBys { - params.Add("aggregation.groupByFields", groupBy) - } -} - -func setSloAggParams(params *url.Values, query *sloQuery, durationSeconds int, intervalMs int64) { - params.Add("aggregation.alignmentPeriod", calculateAlignmentPeriod(query.AlignmentPeriod, intervalMs, durationSeconds)) - if query.SelectorName == "select_slo_health" { - params.Add("aggregation.perSeriesAligner", "ALIGN_MEAN") - } else { - params.Add("aggregation.perSeriesAligner", "ALIGN_NEXT_OLDER") - } -} - func calculateAlignmentPeriod(alignmentPeriod string, intervalMs int64, durationSeconds int) string { if alignmentPeriod == "grafana-auto" || alignmentPeriod == "" { alignmentPeriodValue := int(math.Max(float64(intervalMs)/1000, 60.0)) @@ -515,12 +497,12 @@ func calculateAlignmentPeriod(alignmentPeriod string, intervalMs int64, duration } func formatLegendKeys(metricType string, defaultMetricName string, labels map[string]string, - additionalLabels map[string]string, query *cloudMonitoringTimeSeriesFilter) string { - if query.AliasBy == "" { + additionalLabels map[string]string, query cloudMonitoringQueryExecutor) string { + if query.getAliasBy() == "" { return defaultMetricName } - result := legendKeyFormat.ReplaceAllFunc([]byte(query.AliasBy), func(in []byte) []byte { + result := legendKeyFormat.ReplaceAllFunc([]byte(query.getAliasBy()), func(in []byte) []byte { metaPartName := strings.Replace(string(in), "{{", "", 1) metaPartName = strings.Replace(metaPartName, "}}", "", 1) metaPartName = strings.TrimSpace(metaPartName) @@ -543,20 +525,8 @@ func formatLegendKeys(metricType string, defaultMetricName string, labels map[st return []byte(val) } - if metaPartName == "project" && query.ProjectName != "" { - return []byte(query.ProjectName) - } - - if metaPartName == "service" && query.Service != "" { - return []byte(query.Service) - } - - if metaPartName == "slo" && query.Slo != "" { - return []byte(query.Slo) - } - - if metaPartName == "selector" && query.Selector != "" { - return []byte(query.Selector) + if query.getParameter(metaPartName) != "" { + return []byte(query.getParameter(metaPartName)) } return in @@ -606,27 +576,11 @@ func calcBucketBound(bucketOptions cloudMonitoringBucketOptions, n int) string { return bucketBound } -func (s *Service) createRequest(logger log.Logger, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { - u, err := url.Parse(dsInfo.url) - if err != nil { - return nil, err +func (s *Service) ensureProject(ctx context.Context, dsInfo datasourceInfo, projectName string) (string, error) { + if projectName != "" { + return projectName, nil } - u.Path = path.Join(u.Path, "render") - - method := http.MethodGet - if body != nil { - method = http.MethodPost - } - req, err := http.NewRequest(method, dsInfo.services[cloudMonitor].url, body) - if err != nil { - logger.Error("Failed to create request", "error", err) - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.URL.Path = proxyPass - - return req, nil + return s.getDefaultProject(ctx, dsInfo) } func (s *Service) getDefaultProject(ctx context.Context, dsInfo datasourceInfo) (string, error) { diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go index ad01588cb56..272a64b007c 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go @@ -50,31 +50,88 @@ func TestNewInstanceSettings(t *testing.T) { func TestCloudMonitoring(t *testing.T) { service := &Service{} + t.Run("parses a time series list query", func(t *testing.T) { + req := baseTimeSeriesList() + qes, err := service.buildQueryExecutors(slog, req) + require.NoError(t, err) + queries := getCloudMonitoringListFromInterface(t, qes) + + require.Len(t, queries, 1) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].params.Encode()) + assert.Equal(t, 7, len(queries[0].params)) + assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].params["interval.startTime"][0]) + assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].params["interval.endTime"][0]) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].params["filter"][0]) + assert.Equal(t, "FULL", queries[0].params["view"][0]) + assert.Equal(t, "testalias", queries[0].aliasBy) + }) + + t.Run("parses a time series query", func(t *testing.T) { + req := baseTimeSeriesQuery() + qes, err := service.buildQueryExecutors(slog, req) + require.NoError(t, err) + queries := getCloudMonitoringQueryFromInterface(t, qes) + + require.Len(t, queries, 1) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "foo", queries[0].parameters.Query) + assert.Equal(t, "testalias", queries[0].aliasBy) + }) + + t.Run("parses a time series list with secondary inputs", func(t *testing.T) { + req := baseTimeSeriesList() + req.Queries[0].JSON = json.RawMessage(`{ + "timeSeriesList": { + "filters": ["metric.type=\"a/metric/type\""], + "view": "FULL", + "secondaryAlignmentPeriod": "60s", + "secondaryCrossSeriesReducer": "REDUCE_NONE", + "secondaryPerSeriesAligner": "ALIGN_MEAN", + "secondaryGroupBys": ["metric.label.group"] + }, + "aliasBy": "testalias" + }`) + + qes, err := service.buildQueryExecutors(slog, req) + require.NoError(t, err) + queries := getCloudMonitoringListFromInterface(t, qes) + + require.Len(t, queries, 1) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "+60s", queries[0].params["secondaryAggregation.alignmentPeriod"][0]) + assert.Equal(t, "REDUCE_NONE", queries[0].params["secondaryAggregation.crossSeriesReducer"][0]) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["secondaryAggregation.perSeriesAligner"][0]) + assert.Equal(t, "metric.label.group", queries[0].params["secondaryAggregation.groupByFields"][0]) + assert.Equal(t, "FULL", queries[0].params["view"][0]) + assert.Equal(t, "testalias", queries[0].aliasBy) + }) + t.Run("Parse migrated queries from frontend and build Google Cloud Monitoring API queries", func(t *testing.T) { t.Run("and query has no aggregation set", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() err := migrateRequest(req) require.NoError(t, err) - require.NoError(t, err) qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) require.Len(t, queries, 1) - assert.Equal(t, "A", queries[0].RefID) - assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].Target) - assert.Equal(t, 7, len(queries[0].Params)) - assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].Params["interval.startTime"][0]) - assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].Params["interval.endTime"][0]) - assert.Equal(t, "ALIGN_MEAN", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].Params["filter"][0]) - assert.Equal(t, "FULL", queries[0].Params["view"][0]) - assert.Equal(t, "testalias", queries[0].AliasBy) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].params.Encode()) + assert.Equal(t, 7, len(queries[0].params)) + assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].params["interval.startTime"][0]) + assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].params["interval.endTime"][0]) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].params["filter"][0]) + assert.Equal(t, "FULL", queries[0].params["view"][0]) + assert.Equal(t, "testalias", queries[0].aliasBy) t.Run("and generated deep link has correct parameters", func(t *testing.T) { // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -91,7 +148,7 @@ func TestCloudMonitoring(t *testing.T) { }) t.Run("and query has filters", func(t *testing.T) { - query := baseReq() + query := deprecatedReq() query.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "filters": ["key", "=", "value", "AND", "key2", "=", "value2", "AND", "resource.type", "=", "another/resource/type"] @@ -101,14 +158,14 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, query) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, `metric.type="a/metric/type" key="value" key2="value2" resource.type="another/resource/type"`, queries[0].Params["filter"][0]) + assert.Equal(t, `key="value" key2="value2" resource.type="another/resource/type" metric.type="a/metric/type"`, queries[0].params["filter"][0]) // assign a resource type to query parameters // in the actual workflow this information comes from the response of the Monitoring API // the deep link should not contain this resource type since another resource type is included in the query filters - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -117,14 +174,14 @@ func TestCloudMonitoring(t *testing.T) { "end": "2018-03-15T13:34:00Z", } expectedTimeSeriesFilter := map[string]interface{}{ - "filter": `metric.type="a/metric/type" key="value" key2="value2" resource.type="another/resource/type"`, + "filter": `key="value" key2="value2" resource.type="another/resource/type" metric.type="a/metric/type"`, } verifyDeepLink(t, dl, expectedTimeSelection, expectedTimeSeriesFilter) }) t.Run("and alignmentPeriod is set to grafana-auto", func(t *testing.T) { t.Run("and IntervalMS is larger than 60000", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].Interval = 1000000 * time.Millisecond req.Queries[0].JSON = json.RawMessage(`{ "alignmentPeriod": "grafana-auto", @@ -135,12 +192,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+1000s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+1000s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -154,7 +211,7 @@ func TestCloudMonitoring(t *testing.T) { verifyDeepLink(t, dl, expectedTimeSelection, expectedTimeSeriesFilter) }) t.Run("and IntervalMS is less than 60000", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].Interval = 30000 * time.Millisecond req.Queries[0].JSON = json.RawMessage(`{ "alignmentPeriod": "grafana-auto", @@ -165,12 +222,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -189,7 +246,7 @@ func TestCloudMonitoring(t *testing.T) { now := time.Now().UTC() t.Run("and range is two hours", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.Add(-(time.Hour * 2)) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -201,12 +258,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) }) t.Run("and range is 22 hours", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.Add(-(time.Hour * 22)) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -218,12 +275,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) }) t.Run("and range is 23 hours", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.Add(-(time.Hour * 23)) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -235,12 +292,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+300s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+300s`, queries[0].params["aggregation.alignmentPeriod"][0]) }) t.Run("and range is 7 days", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now req.Queries[0].TimeRange.To = now.AddDate(0, 0, 7) req.Queries[0].JSON = json.RawMessage(`{ @@ -252,8 +309,8 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+3600s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+3600s`, queries[0].params["aggregation.alignmentPeriod"][0]) }) }) @@ -261,7 +318,7 @@ func TestCloudMonitoring(t *testing.T) { now := time.Now().UTC() t.Run("and range is two hours", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.Add(-(time.Hour * 2)) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -273,12 +330,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -293,7 +350,7 @@ func TestCloudMonitoring(t *testing.T) { }) t.Run("and range is 22 hours", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.Add(-(time.Hour * 22)) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -305,12 +362,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -325,7 +382,7 @@ func TestCloudMonitoring(t *testing.T) { }) t.Run("and range is 23 hours", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.Add(-(time.Hour * 23)) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -337,12 +394,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+300s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+300s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -357,7 +414,7 @@ func TestCloudMonitoring(t *testing.T) { }) t.Run("and range is 7 days", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].TimeRange.From = now.AddDate(0, 0, -7) req.Queries[0].TimeRange.To = now req.Queries[0].JSON = json.RawMessage(`{ @@ -369,12 +426,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+3600s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+3600s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -391,7 +448,7 @@ func TestCloudMonitoring(t *testing.T) { t.Run("and alignmentPeriod is set in frontend", func(t *testing.T) { t.Run("and alignment period is within accepted range", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].Interval = 1000 req.Queries[0].JSON = json.RawMessage(`{ "alignmentPeriod": "+600s" @@ -401,12 +458,12 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `+600s`, queries[0].Params["aggregation.alignmentPeriod"][0]) + queries := getCloudMonitoringListFromInterface(t, qes) + assert.Equal(t, `+600s`, queries[0].params["aggregation.alignmentPeriod"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -422,7 +479,7 @@ func TestCloudMonitoring(t *testing.T) { }) t.Run("and query has aggregation mean set", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_SUM", @@ -433,23 +490,23 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "A", queries[0].RefID) - assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_SUM&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].Target) - assert.Equal(t, 7, len(queries[0].Params)) - assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].Params["interval.startTime"][0]) - assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].Params["interval.endTime"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "ALIGN_MEAN", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].Params["filter"][0]) - assert.Equal(t, "FULL", queries[0].Params["view"][0]) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_SUM&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].params.Encode()) + assert.Equal(t, 7, len(queries[0].params)) + assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].params["interval.startTime"][0]) + assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].params["interval.endTime"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].params["filter"][0]) + assert.Equal(t, "FULL", queries[0].params["view"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -467,7 +524,7 @@ func TestCloudMonitoring(t *testing.T) { }) t.Run("and query has group bys", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_NONE", @@ -479,23 +536,23 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "A", queries[0].RefID) - assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].Target) - assert.Equal(t, 8, len(queries[0].Params)) - assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].Params["interval.startTime"][0]) - assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].Params["interval.endTime"][0]) - assert.Equal(t, "ALIGN_MEAN", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "metric.label.group1", queries[0].Params["aggregation.groupByFields"][0]) - assert.Equal(t, "metric.label.group2", queries[0].Params["aggregation.groupByFields"][1]) - assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].Params["filter"][0]) - assert.Equal(t, "FULL", queries[0].Params["view"][0]) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].params.Encode()) + assert.Equal(t, 8, len(queries[0].params)) + assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].params["interval.startTime"][0]) + assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].params["interval.endTime"][0]) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "metric.label.group1", queries[0].params["aggregation.groupByFields"][0]) + assert.Equal(t, "metric.label.group2", queries[0].params["aggregation.groupByFields"][1]) + assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].params["filter"][0]) + assert.Equal(t, "FULL", queries[0].params["view"][0]) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -536,28 +593,31 @@ func TestCloudMonitoring(t *testing.T) { }, }, } + err := migrateRequest(req) + require.NoError(t, err) + t.Run("and query type is metrics", func(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "A", queries[0].RefID) - assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].Target) - assert.Equal(t, 8, len(queries[0].Params)) - assert.Equal(t, "metric.label.group1", queries[0].Params["aggregation.groupByFields"][0]) - assert.Equal(t, "metric.label.group2", queries[0].Params["aggregation.groupByFields"][1]) - assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].Params["interval.startTime"][0]) - assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].Params["interval.endTime"][0]) - assert.Equal(t, "ALIGN_MEAN", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].Params["filter"][0]) - assert.Equal(t, "FULL", queries[0].Params["view"][0]) - assert.Equal(t, "testalias", queries[0].AliasBy) - assert.Equal(t, []string{"metric.label.group1", "metric.label.group2"}, queries[0].GroupBys) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL", queries[0].params.Encode()) + assert.Equal(t, 8, len(queries[0].params)) + assert.Equal(t, "metric.label.group1", queries[0].params["aggregation.groupByFields"][0]) + assert.Equal(t, "metric.label.group2", queries[0].params["aggregation.groupByFields"][1]) + assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].params["interval.startTime"][0]) + assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].params["interval.endTime"][0]) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "metric.type=\"a/metric/type\"", queries[0].params["filter"][0]) + assert.Equal(t, "FULL", queries[0].params["view"][0]) + assert.Equal(t, "testalias", queries[0].aliasBy) + assert.Equal(t, []string{"metric.label.group1", "metric.label.group2"}, queries[0].parameters.GroupBys) // assign resource type to query parameters to be included in the deep link filter // in the actual workflow this information comes from the response of the Monitoring API - queries[0].Params.Set("resourceType", "a/resource/type") + queries[0].params.Set("resourceType", "a/resource/type") dl := queries[0].buildDeepLink() expectedTimeSelection := map[string]string{ @@ -583,21 +643,18 @@ func TestCloudMonitoring(t *testing.T) { }, "sloQuery": {} }`) + err = migrateRequest(req) + require.NoError(t, err) qes, err = service.buildQueryExecutors(slog, req) require.NoError(t, err) - tqueries := make([]*cloudMonitoringTimeSeriesQuery, 0) - for _, qi := range qes { - q, ok := qi.(*cloudMonitoringTimeSeriesQuery) - assert.True(t, ok) - tqueries = append(tqueries, q) - } + tqueries := getCloudMonitoringQueryFromInterface(t, qes) assert.Equal(t, 1, len(tqueries)) - assert.Equal(t, "A", tqueries[0].RefID) - assert.Equal(t, "test-proj", tqueries[0].ProjectName) - assert.Equal(t, "test-query", tqueries[0].Query) - assert.Equal(t, "test-alias", tqueries[0].AliasBy) + assert.Equal(t, "A", tqueries[0].refID) + assert.Equal(t, "test-proj", tqueries[0].parameters.ProjectName) + assert.Equal(t, "test-query", tqueries[0].parameters.Query) + assert.Equal(t, "test-alias", tqueries[0].aliasBy) }) t.Run("and query type is SLOs", func(t *testing.T) { @@ -614,20 +671,22 @@ func TestCloudMonitoring(t *testing.T) { }, "metricQuery": {} }`) + err := migrateRequest(req) + require.NoError(t, err) qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringSLOFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "A", queries[0].RefID) - assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].Params["interval.startTime"][0]) - assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].Params["interval.endTime"][0]) - assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "", queries[0].AliasBy) - assert.Equal(t, "ALIGN_MEAN", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_MEAN&filter=select_slo_health%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`, queries[0].Target) - assert.Equal(t, 5, len(queries[0].Params)) + assert.Equal(t, "A", queries[0].refID) + assert.Equal(t, "2018-03-15T13:00:00Z", queries[0].params["interval.startTime"][0]) + assert.Equal(t, "2018-03-15T13:34:00Z", queries[0].params["interval.endTime"][0]) + assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "", queries[0].aliasBy) + assert.Equal(t, "ALIGN_MEAN", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_MEAN&filter=select_slo_health%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`, queries[0].params.Encode()) + assert.Equal(t, 5, len(queries[0].params)) req.Queries[0].JSON = json.RawMessage(`{ "queryType": "slo", @@ -642,11 +701,13 @@ func TestCloudMonitoring(t *testing.T) { }, "metricQuery": {} }`) + err = migrateRequest(req) + require.NoError(t, err) qes, err = service.buildQueryExecutors(slog, req) require.NoError(t, err) - qqueries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, "ALIGN_NEXT_OLDER", qqueries[0].Params["aggregation.perSeriesAligner"][0]) + qqueries := getCloudMonitoringSLOFromInterface(t, qes) + assert.Equal(t, "ALIGN_NEXT_OLDER", qqueries[0].params["aggregation.perSeriesAligner"][0]) dl := qqueries[0].buildDeepLink() assert.Empty(t, dl) @@ -665,11 +726,13 @@ func TestCloudMonitoring(t *testing.T) { }, "metricQuery": {} }`) + err = migrateRequest(req) + require.NoError(t, err) qes, err = service.buildQueryExecutors(slog, req) require.NoError(t, err) - qqqueries := getCloudMonitoringQueriesFromInterface(t, qes) - assert.Equal(t, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_NEXT_OLDER&filter=select_slo_burn_rate%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%2C+%221h%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`, qqqueries[0].Target) + qqqueries := getCloudMonitoringSLOFromInterface(t, qes) + assert.Equal(t, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_NEXT_OLDER&filter=select_slo_burn_rate%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%2C+%221h%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`, qqqueries[0].params.Encode()) }) }) @@ -735,33 +798,8 @@ func TestCloudMonitoring(t *testing.T) { }) }) - t.Run("when building filter string", func(t *testing.T) { - t.Run("and there's no regex operator", func(t *testing.T) { - t.Run("and there are wildcards in a filter value", func(t *testing.T) { - filterParts := []string{"zone", "=", "*-central1*"} - value := buildFilterString("somemetrictype", filterParts) - assert.Equal(t, `metric.type="somemetrictype" zone=has_substring("-central1")`, value) - }) - - t.Run("and there are no wildcards in any filter value", func(t *testing.T) { - filterParts := []string{"zone", "!=", "us-central1-a"} - value := buildFilterString("somemetrictype", filterParts) - assert.Equal(t, `metric.type="somemetrictype" zone!="us-central1-a"`, value) - }) - }) - - t.Run("and there is a regex operator", func(t *testing.T) { - filterParts := []string{"zone", "=~", "us-central1-a~"} - value := buildFilterString("somemetrictype", filterParts) - assert.NotContains(t, value, `=~`) - assert.Contains(t, value, `zone=`) - - assert.Contains(t, value, `zone=monitoring.regex.full_match("us-central1-a~")`) - }) - }) - t.Run("and query preprocessor is not defined", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_MIN", @@ -775,22 +813,22 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "labelname", queries[0].Params["aggregation.groupByFields"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "labelname", queries[0].params["aggregation.groupByFields"][0]) - assert.NotContains(t, queries[0].Params, "secondaryAggregation.crossSeriesReducer") - assert.NotContains(t, "REDUCE_SUM", queries[0].Params, "secondaryAggregation.perSeriesAligner") - assert.NotContains(t, "+60s", queries[0].Params, "secondaryAggregation.alignmentPeriod") - assert.NotContains(t, "labelname", queries[0].Params, "secondaryAggregation.groupByFields") + assert.NotContains(t, queries[0].params, "secondaryAggregation.crossSeriesReducer") + assert.NotContains(t, "REDUCE_SUM", queries[0].params, "secondaryAggregation.perSeriesAligner") + assert.NotContains(t, "+60s", queries[0].params, "secondaryAggregation.alignmentPeriod") + assert.NotContains(t, "labelname", queries[0].params, "secondaryAggregation.groupByFields") }) t.Run("and query preprocessor is set to none", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_MIN", @@ -805,22 +843,22 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "labelname", queries[0].Params["aggregation.groupByFields"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "labelname", queries[0].params["aggregation.groupByFields"][0]) - assert.NotContains(t, queries[0].Params, "secondaryAggregation.crossSeriesReducer") - assert.NotContains(t, "REDUCE_SUM", queries[0].Params, "secondaryAggregation.perSeriesAligner") - assert.NotContains(t, "+60s", queries[0].Params, "secondaryAggregation.alignmentPeriod") - assert.NotContains(t, "labelname", queries[0].Params, "secondaryAggregation.groupByFields") + assert.NotContains(t, queries[0].params, "secondaryAggregation.crossSeriesReducer") + assert.NotContains(t, "REDUCE_SUM", queries[0].params, "secondaryAggregation.perSeriesAligner") + assert.NotContains(t, "+60s", queries[0].params, "secondaryAggregation.alignmentPeriod") + assert.NotContains(t, "labelname", queries[0].params, "secondaryAggregation.groupByFields") }) t.Run("and query preprocessor is set to rate and there's no group bys", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_SUM", @@ -835,20 +873,20 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "REDUCE_NONE", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "ALIGN_RATE", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "REDUCE_NONE", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "ALIGN_RATE", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["secondaryAggregation.crossSeriesReducer"][0]) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["secondaryAggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["secondaryAggregation.alignmentPeriod"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["secondaryAggregation.crossSeriesReducer"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["secondaryAggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["secondaryAggregation.alignmentPeriod"][0]) }) t.Run("and query preprocessor is set to rate and group bys exist", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_SUM", @@ -863,22 +901,22 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "ALIGN_RATE", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "labelname", queries[0].Params["aggregation.groupByFields"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "ALIGN_RATE", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "labelname", queries[0].params["aggregation.groupByFields"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["secondaryAggregation.crossSeriesReducer"][0]) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["secondaryAggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["secondaryAggregation.alignmentPeriod"][0]) - assert.Equal(t, "labelname", queries[0].Params["secondaryAggregation.groupByFields"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["secondaryAggregation.crossSeriesReducer"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["secondaryAggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["secondaryAggregation.alignmentPeriod"][0]) + assert.Equal(t, "labelname", queries[0].params["secondaryAggregation.groupByFields"][0]) }) t.Run("and query preprocessor is set to delta and there's no group bys", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_MIN", @@ -893,20 +931,20 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "REDUCE_NONE", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "ALIGN_DELTA", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "REDUCE_NONE", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "ALIGN_DELTA", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["secondaryAggregation.crossSeriesReducer"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["secondaryAggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["secondaryAggregation.alignmentPeriod"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["secondaryAggregation.crossSeriesReducer"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["secondaryAggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["secondaryAggregation.alignmentPeriod"][0]) }) t.Run("and query preprocessor is set to delta and group bys exist", func(t *testing.T) { - req := baseReq() + req := deprecatedReq() req.Queries[0].JSON = json.RawMessage(`{ "metricType": "a/metric/type", "crossSeriesReducer": "REDUCE_MIN", @@ -921,27 +959,51 @@ func TestCloudMonitoring(t *testing.T) { qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) - queries := getCloudMonitoringQueriesFromInterface(t, qes) + queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["aggregation.crossSeriesReducer"][0]) - assert.Equal(t, "ALIGN_DELTA", queries[0].Params["aggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["aggregation.alignmentPeriod"][0]) - assert.Equal(t, "labelname", queries[0].Params["aggregation.groupByFields"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["aggregation.crossSeriesReducer"][0]) + assert.Equal(t, "ALIGN_DELTA", queries[0].params["aggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["aggregation.alignmentPeriod"][0]) + assert.Equal(t, "labelname", queries[0].params["aggregation.groupByFields"][0]) - assert.Equal(t, "REDUCE_MIN", queries[0].Params["secondaryAggregation.crossSeriesReducer"][0]) - assert.Equal(t, "REDUCE_SUM", queries[0].Params["secondaryAggregation.perSeriesAligner"][0]) - assert.Equal(t, "+60s", queries[0].Params["secondaryAggregation.alignmentPeriod"][0]) - assert.Equal(t, "labelname", queries[0].Params["secondaryAggregation.groupByFields"][0]) + assert.Equal(t, "REDUCE_MIN", queries[0].params["secondaryAggregation.crossSeriesReducer"][0]) + assert.Equal(t, "REDUCE_SUM", queries[0].params["secondaryAggregation.perSeriesAligner"][0]) + assert.Equal(t, "+60s", queries[0].params["secondaryAggregation.alignmentPeriod"][0]) + assert.Equal(t, "labelname", queries[0].params["secondaryAggregation.groupByFields"][0]) }) } -func getCloudMonitoringQueriesFromInterface(t *testing.T, qes []cloudMonitoringQueryExecutor) []*cloudMonitoringTimeSeriesFilter { +func getCloudMonitoringListFromInterface(t *testing.T, qes []cloudMonitoringQueryExecutor) []*cloudMonitoringTimeSeriesList { t.Helper() - queries := make([]*cloudMonitoringTimeSeriesFilter, 0) + queries := make([]*cloudMonitoringTimeSeriesList, 0) for _, qi := range qes { - q, ok := qi.(*cloudMonitoringTimeSeriesFilter) + q, ok := qi.(*cloudMonitoringTimeSeriesList) + require.Truef(t, ok, "Received wrong type %T", qi) + queries = append(queries, q) + } + return queries +} + +func getCloudMonitoringSLOFromInterface(t *testing.T, qes []cloudMonitoringQueryExecutor) []*cloudMonitoringSLO { + t.Helper() + + queries := make([]*cloudMonitoringSLO, 0) + for _, qi := range qes { + q, ok := qi.(*cloudMonitoringSLO) + require.Truef(t, ok, "Received wrong type %T", qi) + queries = append(queries, q) + } + return queries +} + +func getCloudMonitoringQueryFromInterface(t *testing.T, qes []cloudMonitoringQueryExecutor) []*cloudMonitoringTimeSeriesQuery { + t.Helper() + + queries := make([]*cloudMonitoringTimeSeriesQuery, 0) + for _, qi := range qes { + q, ok := qi.(*cloudMonitoringTimeSeriesQuery) require.Truef(t, ok, "Received wrong type %T", qi) queries = append(queries, q) } @@ -1009,7 +1071,7 @@ func verifyDeepLink(t *testing.T, dl string, expectedTimeSelection map[string]st } } -func baseReq() *backend.QueryDataRequest { +func deprecatedReq() *backend.QueryDataRequest { fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) query := &backend.QueryDataRequest{ Queries: []backend.DataQuery{ @@ -1031,6 +1093,54 @@ func baseReq() *backend.QueryDataRequest { return query } +func baseTimeSeriesList() *backend.QueryDataRequest { + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(34 * time.Minute), + }, + QueryType: "metrics", + JSON: json.RawMessage(`{ + "timeSeriesList": { + "filters": ["metric.type=\"a/metric/type\""], + "view": "FULL" + }, + "aliasBy": "testalias" + }`), + }, + }, + } + return query +} + +func baseTimeSeriesQuery() *backend.QueryDataRequest { + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(34 * time.Minute), + }, + QueryType: "metrics", + JSON: json.RawMessage(`{ + "queryType": "metrics", + "timeSeriesQuery": { + "query": "foo" + }, + "aliasBy": "testalias" + }`), + }, + }, + } + return query +} + func TestCheckHealth(t *testing.T) { t.Run("and using GCE authentation should return proper error", func(t *testing.T) { im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { diff --git a/pkg/tsdb/cloudmonitoring/slo_query.go b/pkg/tsdb/cloudmonitoring/slo_query.go new file mode 100644 index 00000000000..a989449e6a1 --- /dev/null +++ b/pkg/tsdb/cloudmonitoring/slo_query.go @@ -0,0 +1,75 @@ +package cloudmonitoring + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/grafana/pkg/infra/tracing" +) + +func (sloQ *cloudMonitoringSLO) run(ctx context.Context, req *backend.QueryDataRequest, + s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, cloudMonitoringResponse, string, error) { + return runTimeSeriesRequest(ctx, sloQ.logger, req, s, dsInfo, tracer, sloQ.parameters.ProjectName, sloQ.params, nil) +} + +func (sloQ *cloudMonitoringSLO) parseResponse(queryRes *backend.DataResponse, + response cloudMonitoringResponse, executedQueryString string) error { + return parseTimeSeriesResponse(queryRes, response, executedQueryString, sloQ, sloQ.params, []string{}) +} + +func (sloQ *cloudMonitoringSLO) buildDeepLink() string { + return "" +} + +func (sloQ *cloudMonitoringSLO) getRefID() string { + return sloQ.refID +} + +func (sloQ *cloudMonitoringSLO) getAliasBy() string { + return sloQ.aliasBy +} + +func (sloQ *cloudMonitoringSLO) getParameter(i string) string { + switch i { + case "project": + return sloQ.parameters.ProjectName + case "service": + return sloQ.parameters.ServiceId + case "slo": + return sloQ.parameters.SloId + case "selector": + return sloQ.parameters.SelectorName + default: + return "" + } +} + +func (sloQ *cloudMonitoringSLO) getFilter() string { + sloName := fmt.Sprintf("projects/%s/services/%s/serviceLevelObjectives/%s", sloQ.parameters.ProjectName, sloQ.parameters.ServiceId, sloQ.parameters.SloId) + + if sloQ.parameters.SelectorName == "select_slo_burn_rate" { + return fmt.Sprintf(`%s("%s", "%s")`, sloQ.parameters.SelectorName, sloName, sloQ.parameters.LookbackPeriod) + } else { + return fmt.Sprintf(`%s("%s")`, sloQ.parameters.SelectorName, sloName) + } +} + +func (sloQ *cloudMonitoringSLO) setParams(startTime time.Time, endTime time.Time, durationSeconds int, intervalMs int64) { + params := url.Values{} + + params.Add("interval.startTime", startTime.UTC().Format(time.RFC3339)) + params.Add("interval.endTime", endTime.UTC().Format(time.RFC3339)) + + params.Add("filter", sloQ.getFilter()) + params.Add("aggregation.alignmentPeriod", calculateAlignmentPeriod(sloQ.parameters.AlignmentPeriod, intervalMs, durationSeconds)) + if sloQ.parameters.SelectorName == "select_slo_health" { + params.Add("aggregation.perSeriesAligner", "ALIGN_MEAN") + } else { + params.Add("aggregation.perSeriesAligner", "ALIGN_NEXT_OLDER") + } + sloQ.params = params +} diff --git a/pkg/tsdb/cloudmonitoring/slo_query_test.go b/pkg/tsdb/cloudmonitoring/slo_query_test.go new file mode 100644 index 00000000000..907b0075b29 --- /dev/null +++ b/pkg/tsdb/cloudmonitoring/slo_query_test.go @@ -0,0 +1,75 @@ +package cloudmonitoring + +import ( + "net/url" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func SLOQuery(t *testing.T) { + t.Run("when data from query returns slo and alias by is defined", func(t *testing.T) { + data, err := loadTestFile("./test-data/6-series-response-slo.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + t.Run("and alias by is expanded", func(t *testing.T) { + res := &backend.DataResponse{} + query := &cloudMonitoringSLO{ + params: url.Values{}, + parameters: &sloQuery{ + ProjectName: "test-proj", + SelectorName: "select_slo_compliance", + ServiceId: "test-service", + SloId: "test-slo", + }, + aliasBy: "{{project}} - {{service}} - {{slo}} - {{selector}}", + } + err = query.parseResponse(res, data, "") + require.NoError(t, err) + frames := res.Frames + require.NoError(t, err) + assert.Equal(t, "test-proj - test-service - test-slo - select_slo_compliance", frames[0].Fields[1].Name) + }) + }) + + t.Run("when data from query returns slo and alias by is not defined", func(t *testing.T) { + data, err := loadTestFile("./test-data/6-series-response-slo.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + t.Run("and alias by is expanded", func(t *testing.T) { + res := &backend.DataResponse{} + query := &cloudMonitoringSLO{ + params: url.Values{}, + parameters: &sloQuery{ + ProjectName: "test-proj", + SelectorName: "select_slo_compliance", + ServiceId: "test-service", + SloId: "test-slo", + }, + } + err = query.parseResponse(res, data, "") + require.NoError(t, err) + frames := res.Frames + require.NoError(t, err) + assert.Equal(t, "select_slo_compliance(\"projects/test-proj/services/test-service/serviceLevelObjectives/test-slo\")", frames[0].Fields[1].Name) + }) + }) + + t.Run("when data comes from a slo query, it should skip the link", func(t *testing.T) { + data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + query := &cloudMonitoringSLO{params: url.Values{}, parameters: &sloQuery{SloId: "yes"}} + err = query.parseResponse(res, data, "") + require.NoError(t, err) + frames := res.Frames + assert.Equal(t, len(frames[0].Fields[1].Config.Links), 0) + }) +} diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 011d9b1a3f4..2e1ac9813e4 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -4,104 +4,25 @@ import ( "context" "encoding/json" "fmt" - "net/http" "net/url" - "path" "strconv" "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "go.opentelemetry.io/otel/attribute" + "github.com/huandu/xstrings" "github.com/grafana/grafana/pkg/infra/tracing" ) -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) doRequestFilterPage(ctx context.Context, r *http.Request, dsInfo datasourceInfo) (cloudMonitoringResponse, error) { - r.URL.RawQuery = timeSeriesFilter.Params.Encode() - r = r.WithContext(ctx) - res, err := dsInfo.services[cloudMonitor].client.Do(r) - if err != nil { - return cloudMonitoringResponse{}, err - } - - dnext, err := unmarshalResponse(timeSeriesFilter.logger, res) - if err != nil { - return cloudMonitoringResponse{}, err - } - - return dnext, nil -} - -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) run(ctx context.Context, req *backend.QueryDataRequest, +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) run(ctx context.Context, req *backend.QueryDataRequest, s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, cloudMonitoringResponse, string, error) { - dr := &backend.DataResponse{} - projectName := timeSeriesFilter.ProjectName - if projectName == "" { - var err error - projectName, err = s.getDefaultProject(ctx, dsInfo) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - timeSeriesFilter.logger.Info("No project name set on query, using project name from datasource", "projectName", projectName) - } - r, err := s.createRequest(timeSeriesFilter.logger, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries"), nil) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - alignmentPeriod, ok := r.URL.Query()["aggregation.alignmentPeriod"] - if ok { - seconds, err := strconv.ParseInt(alignmentPeriodRe.FindString(alignmentPeriod[0]), 10, 64) - if err == nil { - if len(dr.Frames) == 0 { - dr.Frames = append(dr.Frames, data.NewFrame("")) - } - firstFrame := dr.Frames[0] - if firstFrame.Meta == nil { - firstFrame.SetMeta(&data.FrameMeta{ - Custom: map[string]interface{}{ - "alignmentPeriod": seconds, - }, - }) - } - } - } - - ctx, span := tracer.Start(ctx, "cloudMonitoring query") - span.SetAttributes("target", timeSeriesFilter.Target, attribute.Key("target").String(timeSeriesFilter.Target)) - span.SetAttributes("from", req.Queries[0].TimeRange.From, attribute.Key("from").String(req.Queries[0].TimeRange.From.String())) - span.SetAttributes("until", req.Queries[0].TimeRange.To, attribute.Key("until").String(req.Queries[0].TimeRange.To.String())) - span.SetAttributes("datasource_id", dsInfo.id, attribute.Key("datasource_id").Int64(dsInfo.id)) - span.SetAttributes("org_id", req.PluginContext.OrgID, attribute.Key("org_id").Int64(req.PluginContext.OrgID)) - defer span.End() - tracer.Inject(ctx, r.Header, span) - - d, err := timeSeriesFilter.doRequestFilterPage(ctx, r, dsInfo) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - nextPageToken := d.NextPageToken - for nextPageToken != "" { - timeSeriesFilter.Params["pageToken"] = []string{d.NextPageToken} - nextPage, err := timeSeriesFilter.doRequestFilterPage(ctx, r, dsInfo) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - d.TimeSeries = append(d.TimeSeries, nextPage.TimeSeries...) - nextPageToken = nextPage.NextPageToken - } - - return dr, d, r.URL.RawQuery, nil + return runTimeSeriesRequest(ctx, timeSeriesFilter.logger, req, s, dsInfo, tracer, timeSeriesFilter.parameters.ProjectName, timeSeriesFilter.params, nil) } -//nolint:gocyclo -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes *backend.DataResponse, - response cloudMonitoringResponse, executedQueryString string) error { +func parseTimeSeriesResponse(queryRes *backend.DataResponse, + response cloudMonitoringResponse, executedQueryString string, query cloudMonitoringQueryExecutor, params url.Values, groupBys []string) error { frames := data.Frames{} for _, series := range response.TimeSeries { @@ -110,9 +31,13 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes labels := make(map[string]string) labels["resource.type"] = series.Resource.Type seriesLabels["resource.type"] = series.Resource.Type + groupBysMap := make(map[string]bool) + for _, groupBy := range groupBys { + groupBysMap[groupBy] = true + } frame := data.NewFrameOfFieldTypes("", len(series.Points), data.FieldTypeTime, data.FieldTypeFloat64) - frame.RefID = timeSeriesFilter.RefID + frame.RefID = query.getRefID() frame.Meta = &data.FrameMeta{ ExecutedQueryString: executedQueryString, } @@ -121,7 +46,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes labels["metric.label."+key] = value seriesLabels["metric.label."+key] = value - if len(timeSeriesFilter.GroupBys) == 0 || containsLabel(timeSeriesFilter.GroupBys, "metric.label."+key) { + if len(groupBys) == 0 || groupBysMap["metric.label."+key] { defaultMetricName += " " + value } } @@ -130,14 +55,14 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes labels["resource.label."+key] = value seriesLabels["resource.label."+key] = value - if containsLabel(timeSeriesFilter.GroupBys, "resource.label."+key) { + if groupBysMap["resource.label."+key] { defaultMetricName += " " + value } } for labelType, labelTypeValues := range series.MetaData { for labelKey, labelValue := range labelTypeValues { - key := toSnakeCase(fmt.Sprintf("metadata.%s.%s", labelType, labelKey)) + key := xstrings.ToSnakeCase(fmt.Sprintf("metadata.%s.%s", labelType, labelKey)) switch v := labelValue.(type) { case string: @@ -161,10 +86,10 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes } customFrameMeta := map[string]interface{}{} - customFrameMeta["alignmentPeriod"] = timeSeriesFilter.Params.Get("aggregation.alignmentPeriod") - customFrameMeta["perSeriesAligner"] = timeSeriesFilter.Params.Get("aggregation.perSeriesAligner") + customFrameMeta["alignmentPeriod"] = params.Get("aggregation.alignmentPeriod") + customFrameMeta["perSeriesAligner"] = params.Get("aggregation.perSeriesAligner") customFrameMeta["labels"] = labels - customFrameMeta["groupBys"] = timeSeriesFilter.GroupBys + customFrameMeta["groupBys"] = groupBys if frame.Meta != nil { frame.Meta.Custom = customFrameMeta } else { @@ -173,7 +98,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes // reverse the order to be ascending if series.ValueType != "DISTRIBUTION" { - timeSeriesFilter.handleNonDistributionSeries(series, defaultMetricName, seriesLabels, frame) + handleNonDistributionSeries(series, defaultMetricName, seriesLabels, frame, query) frames = append(frames, frame) continue } @@ -197,7 +122,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []time.Time{}) valueField := data.NewField(data.TimeSeriesValueFieldName, nil, []float64{}) - frameName := formatLegendKeys(series.Metric.Type, defaultMetricName, nil, additionalLabels, timeSeriesFilter) + frameName := formatLegendKeys(series.Metric.Type, defaultMetricName, nil, additionalLabels, query) valueField.Name = frameName valueField.Labels = seriesLabels setDisplayNameAsFieldName(valueField) @@ -208,7 +133,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes timeField, valueField, }, - RefID: timeSeriesFilter.RefID, + RefID: query.getRefID(), Meta: &data.FrameMeta{ ExecutedQueryString: executedQueryString, }, @@ -226,8 +151,8 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes } } if len(response.TimeSeries) > 0 { - dl := timeSeriesFilter.buildDeepLink() - frames = addConfigData(frames, dl, response.Unit, timeSeriesFilter.Params.Get("aggregation.alignmentPeriod")) + dl := query.buildDeepLink() + frames = addConfigData(frames, dl, response.Unit, params.Get("aggregation.alignmentPeriod")) } queryRes.Frames = frames @@ -235,8 +160,13 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes return nil } -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) handleNonDistributionSeries(series timeSeries, - defaultMetricName string, seriesLabels map[string]string, frame *data.Frame) { +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) parseResponse(queryRes *backend.DataResponse, + response cloudMonitoringResponse, executedQueryString string) error { + return parseTimeSeriesResponse(queryRes, response, executedQueryString, timeSeriesFilter, timeSeriesFilter.params, timeSeriesFilter.parameters.GroupBys) +} + +func handleNonDistributionSeries(series timeSeries, + defaultMetricName string, seriesLabels map[string]string, frame *data.Frame, query cloudMonitoringQueryExecutor) { for i := 0; i < len(series.Points); i++ { point := series.Points[i] value := point.Value.DoubleValue @@ -258,21 +188,17 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) handleNonDistributionSe frame.SetRow(len(series.Points)-1-i, point.Interval.EndTime, value) } - metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, seriesLabels, nil, timeSeriesFilter) + metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, seriesLabels, nil, query) dataField := frame.Fields[1] dataField.Name = metricName dataField.Labels = seriesLabels setDisplayNameAsFieldName(dataField) } -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) buildDeepLink() string { - if timeSeriesFilter.Slo != "" { - return "" - } - - filter := timeSeriesFilter.Params.Get("filter") +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) buildDeepLink() string { + filter := timeSeriesFilter.params.Get("filter") if !strings.Contains(filter, "resource.type=") { - resourceType := timeSeriesFilter.Params.Get("resourceType") + resourceType := timeSeriesFilter.params.Get("resourceType") if resourceType != "" { filter = fmt.Sprintf(`resource.type="%s" %s`, resourceType, filter) } @@ -281,12 +207,12 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) buildDeepLink() string u, err := url.Parse("https://console.cloud.google.com/monitoring/metrics-explorer") if err != nil { slog.Error("Failed to generate deep link: unable to parse metrics explorer URL", "ProjectName", - timeSeriesFilter.ProjectName, "query", timeSeriesFilter.RefID) + timeSeriesFilter.parameters.ProjectName, "query", timeSeriesFilter.refID) return "" } rawQuery := u.Query() - rawQuery.Set("project", timeSeriesFilter.ProjectName) + rawQuery.Set("project", timeSeriesFilter.parameters.ProjectName) rawQuery.Set("Grafana_deeplink", "true") pageState := map[string]interface{}{ @@ -296,11 +222,11 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) buildDeepLink() string { "timeSeriesFilter": map[string]interface{}{ "aggregations": []string{}, - "crossSeriesReducer": timeSeriesFilter.Params.Get("aggregation.crossSeriesReducer"), + "crossSeriesReducer": timeSeriesFilter.params.Get("aggregation.crossSeriesReducer"), "filter": filter, - "groupByFields": timeSeriesFilter.Params["aggregation.groupByFields"], - "minAlignmentPeriod": strings.TrimPrefix(timeSeriesFilter.Params.Get("aggregation.alignmentPeriod"), "+"), // get rid of leading + - "perSeriesAligner": timeSeriesFilter.Params.Get("aggregation.perSeriesAligner"), + "groupByFields": timeSeriesFilter.params["aggregation.groupByFields"], + "minAlignmentPeriod": strings.TrimPrefix(timeSeriesFilter.params.Get("aggregation.alignmentPeriod"), "+"), // get rid of leading + + "perSeriesAligner": timeSeriesFilter.params.Get("aggregation.perSeriesAligner"), "secondaryGroupByFields": []string{}, "unitOverride": "1", }, @@ -314,15 +240,15 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) buildDeepLink() string }, "timeSelection": map[string]string{ "timeRange": "custom", - "start": timeSeriesFilter.Params.Get("interval.startTime"), - "end": timeSeriesFilter.Params.Get("interval.endTime"), + "start": timeSeriesFilter.params.Get("interval.startTime"), + "end": timeSeriesFilter.params.Get("interval.endTime"), }, } blob, err := json.Marshal(pageState) if err != nil { - slog.Error("Failed to generate deep link", "pageState", pageState, "ProjectName", timeSeriesFilter.ProjectName, - "query", timeSeriesFilter.RefID) + slog.Error("Failed to generate deep link", "pageState", pageState, "ProjectName", timeSeriesFilter.parameters.ProjectName, + "query", timeSeriesFilter.refID) return "" } @@ -332,7 +258,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) buildDeepLink() string accountChooserURL, err := url.Parse("https://accounts.google.com/AccountChooser") if err != nil { slog.Error("Failed to generate deep link: unable to parse account chooser URL", "ProjectName", - timeSeriesFilter.ProjectName, "query", timeSeriesFilter.RefID) + timeSeriesFilter.parameters.ProjectName, "query", timeSeriesFilter.refID) return "" } accountChooserQuery := accountChooserURL.Query() @@ -349,6 +275,92 @@ func setDisplayNameAsFieldName(f *data.Field) { f.Config.DisplayNameFromDS = f.Name } -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) getRefID() string { - return timeSeriesFilter.RefID +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) getRefID() string { + return timeSeriesFilter.refID +} + +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) getAliasBy() string { + return timeSeriesFilter.aliasBy +} + +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) getParameter(i string) string { + switch i { + case "project": + return timeSeriesFilter.parameters.ProjectName + default: + return "" + } +} + +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) getFilter() string { + filterString := "" + for i, part := range timeSeriesFilter.parameters.Filters { + mod := i % 4 + switch { + case part == "AND": + filterString += " " + case mod == 2: + operator := timeSeriesFilter.parameters.Filters[i-1] + switch { + case operator == "=~" || operator == "!=~": + filterString = xstrings.Reverse(strings.Replace(xstrings.Reverse(filterString), "~", "", 1)) + filterString += fmt.Sprintf(`monitoring.regex.full_match("%s")`, part) + case strings.Contains(part, "*"): + filterString += interpolateFilterWildcards(part) + default: + filterString += fmt.Sprintf(`"%s"`, part) + } + default: + filterString += part + } + } + + return strings.Trim(filterString, " ") +} + +func (timeSeriesFilter *cloudMonitoringTimeSeriesList) setParams(startTime time.Time, endTime time.Time, durationSeconds int, intervalMs int64) { + params := url.Values{} + query := timeSeriesFilter.parameters + + params.Add("interval.startTime", startTime.UTC().Format(time.RFC3339)) + params.Add("interval.endTime", endTime.UTC().Format(time.RFC3339)) + + params.Add("filter", timeSeriesFilter.getFilter()) + params.Add("view", query.View) + + if query.CrossSeriesReducer == "" { + query.CrossSeriesReducer = crossSeriesReducerDefault + } + + if query.PerSeriesAligner == "" { + query.PerSeriesAligner = perSeriesAlignerDefault + } + + alignmentPeriod := calculateAlignmentPeriod(query.AlignmentPeriod, intervalMs, durationSeconds) + params.Add("aggregation.alignmentPeriod", alignmentPeriod) + if query.CrossSeriesReducer != "" { + params.Add("aggregation.crossSeriesReducer", query.CrossSeriesReducer) + } + if query.PerSeriesAligner != "" { + params.Add("aggregation.perSeriesAligner", query.PerSeriesAligner) + } + for _, groupBy := range query.GroupBys { + params.Add("aggregation.groupByFields", groupBy) + } + + if query.SecondaryAlignmentPeriod != "" { + secondaryAlignmentPeriod := calculateAlignmentPeriod(query.AlignmentPeriod, intervalMs, durationSeconds) + params.Add("secondaryAggregation.alignmentPeriod", secondaryAlignmentPeriod) + } + if query.SecondaryCrossSeriesReducer != "" { + params.Add("secondaryAggregation.crossSeriesReducer", query.SecondaryCrossSeriesReducer) + } + if query.SecondaryPerSeriesAligner != "" { + params.Add("secondaryAggregation.perSeriesAligner", query.SecondaryPerSeriesAligner) + } + for _, groupBy := range query.SecondaryGroupBys { + params.Add("secondaryAggregation.groupByFields", groupBy) + } + + timeSeriesFilter.params = params } diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go index 20aa99c02c9..ef379a7d970 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go @@ -24,7 +24,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}} + query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &timeSeriesList{}} err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -47,7 +47,7 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}} + query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &timeSeriesList{}} err = query.parseResponse(res, data, "") require.NoError(t, err) @@ -87,9 +87,9 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, GroupBys: []string{ + query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &timeSeriesList{GroupBys: []string{ "metric.label.instance_name", "resource.label.zone", - }} + }}} err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -108,7 +108,13 @@ func TestTimeSeriesFilter(t *testing.T) { res := &backend.DataResponse{} t.Run("and the alias pattern is for metric type, a metric label and a resource label", func(t *testing.T) { - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{ + GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}, + }, + aliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -121,7 +127,11 @@ func TestTimeSeriesFilter(t *testing.T) { }) t.Run("and the alias pattern is for metric name", func(t *testing.T) { - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "metric {{metric.name}} service {{metric.service}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}, + aliasBy: "metric {{metric.name}} service {{metric.service}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -139,7 +149,11 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "{{bucket}}"} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{}, + aliasBy: "{{bucket}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -180,7 +194,11 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "{{bucket}}"} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{}, + aliasBy: "{{bucket}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -214,7 +232,11 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "{{bucket}}"} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{}, + aliasBy: "{{bucket}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) require.NoError(t, err) @@ -250,7 +272,11 @@ func TestTimeSeriesFilter(t *testing.T) { t.Run("and systemlabel contains key with array of string", func(t *testing.T) { res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "{{metadata.system_labels.test}}"} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{}, + aliasBy: "{{metadata.system_labels.test}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -264,7 +290,11 @@ func TestTimeSeriesFilter(t *testing.T) { t.Run("and systemlabel contains key with array of string2", func(t *testing.T) { res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, AliasBy: "{{metadata.system_labels.test2}}"} + query := &cloudMonitoringTimeSeriesList{ + params: url.Values{}, + parameters: &timeSeriesList{}, + aliasBy: "{{metadata.system_labels.test2}}", + } err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -274,58 +304,13 @@ func TestTimeSeriesFilter(t *testing.T) { }) }) - t.Run("when data from query returns slo and alias by is defined", func(t *testing.T) { - data, err := loadTestFile("./test-data/6-series-response-slo.json") - require.NoError(t, err) - assert.Equal(t, 1, len(data.TimeSeries)) - - t.Run("and alias by is expanded", func(t *testing.T) { - res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{ - Params: url.Values{}, - ProjectName: "test-proj", - Selector: "select_slo_compliance", - Service: "test-service", - Slo: "test-slo", - AliasBy: "{{project}} - {{service}} - {{slo}} - {{selector}}", - } - err = query.parseResponse(res, data, "") - require.NoError(t, err) - frames := res.Frames - require.NoError(t, err) - assert.Equal(t, "test-proj - test-service - test-slo - select_slo_compliance", frames[0].Fields[1].Name) - }) - }) - - t.Run("when data from query returns slo and alias by is not defined", func(t *testing.T) { - data, err := loadTestFile("./test-data/6-series-response-slo.json") - require.NoError(t, err) - assert.Equal(t, 1, len(data.TimeSeries)) - - t.Run("and alias by is expanded", func(t *testing.T) { - res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{ - Params: url.Values{}, - ProjectName: "test-proj", - Selector: "select_slo_compliance", - Service: "test-service", - Slo: "test-slo", - } - err = query.parseResponse(res, data, "") - require.NoError(t, err) - frames := res.Frames - require.NoError(t, err) - assert.Equal(t, "select_slo_compliance(\"projects/test-proj/services/test-service/serviceLevelObjectives/test-slo\")", frames[0].Fields[1].Name) - }) - }) - t.Run("Parse cloud monitoring unit", func(t *testing.T) { t.Run("when mapping is found a unit should be specified on the field config", func(t *testing.T) { data, err := loadTestFile("./test-data/1-series-response-agg-one-metric.json") require.NoError(t, err) assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}} + query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &timeSeriesList{}} err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -338,7 +323,7 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}} + query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &timeSeriesList{}} err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -358,9 +343,11 @@ func TestTimeSeriesFilter(t *testing.T) { res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesQuery{ - ProjectName: "test-proj", - Query: "test-query", - AliasBy: "{{project}} - {{resource.label.zone}} - {{resource.label.instance_id}} - {{metric.label.response_code_class}}", + parameters: &timeSeriesQuery{ + ProjectName: "test-proj", + Query: "test-query", + }, + aliasBy: "{{project}} - {{resource.label.zone}} - {{resource.label.instance_id}} - {{metric.label.response_code_class}}", timeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(34 * time.Minute), @@ -378,7 +365,7 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}} + query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &timeSeriesList{}} err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -394,9 +381,9 @@ func TestTimeSeriesFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{ + query := &cloudMonitoringTimeSeriesList{params: url.Values{ "aggregation.alignmentPeriod": []string{"+60s"}, - }} + }, parameters: &timeSeriesList{}} err = query.parseResponse(res, data, "") require.NoError(t, err) frames := res.Frames @@ -411,7 +398,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &timeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query")) require.NotNil(t, res.Frames[0].Meta) assert.Equal(t, sdkdata.FrameMeta{ @@ -434,7 +421,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &timeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query")) require.NotNil(t, res.Frames[0].Meta) assert.Equal(t, sdkdata.FrameMeta{ @@ -457,7 +444,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &timeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query")) require.NotNil(t, res.Frames[0].Meta) assert.Equal(t, sdkdata.FrameMeta{ @@ -475,17 +462,29 @@ func TestTimeSeriesFilter(t *testing.T) { }) }) - t.Run("when data comes from a slo query, it should skip the link", func(t *testing.T) { - data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json") - require.NoError(t, err) - assert.Equal(t, 1, len(data.TimeSeries)) + t.Run("when building filter string", func(t *testing.T) { + t.Run("and there's no regex operator", func(t *testing.T) { + t.Run("and there are wildcards in a filter value", func(t *testing.T) { + tsl := &cloudMonitoringTimeSeriesList{parameters: &timeSeriesList{Filters: []string{"metric.type", "=", "somemetrictype", "AND", "zone", "=", "*-central1*"}}} + value := tsl.getFilter() + assert.Equal(t, `metric.type="somemetrictype" zone=has_substring("-central1")`, value) + }) - res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{Params: url.Values{}, Slo: "yes"} - err = query.parseResponse(res, data, "") - require.NoError(t, err) - frames := res.Frames - assert.Equal(t, len(frames[0].Fields[1].Config.Links), 0) + t.Run("and there are no wildcards in any filter value", func(t *testing.T) { + tsl := &cloudMonitoringTimeSeriesList{parameters: &timeSeriesList{Filters: []string{"metric.type", "=", "somemetrictype", "AND", "zone", "!=", "us-central1-a"}}} + value := tsl.getFilter() + assert.Equal(t, `metric.type="somemetrictype" zone!="us-central1-a"`, value) + }) + }) + + t.Run("and there is a regex operator", func(t *testing.T) { + tsl := &cloudMonitoringTimeSeriesList{parameters: &timeSeriesList{Filters: []string{"metric.type", "=", "somemetrictype", "AND", "zone", "=~", "us-central1-a~"}}} + value := tsl.getFilter() + assert.NotContains(t, value, `=~`) + assert.Contains(t, value, `zone=`) + + assert.Contains(t, value, `zone=monitoring.regex.full_match("us-central1-a~")`) + }) }) } diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index 05ca9505f51..3f9b7b7fb5c 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -1,23 +1,18 @@ package cloudmonitoring import ( - "bytes" "context" "encoding/json" "fmt" - "io" - "net/http" "net/url" - "path" "strconv" "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "go.opentelemetry.io/otel/attribute" + "github.com/huandu/xstrings" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/intervalv2" ) @@ -25,94 +20,28 @@ import ( func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) appendGraphPeriod(req *backend.QueryDataRequest) string { // GraphPeriod needs to be explicitly disabled. // If not set, the default behavior is to set an automatic value - if timeSeriesQuery.GraphPeriod != "disabled" { - if timeSeriesQuery.GraphPeriod == "auto" || timeSeriesQuery.GraphPeriod == "" { + if timeSeriesQuery.parameters.GraphPeriod != "disabled" { + if timeSeriesQuery.parameters.GraphPeriod == "auto" || timeSeriesQuery.parameters.GraphPeriod == "" { intervalCalculator := intervalv2.NewCalculator(intervalv2.CalculatorOptions{}) interval := intervalCalculator.Calculate(req.Queries[0].TimeRange, time.Duration(timeSeriesQuery.IntervalMS/1000)*time.Second, req.Queries[0].MaxDataPoints) - timeSeriesQuery.GraphPeriod = interval.Text + timeSeriesQuery.parameters.GraphPeriod = interval.Text } - return fmt.Sprintf(" | graph_period %s", timeSeriesQuery.GraphPeriod) + return fmt.Sprintf(" | graph_period %s", timeSeriesQuery.parameters.GraphPeriod) } return "" } -func doRequestQueryPage(log log.Logger, requestBody map[string]interface{}, r *http.Request, dsInfo datasourceInfo) (cloudMonitoringResponse, error) { - buf, err := json.Marshal(requestBody) - if err != nil { - return cloudMonitoringResponse{}, err - } - r.Body = io.NopCloser(bytes.NewBuffer(buf)) - res, err := dsInfo.services[cloudMonitor].client.Do(r) - if err != nil { - return cloudMonitoringResponse{}, err - } - - dnext, err := unmarshalResponse(log, res) - if err != nil { - return cloudMonitoringResponse{}, err - } - return dnext, nil -} - func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, req *backend.QueryDataRequest, s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, cloudMonitoringResponse, string, error) { - dr := &backend.DataResponse{} - projectName := timeSeriesQuery.ProjectName - - if projectName == "" { - var err error - projectName, err = s.getDefaultProject(ctx, dsInfo) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - timeSeriesQuery.logger.Info("No project name set on query, using project name from datasource", "projectName", projectName) - } - - timeSeriesQuery.Query += timeSeriesQuery.appendGraphPeriod(req) + timeSeriesQuery.parameters.Query += timeSeriesQuery.appendGraphPeriod(req) from := req.Queries[0].TimeRange.From to := req.Queries[0].TimeRange.To timeFormat := "2006/01/02-15:04:05" - timeSeriesQuery.Query += fmt.Sprintf(" | within d'%s', d'%s'", from.UTC().Format(timeFormat), to.UTC().Format(timeFormat)) - p := path.Join("/v3/projects", projectName, "timeSeries:query") - - ctx, span := tracer.Start(ctx, "cloudMonitoring MQL query") - span.SetAttributes("query", timeSeriesQuery.Query, attribute.Key("query").String(timeSeriesQuery.Query)) - span.SetAttributes("from", req.Queries[0].TimeRange.From, attribute.Key("from").String(req.Queries[0].TimeRange.From.String())) - span.SetAttributes("until", req.Queries[0].TimeRange.To, attribute.Key("until").String(req.Queries[0].TimeRange.To.String())) - defer span.End() - + timeSeriesQuery.parameters.Query += fmt.Sprintf(" | within d'%s', d'%s'", from.UTC().Format(timeFormat), to.UTC().Format(timeFormat)) requestBody := map[string]interface{}{ - "query": timeSeriesQuery.Query, + "query": timeSeriesQuery.parameters.Query, } - r, err := s.createRequest(timeSeriesQuery.logger, &dsInfo, p, bytes.NewBuffer([]byte{})) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - tracer.Inject(ctx, r.Header, span) - r = r.WithContext(ctx) - - d, err := doRequestQueryPage(timeSeriesQuery.logger, requestBody, r, dsInfo) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - for d.NextPageToken != "" { - requestBody := map[string]interface{}{ - "query": timeSeriesQuery.Query, - "pageToken": d.NextPageToken, - } - nextPage, err := doRequestQueryPage(timeSeriesQuery.logger, requestBody, r, dsInfo) - if err != nil { - dr.Error = err - return dr, cloudMonitoringResponse{}, "", nil - } - d.TimeSeriesData = append(d.TimeSeriesData, nextPage.TimeSeriesData...) - d.NextPageToken = nextPage.NextPageToken - } - - return dr, d, timeSeriesQuery.Query, nil + return runTimeSeriesRequest(ctx, timeSeriesQuery.logger, req, s, dsInfo, tracer, timeSeriesQuery.parameters.ProjectName, nil, requestBody) } func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *backend.DataResponse, @@ -122,14 +51,14 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b for _, series := range response.TimeSeriesData { seriesLabels := make(map[string]string) frame := data.NewFrameOfFieldTypes("", len(series.PointData), data.FieldTypeTime, data.FieldTypeFloat64) - frame.RefID = timeSeriesQuery.RefID + frame.RefID = timeSeriesQuery.refID frame.Meta = &data.FrameMeta{ ExecutedQueryString: executedQueryString, } labels := make(map[string]string) for n, d := range response.TimeSeriesDescriptor.LabelDescriptors { - key := toSnakeCase(d.Key) + key := xstrings.ToSnakeCase(d.Key) key = strings.Replace(key, ".", ".label.", 1) labelValue := series.LabelValues[n] @@ -184,8 +113,12 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b } metricName := formatLegendKeys(d.Key, defaultMetricName, seriesLabels, nil, - &cloudMonitoringTimeSeriesFilter{ - ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, logger: timeSeriesQuery.logger, + &cloudMonitoringTimeSeriesList{ + parameters: &timeSeriesList{ + ProjectName: timeSeriesQuery.parameters.ProjectName, + }, + aliasBy: timeSeriesQuery.aliasBy, + logger: timeSeriesQuery.logger, }) dataField := frame.Fields[1] dataField.Name = metricName @@ -219,7 +152,11 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []time.Time{}) valueField := data.NewField(data.TimeSeriesValueFieldName, nil, []float64{}) - frameName := formatLegendKeys(d.Key, defaultMetricName, nil, additionalLabels, &cloudMonitoringTimeSeriesFilter{ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, logger: timeSeriesQuery.logger}) + frameName := formatLegendKeys(d.Key, defaultMetricName, nil, additionalLabels, &cloudMonitoringTimeSeriesList{ + parameters: &timeSeriesList{ProjectName: timeSeriesQuery.parameters.ProjectName}, + aliasBy: timeSeriesQuery.aliasBy, + logger: timeSeriesQuery.logger, + }) valueField.Name = frameName valueField.Labels = seriesLabels setDisplayNameAsFieldName(valueField) @@ -230,7 +167,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b timeField, valueField, }, - RefID: timeSeriesQuery.RefID, + RefID: timeSeriesQuery.refID, } if maxKey < i { @@ -247,7 +184,11 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b additionalLabels := data.Labels{"bucket": bucketBound} timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []time.Time{}) valueField := data.NewField(data.TimeSeriesValueFieldName, nil, []float64{}) - frameName := formatLegendKeys(d.Key, defaultMetricName, seriesLabels, additionalLabels, &cloudMonitoringTimeSeriesFilter{ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, logger: timeSeriesQuery.logger}) + frameName := formatLegendKeys(d.Key, defaultMetricName, seriesLabels, additionalLabels, &cloudMonitoringTimeSeriesList{ + parameters: &timeSeriesList{ProjectName: timeSeriesQuery.parameters.ProjectName}, + aliasBy: timeSeriesQuery.aliasBy, + logger: timeSeriesQuery.logger, + }) valueField.Name = frameName valueField.Labels = seriesLabels setDisplayNameAsFieldName(valueField) @@ -258,7 +199,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b timeField, valueField, }, - RefID: timeSeriesQuery.RefID, + RefID: timeSeriesQuery.refID, } } } @@ -278,7 +219,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b } if len(response.TimeSeriesData) > 0 { dl := timeSeriesQuery.buildDeepLink() - frames = addConfigData(frames, dl, response.Unit, timeSeriesQuery.GraphPeriod) + frames = addConfigData(frames, dl, response.Unit, timeSeriesQuery.parameters.GraphPeriod) } queryRes.Frames = frames @@ -289,12 +230,12 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { u, err := url.Parse("https://console.cloud.google.com/monitoring/metrics-explorer") if err != nil { - timeSeriesQuery.logger.Error("Failed to generate deep link: unable to parse metrics explorer URL", "projectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) + timeSeriesQuery.logger.Error("Failed to generate deep link: unable to parse metrics explorer URL", "projectName", timeSeriesQuery.parameters.ProjectName, "query", timeSeriesQuery.refID) return "" } q := u.Query() - q.Set("project", timeSeriesQuery.ProjectName) + q.Set("project", timeSeriesQuery.parameters.ProjectName) q.Set("Grafana_deeplink", "true") pageState := map[string]interface{}{ @@ -302,7 +243,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { "constantLines": []string{}, "dataSets": []map[string]interface{}{ { - "timeSeriesQuery": timeSeriesQuery.Query, + "timeSeriesQuery": timeSeriesQuery.parameters.Query, "targetAxis": "Y1", "plotType": "LINE", }, @@ -322,7 +263,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { blob, err := json.Marshal(pageState) if err != nil { - timeSeriesQuery.logger.Error("Failed to generate deep link", "pageState", pageState, "ProjectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) + timeSeriesQuery.logger.Error("Failed to generate deep link", "pageState", pageState, "ProjectName", timeSeriesQuery.parameters.ProjectName, "query", timeSeriesQuery.refID) return "" } @@ -331,7 +272,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { accountChooserURL, err := url.Parse("https://accounts.google.com/AccountChooser") if err != nil { - timeSeriesQuery.logger.Error("Failed to generate deep link: unable to parse account chooser URL", "ProjectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) + timeSeriesQuery.logger.Error("Failed to generate deep link: unable to parse account chooser URL", "ProjectName", timeSeriesQuery.parameters.ProjectName, "query", timeSeriesQuery.refID) return "" } accountChooserQuery := accountChooserURL.Query() @@ -342,5 +283,18 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { } func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) getRefID() string { - return timeSeriesQuery.RefID + return timeSeriesQuery.refID +} + +func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) getAliasBy() string { + return timeSeriesQuery.aliasBy +} + +func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) getParameter(i string) string { + switch i { + case "project": + return timeSeriesQuery.parameters.ProjectName + default: + return "" + } } diff --git a/pkg/tsdb/cloudmonitoring/time_series_query_test.go b/pkg/tsdb/cloudmonitoring/time_series_query_test.go index 2e983dcaa3d..025e8cde225 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query_test.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query_test.go @@ -21,8 +21,10 @@ func TestTimeSeriesQuery(t *testing.T) { t.Run("and alias template is not specified", func(t *testing.T) { res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesQuery{ - ProjectName: "test-proj", - Query: "test-query", + parameters: &timeSeriesQuery{ + ProjectName: "test-proj", + Query: "test-query", + }, timeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(34 * time.Minute), @@ -37,9 +39,11 @@ func TestTimeSeriesQuery(t *testing.T) { t.Run("and alias template is specified", func(t *testing.T) { res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesQuery{ - ProjectName: "test-proj", - Query: "test-query", - AliasBy: "{{project}} - {{resource.label.zone}} - {{resource.label.instance_id}} - {{metric.label.response_code_class}}", + parameters: &timeSeriesQuery{ + ProjectName: "test-proj", + Query: "test-query", + }, + aliasBy: "{{project}} - {{resource.label.zone}} - {{resource.label.instance_id}} - {{metric.label.response_code_class}}", timeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(34 * time.Minute), @@ -62,9 +66,11 @@ func TestTimeSeriesQuery(t *testing.T) { res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesQuery{ - ProjectName: "test-proj", - Query: "test-query", - AliasBy: "{{project}} - {{resource.label.zone}} - {{resource.label.instance_id}} - {{metric.label.response_code_class}}", + parameters: &timeSeriesQuery{ + ProjectName: "test-proj", + Query: "test-query", + }, + aliasBy: "{{project}} - {{resource.label.zone}} - {{resource.label.instance_id}} - {{metric.label.response_code_class}}", timeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(34 * time.Minute), @@ -85,8 +91,10 @@ func TestTimeSeriesQuery(t *testing.T) { fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesQuery{ - ProjectName: "test-proj", - Query: "test-query", + parameters: &timeSeriesQuery{ + ProjectName: "test-proj", + Query: "test-query", + }, timeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(34 * time.Minute), @@ -109,13 +117,15 @@ func TestTimeSeriesQuery(t *testing.T) { fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesQuery{ - ProjectName: "test-proj", - Query: "test-query", + parameters: &timeSeriesQuery{ + ProjectName: "test-proj", + Query: "test-query", + GraphPeriod: "60s", + }, timeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(34 * time.Minute), }, - GraphPeriod: "60s", } err = query.parseResponse(res, data, "") require.NoError(t, err) @@ -125,12 +135,12 @@ func TestTimeSeriesQuery(t *testing.T) { }) t.Run("appends graph_period to the query", func(t *testing.T) { - query := &cloudMonitoringTimeSeriesQuery{} + query := &cloudMonitoringTimeSeriesQuery{parameters: &timeSeriesQuery{}} assert.Equal(t, query.appendGraphPeriod(&backend.QueryDataRequest{Queries: []backend.DataQuery{{}}}), " | graph_period 1ms") }) t.Run("skips graph_period if disabled", func(t *testing.T) { - query := &cloudMonitoringTimeSeriesQuery{GraphPeriod: "disabled"} + query := &cloudMonitoringTimeSeriesQuery{parameters: &timeSeriesQuery{GraphPeriod: "disabled"}} assert.Equal(t, query.appendGraphPeriod(&backend.QueryDataRequest{Queries: []backend.DataQuery{{}}}), "") }) } diff --git a/pkg/tsdb/cloudmonitoring/types.go b/pkg/tsdb/cloudmonitoring/types.go index 1f939b73b88..cebbc385e39 100644 --- a/pkg/tsdb/cloudmonitoring/types.go +++ b/pkg/tsdb/cloudmonitoring/types.go @@ -18,69 +18,82 @@ type ( parseResponse(dr *backend.DataResponse, data cloudMonitoringResponse, executedQueryString string) error buildDeepLink() string getRefID() string + getAliasBy() string + getParameter(i string) string } - // Used to build time series filters - cloudMonitoringTimeSeriesFilter struct { - Target string - Params url.Values - RefID string - GroupBys []string - AliasBy string - ProjectName string - Selector string - Service string - Slo string - logger log.Logger - } - - // Used to build MQL queries - cloudMonitoringTimeSeriesQuery struct { - RefID string - ProjectName string - Query string - IntervalMS int64 - AliasBy string - timeRange backend.TimeRange - GraphPeriod string - logger log.Logger - } - - metricQuery struct { - ProjectName string - MetricType string - CrossSeriesReducer string - AlignmentPeriod string - PerSeriesAligner string - GroupBys []string - Filters []string - AliasBy string - View string - EditorMode string - Query string - Preprocessor string - PreprocessorType preprocessorType - GraphPeriod string - } - - sloQuery struct { - ProjectName string - AlignmentPeriod string - PerSeriesAligner string - AliasBy string - SelectorName string - ServiceId string - SloId string - LookbackPeriod string - } - + // Plugin API query data request used to generate + // a cloudMonitoringTimeSeriesList or cloudMonitoringTimeSeriesQuery grafanaQuery struct { - DatasourceId int - RefId string - QueryType string - MetricQuery metricQuery - SloQuery sloQuery - Type string + AliasBy string `json:"aliasBy"` + TimeSeriesList *timeSeriesList `json:"timeSeriesList,omitempty"` + TimeSeriesQuery *timeSeriesQuery `json:"timeSeriesQuery,omitempty"` + SloQuery *sloQuery `json:"sloQuery,omitempty"` + } + + // These should reflect GCM APIs + // timeSeries.list https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/list + timeSeriesList struct { + ProjectName string `json:"projectName"` + CrossSeriesReducer string `json:"crossSeriesReducer"` + AlignmentPeriod string `json:"alignmentPeriod"` + PerSeriesAligner string `json:"perSeriesAligner"` + GroupBys []string `json:"groupBys"` + Filters []string `json:"filters"` + View string `json:"view"` + SecondaryAlignmentPeriod string `json:"secondaryAlignmentPeriod"` + SecondaryCrossSeriesReducer string `json:"secondaryCrossSeriesReducer"` + SecondaryPerSeriesAligner string `json:"secondaryPerSeriesAligner"` + SecondaryGroupBys []string `json:"secondaryGroupBys"` + } + + // sloQuery is an internal convention but the API is the same as timeSeriesList + sloQuery struct { + ProjectName string `json:"projectName"` + SelectorName string `json:"selectorName"` + ServiceId string `json:"serviceId"` + SloId string `json:"sloId"` + AlignmentPeriod string `json:"alignmentPeriod"` + LookbackPeriod string `json:"lookbackPeriod"` + } + + // timeSeries.query https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/query + timeSeriesQuery struct { + ProjectName string `json:"projectName"` + Query string `json:"query"` + // Not part of the GCM API, will be added to Query + GraphPeriod string `json:"graphPeriod"` + } + + // Internal structs. Include computed values + // cloudMonitoringTimeSeriesList is used to build time series with a filter + cloudMonitoringTimeSeriesList struct { + refID string + aliasBy string + logger log.Logger + parameters *timeSeriesList + // Processed properties + params url.Values + } + // cloudMonitoringSLO is used to build time series with a filter but for the SLO case + cloudMonitoringSLO struct { + refID string + aliasBy string + logger log.Logger + parameters *sloQuery + // Processed properties + params url.Values + } + + // cloudMonitoringTimeSeriesQuery is used to build MQL queries + cloudMonitoringTimeSeriesQuery struct { + refID string + aliasBy string + logger log.Logger + parameters *timeSeriesQuery + // Processed properties + timeRange backend.TimeRange + IntervalMS int64 } cloudMonitoringBucketOptions struct { diff --git a/pkg/tsdb/cloudmonitoring/utils.go b/pkg/tsdb/cloudmonitoring/utils.go index bcaa7550f94..695b17a7b49 100644 --- a/pkg/tsdb/cloudmonitoring/utils.go +++ b/pkg/tsdb/cloudmonitoring/utils.go @@ -1,33 +1,24 @@ package cloudmonitoring import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" "strings" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "go.opentelemetry.io/otel/attribute" ) -func reverse(s string) string { - chars := []rune(s) - for i, j := 0, len(chars)-1; i < j; i, j = i+1, j-1 { - chars[i], chars[j] = chars[j], chars[i] - } - return string(chars) -} - -func toSnakeCase(str string) string { - return strings.ToLower(matchAllCap.ReplaceAllString(str, "${1}_${2}")) -} - -func containsLabel(labels []string, newLabel string) bool { - for _, val := range labels { - if val == newLabel { - return true - } - } - return false -} - func addInterval(period string, field *data.Field) error { period = strings.TrimPrefix(period, "+") p, err := intervalv2.ParseIntervalStringToTimeDuration(period) @@ -45,3 +36,122 @@ func addInterval(period string, field *data.Field) error { } return nil } + +func toString(v interface{}) string { + if v == nil { + return "" + } + return v.(string) +} + +func createRequest(ctx context.Context, logger log.Logger, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { + u, err := url.Parse(dsInfo.url) + if err != nil { + return nil, err + } + u.Path = path.Join(u.Path, "render") + + method := http.MethodGet + if body != nil { + method = http.MethodPost + } + req, err := http.NewRequestWithContext(ctx, method, dsInfo.services[cloudMonitor].url, body) + if err != nil { + logger.Error("Failed to create request", "error", err) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.URL.Path = proxyPass + + return req, nil +} + +func doRequestPage(ctx context.Context, logger log.Logger, r *http.Request, dsInfo datasourceInfo, params url.Values, body map[string]interface{}) (cloudMonitoringResponse, error) { + if params != nil { + r.URL.RawQuery = params.Encode() + } + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return cloudMonitoringResponse{}, err + } + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + r.Method = http.MethodPost + } + res, err := dsInfo.services[cloudMonitor].client.Do(r) + if err != nil { + return cloudMonitoringResponse{}, err + } + + dnext, err := unmarshalResponse(logger, res) + if err != nil { + return cloudMonitoringResponse{}, err + } + + return dnext, nil +} + +func doRequestWithPagination(ctx context.Context, logger log.Logger, r *http.Request, dsInfo datasourceInfo, params url.Values, body map[string]interface{}) (cloudMonitoringResponse, error) { + d, err := doRequestPage(ctx, logger, r, dsInfo, params, body) + if err != nil { + return cloudMonitoringResponse{}, err + } + for d.NextPageToken != "" { + if params != nil { + params["pageToken"] = []string{d.NextPageToken} + } + if body != nil { + body["pageToken"] = d.NextPageToken + } + nextPage, err := doRequestPage(ctx, logger, r, dsInfo, params, body) + if err != nil { + return cloudMonitoringResponse{}, err + } + d.TimeSeries = append(d.TimeSeries, nextPage.TimeSeries...) + d.TimeSeriesData = append(d.TimeSeriesData, nextPage.TimeSeriesData...) + d.NextPageToken = nextPage.NextPageToken + } + return d, nil +} + +func traceReq(ctx context.Context, tracer tracing.Tracer, req *backend.QueryDataRequest, dsInfo datasourceInfo, r *http.Request, target string) tracing.Span { + ctx, span := tracer.Start(ctx, "cloudMonitoring query") + span.SetAttributes("target", target, attribute.Key("target").String(target)) + span.SetAttributes("from", req.Queries[0].TimeRange.From, attribute.Key("from").String(req.Queries[0].TimeRange.From.String())) + span.SetAttributes("until", req.Queries[0].TimeRange.To, attribute.Key("until").String(req.Queries[0].TimeRange.To.String())) + span.SetAttributes("datasource_id", dsInfo.id, attribute.Key("datasource_id").Int64(dsInfo.id)) + span.SetAttributes("org_id", req.PluginContext.OrgID, attribute.Key("org_id").Int64(req.PluginContext.OrgID)) + tracer.Inject(ctx, r.Header, span) + return span +} + +func runTimeSeriesRequest(ctx context.Context, logger log.Logger, req *backend.QueryDataRequest, + s *Service, dsInfo datasourceInfo, tracer tracing.Tracer, projectName string, params url.Values, body map[string]interface{}) (*backend.DataResponse, cloudMonitoringResponse, string, error) { + dr := &backend.DataResponse{} + projectName, err := s.ensureProject(ctx, dsInfo, projectName) + if err != nil { + dr.Error = err + return dr, cloudMonitoringResponse{}, "", nil + } + timeSeriesMethod := "timeSeries" + if body != nil { + timeSeriesMethod += ":query" + } + r, err := createRequest(ctx, logger, &dsInfo, path.Join("/v3/projects", projectName, timeSeriesMethod), nil) + if err != nil { + dr.Error = err + return dr, cloudMonitoringResponse{}, "", nil + } + + span := traceReq(ctx, tracer, req, dsInfo, r, params.Encode()) + defer span.End() + + d, err := doRequestWithPagination(ctx, logger, r, dsInfo, params, body) + if err != nil { + dr.Error = err + return dr, cloudMonitoringResponse{}, "", nil + } + + return dr, d, r.URL.RawQuery, nil +} diff --git a/pkg/tsdb/cloudwatch/clients/metrics.go b/pkg/tsdb/cloudwatch/clients/metrics.go index d205c968cee..f49743d49ea 100644 --- a/pkg/tsdb/cloudwatch/clients/metrics.go +++ b/pkg/tsdb/cloudwatch/clients/metrics.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) type metricsClient struct { @@ -17,16 +18,20 @@ func NewMetricsClient(api models.CloudWatchMetricsAPIProvider, config *setting.C return &metricsClient{CloudWatchMetricsAPIProvider: api, config: config} } -func (l *metricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error) { - var cloudWatchMetrics []*cloudwatch.Metric +func (l *metricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error) { + var cloudWatchMetrics []resources.MetricResponse pageNum := 0 err := l.ListMetricsPages(params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { pageNum++ metrics.MAwsCloudWatchListMetrics.Inc() metrics, err := awsutil.ValuesAtPath(page, "Metrics") if err == nil { - for _, metric := range metrics { - cloudWatchMetrics = append(cloudWatchMetrics, metric.(*cloudwatch.Metric)) + for idx, metric := range metrics { + metric := resources.MetricResponse{Metric: metric.(*cloudwatch.Metric)} + if len(page.OwningAccounts) >= idx && params.IncludeLinkedAccounts != nil && *params.IncludeLinkedAccounts { + metric.AccountId = page.OwningAccounts[idx] + } + cloudWatchMetrics = append(cloudWatchMetrics, metric) } } return !lastPage && pageNum < l.config.AWSListMetricsPageLimit diff --git a/pkg/tsdb/cloudwatch/clients/metrics_test.go b/pkg/tsdb/cloudwatch/clients/metrics_test.go index 91636775ba5..0a28e34c5a2 100644 --- a/pkg/tsdb/cloudwatch/clients/metrics_test.go +++ b/pkg/tsdb/cloudwatch/clients/metrics_test.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,4 +47,33 @@ func TestMetricsClient(t *testing.T) { assert.Equal(t, len(metrics), len(response)) }) + + t.Run("Should return account id in case IncludeLinkedAccounts is set to true", func(t *testing.T) { + fakeApi := &mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{ + {MetricName: aws.String("Test_MetricName1")}, + {MetricName: aws.String("Test_MetricName2")}, + {MetricName: aws.String("Test_MetricName3")}, + }, OwningAccounts: []*string{aws.String("1234567890"), aws.String("1234567890"), aws.String("1234567895")}} + client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: 100}) + + response, err := client.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{IncludeLinkedAccounts: aws.Bool(true)}) + require.NoError(t, err) + expected := []resources.MetricResponse{ + {Metric: &cloudwatch.Metric{MetricName: aws.String("Test_MetricName1")}, AccountId: stringPtr("1234567890")}, + {Metric: &cloudwatch.Metric{MetricName: aws.String("Test_MetricName2")}, AccountId: stringPtr("1234567890")}, + {Metric: &cloudwatch.Metric{MetricName: aws.String("Test_MetricName3")}, AccountId: stringPtr("1234567895")}, + } + assert.Equal(t, expected, response) + }) + + t.Run("Should not return account id in case IncludeLinkedAccounts is set to false", func(t *testing.T) { + fakeApi := &mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{{MetricName: aws.String("Test_MetricName1")}}, OwningAccounts: []*string{aws.String("1234567890")}} + client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: 100}) + + response, err := client.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{IncludeLinkedAccounts: aws.Bool(false)}) + require.NoError(t, err) + assert.Nil(t, response[0].AccountId) + }) } + +func stringPtr(s string) *string { return &s } diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index e78dfb8318f..e0e5843815a 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "regexp" "time" @@ -18,6 +17,7 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/oam" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" "github.com/grafana/grafana-aws-sdk/pkg/awsds" @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -122,8 +121,11 @@ func (e *cloudWatchExecutor) getRequestContext(pluginCtx backend.PluginContext, return models.RequestContext{}, err } return models.RequestContext{ + OAMClientProvider: NewOAMAPI(sess), MetricsClientProvider: clients.NewMetricsClient(NewMetricsAPI(sess), e.cfg), + LogsAPIProvider: NewLogsAPI(sess), Settings: instance.Settings, + Features: e.features, }, nil } @@ -178,11 +180,12 @@ func (e *cloudWatchExecutor) checkHealthMetrics(pluginCtx backend.PluginContext) } func (e *cloudWatchExecutor) checkHealthLogs(pluginCtx backend.PluginContext) error { - parameters := url.Values{ - "limit": []string{"1"}, + session, err := e.newSession(pluginCtx, defaultRegion) + if err != nil { + return err } - - _, err := e.handleGetLogGroups(pluginCtx, parameters) + logsClient := NewLogsAPI(session) + _, err = logsClient.DescribeLogGroups(&cloudwatchlogs.DescribeLogGroupsInput{Limit: aws.Int64(1)}) return err } @@ -423,6 +426,20 @@ var NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvi return cloudwatch.New(sess) } +// NewLogsAPI is a CloudWatch logs api factory. +// +// Stubbable by tests. +var NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider { + return cloudwatchlogs.New(sess) +} + +// NewOAMAPI is a CloudWatch OAM api factory. +// +// Stubbable by tests. +var NewOAMAPI = func(sess *session.Session) models.OAMClientProvider { + return oam.New(sess) +} + // NewCWClient is a CloudWatch client factory. // // Stubbable by tests. diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index bca9bbe4e80..a11c15889fe 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -24,7 +24,9 @@ import ( "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -97,16 +99,18 @@ func TestNewInstanceSettings(t *testing.T) { func Test_CheckHealth(t *testing.T) { origNewMetricsAPI := NewMetricsAPI origNewCWLogsClient := NewCWLogsClient + origNewLogsAPI := NewLogsAPI t.Cleanup(func() { NewMetricsAPI = origNewMetricsAPI NewCWLogsClient = origNewCWLogsClient + NewLogsAPI = origNewLogsAPI }) var client fakeCheckHealthClient NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvider { return client } - NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { + NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider { return client } @@ -536,16 +540,86 @@ func TestQuery_ResourceRequest_DescribeLogGroups(t *testing.T) { }) } +func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *testing.T) { + sender := &mockedCallResourceResponseSenderForOauth{} + origNewMetricsAPI := NewMetricsAPI + origNewOAMAPI := NewOAMAPI + origNewLogsAPI := NewLogsAPI + NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvider { return nil } + NewOAMAPI = func(sess *session.Session) models.OAMClientProvider { return nil } + t.Cleanup(func() { + NewOAMAPI = origNewOAMAPI + NewMetricsAPI = origNewMetricsAPI + NewLogsAPI = origNewLogsAPI + }) + + var logsApi mocks.LogsAPI + NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider { + return &logsApi + } + + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{}}, nil + }) + + t.Run("maps log group api response to resource response of describe-log-groups", func(t *testing.T) { + logsApi = mocks.LogsAPI{} + logsApi.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{ + LogGroups: []*cloudwatchlogs.LogGroup{ + {Arn: aws.String("arn:aws:logs:us-east-1:111:log-group:group_a"), LogGroupName: aws.String("group_a")}, + }, + }, nil) + req := &backend.CallResourceRequest{ + Method: "GET", + Path: `/describe-log-groups?logGroupPattern=some-pattern&accountId=some-account-id`, + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ID: 0}, + PluginID: "cloudwatch", + }, + } + + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + err := executor.CallResource(context.Background(), req, sender) + assert.NoError(t, err) + + assert.JSONEq(t, `[ + { + "accountId":"111", + "value":{ + "arn":"arn:aws:logs:us-east-1:111:log-group:group_a", + "name":"group_a" + } + } + ]`, string(sender.Response.Body)) + + logsApi.AssertCalled(t, "DescribeLogGroups", + &cloudwatchlogs.DescribeLogGroupsInput{ + AccountIdentifiers: []*string{utils.Pointer("some-account-id")}, + IncludeLinkedAccounts: utils.Pointer(true), + Limit: utils.Pointer(int64(50)), + LogGroupNamePrefix: utils.Pointer("some-pattern"), + }) + }) +} + func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sender := &mockedCallResourceResponseSenderForOauth{} origNewMetricsAPI := NewMetricsAPI + origNewOAMAPI := NewOAMAPI + origNewLogsAPI := NewLogsAPI + NewOAMAPI = func(sess *session.Session) models.OAMClientProvider { return nil } + NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider { return nil } t.Cleanup(func() { + NewOAMAPI = origNewOAMAPI NewMetricsAPI = origNewMetricsAPI + NewLogsAPI = origNewLogsAPI }) + var api mocks.FakeMetricsAPI NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvider { return &api } + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { return DataSource{Settings: models.CloudWatchSettings{}}, nil }) @@ -580,10 +654,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sent := sender.Response require.NotNil(t, sent) require.Equal(t, http.StatusOK, sent.Status) - res := []string{} + res := []resources.ResourceResponse[string]{} err = json.Unmarshal(sent.Body, &res) require.Nil(t, err) - assert.Equal(t, []string{"Value1", "Value2", "Value7"}, res) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "Value1"}, {Value: "Value2"}, {Value: "Value7"}}, res) }) t.Run("Should handle dimension key filter query and return keys from the api", func(t *testing.T) { @@ -616,10 +690,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sent := sender.Response require.NotNil(t, sent) require.Equal(t, http.StatusOK, sent.Status) - res := []string{} + res := []resources.ResourceResponse[string]{} err = json.Unmarshal(sent.Body, &res) require.Nil(t, err) - assert.Equal(t, []string{"Test_DimensionName1", "Test_DimensionName2", "Test_DimensionName4", "Test_DimensionName5"}, res) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "Test_DimensionName1"}, {Value: "Test_DimensionName2"}, {Value: "Test_DimensionName4"}, {Value: "Test_DimensionName5"}}, res) }) t.Run("Should handle standard dimension key query and return hard coded keys", func(t *testing.T) { @@ -640,10 +714,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sent := sender.Response require.NotNil(t, sent) require.Equal(t, http.StatusOK, sent.Status) - res := []string{} + res := []resources.ResourceResponse[string]{} err = json.Unmarshal(sent.Body, &res) require.Nil(t, err) - assert.Equal(t, []string{"ClientId", "DomainName"}, res) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "ClientId"}, {Value: "DomainName"}}, res) }) t.Run("Should handle custom namespace dimension key query and return hard coded keys", func(t *testing.T) { @@ -664,10 +738,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sent := sender.Response require.NotNil(t, sent) require.Equal(t, http.StatusOK, sent.Status) - res := []string{} + res := []resources.ResourceResponse[string]{} err = json.Unmarshal(sent.Body, &res) require.Nil(t, err) - assert.Equal(t, []string{"ClientId", "DomainName"}, res) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "ClientId"}, {Value: "DomainName"}}, res) }) t.Run("Should handle custom namespace metrics query and return metrics from api", func(t *testing.T) { @@ -700,10 +774,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sent := sender.Response require.NotNil(t, sent) require.Equal(t, http.StatusOK, sent.Status) - res := []resources.Metric{} + res := []resources.ResourceResponse[resources.Metric]{} err = json.Unmarshal(sent.Body, &res) require.Nil(t, err) - assert.Equal(t, []resources.Metric{{Name: "Test_MetricName1", Namespace: "AWS/EC2"}, {Name: "Test_MetricName2", Namespace: "AWS/EC2"}, {Name: "Test_MetricName3", Namespace: "AWS/ECS"}, {Name: "Test_MetricName10", Namespace: "AWS/ECS"}, {Name: "Test_MetricName4", Namespace: "AWS/ECS"}, {Name: "Test_MetricName5", Namespace: "AWS/Redshift"}}, res) + assert.Equal(t, []resources.ResourceResponse[resources.Metric]{{Value: resources.Metric{Name: "Test_MetricName1", Namespace: "AWS/EC2"}}, {Value: resources.Metric{Name: "Test_MetricName2", Namespace: "AWS/EC2"}}, {Value: resources.Metric{Name: "Test_MetricName3", Namespace: "AWS/ECS"}}, {Value: resources.Metric{Name: "Test_MetricName10", Namespace: "AWS/ECS"}}, {Value: resources.Metric{Name: "Test_MetricName4", Namespace: "AWS/ECS"}}, {Value: resources.Metric{Name: "Test_MetricName5", Namespace: "AWS/Redshift"}}}, res) }) } diff --git a/pkg/tsdb/cloudwatch/log_actions.go b/pkg/tsdb/cloudwatch/log_actions.go index f2f52d9cd64..33344572afe 100644 --- a/pkg/tsdb/cloudwatch/log_actions.go +++ b/pkg/tsdb/cloudwatch/log_actions.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "sort" + "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" @@ -17,6 +18,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) const ( @@ -40,6 +42,7 @@ type LogQueryJson struct { EndTime *int64 LogGroupName string LogGroupNames []string + LogGroups []suggestData LogGroupNamePrefix string LogStreamName string StartFromHead bool @@ -224,15 +227,31 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c // StartTime is effectively floored while here EndTime is ceiled and so we should get the logs user wants // and also a little bit more but as CW logs accept only seconds as integers there is not much to do about // that. - EndTime: aws.Int64(int64(math.Ceil(float64(endTime.UnixNano()) / 1e9))), - LogGroupNames: aws.StringSlice(parameters.LogGroupNames), - QueryString: aws.String(modifiedQueryString), + EndTime: aws.Int64(int64(math.Ceil(float64(endTime.UnixNano()) / 1e9))), + QueryString: aws.String(modifiedQueryString), + } + + if e.features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying) { + if parameters.LogGroups != nil && len(parameters.LogGroups) > 0 { + var logGroupIdentifiers []string + for _, lg := range parameters.LogGroups { + arn := lg.Value + // due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error + logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*")) + } + startQueryInput.LogGroupIdentifiers = aws.StringSlice(logGroupIdentifiers) + } + } + + if startQueryInput.LogGroupIdentifiers == nil { + startQueryInput.LogGroupNames = aws.StringSlice(parameters.LogGroupNames) } if parameters.Limit != nil { startQueryInput.Limit = aws.Int64(*parameters.Limit) } + logger.Debug("calling startquery with context with input", "input", startQueryInput) return logsClient.StartQueryWithContext(ctx, startQueryInput) } diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 00985a930f8..919f3d5b0c1 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -391,6 +391,78 @@ func Test_executeStartQuery(t *testing.T) { require.Len(t, cli.calls.startQueryWithContext, 1) assert.Nil(t, cli.calls.startQueryWithContext[0].Limit) }) + + t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled", func(t *testing.T) { + cli = fakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{}}, nil + }) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12, + "queryString":"fields @message", + "logGroups":[{"value": "fakeARN"}] + }`), + }, + }, + }) + + assert.NoError(t, err) + assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ + { + StartTime: aws.Int64(0), + EndTime: aws.Int64(1), + Limit: aws.Int64(12), + QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupIdentifiers: []*string{aws.String("fakeARN")}, + }, + }, cli.calls.startQueryWithContext) + }) + + t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled and strips out trailing *", func(t *testing.T) { + cli = fakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{}}, nil + }) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12, + "queryString":"fields @message", + "logGroups":[{"value": "*fake**ARN*"}] + }`), + }, + }, + }) + + assert.NoError(t, err) + assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ + { + StartTime: aws.Int64(0), + EndTime: aws.Int64(1), + Limit: aws.Int64(12), + QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupIdentifiers: []*string{aws.String("*fake**ARN")}, + }, + }, cli.calls.startQueryWithContext) + }) } func TestQuery_StopQuery(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder.go b/pkg/tsdb/cloudwatch/metric_data_query_builder.go index 5baf0bb872b..b921a88739a 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder.go @@ -50,6 +50,7 @@ func (e *cloudWatchExecutor) buildMetricDataQuery(logger log.Logger, query *mode }) } mdq.MetricStat.Stat = aws.String(query.Statistic) + mdq.AccountId = query.AccountId } if mdq.Expression != nil { @@ -98,18 +99,27 @@ func buildSearchExpression(query *models.CloudWatchQuery, stat string) string { searchTerm = appendSearch(searchTerm, keyFilter) } + var account string + if query.AccountId != nil && *query.AccountId != "all" { + account = fmt.Sprintf(":aws.AccountId=%q", *query.AccountId) + } + if query.MatchExact { schema := fmt.Sprintf("%q", query.Namespace) if len(dimensionNames) > 0 { sort.Strings(dimensionNames) schema += fmt.Sprintf(",%s", join(dimensionNames, ",", `"`, `"`)) } - return fmt.Sprintf("REMOVE_EMPTY(SEARCH('{%s} %s', '%s', %s))", schema, searchTerm, stat, strconv.Itoa(query.Period)) + schema = fmt.Sprintf("{%s}", schema) + schemaSearchTermAndAccount := strings.TrimSpace(strings.Join([]string{schema, searchTerm, account}, " ")) + return fmt.Sprintf("REMOVE_EMPTY(SEARCH('%s', '%s', %s))", schemaSearchTermAndAccount, stat, strconv.Itoa(query.Period)) } sort.Strings(dimensionNamesWithoutKnownValues) searchTerm = appendSearch(searchTerm, join(dimensionNamesWithoutKnownValues, " ", `"`, `"`)) - return fmt.Sprintf(`REMOVE_EMPTY(SEARCH('Namespace="%s" %s', '%s', %s))`, query.Namespace, searchTerm, stat, strconv.Itoa(query.Period)) + namespace := fmt.Sprintf("Namespace=%q", query.Namespace) + namespaceSearchTermAndAccount := strings.TrimSpace(strings.Join([]string{namespace, searchTerm, account}, " ")) + return fmt.Sprintf(`REMOVE_EMPTY(SEARCH('%s', '%s', %s))`, namespaceSearchTermAndAccount, stat, strconv.Itoa(query.Period)) } func escapeDoubleQuotes(arr []string) []string { diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go index b13c389504c..84b827ec925 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go @@ -3,11 +3,11 @@ package cloudwatch import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - + "github.com/aws/aws-sdk-go/aws" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMetricDataQueryBuilder(t *testing.T) { @@ -24,6 +24,27 @@ func TestMetricDataQueryBuilder(t *testing.T) { assert.Equal(t, query.Namespace, *mdq.MetricStat.Metric.Namespace) }) + t.Run("should pass AccountId in metric stat query", func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + query := getBaseQuery() + query.MetricEditorMode = models.MetricEditorModeBuilder + query.MetricQueryType = models.MetricQueryTypeSearch + query.AccountId = aws.String("some account id") + mdq, err := executor.buildMetricDataQuery(logger, query) + require.NoError(t, err) + assert.Equal(t, "some account id", *mdq.AccountId) + }) + + t.Run("should leave AccountId in metric stat query", func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + query := getBaseQuery() + query.MetricEditorMode = models.MetricEditorModeBuilder + query.MetricQueryType = models.MetricQueryTypeSearch + mdq, err := executor.buildMetricDataQuery(logger, query) + require.NoError(t, err) + assert.Nil(t, mdq.AccountId) + }) + t.Run("should use custom built expression", func(t *testing.T) { executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) query := getBaseQuery() @@ -112,6 +133,42 @@ func TestMetricDataQueryBuilder(t *testing.T) { assert.Nil(t, mdq.Label) }) } + + t.Run(`should not specify accountId when it is "all"`, func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels)) + query := &models.CloudWatchQuery{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Statistic: "Average", + Period: 60, + MatchExact: false, + AccountId: aws.String("all"), + } + + mdq, err := executor.buildMetricDataQuery(logger, query) + + assert.NoError(t, err) + require.Nil(t, mdq.MetricStat) + assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization"', 'Average', 60))`, *mdq.Expression) + }) + + t.Run("should set accountId when it is specified", func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels)) + query := &models.CloudWatchQuery{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Statistic: "Average", + Period: 60, + MatchExact: false, + AccountId: aws.String("12345"), + } + + mdq, err := executor.buildMetricDataQuery(logger, query) + + assert.NoError(t, err) + require.Nil(t, mdq.MetricStat) + assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" :aws.AccountId="12345"', 'Average', 60))`, *mdq.Expression) + }) }) t.Run("Query should be matched exact", func(t *testing.T) { @@ -199,6 +256,24 @@ func TestMetricDataQueryBuilder(t *testing.T) { assert.Equal(t, `REMOVE_EMPTY(SEARCH('{"AWS/EC2","InstanceId","LoadBalancer"} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`, res) }) + t.Run("Query has multiple dimensions and an account Id", func(t *testing.T) { + query := &models.CloudWatchQuery{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Dimensions: map[string][]string{ + "LoadBalancer": {"lb1", "lb2", "lb3"}, + "InstanceId": {"i-123", "*", "i-789"}, + }, + Period: 300, + Expression: "", + MatchExact: matchExact, + AccountId: aws.String("some account id"), + } + + res := buildSearchExpression(query, "Average") + assert.Equal(t, `REMOVE_EMPTY(SEARCH('{"AWS/EC2","InstanceId","LoadBalancer"} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") :aws.AccountId="some account id"', 'Average', 300))`, res) + }) + t.Run("Query has a dimension key with a space", func(t *testing.T) { query := &models.CloudWatchQuery{ Namespace: "AWS/Kafka", @@ -301,6 +376,24 @@ func TestMetricDataQueryBuilder(t *testing.T) { res := buildSearchExpression(query, "Average") assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") "InstanceId"', 'Average', 300))`, res) }) + + t.Run("query has multiple dimensions and an account Id", func(t *testing.T) { + query := &models.CloudWatchQuery{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Dimensions: map[string][]string{ + "LoadBalancer": {"lb1", "lb2", "lb3"}, + "InstanceId": {"i-123", "*", "i-789"}, + }, + Period: 300, + Expression: "", + MatchExact: matchExact, + AccountId: aws.String("some account id"), + } + + res := buildSearchExpression(query, "Average") + assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") "InstanceId" :aws.AccountId="some account id"', 'Average', 300))`, res) + }) }) t.Run("Query has invalid characters in dimension values", func(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index cb7c658c483..cc4e1f3904f 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -17,7 +17,6 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" ) @@ -316,7 +315,6 @@ func (e *cloudWatchExecutor) handleGetLogGroups(pluginCtx backend.PluginContext, if err != nil || response == nil { return nil, err } - result := make([]suggestData, 0) for _, logGroup := range response.LogGroups { logGroupName := *logGroup.LogGroupName diff --git a/pkg/tsdb/cloudwatch/mocks/accounts_service.go b/pkg/tsdb/cloudwatch/mocks/accounts_service.go new file mode 100644 index 00000000000..52f3aff63b5 --- /dev/null +++ b/pkg/tsdb/cloudwatch/mocks/accounts_service.go @@ -0,0 +1,16 @@ +package mocks + +import ( + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/stretchr/testify/mock" +) + +type AccountsServiceMock struct { + mock.Mock +} + +func (a *AccountsServiceMock) GetAccountsForCurrentUserOrRole() ([]resources.ResourceResponse[resources.Account], error) { + args := a.Called() + + return args.Get(0).([]resources.ResourceResponse[resources.Account]), args.Error(1) +} diff --git a/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go b/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go index 8508f6e5697..b36077229a5 100644 --- a/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go +++ b/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go @@ -9,9 +9,8 @@ import ( ) type FakeMetricsAPI struct { - cloudwatchiface.CloudWatchAPI - Metrics []*cloudwatch.Metric + OwningAccounts []*string MetricsPerPage int } @@ -23,7 +22,8 @@ func (c *FakeMetricsAPI) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn for i, metrics := range chunks { response := fn(&cloudwatch.ListMetricsOutput{ - Metrics: metrics, + Metrics: metrics, + OwningAccounts: c.OwningAccounts, }, i+1 == len(chunks)) if !response { break diff --git a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go index e809bc8518c..8c9fb73f697 100644 --- a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go +++ b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go @@ -9,26 +9,20 @@ type ListMetricsServiceMock struct { mock.Mock } -func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]string, error) { +func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]resources.ResourceResponse[string], error) { args := a.Called(r) - return args.Get(0).([]string), args.Error(1) + return args.Get(0).([]resources.ResourceResponse[string]), args.Error(1) } -func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]string, error) { +func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]resources.ResourceResponse[string], error) { args := a.Called(r) - return args.Get(0).([]string), args.Error(1) + return args.Get(0).([]resources.ResourceResponse[string]), args.Error(1) } -func (a *ListMetricsServiceMock) GetDimensionKeysByNamespace(namespace string) ([]string, error) { - args := a.Called(namespace) +func (a *ListMetricsServiceMock) GetMetricsByNamespace(r resources.MetricsRequest) ([]resources.ResourceResponse[resources.Metric], error) { + args := a.Called(r) - return args.Get(0).([]string), args.Error(1) -} - -func (a *ListMetricsServiceMock) GetMetricsByNamespace(namespace string) ([]resources.Metric, error) { - args := a.Called(namespace) - - return args.Get(0).([]resources.Metric), args.Error(1) + return args.Get(0).([]resources.ResourceResponse[resources.Metric]), args.Error(1) } diff --git a/pkg/tsdb/cloudwatch/mocks/logs.go b/pkg/tsdb/cloudwatch/mocks/logs.go new file mode 100644 index 00000000000..c6210eeceac --- /dev/null +++ b/pkg/tsdb/cloudwatch/mocks/logs.go @@ -0,0 +1,37 @@ +package mocks + +import ( + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/stretchr/testify/mock" +) + +type LogsAPI struct { + mock.Mock +} + +func (l *LogsAPI) DescribeLogGroups(input *cloudwatchlogs.DescribeLogGroupsInput) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + args := l.Called(input) + + return args.Get(0).(*cloudwatchlogs.DescribeLogGroupsOutput), args.Error(1) +} + +type LogsService struct { + mock.Mock +} + +func (l *LogsService) GetLogGroups(request resources.LogGroupsRequest) ([]resources.ResourceResponse[resources.LogGroup], error) { + args := l.Called(request) + + return args.Get(0).([]resources.ResourceResponse[resources.LogGroup]), args.Error(1) +} + +type MockFeatures struct { + mock.Mock +} + +func (f *MockFeatures) IsEnabled(feature string) bool { + args := f.Called(feature) + + return args.Bool(0) +} diff --git a/pkg/tsdb/cloudwatch/mocks/metrics_client.go b/pkg/tsdb/cloudwatch/mocks/metrics_client.go index 3b0d818f7f4..057389ba71e 100644 --- a/pkg/tsdb/cloudwatch/mocks/metrics_client.go +++ b/pkg/tsdb/cloudwatch/mocks/metrics_client.go @@ -2,6 +2,7 @@ package mocks import ( "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/mock" ) @@ -9,7 +10,7 @@ type FakeMetricsClient struct { mock.Mock } -func (m *FakeMetricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error) { +func (m *FakeMetricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error) { args := m.Called(params) - return args.Get(0).([]*cloudwatch.Metric), args.Error(1) + return args.Get(0).([]resources.MetricResponse), args.Error(1) } diff --git a/pkg/tsdb/cloudwatch/mocks/oam_client.go b/pkg/tsdb/cloudwatch/mocks/oam_client.go new file mode 100644 index 00000000000..2d617cc143a --- /dev/null +++ b/pkg/tsdb/cloudwatch/mocks/oam_client.go @@ -0,0 +1,20 @@ +package mocks + +import ( + "github.com/aws/aws-sdk-go/service/oam" + "github.com/stretchr/testify/mock" +) + +type FakeOAMClient struct { + mock.Mock +} + +func (o *FakeOAMClient) ListSinks(input *oam.ListSinksInput) (*oam.ListSinksOutput, error) { + args := o.Called(input) + return args.Get(0).(*oam.ListSinksOutput), args.Error(1) +} + +func (o *FakeOAMClient) ListAttachedLinks(input *oam.ListAttachedLinksInput) (*oam.ListAttachedLinksOutput, error) { + args := o.Called(input) + return args.Get(0).(*oam.ListAttachedLinksOutput), args.Error(1) +} diff --git a/pkg/tsdb/cloudwatch/models/api.go b/pkg/tsdb/cloudwatch/models/api.go index b86860e5efd..41455687f5f 100644 --- a/pkg/tsdb/cloudwatch/models/api.go +++ b/pkg/tsdb/cloudwatch/models/api.go @@ -1,21 +1,55 @@ package models import ( + "net/url" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go/service/oam" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) +type RequestContextFactoryFunc func(pluginCtx backend.PluginContext, region string) (reqCtx RequestContext, err error) + +type RouteHandlerFunc func(pluginCtx backend.PluginContext, reqContextFactory RequestContextFactoryFunc, parameters url.Values) ([]byte, *HttpError) + +type RequestContext struct { + MetricsClientProvider MetricsClientProvider + LogsAPIProvider CloudWatchLogsAPIProvider + OAMClientProvider OAMClientProvider + Settings CloudWatchSettings + Features featuremgmt.FeatureToggles +} + type ListMetricsProvider interface { - GetDimensionKeysByDimensionFilter(resources.DimensionKeysRequest) ([]string, error) - GetDimensionKeysByNamespace(string) ([]string, error) - GetDimensionValuesByDimensionFilter(resources.DimensionValuesRequest) ([]string, error) - GetMetricsByNamespace(namespace string) ([]resources.Metric, error) + GetDimensionKeysByDimensionFilter(resources.DimensionKeysRequest) ([]resources.ResourceResponse[string], error) + GetDimensionValuesByDimensionFilter(resources.DimensionValuesRequest) ([]resources.ResourceResponse[string], error) + GetMetricsByNamespace(r resources.MetricsRequest) ([]resources.ResourceResponse[resources.Metric], error) } type MetricsClientProvider interface { - ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error) + ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error) } type CloudWatchMetricsAPIProvider interface { ListMetricsPages(*cloudwatch.ListMetricsInput, func(*cloudwatch.ListMetricsOutput, bool) bool) error } + +type CloudWatchLogsAPIProvider interface { + DescribeLogGroups(*cloudwatchlogs.DescribeLogGroupsInput) (*cloudwatchlogs.DescribeLogGroupsOutput, error) +} + +type OAMClientProvider interface { + ListSinks(*oam.ListSinksInput) (*oam.ListSinksOutput, error) + ListAttachedLinks(*oam.ListAttachedLinksInput) (*oam.ListAttachedLinksOutput, error) +} + +type LogGroupsProvider interface { + GetLogGroups(request resources.LogGroupsRequest) ([]resources.ResourceResponse[resources.LogGroup], error) +} + +type AccountsProvider interface { + GetAccountsForCurrentUserOrRole() ([]resources.ResourceResponse[resources.Account], error) +} diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go index 248796e35b5..a6cf683152e 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go @@ -60,6 +60,7 @@ type CloudWatchQuery struct { TimezoneUTCOffset string MetricQueryType MetricQueryType MetricEditorMode MetricEditorMode + AccountId *string } func (q *CloudWatchQuery) GetGMDAPIMode(logger log.Logger) GMDApiMode { @@ -95,6 +96,10 @@ func (q *CloudWatchQuery) IsInferredSearchExpression() bool { return false } + if q.AccountId != nil && *q.AccountId == "all" { + return true + } + if len(q.Dimensions) == 0 { return !q.MatchExact } @@ -167,6 +172,9 @@ func (q *CloudWatchQuery) BuildDeepLink(startTime time.Time, endTime time.Time, if dynamicLabelEnabled { metricStatMeta.Label = q.Label } + if q.AccountId != nil { + metricStatMeta.AccountId = *q.AccountId + } metricStat = append(metricStat, metricStatMeta) link.Metrics = []interface{}{metricStat} } @@ -214,11 +222,13 @@ type metricsDataQuery struct { QueryType string `json:"type"` Hide *bool `json:"hide"` Alias string `json:"alias"` + AccountId *string `json:"accountId"` } // ParseMetricDataQueries decodes the metric data queries json, validates, sets default values and returns an array of CloudWatchQueries. // The CloudWatchQuery has a 1 to 1 mapping to a query editor row -func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time, endTime time.Time, dynamicLabelsEnabled bool) ([]*CloudWatchQuery, error) { +func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time, endTime time.Time, dynamicLabelsEnabled, + crossAccountQueryingEnabled bool) ([]*CloudWatchQuery, error) { var metricDataQueries = make(map[string]metricsDataQuery) for _, query := range dataQueries { var metricsDataQuery metricsDataQuery @@ -250,7 +260,7 @@ func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time Expression: mdq.Expression, } - if err := cwQuery.validateAndSetDefaults(refId, mdq, startTime, endTime); err != nil { + if err := cwQuery.validateAndSetDefaults(refId, mdq, startTime, endTime, crossAccountQueryingEnabled); err != nil { return nil, &QueryError{Err: err, RefID: refId} } @@ -267,7 +277,8 @@ func (q *CloudWatchQuery) migrateLegacyQuery(query metricsDataQuery, dynamicLabe q.Label = getLabel(query, dynamicLabelsEnabled) } -func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery metricsDataQuery, startTime, endTime time.Time) error { +func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery metricsDataQuery, startTime, endTime time.Time, + crossAccountQueryingEnabled bool) error { if metricsDataQuery.Statistic == nil && metricsDataQuery.Statistics == nil { return fmt.Errorf("query must have either statistic or statistics field") } @@ -283,6 +294,10 @@ func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery return fmt.Errorf("failed to parse dimensions: %v", err) } + if crossAccountQueryingEnabled { + q.AccountId = metricsDataQuery.AccountId + } + if metricsDataQuery.Id == "" { // Why not just use refId if id is not specified in the frontend? When specifying an id in the editor, // and alphabetical must be used. The id must be unique, so if an id like for example a, b or c would be used, diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go index d2467b2ed46..89459f66bea 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go @@ -107,6 +107,53 @@ func TestCloudWatchQuery(t *testing.T) { require.NoError(t, err) assert.NotContains(t, deepLink, "label") }) + + t.Run("includes account id in case its a metric stat query and an account id is set", func(t *testing.T) { + startTime := time.Now() + endTime := startTime.Add(2 * time.Hour) + query := &CloudWatchQuery{ + RefId: "A", + Region: "us-east-1", + Expression: "", + Statistic: "Average", + Period: 300, + Id: "id1", + MatchExact: true, + AccountId: pointer("123456789"), + Label: "${PROP('Namespace')}", + Dimensions: map[string][]string{ + "InstanceId": {"i-12345678"}, + }, + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, + } + + deepLink, err := query.BuildDeepLink(startTime, endTime, false) + require.NoError(t, err) + assert.Contains(t, deepLink, "accountId%22%3A%22123456789") + }) + + t.Run("does not include account id in case its not a metric stat query", func(t *testing.T) { + startTime := time.Now() + endTime := startTime.Add(2 * time.Hour) + query := &CloudWatchQuery{ + RefId: "A", + Region: "us-east-1", + Statistic: "Average", + Expression: "SEARCH(someexpression)", + AccountId: pointer("123456789"), + Period: 300, + Id: "id1", + MatchExact: true, + Label: "${PROP('Namespace')}", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeRaw, + } + + deepLink, err := query.BuildDeepLink(startTime, endTime, false) + require.NoError(t, err) + assert.NotContains(t, deepLink, "accountId%22%3A%22123456789") + }) }) t.Run("SEARCH(someexpression) was specified in the query editor", func(t *testing.T) { @@ -269,7 +316,7 @@ func TestRequestParser(t *testing.T) { }, } - migratedQueries, err := ParseMetricDataQueries(oldQuery, time.Now(), time.Now(), false) + migratedQueries, err := ParseMetricDataQueries(oldQuery, time.Now(), time.Now(), false, false) assert.NoError(t, err) require.Len(t, migratedQueries, 1) require.NotNil(t, migratedQueries[0]) @@ -300,7 +347,7 @@ func TestRequestParser(t *testing.T) { }, } - results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, results, 1) res := results[0] @@ -343,7 +390,7 @@ func TestRequestParser(t *testing.T) { }, } - results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) assert.NoError(t, err) require.Len(t, results, 1) res := results[0] @@ -376,7 +423,7 @@ func TestRequestParser(t *testing.T) { }, } - _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.Error(t, err) assert.Equal(t, `error parsing query "", failed to parse dimensions: unknown type as dimension value`, err.Error()) @@ -405,7 +452,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -437,7 +484,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.Local().Add(time.Minute * time.Duration(5)) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 60, res[0].Period) @@ -447,7 +494,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -1) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 60, res[0].Period) @@ -456,7 +503,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days", func(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 300, res[0].Period) @@ -466,7 +513,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -7) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 900, res[0].Period) @@ -476,7 +523,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -30) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 3600, res[0].Period) @@ -486,7 +533,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -90) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 21600, res[0].Period) @@ -496,7 +543,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(-1, 0, 0) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.Nil(t, err) require.Len(t, res, 1) assert.Equal(t, 21600, res[0].Period) @@ -506,7 +553,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(-2, 0, 0) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 86400, res[0].Period) @@ -515,7 +562,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days, but 16 days ago", func(t *testing.T) { to := time.Now().AddDate(0, 0, -14) from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 300, res[0].Period) @@ -524,7 +571,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days, but 90 days ago", func(t *testing.T) { to := time.Now().AddDate(0, 0, -88) from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 3600, res[0].Period) @@ -533,7 +580,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days, but 456 days ago", func(t *testing.T) { to := time.Now().AddDate(0, 0, -454) from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, false) + res, err := ParseMetricDataQueries(query, from, to, false, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 21600, res[0].Period) @@ -548,7 +595,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { }`), }, } - _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.Error(t, err) assert.Equal(t, `error parsing query "", failed to parse period as duration: time: invalid duration "invalid"`, err.Error()) }) @@ -563,7 +610,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) assert.NoError(t, err) require.Len(t, res, 1) @@ -620,6 +667,18 @@ func Test_ParseMetricDataQueries_query_type_and_metric_editor_mode_and_GMD_query expectedMetricEditorMode: dummyTestEditorMode, expectedGMDApiMode: GMDApiModeMetricStat, }, + "no dimensions, matchExact is false": { + extraDataQueryJson: `"matchExact":false,`, + expectedMetricQueryType: MetricQueryTypeSearch, + expectedMetricEditorMode: MetricEditorModeBuilder, + expectedGMDApiMode: GMDApiModeInferredSearchExpression, + }, + "query metricQueryType": { + extraDataQueryJson: `"metricQueryType":1,`, + expectedMetricQueryType: MetricQueryTypeQuery, + expectedMetricEditorMode: MetricEditorModeBuilder, + expectedGMDApiMode: GMDApiModeSQLExpression, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { @@ -638,7 +697,7 @@ func Test_ParseMetricDataQueries_query_type_and_metric_editor_mode_and_GMD_query ), }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), false) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -664,7 +723,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -685,7 +744,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -706,7 +765,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -725,7 +784,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -746,7 +805,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -767,7 +826,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -790,7 +849,7 @@ func Test_ParseMetricDataQueries_ID(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -811,7 +870,7 @@ func Test_ParseMetricDataQueries_ID(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -838,7 +897,7 @@ func Test_ParseMetricDataQueries_sets_label_when_label_is_present_in_json_query( }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -902,7 +961,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true, false) assert.NoError(t, err) require.Len(t, res, 1) @@ -949,7 +1008,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true, false) assert.NoError(t, err) require.Len(t, res, 2) @@ -1019,7 +1078,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }`, tc.labelJson)), }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), tc.dynamicLabelsFeatureToggleEnabled) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), tc.dynamicLabelsFeatureToggleEnabled, false) assert.NoError(t, err) require.Len(t, res, 1) @@ -1046,7 +1105,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte("{}"), }, - }, time.Now(), time.Now(), false) + }, time.Now(), time.Now(), false, false) assert.Error(t, err) assert.Equal(t, `error parsing query "", query must have either statistic or statistics field`, err.Error()) @@ -1059,7 +1118,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"type":"some other type", "statistic":"Average", "matchExact":false}`), }, - }, time.Now(), time.Now(), false) + }, time.Now(), time.Now(), false, false) assert.NoError(t, err) assert.Empty(t, actual) @@ -1071,7 +1130,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"statistic":"Average"}`), }, - }, time.Now(), time.Now(), false) + }, time.Now(), time.Now(), false, false) assert.NoError(t, err) assert.NotEmpty(t, actual) @@ -1083,7 +1142,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"statistic":"Average"}`), }, - }, time.Now(), time.Now(), false) + }, time.Now(), time.Now(), false, false) assert.NoError(t, err) assert.Len(t, actual, 1) @@ -1097,7 +1156,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"statistic":"Average","matchExact":false}`), }, - }, time.Now(), time.Now(), false) + }, time.Now(), time.Now(), false, false) assert.NoError(t, err) assert.Len(t, actual, 1) @@ -1105,3 +1164,36 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE assert.False(t, actual[0].MatchExact) }) } + +func Test_ParseMetricDataQueries_account_Id(t *testing.T) { + t.Run("account is set when cross account querying enabled", func(t *testing.T) { + actual, err := ParseMetricDataQueries( + []backend.DataQuery{ + { + JSON: []byte(`{"accountId":"some account id", "statistic":"Average"}`), + }, + }, time.Now(), time.Now(), false, true) + assert.NoError(t, err) + + require.Len(t, actual, 1) + require.NotNil(t, actual[0]) + require.NotNil(t, actual[0].AccountId) + assert.Equal(t, "some account id", *actual[0].AccountId) + }) + + t.Run("account is not set when cross account querying disabled", func(t *testing.T) { + actual, err := ParseMetricDataQueries( + []backend.DataQuery{ + { + JSON: []byte(`{"accountId":"some account id", "statistic":"Average"}`), + }, + }, time.Now(), time.Now(), false, false) + assert.NoError(t, err) + + require.Len(t, actual, 1) + require.NotNil(t, actual[0]) + assert.Nil(t, actual[0].AccountId) + }) +} + +func pointer[T any](arg T) *T { return &arg } diff --git a/pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go b/pkg/tsdb/cloudwatch/models/resources/dimension_values_request_test.go similarity index 100% rename from pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go rename to pkg/tsdb/cloudwatch/models/resources/dimension_values_request_test.go diff --git a/pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go b/pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go new file mode 100644 index 00000000000..bf1502c75d0 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go @@ -0,0 +1,53 @@ +package resources + +import ( + "fmt" + "net/url" + "strconv" +) + +const defaultLogGroupLimit = int64(50) + +type LogGroupsRequest struct { + ResourceRequest + Limit int64 + LogGroupNamePrefix, LogGroupNamePattern *string +} + +func (r LogGroupsRequest) IsTargetingAllAccounts() bool { + return *r.AccountId == "all" +} + +func ParseLogGroupsRequest(parameters url.Values) (LogGroupsRequest, error) { + logGroupNamePrefix := setIfNotEmptyString(parameters.Get("logGroupNamePrefix")) + logGroupPattern := setIfNotEmptyString(parameters.Get("logGroupPattern")) + if logGroupNamePrefix != nil && logGroupPattern != nil { + return LogGroupsRequest{}, fmt.Errorf("cannot set both log group name prefix and pattern") + } + + return LogGroupsRequest{ + Limit: getLimit(parameters.Get("limit")), + ResourceRequest: ResourceRequest{ + Region: parameters.Get("region"), + AccountId: setIfNotEmptyString(parameters.Get("accountId")), + }, + LogGroupNamePrefix: logGroupNamePrefix, + LogGroupNamePattern: logGroupPattern, + }, nil +} + +func setIfNotEmptyString(paramValue string) *string { + if paramValue == "" { + return nil + } + return ¶mValue +} + +func getLimit(limit string) int64 { + logGroupLimit := defaultLogGroupLimit + intLimit, err := strconv.ParseInt(limit, 10, 64) + if err == nil && intLimit > 0 { + logGroupLimit = intLimit + } + return logGroupLimit +} diff --git a/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go index 6bc749348cd..2aaf53931f4 100644 --- a/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go @@ -17,13 +17,13 @@ type MetricsRequest struct { Namespace string } -func GetMetricsRequest(parameters url.Values) (*MetricsRequest, error) { +func GetMetricsRequest(parameters url.Values) (MetricsRequest, error) { resourceRequest, err := getResourceRequest(parameters) if err != nil { - return nil, err + return MetricsRequest{}, err } - return &MetricsRequest{ + return MetricsRequest{ ResourceRequest: resourceRequest, Namespace: parameters.Get("namespace"), }, nil diff --git a/pkg/tsdb/cloudwatch/models/resources/resource_request.go b/pkg/tsdb/cloudwatch/models/resources/resource_request.go index e13c11cb558..53b1525b62b 100644 --- a/pkg/tsdb/cloudwatch/models/resources/resource_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/resource_request.go @@ -5,8 +5,15 @@ import ( "net/url" ) +const useLinkedAccountsId = "all" + type ResourceRequest struct { - Region string + Region string + AccountId *string +} + +func (r *ResourceRequest) ShouldTargetAllAccounts() bool { + return r.AccountId != nil && *r.AccountId == useLinkedAccountsId } func getResourceRequest(parameters url.Values) (*ResourceRequest, error) { @@ -14,9 +21,24 @@ func getResourceRequest(parameters url.Values) (*ResourceRequest, error) { Region: parameters.Get("region"), } + accountId := parameters.Get("accountId") + if accountId != "" { + request.AccountId = &accountId + } + if request.Region == "" { return nil, fmt.Errorf("region is required") } return request, nil } + +type LogsRequest struct { + Limit int64 + AccountId, LogGroupNamePrefix, LogGroupNamePattern *string + IsCrossAccountQueryingEnabled bool +} + +func (r LogsRequest) IsTargetingAllAccounts() bool { + return *r.AccountId == useLinkedAccountsId +} diff --git a/pkg/tsdb/cloudwatch/models/resources/types.go b/pkg/tsdb/cloudwatch/models/resources/types.go index a218740a438..728d74c5af6 100644 --- a/pkg/tsdb/cloudwatch/models/resources/types.go +++ b/pkg/tsdb/cloudwatch/models/resources/types.go @@ -1,11 +1,35 @@ package resources +import "github.com/aws/aws-sdk-go/service/cloudwatch" + type Dimension struct { Name string Value string } +type ResourceResponse[T any] struct { + AccountId *string `json:"accountId,omitempty"` + Value T `json:"value"` +} + +type MetricResponse struct { + *cloudwatch.Metric + AccountId *string `json:"accountId,omitempty"` +} + +type Account struct { + Id string `json:"id"` + Arn string `json:"arn"` + Label string `json:"label"` + IsMonitoringAccount bool `json:"isMonitoringAccount"` +} + type Metric struct { Name string `json:"name"` Namespace string `json:"namespace"` } + +type LogGroup struct { + Arn string `json:"arn"` + Name string `json:"name"` +} diff --git a/pkg/tsdb/cloudwatch/models/types.go b/pkg/tsdb/cloudwatch/models/types.go index df41487234d..70664869e3e 100644 --- a/pkg/tsdb/cloudwatch/models/types.go +++ b/pkg/tsdb/cloudwatch/models/types.go @@ -1,20 +1,5 @@ package models -import ( - "net/url" - - "github.com/grafana/grafana-plugin-sdk-go/backend" -) - -type RequestContext struct { - MetricsClientProvider MetricsClientProvider - Settings CloudWatchSettings -} - -type RequestContextFactoryFunc func(pluginCtx backend.PluginContext, region string) (reqCtx RequestContext, err error) - -type RouteHandlerFunc func(pluginCtx backend.PluginContext, reqContextFactory RequestContextFactoryFunc, parameters url.Values) ([]byte, *HttpError) - type cloudWatchLink struct { View string `json:"view"` Stacked bool `json:"stacked"` @@ -31,7 +16,8 @@ type metricExpression struct { } type metricStatMeta struct { - Stat string `json:"stat"` - Period int `json:"period"` - Label string `json:"label,omitempty"` + Stat string `json:"stat"` + Period int `json:"period"` + Label string `json:"label,omitempty"` + AccountId string `json:"accountId,omitempty"` } diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go index d9c88baebd8..55d95b7caff 100644 --- a/pkg/tsdb/cloudwatch/resource_handler.go +++ b/pkg/tsdb/cloudwatch/resource_handler.go @@ -19,10 +19,12 @@ func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux { mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute)) mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns)) mux.HandleFunc("/log-groups", handleResourceReq(e.handleGetLogGroups)) + mux.HandleFunc("/describe-log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext)) // supports CrossAccountQuerying mux.HandleFunc("/all-log-groups", handleResourceReq(e.handleGetAllLogGroups)) mux.HandleFunc("/metrics", routes.ResourceRequestMiddleware(routes.MetricsHandler, logger, e.getRequestContext)) mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, logger, e.getRequestContext)) mux.HandleFunc("/dimension-keys", routes.ResourceRequestMiddleware(routes.DimensionKeysHandler, logger, e.getRequestContext)) + mux.HandleFunc("/accounts", routes.ResourceRequestMiddleware(routes.AccountsHandler, logger, e.getRequestContext)) mux.HandleFunc("/namespaces", routes.ResourceRequestMiddleware(routes.NamespacesHandler, logger, e.getRequestContext)) return mux } diff --git a/pkg/tsdb/cloudwatch/routes/accounts.go b/pkg/tsdb/cloudwatch/routes/accounts.go new file mode 100644 index 00000000000..8d125c55ba4 --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/accounts.go @@ -0,0 +1,55 @@ +package routes + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" +) + +func AccountsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + region := parameters.Get("region") + if region == "" { + return nil, models.NewHttpError("error in AccountsHandler", http.StatusBadRequest, fmt.Errorf("region is required")) + } + + service, err := newAccountsService(pluginCtx, reqCtxFactory, region) + if err != nil { + return nil, models.NewHttpError("error in AccountsHandler", http.StatusInternalServerError, err) + } + + accounts, err := service.GetAccountsForCurrentUserOrRole() + if err != nil { + msg := "error getting accounts for current user or role" + switch { + case errors.Is(err, services.ErrAccessDeniedException): + return nil, models.NewHttpError(msg, http.StatusForbidden, err) + default: + return nil, models.NewHttpError(msg, http.StatusInternalServerError, err) + } + } + + accountsResponse, err := json.Marshal(accounts) + if err != nil { + return nil, models.NewHttpError("error in AccountsHandler", http.StatusInternalServerError, err) + } + + return accountsResponse, nil +} + +// newAccountService is an account service factory. +// +// Stubbable by tests. +var newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) { + oamClient, err := reqCtxFactory(pluginCtx, region) + if err != nil { + return nil, err + } + + return services.NewAccountsService(oamClient.OAMClientProvider), nil +} diff --git a/pkg/tsdb/cloudwatch/routes/accounts_test.go b/pkg/tsdb/cloudwatch/routes/accounts_test.go new file mode 100644 index 00000000000..6562f46b360 --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/accounts_test.go @@ -0,0 +1,96 @@ +package routes + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" + "github.com/stretchr/testify/assert" +) + +func Test_accounts_route(t *testing.T) { + origNewAccountsService := newAccountsService + t.Cleanup(func() { + newAccountsService = origNewAccountsService + }) + + t.Run("successfully returns array of accounts json", func(t *testing.T) { + mockAccountsService := mocks.AccountsServiceMock{} + mockAccountsService.On("GetAccountsForCurrentUserOrRole").Return([]resources.ResourceResponse[resources.Account]{{ + Value: resources.Account{ + Id: "123456789012", + Arn: "some arn", + Label: "some label", + IsMonitoringAccount: true, + }, + }}, nil) + newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) { + return &mockAccountsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/accounts?region=us-east-1", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.JSONEq(t, `[{"value":{"id":"123456789012", "arn":"some arn", "isMonitoringAccount":true, "label":"some label"}}]`, rr.Body.String()) + }) + + t.Run("rejects POST method", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/accounts?region=us-east-1", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil)) + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) + }) + + t.Run("requires region query value", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/accounts", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil)) + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("returns 403 when accounts service returns ErrAccessDeniedException", func(t *testing.T) { + mockAccountsService := mocks.AccountsServiceMock{} + mockAccountsService.On("GetAccountsForCurrentUserOrRole").Return([]resources.ResourceResponse[resources.Account](nil), + fmt.Errorf("%w: %s", services.ErrAccessDeniedException, "some AWS message")) + newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) { + return &mockAccountsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/accounts?region=us-east-1", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.JSONEq(t, + `{"Message":"error getting accounts for current user or role: access denied. please check your IAM policy: some AWS message", + "Error":"access denied. please check your IAM policy: some AWS message","StatusCode":403}`, rr.Body.String()) + }) + + t.Run("returns 500 when accounts service returns unknown error", func(t *testing.T) { + mockAccountsService := mocks.AccountsServiceMock{} + mockAccountsService.On("GetAccountsForCurrentUserOrRole").Return([]resources.ResourceResponse[resources.Account](nil), fmt.Errorf("some error")) + newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) { + return &mockAccountsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/accounts?region=us-east-1", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Equal(t, `{"Message":"error getting accounts for current user or role: some error","Error":"some error","StatusCode":500}`, rr.Body.String()) + }) +} diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys.go b/pkg/tsdb/cloudwatch/routes/dimension_keys.go index 302225dbed3..906f8229170 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys.go @@ -22,7 +22,7 @@ func DimensionKeysHandler(pluginCtx backend.PluginContext, reqCtxFactory models. return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusInternalServerError, err) } - var response []string + var response []resources.ResourceResponse[string] switch dimensionKeysRequest.Type() { case resources.FilterDimensionKeysRequest: response, err = service.GetDimensionKeysByDimensionFilter(dimensionKeysRequest) diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go index 068e0fd4bca..4b02727ffd4 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go @@ -31,7 +31,7 @@ func Test_DimensionKeys_Route(t *testing.T) { len(r.DimensionFilter) == 2 && assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "NodeID", Value: "Shared"}) && assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "stage", Value: "QueryCommit"}) - })).Return([]string{}, nil).Once() + })).Return([]resources.ResourceResponse[string]{}, nil).Once() newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -48,10 +48,10 @@ func Test_DimensionKeys_Route(t *testing.T) { }) haveBeenCalled := false usedNamespace := "" - services.GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]string, error) { + services.GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]resources.ResourceResponse[string], error) { haveBeenCalled = true usedNamespace = namespace - return []string{}, nil + return []resources.ResourceResponse[string]{}, nil } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/dimension-keys?region=us-east-2&namespace=AWS/EC2&metricName=CPUUtilization", nil) @@ -66,7 +66,7 @@ func Test_DimensionKeys_Route(t *testing.T) { t.Run("return 500 if GetDimensionKeysByDimensionFilter returns an error", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.Anything).Return([]string{}, fmt.Errorf("some error")) + mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.Anything).Return([]resources.ResourceResponse[string]{}, fmt.Errorf("some error")) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } diff --git a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go index cf365c1a090..8ecdf240666 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go @@ -26,7 +26,7 @@ func Test_DimensionValues_Route(t *testing.T) { len(r.DimensionFilter) == 2 && assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "NodeID", Value: "Shared"}) && assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "stage", Value: "QueryCommit"}) - })).Return([]string{}, nil).Once() + })).Return([]resources.ResourceResponse[string]{}, nil).Once() newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -38,7 +38,7 @@ func Test_DimensionValues_Route(t *testing.T) { t.Run("returns 500 if GetDimensionValuesByDimensionFilter returns an error", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.Anything).Return([]string{}, fmt.Errorf("some error")) + mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.Anything).Return([]resources.ResourceResponse[string]{}, fmt.Errorf("some error")) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } diff --git a/pkg/tsdb/cloudwatch/routes/log_groups.go b/pkg/tsdb/cloudwatch/routes/log_groups.go new file mode 100644 index 00000000000..d5a803ec01d --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/log_groups.go @@ -0,0 +1,49 @@ +package routes + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" +) + +func LogGroupsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + request, err := resources.ParseLogGroupsRequest(parameters) + if err != nil { + return nil, models.NewHttpError("cannot set both log group name prefix and pattern", http.StatusBadRequest, err) + } + + service, err := newLogGroupsService(pluginCtx, reqCtxFactory, request.Region) + if err != nil { + return nil, models.NewHttpError("newLogGroupsService error", http.StatusInternalServerError, err) + } + + logGroups, err := service.GetLogGroups(request) + if err != nil { + return nil, models.NewHttpError("GetLogGroups error", http.StatusInternalServerError, err) + } + + logGroupsResponse, err := json.Marshal(logGroups) + if err != nil { + return nil, models.NewHttpError("LogGroupsHandler json error", http.StatusInternalServerError, err) + } + + return logGroupsResponse, nil +} + +// newLogGroupsService is a describe log groups service factory. +// +// Stubbable by tests. +var newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + reqCtx, err := reqCtxFactory(pluginCtx, region) + if err != nil { + return nil, err + } + + return services.NewLogGroupsService(reqCtx.LogsAPIProvider, reqCtx.Features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying)), nil +} diff --git a/pkg/tsdb/cloudwatch/routes/log_groups_test.go b/pkg/tsdb/cloudwatch/routes/log_groups_test.go new file mode 100644 index 00000000000..4902679c93a --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/log_groups_test.go @@ -0,0 +1,240 @@ +package routes + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_log_groups_route(t *testing.T) { + origLogGroupsService := newLogGroupsService + t.Cleanup(func() { + newLogGroupsService = origLogGroupsService + }) + + mockFeatures := mocks.MockFeatures{} + mockFeatures.On("IsEnabled", featuremgmt.FlagCloudWatchCrossAccountQuerying).Return(false) + reqCtxFunc := func(pluginCtx backend.PluginContext, region string) (reqCtx models.RequestContext, err error) { + return models.RequestContext{Features: &mockFeatures}, err + } + + t.Run("successfully returns 1 log group with account id", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{{ + Value: resources.LogGroup{ + Arn: "some arn", + Name: "some name", + }, + AccountId: utils.Pointer("111"), + }}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.JSONEq(t, `[{"value":{"name":"some name", "arn":"some arn"},"accountId":"111"}]`, rr.Body.String()) + }) + + t.Run("successfully returns multiple log groups with account id", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return( + []resources.ResourceResponse[resources.LogGroup]{ + { + Value: resources.LogGroup{ + Arn: "arn 1", + Name: "name 1", + }, + AccountId: utils.Pointer("111"), + }, { + Value: resources.LogGroup{ + Arn: "arn 2", + Name: "name 2", + }, + AccountId: utils.Pointer("222"), + }, + }, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.JSONEq(t, `[ + { + "value":{ + "name":"name 1", + "arn":"arn 1" + }, + "accountId":"111" + }, + { + "value":{ + "name":"name 2", + "arn":"arn 2" + }, + "accountId":"222" + } + ]`, rr.Body.String()) + }) + + t.Run("returns error when both logGroupPrefix and logGroup Pattern are provided", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups?logGroupNamePrefix=some-prefix&logGroupPattern=some-pattern", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.JSONEq(t, `{"Error":"cannot set both log group name prefix and pattern", "Message":"cannot set both log group name prefix and pattern: cannot set both log group name prefix and pattern", "StatusCode":400}`, rr.Body.String()) + }) + + t.Run("passes default log group limit and nil for logGroupNamePrefix, accountId, and logGroupPattern", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{ + Limit: 50, + ResourceRequest: resources.ResourceRequest{}, + LogGroupNamePrefix: nil, + LogGroupNamePattern: nil, + }) + }) + + t.Run("passes default log group limit and nil for logGroupNamePrefix when both are absent", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{ + Limit: 50, + LogGroupNamePrefix: nil, + }) + }) + + t.Run("passes log group limit from query parameter", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups?limit=2", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{ + Limit: 2, + }) + }) + + t.Run("passes logGroupPrefix from query parameter", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups?logGroupNamePrefix=some-prefix", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{ + Limit: 50, + LogGroupNamePrefix: utils.Pointer("some-prefix"), + }) + }) + + t.Run("passes logGroupPattern from query parameter", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups?logGroupPattern=some-pattern", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{ + Limit: 50, + LogGroupNamePattern: utils.Pointer("some-pattern"), + }) + }) + + t.Run("passes logGroupPattern from query parameter", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups?accountId=some-account-id", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{ + Limit: 50, + ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("some-account-id")}, + }) + }) + + t.Run("returns error if service returns error", func(t *testing.T) { + mockLogsService := mocks.LogsService{} + mockLogsService.On("GetLogGroups", mock.Anything). + Return([]resources.ResourceResponse[resources.LogGroup]{}, fmt.Errorf("some error")) + newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) { + return &mockLogsService, nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/log-groups", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc)) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.JSONEq(t, `{"Error":"some error","Message":"GetLogGroups error: some error","StatusCode":500}`, rr.Body.String()) + }) +} diff --git a/pkg/tsdb/cloudwatch/routes/metrics.go b/pkg/tsdb/cloudwatch/routes/metrics.go index db90f7b2f2c..54aadfc78c0 100644 --- a/pkg/tsdb/cloudwatch/routes/metrics.go +++ b/pkg/tsdb/cloudwatch/routes/metrics.go @@ -22,20 +22,20 @@ func MetricsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.Reques return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err) } - var metrics []resources.Metric + var response []resources.ResourceResponse[resources.Metric] switch metricsRequest.Type() { case resources.AllMetricsRequestType: - metrics = services.GetAllHardCodedMetrics() + response = services.GetAllHardCodedMetrics() case resources.MetricsByNamespaceRequestType: - metrics, err = services.GetHardCodedMetricsByNamespace(metricsRequest.Namespace) + response, err = services.GetHardCodedMetricsByNamespace(metricsRequest.Namespace) case resources.CustomNamespaceRequestType: - metrics, err = service.GetMetricsByNamespace(metricsRequest.Namespace) + response, err = service.GetMetricsByNamespace(metricsRequest) } if err != nil { return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err) } - metricsResponse, err := json.Marshal(metrics) + metricsResponse, err := json.Marshal(response) if err != nil { return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err) } diff --git a/pkg/tsdb/cloudwatch/routes/metrics_test.go b/pkg/tsdb/cloudwatch/routes/metrics_test.go index 061a846012c..b11da17af18 100644 --- a/pkg/tsdb/cloudwatch/routes/metrics_test.go +++ b/pkg/tsdb/cloudwatch/routes/metrics_test.go @@ -1,27 +1,24 @@ package routes import ( - "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) func Test_Metrics_Route(t *testing.T) { t.Run("calls GetMetricsByNamespace when a CustomNamespaceRequestType is passed", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.Metric{}, nil) + mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.ResourceResponse[resources.Metric]{}, nil) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -38,17 +35,14 @@ func Test_Metrics_Route(t *testing.T) { services.GetAllHardCodedMetrics = origGetAllHardCodedMetrics }) haveBeenCalled := false - services.GetAllHardCodedMetrics = func() []resources.Metric { + services.GetAllHardCodedMetrics = func() []resources.ResourceResponse[resources.Metric] { haveBeenCalled = true - return []resources.Metric{} + return []resources.ResourceResponse[resources.Metric]{} } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/metrics?region=us-east-2", nil) handler := http.HandlerFunc(ResourceRequestMiddleware(MetricsHandler, logger, nil)) handler.ServeHTTP(rr, req) - res := []resources.Metric{} - err := json.Unmarshal(rr.Body.Bytes(), &res) - require.Nil(t, err) assert.True(t, haveBeenCalled) }) @@ -59,25 +53,22 @@ func Test_Metrics_Route(t *testing.T) { }) haveBeenCalled := false usedNamespace := "" - services.GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, error) { + services.GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.ResourceResponse[resources.Metric], error) { haveBeenCalled = true usedNamespace = namespace - return []resources.Metric{}, nil + return []resources.ResourceResponse[resources.Metric]{}, nil } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/metrics?region=us-east-2&namespace=AWS/DMS", nil) handler := http.HandlerFunc(ResourceRequestMiddleware(MetricsHandler, logger, nil)) handler.ServeHTTP(rr, req) - res := []resources.Metric{} - err := json.Unmarshal(rr.Body.Bytes(), &res) - require.Nil(t, err) assert.True(t, haveBeenCalled) assert.Equal(t, "AWS/DMS", usedNamespace) }) t.Run("returns 500 if GetMetricsByNamespace returns an error", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.Metric{}, fmt.Errorf("some error")) + mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.ResourceResponse[resources.Metric]{}, fmt.Errorf("some error")) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } diff --git a/pkg/tsdb/cloudwatch/routes/namespaces.go b/pkg/tsdb/cloudwatch/routes/namespaces.go index 040748c5407..a79f7bb0def 100644 --- a/pkg/tsdb/cloudwatch/routes/namespaces.go +++ b/pkg/tsdb/cloudwatch/routes/namespaces.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" ) @@ -18,14 +19,19 @@ func NamespacesHandler(pluginCtx backend.PluginContext, reqCtxFactory models.Req return nil, models.NewHttpError("error in NamespacesHandler", http.StatusInternalServerError, err) } - result := services.GetHardCodedNamespaces() + response := services.GetHardCodedNamespaces() customNamespace := reqCtx.Settings.Namespace if customNamespace != "" { - result = append(result, strings.Split(customNamespace, ",")...) + customNamespaces := strings.Split(customNamespace, ",") + for _, customNamespace := range customNamespaces { + response = append(response, resources.ResourceResponse[string]{Value: customNamespace}) + } } - sort.Strings(result) + sort.Slice(response, func(i, j int) bool { + return response[i].Value < response[j].Value + }) - namespacesResponse, err := json.Marshal(result) + namespacesResponse, err := json.Marshal(response) if err != nil { return nil, models.NewHttpError("error in NamespacesHandler", http.StatusInternalServerError, err) } diff --git a/pkg/tsdb/cloudwatch/routes/namespaces_test.go b/pkg/tsdb/cloudwatch/routes/namespaces_test.go index 9eeec6f0a94..f2006677b19 100644 --- a/pkg/tsdb/cloudwatch/routes/namespaces_test.go +++ b/pkg/tsdb/cloudwatch/routes/namespaces_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" ) @@ -28,9 +29,9 @@ func Test_Namespaces_Route(t *testing.T) { services.GetHardCodedNamespaces = origGetHardCodedNamespaces }) haveBeenCalled := false - services.GetHardCodedNamespaces = func() []string { + services.GetHardCodedNamespaces = func() []resources.ResourceResponse[string] { haveBeenCalled = true - return []string{} + return []resources.ResourceResponse[string]{} } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/namespaces", nil) @@ -44,15 +45,15 @@ func Test_Namespaces_Route(t *testing.T) { t.Cleanup(func() { services.GetHardCodedNamespaces = origGetHardCodedNamespaces }) - services.GetHardCodedNamespaces = func() []string { - return []string{"AWS/EC2", "AWS/ELB"} + services.GetHardCodedNamespaces = func() []resources.ResourceResponse[string] { + return []resources.ResourceResponse[string]{{Value: "AWS/EC2"}, {Value: "AWS/ELB"}} } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/namespaces", nil) customNamespaces = "customNamespace1,customNamespace2" handler := http.HandlerFunc(ResourceRequestMiddleware(NamespacesHandler, logger, factoryFunc)) handler.ServeHTTP(rr, req) - assert.JSONEq(t, `["AWS/EC2", "AWS/ELB", "customNamespace1", "customNamespace2"]`, rr.Body.String()) + assert.JSONEq(t, `[{"value":"AWS/EC2"}, {"value":"AWS/ELB"}, {"value":"customNamespace1"}, {"value":"customNamespace2"}]`, rr.Body.String()) }) t.Run("sorts result", func(t *testing.T) { @@ -60,14 +61,14 @@ func Test_Namespaces_Route(t *testing.T) { t.Cleanup(func() { services.GetHardCodedNamespaces = origGetHardCodedNamespaces }) - services.GetHardCodedNamespaces = func() []string { - return []string{"AWS/XYZ", "AWS/ELB"} + services.GetHardCodedNamespaces = func() []resources.ResourceResponse[string] { + return []resources.ResourceResponse[string]{{Value: "AWS/XYZ"}, {Value: "AWS/ELB"}} } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/namespaces", nil) customNamespaces = "DCustomNamespace1,ACustomNamespace2" handler := http.HandlerFunc(ResourceRequestMiddleware(NamespacesHandler, logger, factoryFunc)) handler.ServeHTTP(rr, req) - assert.JSONEq(t, `["ACustomNamespace2", "AWS/ELB", "AWS/XYZ", "DCustomNamespace1"]`, rr.Body.String()) + assert.JSONEq(t, `[{"value":"ACustomNamespace2"}, {"value":"AWS/ELB"}, {"value":"AWS/XYZ"}, {"value":"DCustomNamespace1"}]`, rr.Body.String()) }) } diff --git a/pkg/tsdb/cloudwatch/services/accounts.go b/pkg/tsdb/cloudwatch/services/accounts.go new file mode 100644 index 00000000000..ab8db64147e --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/accounts.go @@ -0,0 +1,89 @@ +package services + +import ( + "errors" + "fmt" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/oam" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" +) + +var ErrAccessDeniedException = errors.New("access denied. please check your IAM policy") + +type AccountsService struct { + models.OAMClientProvider +} + +func NewAccountsService(oamClient models.OAMClientProvider) models.AccountsProvider { + return &AccountsService{oamClient} +} + +func (a *AccountsService) GetAccountsForCurrentUserOrRole() ([]resources.ResourceResponse[resources.Account], error) { + var nextToken *string + sinks := []*oam.ListSinksItem{} + for { + response, err := a.ListSinks(&oam.ListSinksInput{NextToken: nextToken}) + if err != nil { + var aerr awserr.Error + if errors.As(err, &aerr) { + switch aerr.Code() { + // unlike many other services, OAM doesn't define this error code. however, it's returned in case calling role/user has insufficient permissions + case "AccessDeniedException": + return nil, fmt.Errorf("%w: %s", ErrAccessDeniedException, aerr.Message()) + } + } + } + if err != nil { + return nil, fmt.Errorf("ListSinks error: %w", err) + } + + sinks = append(sinks, response.Items...) + + if response.NextToken == nil { + break + } + nextToken = response.NextToken + } + + if len(sinks) == 0 { + return nil, nil + } + + sinkIdentifier := sinks[0].Arn + response := []resources.Account{{ + Id: getAccountId(*sinkIdentifier), + Label: *sinks[0].Name, + Arn: *sinkIdentifier, + IsMonitoringAccount: true, + }} + + nextToken = nil + for { + links, err := a.ListAttachedLinks(&oam.ListAttachedLinksInput{ + SinkIdentifier: sinkIdentifier, + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("ListAttachedLinks error: %w", err) + } + + for _, link := range links.Items { + arn := *link.LinkArn + response = append(response, resources.Account{ + Id: getAccountId(arn), + Label: *link.Label, + Arn: arn, + IsMonitoringAccount: false, + }) + } + + if links.NextToken == nil { + break + } + nextToken = links.NextToken + } + + return valuesToListMetricRespone(response), nil +} diff --git a/pkg/tsdb/cloudwatch/services/accounts_test.go b/pkg/tsdb/cloudwatch/services/accounts_test.go new file mode 100644 index 00000000000..7e74e6fbec9 --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/accounts_test.go @@ -0,0 +1,165 @@ +package services + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/oam" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestHandleGetAccounts(t *testing.T) { + t.Run("Should return an error in case of insufficient permissions from ListSinks", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{}, awserr.New("AccessDeniedException", + "AWS message", nil)) + accounts := NewAccountsService(fakeOAMClient) + + resp, err := accounts.GetAccountsForCurrentUserOrRole() + + assert.Error(t, err) + assert.Nil(t, resp) + assert.Equal(t, err.Error(), "access denied. please check your IAM policy: AWS message") + assert.ErrorIs(t, err, ErrAccessDeniedException) + }) + + t.Run("Should return an error in case of any error from ListSinks", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{}, fmt.Errorf("some error")) + accounts := NewAccountsService(fakeOAMClient) + + resp, err := accounts.GetAccountsForCurrentUserOrRole() + + assert.Error(t, err) + assert.Nil(t, resp) + assert.Equal(t, err.Error(), "ListSinks error: some error") + }) + + t.Run("Should return empty array in case no monitoring account exists", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{}, nil) + accounts := NewAccountsService(fakeOAMClient) + + resp, err := accounts.GetAccountsForCurrentUserOrRole() + + assert.NoError(t, err) + assert.Empty(t, resp) + }) + + t.Run("Should return one monitoring account (the first) even though ListSinks returns multiple sinks", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{ + {Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")}, + {Name: aws.String("Account 2"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group2")}, + }, + NextToken: new(string), + }, nil).Once() + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{ + {Name: aws.String("Account 3"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group3")}, + }, + NextToken: nil, + }, nil) + fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{}, nil) + accounts := NewAccountsService(fakeOAMClient) + + resp, err := accounts.GetAccountsForCurrentUserOrRole() + + assert.NoError(t, err) + fakeOAMClient.AssertNumberOfCalls(t, "ListSinks", 2) + require.Len(t, resp, 1) + assert.True(t, resp[0].Value.IsMonitoringAccount) + assert.Equal(t, "Account 1", resp[0].Value.Label) + assert.Equal(t, "arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1", resp[0].Value.Arn) + }) + + t.Run("Should merge the first sink with attached links", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{ + {Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")}, + {Name: aws.String("Account 2"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group2")}, + }, + NextToken: new(string), + }, nil).Once() + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{ + {Name: aws.String("Account 3"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group3")}, + }, + NextToken: nil, + }, nil) + fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{ + Items: []*oam.ListAttachedLinksItem{ + {Label: aws.String("Account 10"), LinkArn: aws.String("arn:aws:logs:us-east-1:123456789013:log-group:my-log-group10")}, + {Label: aws.String("Account 11"), LinkArn: aws.String("arn:aws:logs:us-east-1:123456789014:log-group:my-log-group11")}, + }, + NextToken: new(string), + }, nil).Once() + fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{ + Items: []*oam.ListAttachedLinksItem{ + {Label: aws.String("Account 12"), LinkArn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group12")}, + }, + NextToken: nil, + }, nil) + accounts := NewAccountsService(fakeOAMClient) + + resp, err := accounts.GetAccountsForCurrentUserOrRole() + + assert.NoError(t, err) + fakeOAMClient.AssertNumberOfCalls(t, "ListSinks", 2) + fakeOAMClient.AssertNumberOfCalls(t, "ListAttachedLinks", 2) + expectedAccounts := []resources.ResourceResponse[resources.Account]{ + {Value: resources.Account{Id: "123456789012", Label: "Account 1", Arn: "arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1", IsMonitoringAccount: true}}, + {Value: resources.Account{Id: "123456789013", Label: "Account 10", Arn: "arn:aws:logs:us-east-1:123456789013:log-group:my-log-group10", IsMonitoringAccount: false}}, + {Value: resources.Account{Id: "123456789014", Label: "Account 11", Arn: "arn:aws:logs:us-east-1:123456789014:log-group:my-log-group11", IsMonitoringAccount: false}}, + {Value: resources.Account{Id: "123456789012", Label: "Account 12", Arn: "arn:aws:logs:us-east-1:123456789012:log-group:my-log-group12", IsMonitoringAccount: false}}, + } + assert.Equal(t, expectedAccounts, resp) + }) + + t.Run("Should call ListAttachedLinks with arn of first sink", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{ + {Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")}, + }, + NextToken: new(string), + }, nil).Once() + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{ + {Name: aws.String("Account 3"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group3")}, + }, + NextToken: nil, + }, nil).Once() + fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{}, nil) + accounts := NewAccountsService(fakeOAMClient) + + _, _ = accounts.GetAccountsForCurrentUserOrRole() + + fakeOAMClient.AssertCalled(t, "ListAttachedLinks", &oam.ListAttachedLinksInput{ + SinkIdentifier: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1"), + }) + }) + + t.Run("Should return an error in case of any error from ListAttachedLinks", func(t *testing.T) { + fakeOAMClient := &mocks.FakeOAMClient{} + fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{ + Items: []*oam.ListSinksItem{{Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")}}, + }, nil) + fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{}, fmt.Errorf("some error")).Once() + accounts := NewAccountsService(fakeOAMClient) + + resp, err := accounts.GetAccountsForCurrentUserOrRole() + + assert.Error(t, err) + assert.Nil(t, resp) + assert.Equal(t, err.Error(), "ListAttachedLinks error: some error") + }) +} diff --git a/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go b/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go index 2268089b566..8680fdf7184 100644 --- a/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go +++ b/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go @@ -7,16 +7,16 @@ import ( "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) -var GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]string, error) { - var dimensionKeys []string +var GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]resources.ResourceResponse[string], error) { + var response []string exists := false - if dimensionKeys, exists = constants.NamespaceDimensionKeysMap[namespace]; !exists { + if response, exists = constants.NamespaceDimensionKeysMap[namespace]; !exists { return nil, fmt.Errorf("unable to find dimensions for namespace '%q'", namespace) } - return dimensionKeys, nil + return valuesToListMetricRespone(response), nil } -var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, error) { +var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.ResourceResponse[resources.Metric], error) { response := []resources.Metric{} exists := false var metrics []string @@ -28,10 +28,10 @@ var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, response = append(response, resources.Metric{Namespace: namespace, Name: metric}) } - return response, nil + return valuesToListMetricRespone(response), nil } -var GetAllHardCodedMetrics = func() []resources.Metric { +var GetAllHardCodedMetrics = func() []resources.ResourceResponse[resources.Metric] { response := []resources.Metric{} for namespace, metrics := range constants.NamespaceMetricsMap { for _, metric := range metrics { @@ -39,14 +39,14 @@ var GetAllHardCodedMetrics = func() []resources.Metric { } } - return response + return valuesToListMetricRespone(response) } -var GetHardCodedNamespaces = func() []string { - var namespaces []string +var GetHardCodedNamespaces = func() []resources.ResourceResponse[string] { + response := []string{} for key := range constants.NamespaceMetricsMap { - namespaces = append(namespaces, key) + response = append(response, key) } - return namespaces + return valuesToListMetricRespone(response) } diff --git a/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go b/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go index 4895ef9dc61..e5c9d4f6ade 100644 --- a/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go @@ -19,7 +19,7 @@ func TestHardcodedMetrics_GetHardCodedDimensionKeysByNamespace(t *testing.T) { t.Run("Should return keys if namespace exist", func(t *testing.T) { resp, err := GetHardCodedDimensionKeysByNamespace("AWS/EC2") require.NoError(t, err) - assert.Equal(t, []string{"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"}, resp) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "AutoScalingGroupName"}, {Value: "ImageId"}, {Value: "InstanceId"}, {Value: "InstanceType"}}, resp) }) } @@ -34,6 +34,6 @@ func TestHardcodedMetrics_GetHardCodedMetricsByNamespace(t *testing.T) { t.Run("Should return metrics if namespace exist", func(t *testing.T) { resp, err := GetHardCodedMetricsByNamespace("AWS/IoTAnalytics") require.NoError(t, err) - assert.Equal(t, []resources.Metric{{Name: "ActionExecution", Namespace: "AWS/IoTAnalytics"}, {Name: "ActivityExecutionError", Namespace: "AWS/IoTAnalytics"}, {Name: "IncomingMessages", Namespace: "AWS/IoTAnalytics"}}, resp) + assert.Equal(t, []resources.ResourceResponse[resources.Metric]{{Value: resources.Metric{Name: "ActionExecution", Namespace: "AWS/IoTAnalytics"}}, {Value: resources.Metric{Name: "ActivityExecutionError", Namespace: "AWS/IoTAnalytics"}}, {Value: resources.Metric{Name: "IncomingMessages", Namespace: "AWS/IoTAnalytics"}}}, resp) }) } diff --git a/pkg/tsdb/cloudwatch/services/list_metrics.go b/pkg/tsdb/cloudwatch/services/list_metrics.go index 8ab2d2bebad..f4f66914dfe 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics.go @@ -18,7 +18,7 @@ func NewListMetricsService(metricsClient models.MetricsClientProvider) models.Li return &ListMetricsService{metricsClient} } -func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]string, error) { +func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]resources.ResourceResponse[string], error) { input := &cloudwatch.ListMetricsInput{} if r.Namespace != "" { input.Namespace = aws.String(r.Namespace) @@ -27,13 +27,14 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.Dimen input.MetricName = aws.String(r.MetricName) } setDimensionFilter(input, r.DimensionFilter) + setAccount(input, r.ResourceRequest) metrics, err := l.ListMetricsWithPageLimit(input) if err != nil { return nil, fmt.Errorf("%v: %w", "unable to call AWS API", err) } - var dimensionKeys []string + response := []resources.ResourceResponse[string]{} // remove duplicates dupCheck := make(map[string]struct{}) for _, metric := range metrics { @@ -56,26 +57,27 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.Dimen } dupCheck[*dim.Name] = struct{}{} - dimensionKeys = append(dimensionKeys, *dim.Name) + response = append(response, resources.ResourceResponse[string]{AccountId: metric.AccountId, Value: *dim.Name}) } } - return dimensionKeys, nil + return response, nil } -func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]string, error) { +func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]resources.ResourceResponse[string], error) { input := &cloudwatch.ListMetricsInput{ Namespace: aws.String(r.Namespace), MetricName: aws.String(r.MetricName), } setDimensionFilter(input, r.DimensionFilter) + setAccount(input, r.ResourceRequest) metrics, err := l.ListMetricsWithPageLimit(input) if err != nil { return nil, fmt.Errorf("%v: %w", "unable to call AWS API", err) } - var dimensionValues []string + response := []resources.ResourceResponse[string]{} dupCheck := make(map[string]bool) for _, metric := range metrics { for _, dim := range metric.Dimensions { @@ -85,51 +87,33 @@ func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.Dim } dupCheck[*dim.Value] = true - dimensionValues = append(dimensionValues, *dim.Value) + response = append(response, resources.ResourceResponse[string]{AccountId: metric.AccountId, Value: *dim.Value}) } } } - sort.Strings(dimensionValues) - return dimensionValues, nil + sort.Slice(response, func(i, j int) bool { + return response[i].Value < response[j].Value + }) + return response, nil } -func (l *ListMetricsService) GetDimensionKeysByNamespace(namespace string) ([]string, error) { - metrics, err := l.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)}) - if err != nil { - return []string{}, err - } - - var dimensionKeys []string - dupCheck := make(map[string]struct{}) - for _, metric := range metrics { - for _, dim := range metric.Dimensions { - if _, exists := dupCheck[*dim.Name]; exists { - continue - } - - dupCheck[*dim.Name] = struct{}{} - dimensionKeys = append(dimensionKeys, *dim.Name) - } - } - - return dimensionKeys, nil -} - -func (l *ListMetricsService) GetMetricsByNamespace(namespace string) ([]resources.Metric, error) { - metrics, err := l.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)}) +func (l *ListMetricsService) GetMetricsByNamespace(r resources.MetricsRequest) ([]resources.ResourceResponse[resources.Metric], error) { + input := &cloudwatch.ListMetricsInput{Namespace: aws.String(r.Namespace)} + setAccount(input, r.ResourceRequest) + metrics, err := l.ListMetricsWithPageLimit(input) if err != nil { return nil, err } - response := []resources.Metric{} + response := []resources.ResourceResponse[resources.Metric]{} dupCheck := make(map[string]struct{}) for _, metric := range metrics { if _, exists := dupCheck[*metric.MetricName]; exists { continue } dupCheck[*metric.MetricName] = struct{}{} - response = append(response, resources.Metric{Name: *metric.MetricName, Namespace: *metric.Namespace}) + response = append(response, resources.ResourceResponse[resources.Metric]{AccountId: metric.AccountId, Value: resources.Metric{Name: *metric.MetricName, Namespace: *metric.Namespace}}) } return response, nil @@ -146,3 +130,12 @@ func setDimensionFilter(input *cloudwatch.ListMetricsInput, dimensionFilter []*r input.Dimensions = append(input.Dimensions, df) } } + +func setAccount(input *cloudwatch.ListMetricsInput, r *resources.ResourceRequest) { + if r != nil && r.AccountId != nil { + input.IncludeLinkedAccounts = aws.Bool(true) + if !r.ShouldTargetAllAccounts() { + input.OwningAccount = r.AccountId + } + } +} diff --git a/pkg/tsdb/cloudwatch/services/list_metrics_test.go b/pkg/tsdb/cloudwatch/services/list_metrics_test.go index 34548ffaa98..8d63baabcbf 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics_test.go @@ -7,40 +7,55 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -var metricResponse = []*cloudwatch.Metric{ +const useLinkedAccountsId = "all" + +var metricResponse = []resources.MetricResponse{ { - MetricName: aws.String("CPUUtilization"), - Namespace: aws.String("AWS/EC2"), - Dimensions: []*cloudwatch.Dimension{ - {Name: aws.String("InstanceId"), Value: aws.String("i-1234567890abcdef0")}, - {Name: aws.String("InstanceType"), Value: aws.String("t2.micro")}, + Metric: &cloudwatch.Metric{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-1234567890abcdef0")}, + {Name: aws.String("InstanceType"), Value: aws.String("t2.micro")}, + }, }, }, { - MetricName: aws.String("CPUUtilization"), - Namespace: aws.String("AWS/EC2"), - Dimensions: []*cloudwatch.Dimension{ - {Name: aws.String("InstanceId"), Value: aws.String("i-5234567890abcdef0")}, - {Name: aws.String("InstanceType"), Value: aws.String("t2.micro")}, - {Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg")}, + Metric: &cloudwatch.Metric{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-5234567890abcdef0")}, + {Name: aws.String("InstanceType"), Value: aws.String("t2.micro")}, + {Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg")}, + }, }, }, { - MetricName: aws.String("CPUUtilization"), - Namespace: aws.String("AWS/EC2"), - Dimensions: []*cloudwatch.Dimension{ - {Name: aws.String("InstanceId"), Value: aws.String("i-64234567890abcdef0")}, - {Name: aws.String("InstanceType"), Value: aws.String("t3.micro")}, - {Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg2")}, + Metric: &cloudwatch.Metric{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-64234567890abcdef0")}, + {Name: aws.String("InstanceType"), Value: aws.String("t3.micro")}, + {Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg2")}, + }, }, }, } +type validateInputTestCase[T resources.DimensionKeysRequest | resources.DimensionValuesRequest] struct { + name string + input T + listMetricsWithPageLimitInput *cloudwatch.ListMetricsInput +} + func TestListMetricsService_GetDimensionKeysByDimensionFilter(t *testing.T) { t.Run("Should filter out duplicates and keys matching dimension filter keys", func(t *testing.T) { fakeMetricsClient := &mocks.FakeMetricsClient{} @@ -51,27 +66,68 @@ func TestListMetricsService_GetDimensionKeysByDimensionFilter(t *testing.T) { ResourceRequest: &resources.ResourceRequest{Region: "us-east-1"}, Namespace: "AWS/EC2", MetricName: "CPUUtilization", - DimensionFilter: []*resources.Dimension{ - {Name: "InstanceId", Value: ""}, - }, + DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}}, }) require.NoError(t, err) - assert.Equal(t, []string{"InstanceType", "AutoScalingGroupName"}, resp) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "InstanceType"}, {Value: "AutoScalingGroupName"}}, resp) }) -} -func TestListMetricsService_GetDimensionKeysByNamespace(t *testing.T) { - t.Run("Should filter out duplicates and keys matching dimension filter keys", func(t *testing.T) { - fakeMetricsClient := &mocks.FakeMetricsClient{} - fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) - listMetricsService := NewListMetricsService(fakeMetricsClient) + testCases := []validateInputTestCase[resources.DimensionKeysRequest]{ + { + name: "Should set account correctly on list metric input if it cross account is defined on the request", + input: resources.DimensionKeysRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer(useLinkedAccountsId)}, + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}}, + }, + listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}}, + IncludeLinkedAccounts: aws.Bool(true), + }, + }, + { + name: "Should set account correctly on list metric input if single account is defined on the request", + input: resources.DimensionKeysRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer("1234567890")}, + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}}, + }, + listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}}, + IncludeLinkedAccounts: aws.Bool(true), + OwningAccount: aws.String("1234567890"), + }, + }, + { + name: "Should not set namespace and metricName on list metric input if empty strings are set for these in the request", + input: resources.DimensionKeysRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1"}, + Namespace: "", + MetricName: "", + DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}}, + }, + listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}}}, + }, + } - resp, err := listMetricsService.GetDimensionKeysByNamespace("AWS/EC2") - - require.NoError(t, err) - assert.Equal(t, []string{"InstanceId", "InstanceType", "AutoScalingGroupName"}, resp) - }) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fakeMetricsClient := &mocks.FakeMetricsClient{} + fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) + listMetricsService := NewListMetricsService(fakeMetricsClient) + res, err := listMetricsService.GetDimensionKeysByDimensionFilter(tc.input) + require.NoError(t, err) + require.NotEmpty(t, res) + fakeMetricsClient.AssertCalled(t, "ListMetricsWithPageLimit", tc.listMetricsWithPageLimitInput) + }) + } } func TestListMetricsService_GetDimensionValuesByDimensionFilter(t *testing.T) { @@ -91,6 +147,52 @@ func TestListMetricsService_GetDimensionValuesByDimensionFilter(t *testing.T) { }) require.NoError(t, err) - assert.Equal(t, []string{"i-1234567890abcdef0", "i-5234567890abcdef0", "i-64234567890abcdef0"}, resp) + assert.Equal(t, []resources.ResourceResponse[string]{{Value: "i-1234567890abcdef0"}, {Value: "i-5234567890abcdef0"}, {Value: "i-64234567890abcdef0"}}, resp) }) + + testCases := []validateInputTestCase[resources.DimensionValuesRequest]{ + { + name: "Should set account correctly on list metric input if it cross account is defined on the request", + input: resources.DimensionValuesRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer(useLinkedAccountsId)}, + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}}, + }, + listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}}, + IncludeLinkedAccounts: aws.Bool(true), + }, + }, + { + name: "Should set account correctly on list metric input if single account is defined on the request", + input: resources.DimensionValuesRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer("1234567890")}, + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}}, + }, + listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{ + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}}, + IncludeLinkedAccounts: aws.Bool(true), + OwningAccount: aws.String("1234567890"), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fakeMetricsClient := &mocks.FakeMetricsClient{} + fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) + listMetricsService := NewListMetricsService(fakeMetricsClient) + res, err := listMetricsService.GetDimensionValuesByDimensionFilter(tc.input) + require.NoError(t, err) + require.Empty(t, res) + fakeMetricsClient.AssertCalled(t, "ListMetricsWithPageLimit", tc.listMetricsWithPageLimitInput) + }) + } } diff --git a/pkg/tsdb/cloudwatch/services/logs.go b/pkg/tsdb/cloudwatch/services/logs.go new file mode 100644 index 00000000000..64658bc51df --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/logs.go @@ -0,0 +1,53 @@ +package services + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" +) + +type LogGroupsService struct { + logGroupsAPI models.CloudWatchLogsAPIProvider + isCrossAccountEnabled bool +} + +func NewLogGroupsService(logsClient models.CloudWatchLogsAPIProvider, isCrossAccountEnabled bool) models.LogGroupsProvider { + return &LogGroupsService{logGroupsAPI: logsClient, isCrossAccountEnabled: isCrossAccountEnabled} +} + +func (s *LogGroupsService) GetLogGroups(req resources.LogGroupsRequest) ([]resources.ResourceResponse[resources.LogGroup], error) { + input := &cloudwatchlogs.DescribeLogGroupsInput{ + Limit: aws.Int64(req.Limit), + LogGroupNamePrefix: req.LogGroupNamePrefix, + } + + if s.isCrossAccountEnabled && req.AccountId != nil { + input.IncludeLinkedAccounts = aws.Bool(true) + if req.LogGroupNamePattern != nil { + input.LogGroupNamePrefix = req.LogGroupNamePattern + } + if !req.IsTargetingAllAccounts() { + // TODO: accept more than one account id in search + input.AccountIdentifiers = []*string{req.AccountId} + } + } + response, err := s.logGroupsAPI.DescribeLogGroups(input) + if err != nil || response == nil { + return nil, err + } + + var result []resources.ResourceResponse[resources.LogGroup] + for _, logGroup := range response.LogGroups { + result = append(result, resources.ResourceResponse[resources.LogGroup]{ + Value: resources.LogGroup{ + Arn: *logGroup.Arn, + Name: *logGroup.LogGroupName, + }, + AccountId: utils.Pointer(getAccountId(*logGroup.Arn)), + }) + } + + return result, nil +} diff --git a/pkg/tsdb/cloudwatch/services/logs_test.go b/pkg/tsdb/cloudwatch/services/logs_test.go new file mode 100644 index 00000000000..a1b81a8d094 --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/logs_test.go @@ -0,0 +1,200 @@ +package services + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_GetLogGroups(t *testing.T) { + t.Run("Should map log groups response", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return( + &cloudwatchlogs.DescribeLogGroupsOutput{ + LogGroups: []*cloudwatchlogs.LogGroup{ + {Arn: utils.Pointer("arn:aws:logs:us-east-1:111:log-group:group_a"), LogGroupName: utils.Pointer("group_a")}, + {Arn: utils.Pointer("arn:aws:logs:us-east-1:222:log-group:group_b"), LogGroupName: utils.Pointer("group_b")}, + {Arn: utils.Pointer("arn:aws:logs:us-east-1:333:log-group:group_c"), LogGroupName: utils.Pointer("group_c")}, + }, + }, nil) + service := NewLogGroupsService(mockLogsAPI, false) + + resp, err := service.GetLogGroups(resources.LogGroupsRequest{}) + + assert.NoError(t, err) + assert.Equal(t, []resources.ResourceResponse[resources.LogGroup]{ + { + AccountId: utils.Pointer("111"), + Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:111:log-group:group_a", Name: "group_a"}, + }, + { + AccountId: utils.Pointer("222"), + Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:222:log-group:group_b", Name: "group_b"}, + }, + { + AccountId: utils.Pointer("333"), + Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:333:log-group:group_c", Name: "group_c"}, + }, + }, resp) + }) + + t.Run("Should only use LogGroupNamePrefix even if LogGroupNamePattern passed in resource call", func(t *testing.T) { + // TODO: use LogGroupNamePattern when we have accounted for its behavior, still a little unexpected at the moment + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, false) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + Limit: 0, + LogGroupNamePrefix: utils.Pointer("test"), + }) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + Limit: utils.Pointer(int64(0)), + LogGroupNamePrefix: utils.Pointer("test"), + }) + }) + + t.Run("Should call api without LogGroupNamePrefix nor LogGroupNamePattern if not passed in resource call", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, false) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{}) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + Limit: utils.Pointer(int64(0)), + }) + }) + + t.Run("Should return an error when API returns error", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, + fmt.Errorf("some error")) + service := NewLogGroupsService(mockLogsAPI, false) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{}) + + assert.Error(t, err) + assert.Equal(t, "some error", err.Error()) + }) +} + +func Test_GetLogGroups_crossAccountQuerying(t *testing.T) { + t.Run("Should not includeLinkedAccounts or accountId if isCrossAccountEnabled is set to false", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, false) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")}, + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + Limit: utils.Pointer(int64(0)), + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + }) + + t.Run("Should replace LogGroupNamePrefix if LogGroupNamePattern passed in resource call", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, true) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")}, + LogGroupNamePrefix: utils.Pointer("prefix"), + LogGroupNamePattern: utils.Pointer("pattern"), + }) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + AccountIdentifiers: []*string{utils.Pointer("accountId")}, + Limit: utils.Pointer(int64(0)), + LogGroupNamePrefix: utils.Pointer("pattern"), + IncludeLinkedAccounts: utils.Pointer(true), + }) + }) + + t.Run("Should includeLinkedAccounts,and accountId if isCrossAccountEnabled is set to true", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, true) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")}, + }) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + Limit: utils.Pointer(int64(0)), + IncludeLinkedAccounts: utils.Pointer(true), + AccountIdentifiers: []*string{utils.Pointer("accountId")}, + }) + }) + + t.Run("Should should not override prefix is there is no logGroupNamePattern", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, true) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")}, + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + AccountIdentifiers: []*string{utils.Pointer("accountId")}, + Limit: utils.Pointer(int64(0)), + LogGroupNamePrefix: utils.Pointer("prefix"), + IncludeLinkedAccounts: utils.Pointer(true), + }) + }) + + t.Run("Should not includeLinkedAccounts, or accountId if accountId is nil", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, true) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + Limit: utils.Pointer(int64(0)), + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + }) + + t.Run("Should should not override prefix is there is no logGroupNamePattern", func(t *testing.T) { + mockLogsAPI := &mocks.LogsAPI{} + mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil) + service := NewLogGroupsService(mockLogsAPI, true) + + _, err := service.GetLogGroups(resources.LogGroupsRequest{ + ResourceRequest: resources.ResourceRequest{ + AccountId: utils.Pointer("accountId"), + }, + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + + assert.NoError(t, err) + mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{ + AccountIdentifiers: []*string{utils.Pointer("accountId")}, + IncludeLinkedAccounts: utils.Pointer(true), + Limit: utils.Pointer(int64(0)), + LogGroupNamePrefix: utils.Pointer("prefix"), + }) + }) +} diff --git a/pkg/tsdb/cloudwatch/services/utils.go b/pkg/tsdb/cloudwatch/services/utils.go new file mode 100644 index 00000000000..4684aa2acad --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/utils.go @@ -0,0 +1,27 @@ +package services + +import ( + "strings" + + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" +) + +func valuesToListMetricRespone[T any](values []T) []resources.ResourceResponse[T] { + var response []resources.ResourceResponse[T] + for _, value := range values { + response = append(response, resources.ResourceResponse[T]{Value: value}) + } + + return response +} + +func getAccountId(arn string) string { + // format: arn:partition:service:region:account-id:resource-id + parts := strings.Split(arn, ":") + + if len(parts) >= 4 { + return parts[4] + } + + return "" +} diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index b3d2fc4d8b3..fbec70a42b1 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -31,7 +31,9 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger return nil, fmt.Errorf("invalid time range: start time must be before end time") } - requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels)) + requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, + e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels), + e.features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying)) if err != nil { return nil, err } diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index 1dd7042ada6..29728c729fc 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -588,3 +588,186 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }) } } + +func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { + origNewCWClient := NewCWClient + t.Cleanup(func() { + NewCWClient = origNewCWClient + }) + var api mocks.MetricsAPI + + NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { + return &api + } + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{}}, nil + }) + + t.Run("should call GetMetricDataInput with AccountId nil when no AccountId is provided", func(t *testing.T) { + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)}, + JSON: json.RawMessage(`{ + "type": "timeSeriesQuery", + "subtype": "metrics", + "namespace": "AWS/EC2", + "metricName": "NetworkOut", + "dimensions": { + "InstanceId": "i-00645d91ed77d87ac" + }, + "region": "us-east-2", + "id": "a", + "alias": "NetworkOut", + "statistic": "Maximum", + "period": "300", + "hide": false, + "matchExact": true, + "refId": "A" + }`), + }, + }, + }) + require.NoError(t, err) + actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput) + require.True(t, ok) + require.Len(t, actualInput.MetricDataQueries, 1) + + assert.Nil(t, actualInput.MetricDataQueries[0].Expression) + assert.Nil(t, actualInput.MetricDataQueries[0].AccountId) + }) + + t.Run("should call GetMetricDataInput with AccountId nil when feature flag is false", func(t *testing.T) { + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)}, + JSON: json.RawMessage(`{ + "type": "timeSeriesQuery", + "subtype": "metrics", + "namespace": "AWS/EC2", + "metricName": "NetworkOut", + "dimensions": { + "InstanceId": "i-00645d91ed77d87ac" + }, + "region": "us-east-2", + "id": "a", + "alias": "NetworkOut", + "statistic": "Maximum", + "period": "300", + "hide": false, + "matchExact": true, + "refId": "A", + "accountId":"some account Id" + }`), + }, + }, + }) + require.NoError(t, err) + actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput) + require.True(t, ok) + require.Len(t, actualInput.MetricDataQueries, 1) + + assert.Nil(t, actualInput.MetricDataQueries[0].Expression) + assert.Nil(t, actualInput.MetricDataQueries[0].AccountId) + }) + + t.Run("should call GetMetricDataInput with AccountId in a MetricStat query", func(t *testing.T) { + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)}, + JSON: json.RawMessage(`{ + "type": "timeSeriesQuery", + "subtype": "metrics", + "namespace": "AWS/EC2", + "metricName": "NetworkOut", + "dimensions": { + "InstanceId": "i-00645d91ed77d87ac" + }, + "region": "us-east-2", + "id": "a", + "alias": "NetworkOut", + "statistic": "Maximum", + "period": "300", + "hide": false, + "matchExact": true, + "refId": "A", + "accountId":"some account Id" + }`), + }, + }, + }) + require.NoError(t, err) + actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput) + require.True(t, ok) + require.Len(t, actualInput.MetricDataQueries, 1) + + require.NotNil(t, actualInput.MetricDataQueries[0].AccountId) + assert.Equal(t, "some account Id", *actualInput.MetricDataQueries[0].AccountId) + }) + + t.Run("should GetMetricDataInput with AccountId in an inferred search expression query", func(t *testing.T) { + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)}, + JSON: json.RawMessage(`{ + "type": "timeSeriesQuery", + "subtype": "metrics", + "namespace": "AWS/EC2", + "metricName": "NetworkOut", + "dimensions": { + "InstanceId": "*" + }, + "region": "us-east-2", + "id": "a", + "alias": "NetworkOut", + "statistic": "Maximum", + "period": "300", + "hide": false, + "matchExact": true, + "refId": "A", + "accountId":"some account Id" + }`), + }, + }, + }) + require.NoError(t, err) + actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput) + require.True(t, ok) + require.Len(t, actualInput.MetricDataQueries, 1) + + require.NotNil(t, actualInput.MetricDataQueries[0].Expression) + assert.Equal(t, `REMOVE_EMPTY(SEARCH('{"AWS/EC2","InstanceId"} MetricName="NetworkOut" :aws.AccountId="some account Id"', 'Maximum', 300))`, *actualInput.MetricDataQueries[0].Expression) + }) +} diff --git a/pkg/tsdb/cloudwatch/utils/utils.go b/pkg/tsdb/cloudwatch/utils/utils.go new file mode 100644 index 00000000000..65a4cc8ba4d --- /dev/null +++ b/pkg/tsdb/cloudwatch/utils/utils.go @@ -0,0 +1,3 @@ +package utils + +func Pointer[T any](arg T) *T { return &arg } diff --git a/pkg/tsdb/cloudwatch/utils_test.go b/pkg/tsdb/cloudwatch/utils_test.go index 90577a090dc..0b3a07039c0 100644 --- a/pkg/tsdb/cloudwatch/utils_test.go +++ b/pkg/tsdb/cloudwatch/utils_test.go @@ -166,9 +166,6 @@ func (c fakeRGTAClient) GetResourcesPages(in *resourcegroupstaggingapi.GetResour } type fakeCheckHealthClient struct { - cloudwatchiface.CloudWatchAPI - cloudwatchlogsiface.CloudWatchLogsAPI - listMetricsPages func(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error describeLogGroups func(input *cloudwatchlogs.DescribeLogGroupsInput) (*cloudwatchlogs.DescribeLogGroupsOutput, error) } diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index 6dc8eef3538..5e3b07defab 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -42,22 +42,27 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return &backend.QueryDataResponse{}, err } + return queryData(ctx, req.Queries, dsInfo, s.intervalCalculator) +} + +// separate function to allow testing the whole transformation and query flow +func queryData(ctx context.Context, queries []backend.DataQuery, dsInfo *es.DatasourceInfo, intervalCalculator intervalv2.Calculator) (*backend.QueryDataResponse, error) { // Support for version after their end-of-life (currently <7.10.0) was removed lastSupportedVersion, _ := semver.NewVersion("7.10.0") if dsInfo.ESVersion.LessThan(lastSupportedVersion) { return &backend.QueryDataResponse{}, fmt.Errorf("support for elasticsearch versions after their end-of-life (currently versions < 7.10) was removed") } - if len(req.Queries) == 0 { + if len(queries) == 0 { return &backend.QueryDataResponse{}, fmt.Errorf("query contains no queries") } - client, err := es.NewClient(ctx, dsInfo, req.Queries[0].TimeRange) + client, err := es.NewClient(ctx, dsInfo, queries[0].TimeRange) if err != nil { return &backend.QueryDataResponse{}, err } - query := newTimeSeriesQuery(client, req.Queries, s.intervalCalculator) + query := newTimeSeriesQuery(client, queries, intervalCalculator) return query.execute() } diff --git a/pkg/tsdb/elasticsearch/querydata_test.go b/pkg/tsdb/elasticsearch/querydata_test.go new file mode 100644 index 00000000000..162fb0cb8f1 --- /dev/null +++ b/pkg/tsdb/elasticsearch/querydata_test.go @@ -0,0 +1,146 @@ +package elasticsearch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Masterminds/semver" + "github.com/grafana/grafana-plugin-sdk-go/backend" + es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/intervalv2" +) + +type queryDataTestRoundTripper struct { + requestCallback func(req *http.Request) error + body []byte +} + +// we fake the http-request-call. we return a fixed byte-array (defined by the test snapshot), +// and we also check if the http-request-object has the correct data +func (rt *queryDataTestRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + err := rt.requestCallback(req) + if err != nil { + return nil, err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader(rt.body)), + }, nil +} + +// we setup a fake datasource-info +func newFlowTestDsInfo(body []byte, reuestCallback func(req *http.Request) error) *es.DatasourceInfo { + client := http.Client{ + Transport: &queryDataTestRoundTripper{body: body, requestCallback: reuestCallback}, + } + return &es.DatasourceInfo{ + ESVersion: semver.MustParse("8.5.0"), + Interval: "Daily", + Database: "[testdb-]YYYY.MM.DD", + TimeField: "testtime", + TimeInterval: "1s", + URL: "http://localhost:9200", + HTTPClient: &client, + MaxConcurrentShardRequests: 42, + IncludeFrozen: false, + XPack: true, + } +} + +type queryDataTestQueryJSON struct { + IntervalMs int64 + MaxDataPoints int64 + RefID string +} + +// we take an array of json-bytes, that define the elastic queries, +// and create full backend.DataQuery objects from them +func newFlowTestQueries(allJsonBytes []byte) ([]backend.DataQuery, error) { + timeRange := backend.TimeRange{ + From: time.UnixMilli(1668422437218), + To: time.UnixMilli(1668422625668), + } + + // we will need every separate query-item as a json-byte-array later, + // so we only decode the "array", and keep the "items" undecoded. + var jsonBytesArray []json.RawMessage + + err := json.Unmarshal(allJsonBytes, &jsonBytesArray) + if err != nil { + return nil, fmt.Errorf("error unmarshaling query-json: %w", err) + } + + var queries []backend.DataQuery + + for _, jsonBytes := range jsonBytesArray { + // we need to extract some fields from the json-array + var jsonInfo queryDataTestQueryJSON + err = json.Unmarshal(jsonBytes, &jsonInfo) + if err != nil { + return nil, err + } + // we setup the DataQuery, with values loaded from the json + query := backend.DataQuery{ + RefID: jsonInfo.RefID, + MaxDataPoints: jsonInfo.MaxDataPoints, + Interval: time.Duration(jsonInfo.IntervalMs) * time.Millisecond, + TimeRange: timeRange, + JSON: jsonBytes, + } + queries = append(queries, query) + } + return queries, nil +} + +type queryDataTestResult struct { + response *backend.QueryDataResponse + requestBytes []byte +} + +func queryDataTest(queriesBytes []byte, responseBytes []byte) (queryDataTestResult, error) { + queries, err := newFlowTestQueries(queriesBytes) + if err != nil { + return queryDataTestResult{}, err + } + + requestBytesStored := false + var requestBytes []byte + + dsInfo := newFlowTestDsInfo(responseBytes, func(req *http.Request) error { + requestBytes, err = io.ReadAll(req.Body) + + bodyCloseError := req.Body.Close() + + if err != nil { + return err + } + + if bodyCloseError != nil { + return bodyCloseError + } + + requestBytesStored = true + return nil + }) + + result, err := queryData(context.Background(), queries, dsInfo, intervalv2.NewCalculator()) + if err != nil { + return queryDataTestResult{}, err + } + + if !requestBytesStored { + return queryDataTestResult{}, fmt.Errorf("request-bytes not stored") + } + + return queryDataTestResult{ + response: result, + requestBytes: requestBytes, + }, nil +} diff --git a/pkg/tsdb/elasticsearch/response_parser_frontend_test.go b/pkg/tsdb/elasticsearch/response_parser_frontend_test.go new file mode 100644 index 00000000000..5105548f16b --- /dev/null +++ b/pkg/tsdb/elasticsearch/response_parser_frontend_test.go @@ -0,0 +1,1685 @@ +package elasticsearch + +import ( + "fmt" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/require" +) + +func requireTimeValue(t *testing.T, expected int64, frame *data.Frame, index int) { + getField := func() *data.Field { + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeTime { + return field + } + } + return nil + } + + field := getField() + require.NotNil(t, field, "missing time-field") + + require.Equal(t, time.UnixMilli(expected).UTC(), field.At(index), fmt.Sprintf("wrong time at index %v", index)) +} + +func requireNumberValue(t *testing.T, expected float64, frame *data.Frame, index int) { + getField := func() *data.Field { + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeNullableFloat64 { + return field + } + } + return nil + } + + field := getField() + require.NotNil(t, field, "missing number-field") + + v := field.At(index).(*float64) + + require.Equal(t, expected, *v, fmt.Sprintf("wrong number at index %v", index)) +} + +func requireFrameLength(t *testing.T, frame *data.Frame, expectedLength int) { + l, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, expectedLength, l, "wrong frame-length") +} + +func requireStringAt(t *testing.T, expected string, field *data.Field, index int) { + v := field.At(index).(*string) + require.Equal(t, expected, *v, fmt.Sprintf("wrong string at index %v", index)) +} + +func requireFloatAt(t *testing.T, expected float64, field *data.Field, index int) { + v := field.At(index).(*float64) + require.Equal(t, expected, *v, fmt.Sprintf("wrong flaot at index %v", index)) +} + +func TestRefIdMatching(t *testing.T) { + require.NoError(t, nil) + query := []byte(` + [ + { + "timeField": "t", + "refId": "COUNT_GROUPBY_DATE_HISTOGRAM", + "metrics": [{ "type": "count", "id": "c_1" }], + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "c_2" }] + }, + { + "timeField": "t", + "refId": "COUNT_GROUPBY_HISTOGRAM", + "metrics": [{ "type": "count", "id": "h_3" }], + "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "h_4" }] + }, + { + "timeField": "t", + "refId": "RAW_DOC", + "metrics": [{ "type": "raw_document", "id": "r_5" }], + "bucketAggs": [] + }, + { + "timeField": "t", + "refId": "PERCENTILE", + "metrics": [ + { + "type": "percentiles", + "settings": { "percents": ["75", "90"] }, + "id": "p_1" + } + ], + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "p_3" }] + }, + { + "timeField": "t", + "refId": "EXTENDEDSTATS", + "metrics": [ + { + "type": "extended_stats", + "meta": { "max": true, "std_deviation_bounds_upper": true }, + "id": "e_1" + } + ], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "e_3" }, + { "type": "date_histogram", "id": "e_4" } + ] + }, + { + "timeField": "t", + "refId": "D", + "metrics": [{ "type": "raw_data", "id": "6" }], + "bucketAggs": [] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "c_2": { + "buckets": [{"doc_count": 10, "key": 1000}] + } + } + }, + { + "aggregations": { + "h_4": { + "buckets": [{ "doc_count": 1, "key": 1000 }] + } + } + }, + { + "hits": { + "total": 2, + "hits": [ + { + "_id": "5", + "_type": "type", + "_index": "index", + "_source": { "sourceProp": "asd" }, + "fields": { "fieldProp": "field" } + }, + { + "_source": { "sourceProp": "asd2" }, + "fields": { "fieldProp": "field2" } + } + ] + } + }, + { + "aggregations": { + "p_3": { + "buckets": [ + { + "p_1": { "values": { "75": 3.3, "90": 5.5 } }, + "doc_count": 10, + "key": 1000 + }, + { + "p_1": { "values": { "75": 2.3, "90": 4.5 } }, + "doc_count": 15, + "key": 2000 + } + ] + } + } + }, + { + "aggregations": { + "e_3": { + "buckets": [ + { + "key": "server1", + "e_4": { + "buckets": [ + { + "e_1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + } + }, + { + "key": "server2", + "e_4": { + "buckets": [ + { + "e_1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + } + } + ] + } + } + }, + { + "hits": { + "total": { + "relation": "eq", + "value": 1 + }, + "hits": [ + { + "_id": "6", + "_type": "_doc", + "_index": "index", + "_source": { "sourceProp": "asd" } + } + ] + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + verifyFrames := func(name string, expectedLength int) { + r, found := result.response.Responses[name] + require.True(t, found, "not found: "+name) + require.NoError(t, r.Error) + require.Len(t, r.Frames, expectedLength, "length wrong for "+name) + } + + verifyFrames("COUNT_GROUPBY_DATE_HISTOGRAM", 1) + verifyFrames("COUNT_GROUPBY_HISTOGRAM", 1) + // verifyFrames("RAW_DOC", 1) // FIXME + verifyFrames("PERCENTILE", 2) + verifyFrames("EXTENDEDSTATS", 4) + // verifyFrames("D", 1) // FIXME +} + +func TestSimpleQueryReturns1Frame(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "2" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "doc_count": 10, "key": 1000 }, + { "doc_count": 15, "key": 2000 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1, "frame-count wrong") + frame := frames[0] + // require.Equal(t, "Count", frame.Name) // FIXME + + requireFrameLength(t, frame, 2) + requireTimeValue(t, 1000, frame, 0) + requireNumberValue(t, 10, frame, 0) +} + +func TestSimpleQueryCountAndAvg(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "type": "count", "id": "1" }, + { "type": "avg", "field": "value", "id": "2" } + ], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { "2": { "value": 88 }, "doc_count": 10, "key": 1000 }, + { "2": { "value": 99 }, "doc_count": 15, "key": 2000 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + frame1 := frames[0] + frame2 := frames[1] + + requireFrameLength(t, frame1, 2) + requireFrameLength(t, frame2, 2) + + requireTimeValue(t, 1000, frame1, 0) + requireNumberValue(t, 10, frame1, 0) + + // require.Equal(t, "average value", frame2.Name) // FIXME + + requireNumberValue(t, 88, frame2, 0) + requireNumberValue(t, 99, frame2, 1) +} + +func TestSimpleGroupBy1Metric2Frames(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + }, + "doc_count": 10, + "key": "server2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + requireFrameLength(t, frames[0], 2) + // require.Equal(t, "server1", frames[0].Name) // FIXME + // require.Equal(t, "server2", frames[1].Name) // FIXME +} + +func TestSimpleGroupBy2Metrics4Frames(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "type": "count", "id": "1" }, + { "type": "avg", "field": "@value", "id": "4" } + ], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [ + { "4": { "value": 10 }, "doc_count": 1, "key": 1000 }, + { "4": { "value": 12 }, "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [ + { "4": { "value": 20 }, "doc_count": 1, "key": 1000 }, + { "4": { "value": 32 }, "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 10, + "key": "server2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 4) + requireFrameLength(t, frames[0], 2) + // require.Equal(t, "server1 Count", frames[0].Name) // FIXME + // require.Equal(t, "server1 Average @value", frames[1].Name) // FIXME + // require.Equal(t, "server2 Count", frames[2].Name) // FIXME + // require.Equal(t, "server2 Average @value", frames[3].Name) // FIXME +} + +func TestPercentiles2Frames(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { + "type": "percentiles", + "settings": { "percents": ["75", "90"] }, + "id": "1", + "field": "@value" + } + ], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "1": { "values": { "75": 3.3, "90": 5.5 } }, + "doc_count": 10, + "key": 1000 + }, + { + "1": { "values": { "75": 2.3, "90": 4.5 } }, + "doc_count": 15, + "key": 2000 + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + requireFrameLength(t, frames[0], 2) + // require.Equal(t, "p75 @value", frames[0].Name) // FIXME + // require.Equal(t, "p90 @value", frames[1].Name) // FIXME + + requireNumberValue(t, 3.3, frames[0], 0) + requireTimeValue(t, 1000, frames[0], 0) + requireNumberValue(t, 4.5, frames[1], 1) +} + +func TestExtendedStats4Frames(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { + "type": "extended_stats", + "meta": { "max": true, "std_deviation_bounds_upper": true }, + "id": "1", + "field": "@value" + } + ], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "3" }, + { "type": "date_histogram", "id": "4" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "4": { + "buckets": [ + { + "1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + }, + "key": "server1" + }, + { + "4": { + "buckets": [ + { + "1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + }, + "key": "server2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 4) + requireFrameLength(t, frames[0], 1) + // require.Equal(t, "server1 Max @value", frames[0].Name) // FIXME + // require.Equal(t, "server1 Std Dev Upper @value", frames[1].Name) // FIXME + + requireNumberValue(t, 10.2, frames[0], 0) + requireNumberValue(t, 3, frames[1], 0) +} + +func TestTopMetrics2Frames(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { + "type": "top_metrics", + "settings": { + "order": "top", + "orderBy": "@timestamp", + "metrics": ["@value", "@anotherValue"] + }, + "id": "1" + } + ], + "bucketAggs": [{ "type": "date_histogram", "id": "2" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { + "top": [ + { + "sort": ["2021-01-01T00:00:00.000Z"], + "metrics": { "@value": 1, "@anotherValue": 2 } + } + ] + }, + "key": 1609459200000, + "key_as_string": "2021-01-01T00:00:00.000Z" + }, + { + "1": { + "top": [ + { + "sort": ["2021-01-01T00:00:10.000Z"], + "metrics": { "@value": 1, "@anotherValue": 2 } + } + ] + }, + "key": 1609459210000, + "key_as_string": "2021-01-01T00:00:10.000Z" + } + ] + } + } + } + ] + } + `) + + time1, err := time.Parse(time.RFC3339, "2021-01-01T00:00:00.000Z") + require.NoError(t, err) + time2, err := time.Parse(time.RFC3339, "2021-01-01T00:00:10.000Z") + require.NoError(t, err) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + frame1 := frames[0] + frame2 := frames[1] + + // require.Equal(t, "Top Metrics Value", frame1.Name) // FIXME + requireFrameLength(t, frame1, 2) + requireTimeValue(t, time1.UTC().UnixMilli(), frame1, 0) + requireTimeValue(t, time2.UTC().UnixMilli(), frame1, 1) + requireNumberValue(t, 1, frame1, 0) + requireNumberValue(t, 1, frame1, 1) + + // require.Equal(t, "Top Metrics @anotherValue", frame2.Name) // FIXME + requireFrameLength(t, frame2, 2) + requireTimeValue(t, time1.UTC().UnixMilli(), frame2, 0) + requireTimeValue(t, time2.UTC().UnixMilli(), frame2, 1) + requireNumberValue(t, 2, frame2, 0) + requireNumberValue(t, 2, frame2, 1) +} + +func TestSingleGroupWithAliasPattern3Frames(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "count", "id": "1" }], + "alias": "{{term @host}} {{metric}} and {{not_exist}} {{@host}}", + "bucketAggs": [ + { "type": "terms", "field": "@host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + }, + "doc_count": 10, + "key": "server2" + }, + { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + }, + "doc_count": 10, + "key": 0 + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 3) + + requireFrameLength(t, frames[0], 2) + // require.Equal(t, "server1 Count and {{not_exist}} server1", frames[0].Name) // FIXME + // require.Equal(t, "server2 Count and {{not_exist}} server2", frames[1].Name) // FIXME + // require.Equal(t, "0 Count and {{not_exist}} 0", frames[2].Name) // FIXME +} + +func TestHistogramSimple(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "3" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 }, + { "doc_count": 2, "key": 1000 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + // require.Len(t, frames, 3) // FIXME + + fields := frames[0].Fields + require.Len(t, fields, 2) + + field1 := fields[0] + field2 := fields[1] + + require.Equal(t, "bytes", field1.Name) + + // trueValue := true + // filterableConfig := data.FieldConfig{Filterable: &trueValue} + + // we need to test that the only changed setting is `filterable` + // require.Equal(t, filterableConfig, *field1.Config) // FIXME + + require.Equal(t, "Count", field2.Name) + + // we need to test that the fieldConfig is "empty" + require.Equal(t, data.FieldConfig{}, *field2.Config) +} + +func TestHistogramWith2FiltersAgg(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { + "id": "2", + "type": "filters", + "settings": { + "filters": [ + { "query": "@metric:cpu", "label": "" }, + { "query": "@metric:logins.count", "label": "" } + ] + } + }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": { + "@metric:cpu": { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 } + ] + } + }, + "@metric:logins.count": { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + } + } + } + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + requireFrameLength(t, frames[0], 2) + // require.Equal(t, "@metric:cpu", frames[0].Name) + // require.Equal(t, "@metric:logins.count", frames[1].Name) +} + +func TestTrimEdges(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "type": "avg", "id": "1", "field": "@value" }, + { "type": "count", "id": "3" } + ], + "bucketAggs": [ + { + "id": "2", + "type": "date_histogram", + "field": "host", + "settings": { "trimEdges": "1" } + } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "1": { "value": 1000 }, "key": 1, "doc_count": 369 }, + { "1": { "value": 2000 }, "key": 2, "doc_count": 200 }, + { "1": { "value": 2000 }, "key": 3, "doc_count": 200 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + // should remove first and last value + requireFrameLength(t, frames[0], 1) +} + +func TestTermsAggWithoutDateHistogram(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "type": "avg", "id": "1", "field": "@value" }, + { "type": "count", "id": "3" } + ], + "bucketAggs": [{ "id": "2", "type": "terms", "field": "host" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "1": { "value": 1000 }, "key": "server-1", "doc_count": 369 }, + { "1": { "value": 2000 }, "key": "server-2", "doc_count": 200 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + + frame1 := frames[0] + requireFrameLength(t, frame1, 2) + require.Len(t, frame1.Fields, 3) + + f1 := frame1.Fields[0] + f2 := frame1.Fields[1] + f3 := frame1.Fields[2] + + requireStringAt(t, "server-1", f1, 0) + requireStringAt(t, "server-2", f1, 1) + + requireFloatAt(t, 1000.0, f2, 0) + requireFloatAt(t, 2000.0, f2, 1) + + requireFloatAt(t, 369.0, f3, 0) + requireFloatAt(t, 200.0, f3, 1) +} + +func TestPercentilesWithoutDateHistogram(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { + "type": "percentiles", + "field": "value", + "settings": { "percents": ["75", "90"] }, + "id": "1" + } + ], + "bucketAggs": [{ "type": "terms", "field": "id", "id": "3" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "1": { "values": { "75": 3.3, "90": 5.5 } }, + "doc_count": 10, + "key": "id1" + }, + { + "1": { "values": { "75": 2.3, "90": 4.5 } }, + "doc_count": 15, + "key": "id2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + requireFrameLength(t, frames[0], 2) + + // require.Len(t, frames[0].Fields, 3) // FIXME + + // f1 := frames[0].Fields[0] // FIXME + // f2 := frames[0].Fields[1] // FIXME + // f3 := frames[0].Fields[2] // FIXME + + // require.Equal(t, "id", f1.Name) // FIXME + // require.Equal(t, "p75 value", f2.Name) // FIXME + // require.Equal(t, "p90 value", f3.Name) // FIXME + + // requireStringAt(t, "id1", f1, 0) // FIXME + // requireStringAt(t, "id2", f1, 1) // FIXME + + // requireFloatAt(t, 3.3, f2, 0) // FIXME + // requireFloatAt(t, 2.3, f2, 1) // FIXME + + // requireFloatAt(t, 5.5, f3, 0) // FIXME + // requireFloatAt(t, 4.5, f3, 1) // FIXME +} + +func TestMultipleMetricsOfTheSameType(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "type": "avg", "id": "1", "field": "test" }, + { "type": "avg", "id": "2", "field": "test2" } + ], + "bucketAggs": [{ "id": "2", "type": "terms", "field": "host" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 1000 }, + "2": { "value": 3000 }, + "key": "server-1", + "doc_count": 369 + } + ] + } + } + } + ] + } + + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + requireFrameLength(t, frames[0], 1) + require.Len(t, frames[0].Fields, 3) + + requireStringAt(t, "server-1", frames[0].Fields[0], 0) + requireFloatAt(t, 1000.0, frames[0].Fields[1], 0) + requireFloatAt(t, 3000.0, frames[0].Fields[2], 0) +} + +func TestRawDocumentQuery(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "raw_document", "id": "1" }], + "bucketAggs": [] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "hits": { + "total": 100, + "hits": [ + { + "_id": "1", + "_type": "type", + "_index": "index", + "_source": { "sourceProp": "asd" }, + "fields": { "fieldProp": "field" } + }, + { + "_source": { "sourceProp": "asd2" }, + "fields": { "fieldProp": "field2" } + } + ] + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + // FIXME: the whole raw_document format is not implemented currently + // frames := result.response.Responses["A"].Frames + // require.Len(t, frames, 1) + // fields := frames[0].Fields + + // require.Len(t, fields, 1) + // f := fields[0] + + // require.Equal(t, data.FieldTypeJSON, f.Type()) + // require.Equal(t, 2, f.Len()) + + // v := f.At(0).(json.RawMessage) + // var jsonData map[string]interface{} + // err = json.Unmarshal(v, &jsonData) + // require.NoError(t, err) + + // require.Equal(t, "asd", jsonData["sourceProp"]) + // require.Equal(t, "field", jsonData["fieldProp"]) +} + +func TestBucketScript(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "id": "1", "type": "sum", "field": "@value" }, + { "id": "3", "type": "max", "field": "@value" }, + { + "id": "4", + "pipelineVariables": [ + { "name": "var1", "pipelineAgg": "1" }, + { "name": "var2", "pipelineAgg": "3" } + ], + "settings": { "script": "params.var1 * params.var2" }, + "type": "bucket_script" + } + ], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "2" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 2 }, + "3": { "value": 3 }, + "4": { "value": 6 }, + "doc_count": 60, + "key": 1000 + }, + { + "1": { "value": 3 }, + "3": { "value": 4 }, + "4": { "value": 12 }, + "doc_count": 60, + "key": 2000 + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 3) + requireFrameLength(t, frames[0], 2) + // require.Equal(t, "Sum @value", frames[0].Name) + // require.Equal(t, "Max @value", frames[1].Name) + // require.Equal(t, "Sum @value * Max @value", frames[2].Name) + + requireNumberValue(t, 2, frames[0], 0) + requireNumberValue(t, 3, frames[1], 0) + requireNumberValue(t, 6, frames[2], 0) + + requireNumberValue(t, 3, frames[0], 1) + requireNumberValue(t, 4, frames[1], 1) + requireNumberValue(t, 12, frames[2], 1) +} + +func TestTwoBucketScripts(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [ + { "id": "1", "type": "sum", "field": "@value" }, + { "id": "3", "type": "max", "field": "@value" }, + { + "id": "4", + "pipelineVariables": [ + { "name": "var1", "pipelineAgg": "1" }, + { "name": "var2", "pipelineAgg": "3" } + ], + "settings": { "script": "params.var1 * params.var2" }, + "type": "bucket_script" + }, + { + "id": "5", + "pipelineVariables": [ + { "name": "var1", "pipelineAgg": "1" }, + { "name": "var2", "pipelineAgg": "3" } + ], + "settings": { "script": "params.var1 * params.var2 * 4" }, + "type": "bucket_script" + } + ], + "bucketAggs": [{ "type": "terms", "field": "@timestamp", "id": "2" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 2 }, + "3": { "value": 3 }, + "4": { "value": 6 }, + "5": { "value": 24 }, + "doc_count": 60, + "key": 1000 + }, + { + "1": { "value": 3 }, + "3": { "value": 4 }, + "4": { "value": 12 }, + "5": { "value": 48 }, + "doc_count": 60, + "key": 2000 + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + requireFrameLength(t, frames[0], 2) + + fields := frames[0].Fields + require.Len(t, fields, 5) + + requireFloatAt(t, 1000.0, fields[0], 0) + requireFloatAt(t, 2000.0, fields[0], 1) + requireFloatAt(t, 2.0, fields[1], 0) + requireFloatAt(t, 3.0, fields[1], 1) + requireFloatAt(t, 3.0, fields[2], 0) + requireFloatAt(t, 4.0, fields[2], 1) + requireFloatAt(t, 6.0, fields[3], 0) + requireFloatAt(t, 12.0, fields[3], 1) + requireFloatAt(t, 24.0, fields[4], 0) + requireFloatAt(t, 48.0, fields[4], 1) +} + +func TestRawData(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "timeField": "t", + "metrics": [{ "type": "raw_data", "id": "1" }], + "bucketAggs": [] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "hits": { + "total": { "relation": "eq", "value": 1 }, + "hits": [ + { + "_id": "1", + "_type": "_doc", + "_index": "index", + "_source": { "sourceProp": "asd" } + } + ] + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + // frames := result.response.Responses["A"].Frames + // require.True(t, len(frames) > 0) // FIXME + + // for _, field := range frames[0].Fields { + // trueValue := true + // filterableConfig := data.FieldConfig{Filterable: &trueValue} + + // // we need to test that the only changed setting is `filterable` + // require.Equal(t, filterableConfig, *field.Config) // FIXME + // } +} + +func TestLogsAndCount(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { + "type": "date_histogram", + "settings": { "interval": "auto" }, + "id": "2" + } + ], + "key": "Q-1561369883389-0.7611823271062786-0", + "query": "hello AND message", + "timeField": "@timestamp" + } + ] +`) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "doc_count": 10, "key": 1000 }, + { "doc_count": 15, "key": 2000 } + ] + } + }, + "hits": { + "hits": [ + { + "_id": "fdsfs", + "_type": "_doc", + "_index": "mock-index", + "_source": { + "@timestamp": "2019-06-24T09:51:19.765Z", + "host": "djisaodjsoad", + "number": 1, + "message": "hello, i am a message", + "level": "debug", + "fields": { "lvl": "debug" } + }, + "highlight": { + "message": [ + "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" + ] + } + }, + { + "_id": "kdospaidopa", + "_type": "_doc", + "_index": "mock-index", + "_source": { + "@timestamp": "2019-06-24T09:52:19.765Z", + "host": "dsalkdakdop", + "number": 2, + "message": "hello, i am also message", + "level": "error", + "fields": { "lvl": "info" } + }, + "highlight": { + "message": [ + "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" + ] + } + } + ] + } + } + ] + } +`) + + t.Run("response", func(t *testing.T) { + // FIXME: config datasource with messageField=, levelField= + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + // require.Len(t, frames, 2) // FIXME + + // logsFrame := frames[0] + + // m := logsFrame.Meta + // require.Equal(t, "['hello', 'message']", m.SearchWords) // FIXME + // require.Equal(t, data.VisTypeLogs, m.PreferredVisualization) // FIXME + + // logsFieldMap := make(map[string]*data.Field) + // for _, field := range logsFrame.Fields { + // logsFieldMap[field.Name] = field + // } + + // require.Contains(t, logsFieldMap, "@timestamp") + // require.Equal(t, data.FieldTypeTime, logsFieldMap["@timestamp"].Type()) + + // require.Contains(t, logsFieldMap, "host") + // require.Equal(t, data.FieldTypeString, logsFieldMap["host"].Type()) + + // require.Contains(t, logsFieldMap, "message") + // require.Equal(t, data.FieldTypeString, logsFieldMap["message"].Type()) + + // require.Contains(t, logsFieldMap, "number") + // require.Equal(t, data.FieldTypeNullableFloat64, logsFieldMap["number"].Type()) + + // requireStringAt(t, "fdsfs", logsFieldMap["_id"], 0) + // requireStringAt(t, "kdospaidopa", logsFieldMap["_id"], 1) + // requireStringAt(t, "_doc", logsFieldMap["_type"], 0) + // requireStringAt(t, "_doc", logsFieldMap["_type"], 1) + // requireStringAt(t, "mock-index", logsFieldMap["_index"], 0) + // requireStringAt(t, "mock-index", logsFieldMap["_index"], 1) + + // actualJson1 := logsFieldMap["_source"].At(0).(*json.RawMessage) + // actualJson2 := logsFieldMap["_source"].At(1).(*json.RawMessage) + + // expectedJson1 := []byte(` + // { + // "@timestamp": "2019-06-24T09:51:19.765Z", + // "host": "djisaodjsoad", + // "number": 1, + // "message": "hello, i am a message", + // "level": "debug", + // "fields.lvl": "debug" + // } + // `) + + // expectedJson2 := []byte(` + // { + // "@timestamp": "2019-06-24T09:52:19.765Z", + // "host": "dsalkdakdop", + // "number": 2, + // "message": "hello, i am also message", + // "level": "error", + // "fields.lvl": "info" + // } + // `) + + // require.Equal(t, expectedJson1, actualJson1) + // require.Equal(t, expectedJson2, actualJson2) + + histogramFrame := frames[len(frames)-1] // the "last" frame + + histFieldMap := make(map[string]*data.Field) + for _, field := range histogramFrame.Fields { + histFieldMap[field.Name] = field + } + + // FIXME: the go-version uses lowercase-names, `time` and `value` + // t1 := histFieldMap["Time"].At(0).(time.Time) + // t2 := histFieldMap["Time"].At(1).(time.Time) + + // v1 := histFieldMap["Value"].At(0).(*float64) + // v2 := histFieldMap["Value"].At(1).(*float64) + + // testData := make(map[int64]float64) + // testData[(t1).UnixMilli()] = *v1 + // testData[(t2).UnixMilli()] = *v2 + + // require.Equal(t, 10.0, testData[1000]) + // require.Equal(t, 15.0, testData[2000]) + }) + + t.Run("level field", func(t *testing.T) { + // FIXME: config datasource with messageField=, levelField="level" + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + + requireFrameLength(t, frames[0], 2) + fieldMap := make(map[string]*data.Field) + for _, field := range frames[0].Fields { + fieldMap[field.Name] = field + } + + // require.Contains(t, fieldMap, "level") // FIXME + // field := fieldMap["level"] + + // requireStringAt(t, "debug", field, 0) + // requireStringAt(t, "error", field, 1) + }) + + t.Run("level field remap", func(t *testing.T) { + // FIXME: config datasource with messageField=, levelField="fields.lvl" + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + + requireFrameLength(t, frames[0], 2) + fieldMap := make(map[string]*data.Field) + for _, field := range frames[0].Fields { + fieldMap[field.Name] = field + } + + // require.Contains(t, fieldMap, "level") // FIXME + // field := fieldMap["level"] + + // requireStringAt(t, "debug", field, 0) + // requireStringAt(t, "info", field, 1) + }) +} + +func TestLogsEmptyResponse(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "logs", "id": "2" }], + "bucketAggs": [ + { + "type": "date_histogram", + "settings": { "interval": "auto" }, + "id": "1" + } + ], + "key": "Q-1561369883389-0.7611823271062786-0", + "query": "hello AND message", + "timeField": "@timestamp" + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "hits": { "hits": [] }, + "aggregations": { + "1": { + "buckets": [ + { + "key_as_string": "1633676760000", + "key": 1633676760000, + "doc_count": 0 + }, + { + "key_as_string": "1633676770000", + "key": 1633676770000, + "doc_count": 0 + }, + { + "key_as_string": "1633676780000", + "key": 1633676780000, + "doc_count": 0 + } + ] + } + }, + "status": 200 + } + ] + } + `) + + // FIXME: config datasource with messageField="message", levelField="level" + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + // frames := result.response.Responses["A"].Frames + // require.Len(t, frames, 2) // FIXME +} diff --git a/pkg/tsdb/elasticsearch/snapshot_test.go b/pkg/tsdb/elasticsearch/snapshot_test.go new file mode 100644 index 00000000000..d73e077ee71 --- /dev/null +++ b/pkg/tsdb/elasticsearch/snapshot_test.go @@ -0,0 +1,167 @@ +package elasticsearch + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/experimental" + "github.com/stretchr/testify/require" +) + +// these snapshot-tests test the whole request-response flow: +// the inputs: +// - the backend.DataQuery query +// - the elastic-response json +// the snapshot verifies: +// - the elastic-request json +// - the dataframe result + +// a regex that matches the request-snapshot-filenames, and extracts the name of the test +var requestRe = regexp.MustCompile(`^(.*)\.request\.line\d+\.json$`) + +// the "elastic request" is often in multiple json-snapshot-files, +// so we have to find them on disk, so we have to look at every file in +// the folder. +func findRequestSnapshots(t *testing.T, folder string) map[string][]string { + allTestSnapshotFiles, err := os.ReadDir(folder) + require.NoError(t, err) + + snapshots := make(map[string][]string) + + for _, file := range allTestSnapshotFiles { + fileName := file.Name() + match := requestRe.FindStringSubmatch(fileName) + if len(match) == 2 { + testName := match[1] + files := append(snapshots[testName], filepath.Join(folder, fileName)) + snapshots[testName] = files + } + } + + return snapshots +} + +// a regex that matches the response-snapshot-filenames, and extracts the name of the test +var responseRe = regexp.MustCompile(`^([^\.]+)\.[^\.]+.golden.jsonc$`) + +func findResponseSnapshotCounts(t *testing.T, folder string) map[string]int { + allTestSnapshotFiles, err := os.ReadDir(folder) + require.NoError(t, err) + + snapshots := make(map[string]int) + + for _, file := range allTestSnapshotFiles { + fileName := file.Name() + match := responseRe.FindStringSubmatch(fileName) + if len(match) == 2 { + testName := match[1] + snapshots[testName] = snapshots[testName] + 1 + } + } + + return snapshots +} + +func TestRequestSnapshots(t *testing.T) { + tt := []struct { + name string + path string + }{ + {name: "simple metric test", path: "metric_simple"}, + {name: "complex metric test", path: "metric_complex"}, + {name: "multi metric test", path: "metric_multi"}, + } + + queryHeader := []byte(` + { + "ignore_unavailable": true, + "index": "testdb-2022.11.14", + "search_type": "query_then_fetch" + } + `) + + requestSnapshots := findRequestSnapshots(t, "testdata_request") + + for _, test := range tt { + t.Run(test.name, func(t *testing.T) { + responseBytes := []byte(`{"responses":[]}`) + + queriesFileName := filepath.Join("testdata_request", test.path+".queries.json") + queriesBytes, err := os.ReadFile(filepath.Clean(queriesFileName)) + require.NoError(t, err) + + var requestLines [][]byte + + for _, fileName := range requestSnapshots[test.path] { + bytes, err := os.ReadFile(filepath.Clean(fileName)) + require.NoError(t, err) + requestLines = append(requestLines, bytes) + } + + require.True(t, len(requestLines) > 0, "requestLines must not be empty") + + result, err := queryDataTest(queriesBytes, responseBytes) + require.NoError(t, err) + + reqLines := strings.Split(strings.TrimSpace(string(result.requestBytes)), "\n") + require.Len(t, reqLines, len(requestLines)*2) + + for i, expectedRequestLine := range requestLines { + actualRequestHeaderLine := reqLines[2*i] + actualRequestLine := reqLines[2*i+1] + require.JSONEq(t, string(queryHeader), actualRequestHeaderLine, fmt.Sprintf("invalid request-header at index: %v", i)) + require.JSONEq(t, string(expectedRequestLine), actualRequestLine, fmt.Sprintf("invalid request at index: %v", i)) + } + }) + } +} + +func TestResponseSnapshots(t *testing.T) { + tt := []struct { + name string + path string + }{ + {name: "simple metric test", path: "metric_simple"}, + {name: "complex metric test", path: "metric_complex"}, + {name: "multi metric test", path: "metric_multi"}, + } + + snapshotCount := findResponseSnapshotCounts(t, "testdata_response") + + for _, test := range tt { + t.Run(test.name, func(t *testing.T) { + responseFileName := filepath.Join("testdata_response", test.path+".response.json") + responseBytes, err := os.ReadFile(filepath.Clean(responseFileName)) + require.NoError(t, err) + + queriesFileName := filepath.Join("testdata_response", test.path+".queries.json") + queriesBytes, err := os.ReadFile(filepath.Clean(queriesFileName)) + require.NoError(t, err) + + result, err := queryDataTest(queriesBytes, responseBytes) + require.NoError(t, err) + + // first we need to test that the number of items in `result.response.Responses`, + // is exactly the same as the count of our response snapshot files + // (this is so that we avoid situations where we provide more snapshot-files than + // what is returned) + + expectedResponseCount := snapshotCount[test.path] + require.True(t, expectedResponseCount > 0, "response snapshots not found") + + require.Len(t, result.response.Responses, expectedResponseCount) + + for refId, dataRes := range result.response.Responses { + goldenFileName := fmt.Sprintf("%v.%v.golden", test.path, refId) + // we make a copy of the variable to avoid this linter-warning: + // "G601: Implicit memory aliasing in for loop." + dataResCopy := dataRes + experimental.CheckGoldenJSONResponse(t, "testdata_response", goldenFileName, &dataResCopy, false) + } + }) + } +} diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_complex.queries.json b/pkg/tsdb/elasticsearch/testdata_request/metric_complex.queries.json new file mode 100644 index 00000000000..183028ed10d --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_complex.queries.json @@ -0,0 +1,52 @@ +[ + { + "alias": "", + "bucketAggs": [ + { + "field": "label", + "id": "4", + "settings": { + "min_doc_count": "1", + "order": "desc", + "orderBy": "_term", + "size": "10" + }, + "type": "terms" + }, + { + "field": "testtime", + "id": "2", + "settings": { + "interval": "1m", + "min_doc_count": "0", + "timeZone": "utc", + "trimEdges": "0" + }, + "type": "date_histogram" + } + ], + "datasource": { + "uid": "haha", + "type": "elasticsearch" + }, + "datasourceId": 42, + "hide": true, + "metrics": [ + { + "field": "float", + "id": "1", + "type": "max" + }, + { + "field": "float", + "id": "3", + "type": "min" + } + ], + "query": "", + "refId": "a", + "timeField": "testtime", + "intervalMs": 200, + "maxDataPoints": 779 + } +] diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json b/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json new file mode 100644 index 00000000000..17904da9b67 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json @@ -0,0 +1,53 @@ +{ + "aggs": { + "4": { + "aggs": { + "2": { + "aggs": { + "1": { + "max": { + "field": "float" + } + }, + "3": { + "min": { + "field": "float" + } + } + }, + "date_histogram": { + "extended_bounds": { + "max": 1668422625668, + "min": 1668422437218 + }, + "field": "testtime", + "fixed_interval": "1m", + "format": "epoch_millis", + "min_doc_count": 0 + } + } + }, + "terms": { + "field": "label", + "order": { + "_key": "desc" + }, + "size": 10 + } + } + }, + "query": { + "bool": { + "filter": { + "range": { + "testtime": { + "format": "epoch_millis", + "gte": 1668422437218, + "lte": 1668422625668 + } + } + } + } + }, + "size": 0 +} diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_multi.queries.json b/pkg/tsdb/elasticsearch/testdata_request/metric_multi.queries.json new file mode 100644 index 00000000000..547950c1d63 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_multi.queries.json @@ -0,0 +1,41 @@ +[ + { + "refId": "a", + "datasource": { "uid": "haha", "type": "elasticsearch" }, + "datasourceId": 42, + "query": "", + "alias": "", + "metrics": [{ "id": "1", "type": "max", "field": "float" }], + "bucketAggs": [ + { + "type": "date_histogram", + "id": "2", + "settings": { "interval": "auto" }, + "field": "testtime" + } + ], + "timeField": "testtime", + "intervalMs": 30000, + "maxDataPoints": 814 + }, + { + "datasource": { "uid": "haha", "type": "elasticsearch" }, + "datasourceId": 42, + "refId": "b", + "hide": false, + "query": "", + "alias": "", + "metrics": [{ "id": "1", "type": "min", "field": "float" }], + "bucketAggs": [ + { + "type": "date_histogram", + "id": "2", + "settings": { "interval": "auto" }, + "field": "testtime" + } + ], + "timeField": "testtime", + "intervalMs": 30000, + "maxDataPoints": 814 + } +] diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_multi.request.line1.json b/pkg/tsdb/elasticsearch/testdata_request/metric_multi.request.line1.json new file mode 100644 index 00000000000..f1494969677 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_multi.request.line1.json @@ -0,0 +1,37 @@ +{ + "size": 0, + "query": { + "bool": { + "filter": { + "range": { + "testtime": { + "gte": 1668422437218, + "lte": 1668422625668, + "format": "epoch_millis" + } + } + } + } + }, + "aggs": { + "2": { + "date_histogram": { + "field": "testtime", + "min_doc_count": 0, + "extended_bounds": { + "min": 1668422437218, + "max": 1668422625668 + }, + "format": "epoch_millis", + "fixed_interval": "1000ms" + }, + "aggs": { + "1": { + "max": { + "field": "float" + } + } + } + } + } +} diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_multi.request.line2.json b/pkg/tsdb/elasticsearch/testdata_request/metric_multi.request.line2.json new file mode 100644 index 00000000000..40a0531e6cc --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_multi.request.line2.json @@ -0,0 +1,37 @@ +{ + "size": 0, + "query": { + "bool": { + "filter": { + "range": { + "testtime": { + "gte": 1668422437218, + "lte": 1668422625668, + "format": "epoch_millis" + } + } + } + } + }, + "aggs": { + "2": { + "date_histogram": { + "field": "testtime", + "min_doc_count": 0, + "extended_bounds": { + "min": 1668422437218, + "max": 1668422625668 + }, + "format": "epoch_millis", + "fixed_interval": "1000ms" + }, + "aggs": { + "1": { + "min": { + "field": "float" + } + } + } + } + } +} diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_simple.queries.json b/pkg/tsdb/elasticsearch/testdata_request/metric_simple.queries.json new file mode 100644 index 00000000000..ce5e1a76e6b --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_simple.queries.json @@ -0,0 +1,45 @@ +[ + { + "alias": "", + "bucketAggs": [ + { + "field": "label", + "id": "3", + "settings": { + "min_doc_count": "1", + "order": "desc", + "orderBy": "_term", + "size": "10" + }, + "type": "terms" + }, + { + "field": "testtime", + "id": "2", + "settings": { + "interval": "1m" + }, + "type": "date_histogram" + } + ], + "datasource": { + "type": "elasticsearch", + "uid": "haha" + }, + "datasourceId": 42, + "expression": "", + "hide": true, + "intervalMs": 200, + "maxDataPoints": 1248, + "metrics": [ + { + "id": "1", + "type": "count" + } + ], + "query": "", + "refId": "a", + "timeField": "testtime", + "window": "" + } +] diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json b/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json new file mode 100644 index 00000000000..f088a768ac3 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json @@ -0,0 +1,43 @@ +{ + "aggs": { + "3": { + "aggs": { + "2": { + "date_histogram": { + "extended_bounds": { + "max": 1668422625668, + "min": 1668422437218 + }, + "field": "testtime", + "fixed_interval": "1m", + "format": "epoch_millis", + "min_doc_count": 0 + } + } + }, + "terms": { + "field": "label", + "order": { + "_key": "desc" + }, + "size": 10 + } + } + }, + "query": { + "bool": { + "filter": { + "range": { + "testtime": { + "format": "epoch_millis", + "gte": 1668422437218, + "lte": 1668422625668 + } + } + } + } + }, + "size": 0 +} + + \ No newline at end of file diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc new file mode 100644 index 00000000000..79a1b5ed94d --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc @@ -0,0 +1,391 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val3 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 97.85990905761719 | +// | 2022-11-22 12:46:00 +0000 UTC | 98.39459228515625 | +// | 2022-11-22 12:47:00 +0000 UTC | 99.76652526855469 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[1] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val3 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 8.375883102416992 | +// | 2022-11-22 12:46:00 +0000 UTC | 0.2577083110809326 | +// | 2022-11-22 12:47:00 +0000 UTC | 6.422464847564697 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[2] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val2 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 87.77692413330078 | +// | 2022-11-22 12:46:00 +0000 UTC | 98.47160339355469 | +// | 2022-11-22 12:47:00 +0000 UTC | 92.53878784179688 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[3] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val2 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 4.540984630584717 | +// | 2022-11-22 12:46:00 +0000 UTC | 3.343874216079712 | +// | 2022-11-22 12:47:00 +0000 UTC | 26.237546920776367 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[4] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val1 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 98.57181549072266 | +// | 2022-11-22 12:46:00 +0000 UTC | 97.99356079101562 | +// | 2022-11-22 12:47:00 +0000 UTC | 94.45416259765625 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[5] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val1 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 2.859630584716797 | +// | 2022-11-22 12:46:00 +0000 UTC | 0.4383751451969147 | +// | 2022-11-22 12:47:00 +0000 UTC | 1.4335381984710693 | +// +-------------------------------+--------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val3 Max float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val3" + }, + "config": { + "displayNameFromDS": "val3 Max float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669121100000, + 1669121160000, + 1669121220000 + ], + [ + 97.85990905761719, + 98.39459228515625, + 99.76652526855469 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val3 Min float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val3" + }, + "config": { + "displayNameFromDS": "val3 Min float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669121100000, + 1669121160000, + 1669121220000 + ], + [ + 8.375883102416992, + 0.2577083110809326, + 6.422464847564697 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val2 Max float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val2" + }, + "config": { + "displayNameFromDS": "val2 Max float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669121100000, + 1669121160000, + 1669121220000 + ], + [ + 87.77692413330078, + 98.47160339355469, + 92.53878784179688 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val2 Min float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val2" + }, + "config": { + "displayNameFromDS": "val2 Min float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669121100000, + 1669121160000, + 1669121220000 + ], + [ + 4.540984630584717, + 3.343874216079712, + 26.237546920776367 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val1 Max float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val1" + }, + "config": { + "displayNameFromDS": "val1 Max float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669121100000, + 1669121160000, + 1669121220000 + ], + [ + 98.57181549072266, + 97.99356079101562, + 94.45416259765625 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val1 Min float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val1" + }, + "config": { + "displayNameFromDS": "val1 Min float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669121100000, + 1669121160000, + 1669121220000 + ], + [ + 2.859630584716797, + 0.4383751451969147, + 1.4335381984710693 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_complex.queries.json b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.queries.json new file mode 100644 index 00000000000..183028ed10d --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.queries.json @@ -0,0 +1,52 @@ +[ + { + "alias": "", + "bucketAggs": [ + { + "field": "label", + "id": "4", + "settings": { + "min_doc_count": "1", + "order": "desc", + "orderBy": "_term", + "size": "10" + }, + "type": "terms" + }, + { + "field": "testtime", + "id": "2", + "settings": { + "interval": "1m", + "min_doc_count": "0", + "timeZone": "utc", + "trimEdges": "0" + }, + "type": "date_histogram" + } + ], + "datasource": { + "uid": "haha", + "type": "elasticsearch" + }, + "datasourceId": 42, + "hide": true, + "metrics": [ + { + "field": "float", + "id": "1", + "type": "max" + }, + { + "field": "float", + "id": "3", + "type": "min" + } + ], + "query": "", + "refId": "a", + "timeField": "testtime", + "intervalMs": 200, + "maxDataPoints": 779 + } +] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_complex.response.json b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.response.json new file mode 100644 index 00000000000..aa4ce6b5e0d --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.response.json @@ -0,0 +1,155 @@ +{ + "took": 4, + "responses": [ + { + "took": 4, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 187, + "relation": "eq" + }, + "max_score": null, + "hits": [] + }, + "aggregations": { + "4": { + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + "buckets": [ + { + "key": "val3", + "doc_count": 60, + "2": { + "buckets": [ + { + "key_as_string": "1669121100000", + "key": 1669121100000, + "doc_count": 14, + "1": { + "value": 97.85990905761719 + }, + "3": { + "value": 8.375883102416992 + } + }, + { + "key_as_string": "1669121160000", + "key": 1669121160000, + "doc_count": 42, + "1": { + "value": 98.39459228515625 + }, + "3": { + "value": 0.2577083110809326 + } + }, + { + "key_as_string": "1669121220000", + "key": 1669121220000, + "doc_count": 4, + "1": { + "value": 99.76652526855469 + }, + "3": { + "value": 6.422464847564697 + } + } + ] + } + }, + { + "key": "val2", + "doc_count": 68, + "2": { + "buckets": [ + { + "key_as_string": "1669121100000", + "key": 1669121100000, + "doc_count": 14, + "1": { + "value": 87.77692413330078 + }, + "3": { + "value": 4.540984630584717 + } + }, + { + "key_as_string": "1669121160000", + "key": 1669121160000, + "doc_count": 45, + "1": { + "value": 98.47160339355469 + }, + "3": { + "value": 3.343874216079712 + } + }, + { + "key_as_string": "1669121220000", + "key": 1669121220000, + "doc_count": 9, + "1": { + "value": 92.53878784179688 + }, + "3": { + "value": 26.237546920776367 + } + } + ] + } + }, + { + "key": "val1", + "doc_count": 59, + "2": { + "buckets": [ + { + "key_as_string": "1669121100000", + "key": 1669121100000, + "doc_count": 18, + "1": { + "value": 98.57181549072266 + }, + "3": { + "value": 2.859630584716797 + } + }, + { + "key_as_string": "1669121160000", + "key": 1669121160000, + "doc_count": 32, + "1": { + "value": 97.99356079101562 + }, + "3": { + "value": 0.4383751451969147 + } + }, + { + "key_as_string": "1669121220000", + "key": 1669121220000, + "doc_count": 9, + "1": { + "value": 94.45416259765625 + }, + "3": { + "value": 1.4335381984710693 + } + } + ] + } + } + ] + } + }, + "status": 200 + } + ] +} diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc new file mode 100644 index 00000000000..0dd9be673de --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc @@ -0,0 +1,69 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+-------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+-------------------+ +// | 2022-11-28 10:59:00 +0000 UTC | 97.96025848388672 | +// | 2022-11-28 11:00:00 +0000 UTC | 98.74357604980469 | +// | 2022-11-28 11:01:00 +0000 UTC | 99.94505310058594 | +// +-------------------------------+-------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "Max float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": {}, + "config": { + "displayNameFromDS": "Max float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669633140000, + 1669633200000, + 1669633260000 + ], + [ + 97.96025848388672, + 98.74357604980469, + 99.94505310058594 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc new file mode 100644 index 00000000000..91ab9c7a89d --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc @@ -0,0 +1,69 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 3 Rows +// +-------------------------------+---------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+---------------------+ +// | 2022-11-28 10:59:00 +0000 UTC | 1.8121581077575684 | +// | 2022-11-28 11:00:00 +0000 UTC | 0.776007354259491 | +// | 2022-11-28 11:01:00 +0000 UTC | 0.26452451944351196 | +// +-------------------------------+---------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "Min float" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": {}, + "config": { + "displayNameFromDS": "Min float" + } + } + ] + }, + "data": { + "values": [ + [ + 1669633140000, + 1669633200000, + 1669633260000 + ], + [ + 1.8121581077575684, + 0.776007354259491, + 0.26452451944351196 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.queries.json b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.queries.json new file mode 100644 index 00000000000..547950c1d63 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.queries.json @@ -0,0 +1,41 @@ +[ + { + "refId": "a", + "datasource": { "uid": "haha", "type": "elasticsearch" }, + "datasourceId": 42, + "query": "", + "alias": "", + "metrics": [{ "id": "1", "type": "max", "field": "float" }], + "bucketAggs": [ + { + "type": "date_histogram", + "id": "2", + "settings": { "interval": "auto" }, + "field": "testtime" + } + ], + "timeField": "testtime", + "intervalMs": 30000, + "maxDataPoints": 814 + }, + { + "datasource": { "uid": "haha", "type": "elasticsearch" }, + "datasourceId": 42, + "refId": "b", + "hide": false, + "query": "", + "alias": "", + "metrics": [{ "id": "1", "type": "min", "field": "float" }], + "bucketAggs": [ + { + "type": "date_histogram", + "id": "2", + "settings": { "interval": "auto" }, + "field": "testtime" + } + ], + "timeField": "testtime", + "intervalMs": 30000, + "maxDataPoints": 814 + } +] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.response.json b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.response.json new file mode 100644 index 00000000000..2940d34cf0b --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.response.json @@ -0,0 +1,103 @@ +{ + "took": 5, + "responses": [ + { + "took": 5, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 188, + "relation": "eq" + }, + "max_score": null, + "hits": [] + }, + "aggregations": { + "2": { + "buckets": [ + { + "key_as_string": "1669633140000", + "key": 1669633140000, + "doc_count": 34, + "1": { + "value": 97.96025848388672 + } + }, + { + "key_as_string": "1669633200000", + "key": 1669633200000, + "doc_count": 74, + "1": { + "value": 98.74357604980469 + } + }, + { + "key_as_string": "1669633260000", + "key": 1669633260000, + "doc_count": 80, + "1": { + "value": 99.94505310058594 + } + } + ] + } + }, + "status": 200 + }, + { + "took": 4, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 188, + "relation": "eq" + }, + "max_score": null, + "hits": [] + }, + "aggregations": { + "2": { + "buckets": [ + { + "key_as_string": "1669633140000", + "key": 1669633140000, + "doc_count": 34, + "1": { + "value": 1.8121581077575684 + } + }, + { + "key_as_string": "1669633200000", + "key": 1669633200000, + "doc_count": 74, + "1": { + "value": 0.776007354259491 + } + }, + { + "key_as_string": "1669633260000", + "key": 1669633260000, + "doc_count": 80, + "1": { + "value": 0.26452451944351196 + } + } + ] + } + }, + "status": 200 + } + ] +} diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc new file mode 100644 index 00000000000..87b41c54ee1 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc @@ -0,0 +1,208 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 4 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val3 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-14 10:40:00 +0000 UTC | 0 | +// | 2022-11-14 10:41:00 +0000 UTC | 27 | +// | 2022-11-14 10:42:00 +0000 UTC | 21 | +// | 2022-11-14 10:43:00 +0000 UTC | 31 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[1] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 4 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val2 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-14 10:40:00 +0000 UTC | 0 | +// | 2022-11-14 10:41:00 +0000 UTC | 28 | +// | 2022-11-14 10:42:00 +0000 UTC | 22 | +// | 2022-11-14 10:43:00 +0000 UTC | 39 | +// +-------------------------------+--------------------+ +// +// +// +// Frame[2] { +// "custom": null +// } +// Name: +// Dimensions: 2 Fields by 4 Rows +// +-------------------------------+--------------------+ +// | Name: time | Name: value | +// | Labels: | Labels: label=val1 | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+--------------------+ +// | 2022-11-14 10:40:00 +0000 UTC | 0 | +// | 2022-11-14 10:41:00 +0000 UTC | 26 | +// | 2022-11-14 10:42:00 +0000 UTC | 20 | +// | 2022-11-14 10:43:00 +0000 UTC | 41 | +// +-------------------------------+--------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val3" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val3" + }, + "config": { + "displayNameFromDS": "val3" + } + } + ] + }, + "data": { + "values": [ + [ + 1668422400000, + 1668422460000, + 1668422520000, + 1668422580000 + ], + [ + 0, + 27, + 21, + 31 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val2" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val2" + }, + "config": { + "displayNameFromDS": "val2" + } + } + ] + }, + "data": { + "values": [ + [ + 1668422400000, + 1668422460000, + 1668422520000, + 1668422580000 + ], + [ + 0, + 28, + 22, + 39 + ] + ] + } + }, + { + "schema": { + "meta": { + "custom": null + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + }, + "config": { + "displayNameFromDS": "val1" + } + }, + { + "name": "value", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "label": "val1" + }, + "config": { + "displayNameFromDS": "val1" + } + } + ] + }, + "data": { + "values": [ + [ + 1668422400000, + 1668422460000, + 1668422520000, + 1668422580000 + ], + [ + 0, + 26, + 20, + 41 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_simple.queries.json b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.queries.json new file mode 100644 index 00000000000..ce5e1a76e6b --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.queries.json @@ -0,0 +1,45 @@ +[ + { + "alias": "", + "bucketAggs": [ + { + "field": "label", + "id": "3", + "settings": { + "min_doc_count": "1", + "order": "desc", + "orderBy": "_term", + "size": "10" + }, + "type": "terms" + }, + { + "field": "testtime", + "id": "2", + "settings": { + "interval": "1m" + }, + "type": "date_histogram" + } + ], + "datasource": { + "type": "elasticsearch", + "uid": "haha" + }, + "datasourceId": 42, + "expression": "", + "hide": true, + "intervalMs": 200, + "maxDataPoints": 1248, + "metrics": [ + { + "id": "1", + "type": "count" + } + ], + "query": "", + "refId": "a", + "timeField": "testtime", + "window": "" + } +] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_simple.response.json b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.response.json new file mode 100644 index 00000000000..41d70ec81c3 --- /dev/null +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.response.json @@ -0,0 +1,56 @@ +{ + "took": 7, + "responses": [ + { + "took": 7, + "timed_out": false, + "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, + "hits": { "total": { "value": 255, "relation": "eq" }, "max_score": null, "hits": [] }, + "aggregations": { + "3": { + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + "buckets": [ + { + "key": "val3", + "doc_count": 79, + "2": { + "buckets": [ + { "key_as_string": "1668422400000", "key": 1668422400000, "doc_count": 0 }, + { "key_as_string": "1668422460000", "key": 1668422460000, "doc_count": 27 }, + { "key_as_string": "1668422520000", "key": 1668422520000, "doc_count": 21 }, + { "key_as_string": "1668422580000", "key": 1668422580000, "doc_count": 31 } + ] + } + }, + { + "key": "val2", + "doc_count": 89, + "2": { + "buckets": [ + { "key_as_string": "1668422400000", "key": 1668422400000, "doc_count": 0 }, + { "key_as_string": "1668422460000", "key": 1668422460000, "doc_count": 28 }, + { "key_as_string": "1668422520000", "key": 1668422520000, "doc_count": 22 }, + { "key_as_string": "1668422580000", "key": 1668422580000, "doc_count": 39 } + ] + } + }, + { + "key": "val1", + "doc_count": 87, + "2": { + "buckets": [ + { "key_as_string": "1668422400000", "key": 1668422400000, "doc_count": 0 }, + { "key_as_string": "1668422460000", "key": 1668422460000, "doc_count": 26 }, + { "key_as_string": "1668422520000", "key": 1668422520000, "doc_count": 20 }, + { "key_as_string": "1668422580000", "key": 1668422580000, "doc_count": 41 } + ] + } + } + ] + } + }, + "status": 200 + } + ] +} diff --git a/pkg/tsdb/parca/query.go b/pkg/tsdb/parca/query.go index 3cb8efe8031..a44ac6c2b94 100644 --- a/pkg/tsdb/parca/query.go +++ b/pkg/tsdb/parca/query.go @@ -24,7 +24,7 @@ const queryTypeProfile = "profile" const queryTypeMetrics = "metrics" const queryTypeBoth = "both" -// query processes single Fire query transforming the response to data.Frame packaged in DataResponse +// query processes single Parca query transforming the response to data.Frame packaged in DataResponse func (d *ParcaDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { var qm queryModel response := backend.DataResponse{} @@ -97,7 +97,7 @@ type CustomMeta struct { ProfileTypeID string } -// responseToDataFrames turns fire response to data.Frame. We encode the data into a nested set format where we have +// responseToDataFrames turns Parca response to data.Frame. We encode the data into a nested set format where we have // [level, value, label] columns and by ordering the items in a depth first traversal order we can recreate the whole // tree back. func responseToDataFrames(resp *connect.Response[v1alpha1.QueryResponse]) *data.Frame { diff --git a/pkg/tsdb/parca/service.go b/pkg/tsdb/parca/service.go index fa5c6cc0742..113dc5d4344 100644 --- a/pkg/tsdb/parca/service.go +++ b/pkg/tsdb/parca/service.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ) -// Make sure FireDatasource implements required interfaces. This is important to do +// Make sure ParcaDatasource implements required interfaces. This is important to do // since otherwise we will only get a not implemented error response from plugin in // runtime. In this example datasource instance implements backend.QueryDataHandler, // backend.CheckHealthHandler, backend.StreamHandler interfaces. Plugin should not diff --git a/pkg/tsdb/phlare/query.go b/pkg/tsdb/phlare/query.go index 7abc80be5f1..fda9d4d1e4f 100644 --- a/pkg/tsdb/phlare/query.go +++ b/pkg/tsdb/phlare/query.go @@ -35,7 +35,7 @@ const ( queryTypeBoth = "both" ) -// query processes single Fire query transforming the response to data.Frame packaged in DataResponse +// query processes single Phlare query transforming the response to data.Frame packaged in DataResponse func (d *PhlareDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { var qm queryModel response := backend.DataResponse{} @@ -121,7 +121,7 @@ func makeRequest(qm queryModel, query backend.DataQuery) *connect.Request[querie } } -// responseToDataFrames turns fire response to data.Frame. We encode the data into a nested set format where we have +// responseToDataFrames turns Phlare response to data.Frame. We encode the data into a nested set format where we have // [level, value, label] columns and by ordering the items in a depth first traversal order we can recreate the whole // tree back. func responseToDataFrames(resp *connect.Response[querierv1.SelectMergeStacktracesResponse], profileTypeID string) *data.Frame { @@ -154,7 +154,7 @@ type ProfileTree struct { } // levelsToTree converts flamebearer format into a tree. This is needed to then convert it into nested set format -// dataframe. This should be temporary, and ideally we should get some sort of tree struct directly from Fire API. +// dataframe. This should be temporary, and ideally we should get some sort of tree struct directly from Phlare API. func levelsToTree(levels []*querierv1.Level, names []string) *ProfileTree { tree := &ProfileTree{ Start: 0, diff --git a/pkg/tsdb/phlare/service.go b/pkg/tsdb/phlare/service.go index e4ddc9b6afc..e604c1bf7c9 100644 --- a/pkg/tsdb/phlare/service.go +++ b/pkg/tsdb/phlare/service.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ) -// Make sure FireDatasource implements required interfaces. This is important to do +// Make sure PhlareDatasource implements required interfaces. This is important to do // since otherwise we will only get a not implemented error response from plugin in // runtime. In this example datasource instance implements backend.QueryDataHandler, // backend.CheckHealthHandler, backend.StreamHandler interfaces. Plugin should not diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index a837ec8bf63..e829d93ef14 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -297,7 +297,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG // If no rows were returned, no point checking anything else. if frame.Rows() == 0 { - queryResult.dataResponse.Frames = data.Frames{frame} + queryResult.dataResponse.Frames = data.Frames{} ch <- queryResult return } diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index adca0fd977e..07349c9bf84 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -43,6 +43,7 @@ const ( rawFrameQuery queryType = "raw_frame" csvFileQueryType queryType = "csv_file" csvContentQueryType queryType = "csv_content" + traceType queryType = "trace" ) type queryType string @@ -218,6 +219,11 @@ Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means handler: s.handleCsvContentScenario, }) + s.registerScenario(&Scenario{ + ID: string(traceType), + Name: "Trace", + }) + s.queryMux.HandleFunc("", s.handleFallbackScenario) } diff --git a/public/api-merged.json b/public/api-merged.json index f0aebc474db..23aeab68549 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -1400,7 +1400,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateUserQuotaCmd" + "$ref": "#/definitions/UpdateQuotaCmd" } }, { @@ -2458,6 +2458,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { @@ -2527,6 +2532,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { @@ -6317,6 +6327,33 @@ } } }, + "/org/quotas": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", + "tags": [ + "getCurrentOrg" + ], + "summary": "Fetch Organization quota.", + "operationId": "getCurrentOrgQuota", + "responses": { + "200": { + "$ref": "#/responses/getQuotaResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/org/users": { "get": { "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", @@ -6840,7 +6877,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateOrgQuotaCmd" + "$ref": "#/definitions/UpdateQuotaCmd" } }, { @@ -11630,7 +11667,7 @@ "description": { "type": "string" }, - "parent_uid": { + "parentUid": { "type": "string" }, "title": { @@ -12989,7 +13026,7 @@ "type": "integer", "format": "int64" }, - "parent_uid": { + "parentUid": { "description": "only used if nested folders are enabled", "type": "string" }, @@ -13431,12 +13468,6 @@ "GettableNGalertConfig": { "type": "object", "properties": { - "alertmanagers": { - "type": "array", - "items": { - "type": "string" - } - }, "alertmanagersChoice": { "type": "string", "enum": [ @@ -14368,32 +14399,6 @@ } } }, - "Model": { - "description": "THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.\nEquivalent Go types at stable import paths are provided in https://github.com/grafana/grok.", - "type": "object", - "title": "Model is the Go representation of a playlist.", - "properties": { - "interval": { - "description": "Interval sets the time between switching views in a playlist.\nFIXME: Is this based on a standardized format or what options are available? Can datemath be used?", - "type": "string" - }, - "items": { - "description": "The ordered list of items that the playlist will iterate over.\nFIXME! This should not be optional, but changing it makes the godegen awkward", - "type": "array", - "items": { - "$ref": "#/definitions/PlaylistItem" - } - }, - "name": { - "description": "Name of the playlist.", - "type": "string" - }, - "uid": { - "description": "Unique playlist identifier. Generated on creation, either by the\ncreator of the playlist of by the application.", - "type": "string" - } - } - }, "MultiStatus": { "type": "object" }, @@ -14898,7 +14903,7 @@ "homeDashboardUID": { "type": "string" }, - "locale": { + "language": { "type": "string" }, "navbar": { @@ -14992,12 +14997,15 @@ "format": "int64" }, "interval": { + "description": "Interval sets the time between switching views in a playlist.\nFIXME: Is this based on a standardized format or what options are available? Can datemath be used?", "type": "string" }, "name": { + "description": "Name of the playlist.", "type": "string" }, "uid": { + "description": "Unique playlist identifier. Generated on creation, either by the\ncreator of the playlist of by the application.", "type": "string" } } @@ -15034,9 +15042,8 @@ } }, "PlaylistItem": { - "description": "THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.\nEquivalent Go types at stable import paths are provided in https://github.com/grafana/grok.", "type": "object", - "title": "PlaylistItem is the Go representation of a playlist.Item.", + "title": "PlaylistItem defines model for playlist.Item.", "properties": { "title": { "description": "Title is an unused property -- it will be removed in the future", @@ -15052,7 +15059,6 @@ } }, "PlaylistItemType": { - "description": "THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.\nEquivalent Go types at stable import paths are provided in https://github.com/grafana/grok.", "type": "string", "title": "Type of the item." }, @@ -15350,12 +15356,6 @@ "PostableNGalertConfig": { "type": "object", "properties": { - "alertmanagers": { - "type": "array", - "items": { - "type": "string" - } - }, "alertmanagersChoice": { "type": "string", "enum": [ @@ -15407,7 +15407,7 @@ "homeDashboardUID": { "type": "string" }, - "locale": { + "language": { "type": "string" }, "navbar": { @@ -15848,6 +15848,30 @@ } } }, + "QuotaDTO": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int64" + }, + "org_id": { + "type": "integer", + "format": "int64" + }, + "target": { + "type": "string" + }, + "used": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "integer", + "format": "int64" + } + } + }, "Receiver": { "type": "object", "title": "Receiver configuration provides configuration on how to contact a receiver.", @@ -17523,8 +17547,9 @@ "type": "string" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "type": "object", - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { "ForceQuery": { "type": "boolean" @@ -17535,9 +17560,6 @@ "Host": { "type": "string" }, - "OmitHost": { - "type": "boolean" - }, "Opaque": { "type": "string" }, @@ -17758,6 +17780,9 @@ "UpdateFolderCommand": { "type": "object", "properties": { + "description": { + "type": "string" + }, "overwrite": { "type": "boolean" }, @@ -17804,18 +17829,6 @@ } } }, - "UpdateOrgQuotaCmd": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int64" - }, - "target": { - "type": "string" - } - } - }, "UpdateOrgUserCommand": { "type": "object", "properties": { @@ -17861,7 +17874,7 @@ "homeDashboardUID": { "type": "string" }, - "locale": { + "language": { "type": "string" }, "navbar": { @@ -17889,6 +17902,18 @@ } } }, + "UpdateQuotaCmd": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int64" + }, + "target": { + "type": "string" + } + } + }, "UpdateRoleCommand": { "type": "object", "properties": { @@ -17981,18 +18006,6 @@ } } }, - "UpdateUserQuotaCmd": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int64" - }, - "target": { - "type": "string" - } - } - }, "UserIdDTO": { "type": "object", "properties": { @@ -18097,26 +18110,6 @@ } } }, - "UserQuotaDTO": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int64" - }, - "target": { - "type": "string" - }, - "used": { - "type": "integer", - "format": "int64" - }, - "user_id": { - "type": "integer", - "format": "int64" - } - } - }, "UserSearchHitDTO": { "type": "object", "properties": { @@ -18362,6 +18355,7 @@ } }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -18490,7 +18484,6 @@ } }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -18553,6 +18546,7 @@ } }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -18601,6 +18595,7 @@ } }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" @@ -18751,6 +18746,7 @@ } }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -18788,7 +18784,6 @@ } }, "receiver": { - "description": "Receiver receiver", "type": "object", "required": [ "active", @@ -19579,7 +19574,7 @@ "getPlaylistResponse": { "description": "(empty)", "schema": { - "$ref": "#/definitions/Model" + "$ref": "#/definitions/Playlist" } }, "getPreferencesResponse": { @@ -19617,7 +19612,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/UserQuotaDTO" + "$ref": "#/definitions/QuotaDTO" } } }, @@ -20101,7 +20096,7 @@ "updatePlaylistResponse": { "description": "(empty)", "schema": { - "$ref": "#/definitions/Model" + "$ref": "#/definitions/Playlist" } }, "updateServiceAccountResponse": { diff --git a/public/api-spec.json b/public/api-spec.json index 6d479bd6e5b..1a16ebff937 100644 --- a/public/api-spec.json +++ b/public/api-spec.json @@ -1400,7 +1400,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateUserQuotaCmd" + "$ref": "#/definitions/UpdateQuotaCmd" } }, { @@ -5670,6 +5670,33 @@ } } }, + "/org/quotas": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", + "tags": [ + "getCurrentOrg" + ], + "summary": "Fetch Organization quota.", + "operationId": "getCurrentOrgQuota", + "responses": { + "200": { + "$ref": "#/responses/getQuotaResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/org/users": { "get": { "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", @@ -6193,7 +6220,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateOrgQuotaCmd" + "$ref": "#/definitions/UpdateQuotaCmd" } }, { @@ -10650,7 +10677,7 @@ "description": { "type": "string" }, - "parent_uid": { + "parentUid": { "type": "string" }, "title": { @@ -11808,7 +11835,7 @@ "type": "integer", "format": "int64" }, - "parent_uid": { + "parentUid": { "description": "only used if nested folders are enabled", "type": "string" }, @@ -12573,32 +12600,6 @@ } } }, - "Model": { - "description": "THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.\nEquivalent Go types at stable import paths are provided in https://github.com/grafana/grok.", - "type": "object", - "title": "Model is the Go representation of a playlist.", - "properties": { - "interval": { - "description": "Interval sets the time between switching views in a playlist.\nFIXME: Is this based on a standardized format or what options are available? Can datemath be used?", - "type": "string" - }, - "items": { - "description": "The ordered list of items that the playlist will iterate over.\nFIXME! This should not be optional, but changing it makes the godegen awkward", - "type": "array", - "items": { - "$ref": "#/definitions/PlaylistItem" - } - }, - "name": { - "description": "Name of the playlist.", - "type": "string" - }, - "uid": { - "description": "Unique playlist identifier. Generated on creation, either by the\ncreator of the playlist of by the application.", - "type": "string" - } - } - }, "NavLink": { "type": "object", "properties": { @@ -12852,7 +12853,7 @@ "homeDashboardUID": { "type": "string" }, - "locale": { + "language": { "type": "string" }, "navbar": { @@ -12943,12 +12944,15 @@ "format": "int64" }, "interval": { + "description": "Interval sets the time between switching views in a playlist.\nFIXME: Is this based on a standardized format or what options are available? Can datemath be used?", "type": "string" }, "name": { + "description": "Name of the playlist.", "type": "string" }, "uid": { + "description": "Unique playlist identifier. Generated on creation, either by the\ncreator of the playlist of by the application.", "type": "string" } } @@ -12985,9 +12989,8 @@ } }, "PlaylistItem": { - "description": "THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.\nEquivalent Go types at stable import paths are provided in https://github.com/grafana/grok.", "type": "object", - "title": "PlaylistItem is the Go representation of a playlist.Item.", + "title": "PlaylistItem defines model for playlist.Item.", "properties": { "title": { "description": "Title is an unused property -- it will be removed in the future", @@ -13003,7 +13006,6 @@ } }, "PlaylistItemType": { - "description": "THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.\nEquivalent Go types at stable import paths are provided in https://github.com/grafana/grok.", "type": "string", "title": "Type of the item." }, @@ -13078,7 +13080,7 @@ "homeDashboardUID": { "type": "string" }, - "locale": { + "language": { "type": "string" }, "navbar": { @@ -13329,6 +13331,30 @@ } } }, + "QuotaDTO": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int64" + }, + "org_id": { + "type": "integer", + "format": "int64" + }, + "target": { + "type": "string" + }, + "used": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "integer", + "format": "int64" + } + } + }, "RecordingRuleJSON": { "description": "RecordingRuleJSON is the external representation of a recording rule", "type": "object", @@ -14460,6 +14486,9 @@ "UpdateFolderCommand": { "type": "object", "properties": { + "description": { + "type": "string" + }, "overwrite": { "type": "boolean" }, @@ -14506,18 +14535,6 @@ } } }, - "UpdateOrgQuotaCmd": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int64" - }, - "target": { - "type": "string" - } - } - }, "UpdateOrgUserCommand": { "type": "object", "properties": { @@ -14563,7 +14580,7 @@ "homeDashboardUID": { "type": "string" }, - "locale": { + "language": { "type": "string" }, "navbar": { @@ -14591,6 +14608,18 @@ } } }, + "UpdateQuotaCmd": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int64" + }, + "target": { + "type": "string" + } + } + }, "UpdateRoleCommand": { "type": "object", "properties": { @@ -14683,18 +14712,6 @@ } } }, - "UpdateUserQuotaCmd": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int64" - }, - "target": { - "type": "string" - } - } - }, "UserIdDTO": { "type": "object", "properties": { @@ -14799,26 +14816,6 @@ } } }, - "UserQuotaDTO": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int64" - }, - "target": { - "type": "string" - }, - "used": { - "type": "integer", - "format": "int64" - }, - "user_id": { - "type": "integer", - "format": "int64" - } - } - }, "UserSearchHitDTO": { "type": "object", "properties": { @@ -15603,7 +15600,7 @@ "getPlaylistResponse": { "description": "", "schema": { - "$ref": "#/definitions/Model" + "$ref": "#/definitions/Playlist" } }, "getPreferencesResponse": { @@ -15641,7 +15638,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/UserQuotaDTO" + "$ref": "#/definitions/QuotaDTO" } } }, @@ -16116,7 +16113,7 @@ "updatePlaylistResponse": { "description": "", "schema": { - "$ref": "#/definitions/Model" + "$ref": "#/definitions/Playlist" } }, "updateServiceAccountResponse": { diff --git a/public/app/core/components/AppChrome/TopBar/TopNavBarMenu.tsx b/public/app/core/components/AppChrome/TopBar/TopNavBarMenu.tsx index a740f5afc60..605e196f7ec 100644 --- a/public/app/core/components/AppChrome/TopBar/TopNavBarMenu.tsx +++ b/public/app/core/components/AppChrome/TopBar/TopNavBarMenu.tsx @@ -26,6 +26,9 @@ export function TopNavBarMenu({ node: nodePlain }: TopNavBarMenuProps) { return ( e.stopPropagation()} className={styles.header}>
{getNavTitle(node.id) ?? node.text}
{node.subTitle &&
{node.subTitle}
} diff --git a/public/app/core/components/Layers/LayerName.tsx b/public/app/core/components/Layers/LayerName.tsx index 660fc295636..1ed35e1a3a1 100644 --- a/public/app/core/components/Layers/LayerName.tsx +++ b/public/app/core/components/Layers/LayerName.tsx @@ -8,9 +8,10 @@ export interface LayerNameProps { name: string; onChange: (v: string) => void; verifyLayerNameUniqueness?: (nameToCheck: string) => boolean; + overrideStyles?: boolean; } -export const LayerName = ({ name, onChange, verifyLayerNameUniqueness }: LayerNameProps) => { +export const LayerName = ({ name, onChange, verifyLayerNameUniqueness, overrideStyles }: LayerNameProps) => { const styles = useStyles2(getStyles); const [isEditing, setIsEditing] = useState(false); @@ -75,7 +76,7 @@ export const LayerName = ({ name, onChange, verifyLayerNameUniqueness }: LayerNa onClick={onEditLayer} data-testid="layer-name-div" > - {name} + {name} )} diff --git a/public/app/core/components/NavBar/navBarItem-translations.ts b/public/app/core/components/NavBar/navBarItem-translations.ts index ea674073cf7..9fc0820c0e0 100644 --- a/public/app/core/components/NavBar/navBarItem-translations.ts +++ b/public/app/core/components/NavBar/navBarItem-translations.ts @@ -97,7 +97,7 @@ export function getNavTitle(navId: string | undefined) { return t('nav.admin.title', 'Server admin'); case 'global-users': return config.featureToggles.topnav - ? t('nav.global-users.title', 'Users (All orgs)') + ? t('nav.global-users.title', 'Users') : t('nav.global-users.titleBeforeTopnav', 'Users'); case 'global-orgs': return t('nav.global-orgs.title', 'Organizations'); @@ -184,7 +184,7 @@ export function getNavSubTitle(navId: string | undefined) { case 'serviceaccounts': return t('nav.service-accounts.subtitle', 'Use service accounts to run automated workloads in Grafana'); case 'global-users': - return t('nav.global-users.subtitle', 'Manage and create users across the whole Grafana server'); + return t('nav.global-users.subtitle', 'Manage users in Grafana'); case 'global-orgs': return t('nav.global-orgs.subtitle', 'Isolated instances of Grafana running on the same server'); case 'server-settings': diff --git a/public/app/core/components/OptionsUI/slider.tsx b/public/app/core/components/OptionsUI/slider.tsx index be4d46de1fd..a668a66868e 100644 --- a/public/app/core/components/OptionsUI/slider.tsx +++ b/public/app/core/components/OptionsUI/slider.tsx @@ -96,7 +96,7 @@ export const SliderValueEditor: React.FC {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} -
+
+ ); }; diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index eac3fcc8f3c..f85f0bab465 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -1,18 +1,33 @@ import React, { PureComponent } from 'react'; +import { SelectableValue } from '@grafana/data'; import { LinkButton, FilterInput } from '@grafana/ui'; +import { SortPicker } from '../Select/SortPicker'; + export interface Props { searchQuery: string; setSearchQuery: (value: string) => void; linkButton?: { href: string; title: string; disabled?: boolean }; target?: string; placeholder?: string; + sortPicker?: { + onChange: (sortValue: SelectableValue) => void; + value?: string; + getSortOptions?: () => Promise; + }; } export default class PageActionBar extends PureComponent { render() { - const { searchQuery, linkButton, setSearchQuery, target, placeholder = 'Search by name or type' } = this.props; + const { + searchQuery, + linkButton, + setSearchQuery, + target, + placeholder = 'Search by name or type', + sortPicker, + } = this.props; const linkProps: typeof LinkButton.defaultProps = { href: linkButton?.href, disabled: linkButton?.disabled }; if (target) { @@ -24,6 +39,13 @@ export default class PageActionBar extends PureComponent {
+ {sortPicker && ( + + )} {linkButton && {linkButton.title}} ); diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx index 7daa5ab05c4..e6b4fc69fcc 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx @@ -102,6 +102,7 @@ jest.mock('app/core/services/PreferencesService', () => ({ const props = { resourceUri: '/fake-api/user/1', + preferenceType: 'user' as const, }; describe('SharedPreferences', () => { diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index f82808a350e..5a6486e250d 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react'; import { FeatureState, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { config } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Button, Field, @@ -26,17 +26,12 @@ import { UserPreferencesDTO } from 'app/types'; export interface Props { resourceUri: string; disabled?: boolean; + preferenceType: 'org' | 'team' | 'user'; onConfirm?: () => Promise; } export type State = UserPreferencesDTO; -const themes: SelectableValue[] = [ - { value: '', label: t('shared-preferences.theme.default-label', 'Default') }, - { value: 'dark', label: t('shared-preferences.theme.dark-label', 'Dark') }, - { value: 'light', label: t('shared-preferences.theme.light-label', 'Light') }, -]; - function getLanguageOptions(): Array> { const languageOptions = LANGUAGES.map((v) => ({ value: v.code, @@ -58,6 +53,7 @@ const i18nFlag = Boolean(config.featureToggles.internationalization); export class SharedPreferences extends PureComponent { service: PreferencesService; + themeOptions: SelectableValue[]; constructor(props: Props) { super(props); @@ -70,6 +66,12 @@ export class SharedPreferences extends PureComponent { language: '', queryHistory: { homeTab: '' }, }; + + this.themeOptions = [ + { value: '', label: t('shared-preferences.theme.default-label', 'Default') }, + { value: 'dark', label: t('shared-preferences.theme.dark-label', 'Dark') }, + { value: 'light', label: t('shared-preferences.theme.light-label', 'Light') }, + ]; } async componentDidMount() { @@ -116,6 +118,11 @@ export class SharedPreferences extends PureComponent { onLanguageChanged = (language: string) => { this.setState({ language }); + + reportInteraction('grafana_preferences_language_changed', { + toLanguage: language, + preferenceType: this.props.preferenceType, + }); }; render() { @@ -131,8 +138,8 @@ export class SharedPreferences extends PureComponent {
Preferences} disabled={disabled}> item.value === theme)?.value} + options={this.themeOptions} + value={this.themeOptions.find((item) => item.value === theme)?.value} onChange={this.onThemeChanged} /> diff --git a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts index a5e1217b390..b765246a391 100644 --- a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts +++ b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts @@ -75,6 +75,7 @@ export class RudderstackBackend implements EchoBackend = T extends false | '' | 0 | null | undefined ? never : T; - -export const isTruthy = (value: T): value is Truthy => Boolean(value); diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index 8a7f0599b5c..25fb7f584ea 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -112,9 +112,11 @@ export class UserAdminPage extends PureComponent { user?.isExternal && user?.authLabels?.some((r) => SyncedOAuthLabels.includes(r)); const isSAMLUser = user?.isExternal && user?.authLabels?.includes('SAML'); const isGoogleUser = user?.isExternal && user?.authLabels?.includes('Google'); + const isAuthProxyUser = user?.isExternal && user?.authLabels?.includes('Auth Proxy'); const isUserSynced = !config.auth.DisableSyncLock && - ((user?.isExternal && !(isGoogleUser || isOAuthUserWithSkippableSync || isSAMLUser || isLDAPUser)) || + ((user?.isExternal && + !(isAuthProxyUser || isGoogleUser || isOAuthUserWithSkippableSync || isSAMLUser || isLDAPUser)) || (!config.auth.OAuthSkipOrgRoleUpdateSync && isOAuthUserWithSkippableSync) || (!config.auth.SAMLSkipOrgRoleSync && isSAMLUser) || (!config.auth.LDAPSkipOrgRoleSync && isLDAPUser)); diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 308c6841427..a1119b509d9 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -77,98 +77,105 @@ const UserListAdminPageUnConnected = ({ const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]); return ( - - -
-
- - changeFilter({ name: 'activeLast30Days', value })} - value={filters.find((f) => f.name === 'activeLast30Days')?.value} - className={styles.filter} - /> - {extraFilters.map((FilterComponent, index) => ( - - ))} -
- {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( - - New user - - )} + +
+
+ + changeFilter({ name: 'activeLast30Days', value })} + value={filters.find((f) => f.name === 'activeLast30Days')?.value} + className={styles.filter} + /> + {extraFilters.map((FilterComponent, index) => ( + + ))}
- {isLoading ? ( - - ) : ( - <> -
-
- - - - - - - - {showLicensedRole && ( - - )} + {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( + + New user + + )} + + {isLoading ? ( + + ) : ( + <> +
+
LoginEmailNameBelongs to - Licensed role{' '} - - Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their - dashboard/folder permissions.{' '} - - Learn more - - - } - > - - -
+ + + + + + + + {showLicensedRole && ( - - - - - {users.map((user) => ( - - ))} - -
LoginEmailNameBelongs to - Last active  - + Licensed role{' '} + + Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their + dashboard/folder permissions.{' '} + + Learn more + + + } + >
-
- {showPaging && } - - )} - - + )} + + Last active  + + + + + + + + + {users.map((user) => ( + + ))} + + +
+ {showPaging && } + + )} + ); }; +export const UserListAdminPageContent = connector(UserListAdminPageUnConnected); +export function UserListAdminPage() { + return ( + + + + ); +} + const getUsersAriaLabel = (name: string) => { return `Edit user's ${name} details`; }; @@ -349,4 +356,4 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -export default connector(UserListAdminPageUnConnected); +export default UserListAdminPage; diff --git a/public/app/features/admin/UserListPage.tsx b/public/app/features/admin/UserListPage.tsx new file mode 100644 index 00000000000..fa1cb2ebaeb --- /dev/null +++ b/public/app/features/admin/UserListPage.tsx @@ -0,0 +1,52 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { RadioButtonGroup, Field, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { Page } from '../../core/components/Page/Page'; +import { AccessControlAction } from '../../types'; +import { UsersListPageContent } from '../users/UsersListPage'; + +import { UserListAdminPageContent } from './UserListAdminPage'; + +const views = [ + { value: 'admin', label: 'All organisations' }, + { value: 'org', label: 'This organisation' }, +]; + +export default function UserListPage() { + const hasAccessToAdminUsers = contextSrv.hasAccess(AccessControlAction.UsersRead, contextSrv.isGrafanaAdmin); + const hasAccessToOrgUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); + const styles = useStyles2(getStyles); + const [view, setView] = useState(() => { + if (hasAccessToAdminUsers) { + return 'admin'; + } else if (hasAccessToOrgUsers) { + return 'org'; + } + return null; + }); + + const showToggle = hasAccessToOrgUsers && hasAccessToAdminUsers; + + return ( + + {showToggle && ( + + + + )} + {view === 'admin' ? : } + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + margin: ${theme.spacing(2, 0)}; + `, + }; +}; diff --git a/public/app/features/alerting/unified/ExistingRuleEditor.tsx b/public/app/features/alerting/unified/ExistingRuleEditor.tsx index 612b7c345cd..2a704fcff05 100644 --- a/public/app/features/alerting/unified/ExistingRuleEditor.tsx +++ b/public/app/features/alerting/unified/ExistingRuleEditor.tsx @@ -19,9 +19,21 @@ interface ExistingRuleEditorProps { export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) { useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState)); - const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); + + const { + loading: loadingAlertRule, + result, + error, + dispatched, + } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); + const dispatch = useDispatch(); - const { isEditable } = useIsRuleEditable(ruleId.ruleIdentifierToRuleSourceName(identifier), result?.rule); + const { isEditable, loading: loadingEditable } = useIsRuleEditable( + ruleId.ruleIdentifierToRuleSourceName(identifier), + result?.rule + ); + + const loading = loadingAlertRule || loadingEditable; useEffect(() => { if (!dispatched) { diff --git a/public/app/features/alerting/unified/components/CollapseToggle.tsx b/public/app/features/alerting/unified/components/CollapseToggle.tsx index d5ae0433b00..9ea400226e6 100644 --- a/public/app/features/alerting/unified/components/CollapseToggle.tsx +++ b/public/app/features/alerting/unified/components/CollapseToggle.tsx @@ -29,6 +29,7 @@ export const CollapseToggle: FC = ({