diff --git a/.betterer.results b/.betterer.results index 235ffb75cae..9bbf07ee673 100644 --- a/.betterer.results +++ b/.betterer.results @@ -44,9 +44,6 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/Logs/LogRows.test.tsx:2288254498": [ [3, 17, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/Modal/Modal.test.tsx:4235780832": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/QueryField/QueryField.test.tsx:1906163280": [ [1, 19, 13, "RegExp match", "2409514259"] ], diff --git a/.drone.star b/.drone.star index d2723f88be8..0829f79f299 100644 --- a/.drone.star +++ b/.drone.star @@ -14,7 +14,7 @@ load('scripts/drone/vault.star', 'secrets') def main(ctx): edition = 'oss' - return pr_pipelines(edition=edition) + docs_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ + return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ publish_image_pipelines('public') + publish_image_pipelines('security') + \ publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \ publish_npm_pipelines('public') + publish_packages_pipeline() + \ diff --git a/.drone.yml b/.drone.yml index a3ec9b7f565..a7ce2b6a034 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -23,7 +23,7 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl verify-drone @@ -43,13 +43,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss @@ -57,7 +57,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend - commands: - yarn run prettier:check @@ -68,19 +68,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -88,7 +88,7 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-frontend trigger: event: @@ -114,7 +114,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -126,7 +126,7 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -134,29 +134,29 @@ steps: depends_on: - initialize environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + --no-pull-enterprise depends_on: - initialize environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps + - ./bin/grabpl build-plugins --jobs 8 --edition oss depends_on: - initialize environment: null - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -177,7 +177,7 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: ensure-cuetsified - commands: - . scripts/build/gpg-test-vars.sh && ./bin/grabpl package --jobs 8 --edition oss @@ -187,7 +187,7 @@ steps: - build-backend - build-frontend environment: null - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package - commands: - ./scripts/grafana-server/start-server @@ -199,7 +199,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: grafana-server - commands: - apt-get install -y netcat @@ -276,7 +276,7 @@ steps: - build-frontend environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-storybook - commands: - yarn wait-on http://$HOST:$PORT @@ -296,7 +296,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: copy-packages-for-docker - commands: - ./bin/grabpl build-docker --edition oss -archs amd64 @@ -354,7 +354,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -371,7 +371,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: postgres-integration-tests - commands: - apt-get update @@ -386,7 +386,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: mysql-integration-tests trigger: event: @@ -418,7 +418,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -430,22 +430,16 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise - depends_on: - - initialize - environment: - NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 - name: build-frontend -- commands: + - yarn packages:build + - yarn packages:docsExtract + - yarn packages:docsToMarkdown - ./scripts/ci-reference-docs-lint.sh ci depends_on: - - build-frontend - image: grafana/build-container:1.4.9 + - initialize + image: grafana/build-container:1.5.1 name: build-frontend-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -470,7 +464,7 @@ volumes: --- depends_on: [] kind: pipeline -name: main-test +name: main-docs node: type: no-parallel platform: @@ -480,7 +474,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -492,7 +486,60 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 + name: initialize +- commands: + - yarn packages:build + - yarn packages:docsExtract + - yarn packages:docsToMarkdown + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - initialize + image: grafana/build-container:1.5.1 + name: build-frontend-docs +- commands: + - mkdir -p /hugo/content/docs/grafana + - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ + - cd /hugo && make prod + depends_on: + - build-frontend-docs + image: grafana/docs-base:latest + name: build-docs-website +trigger: + branch: main + event: + - push +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: main-test +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl verify-drone @@ -512,13 +559,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss @@ -526,7 +573,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend - commands: - yarn run prettier:check @@ -537,19 +584,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -557,7 +604,7 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-frontend trigger: branch: main @@ -581,7 +628,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -593,13 +640,13 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - image: grafana/drone-downstream name: trigger-enterprise-downstream settings: params: - - SOURCE_BUILD_NUMBER=${DRONE_BUILD_NUMBER} + - SOURCE_BUILD_NUMBER=${DRONE_COMMIT} - SOURCE_COMMIT=${DRONE_COMMIT} repositories: - grafana/grafana-enterprise@main @@ -612,31 +659,31 @@ steps: depends_on: - initialize environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + --no-pull-enterprise depends_on: - initialize environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -657,7 +704,7 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: ensure-cuetsified - commands: - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise @@ -677,7 +724,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package - commands: - ./scripts/grafana-server/start-server @@ -689,7 +736,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: grafana-server - commands: - apt-get install -y netcat @@ -766,7 +813,7 @@ steps: - build-frontend environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-storybook - commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json @@ -806,20 +853,14 @@ steps: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key failure: ignore - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: publish-frontend-metrics -- commands: - - ./scripts/ci-reference-docs-lint.sh ci - depends_on: - - build-frontend - image: grafana/build-container:1.4.9 - name: build-frontend-docs - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: copy-packages-for-docker - commands: - ./bin/grabpl build-docker --edition oss @@ -891,7 +932,7 @@ steps: environment: NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: release-canary-npm-packages - commands: - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads @@ -966,7 +1007,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -983,7 +1024,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: postgres-integration-tests - commands: - apt-get update @@ -998,7 +1039,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: mysql-integration-tests trigger: branch: main @@ -1034,7 +1075,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1117,7 +1158,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1127,7 +1168,7 @@ steps: name: identify-runner - commands: - make gen-go - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl store-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id @@ -1199,7 +1240,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1212,7 +1253,7 @@ steps: - ./bin/grabpl verify-version ${DRONE_TAG} - ./bin/grabpl gen-version ${DRONE_TAG} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} @@ -1222,31 +1263,31 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps - --edition oss --no-pull-enterprise ${DRONE_TAG} + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition + oss --no-pull-enterprise ${DRONE_TAG} depends_on: - initialize environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -1267,7 +1308,7 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: ensure-cuetsified - commands: - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise @@ -1287,14 +1328,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: copy-packages-for-docker - commands: - ./bin/grabpl build-docker --edition oss --shouldSave @@ -1330,7 +1371,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: grafana-server - commands: - apt-get install -y netcat @@ -1407,7 +1448,7 @@ steps: - build-frontend environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-storybook - commands: - ./bin/grabpl upload-cdn --edition oss --src-bucket "$${PRERELEASE_BUCKET}" --src-dir @@ -1457,7 +1498,7 @@ steps: - ./scripts/build/build-npm-packages.sh ${DRONE_TAG} depends_on: - store-storybook - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-npm-packages - commands: - ./bin/grabpl artifacts npm store --tag ${DRONE_TAG} @@ -1503,7 +1544,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1516,7 +1557,7 @@ steps: - ./bin/grabpl verify-version ${DRONE_TAG} - ./bin/grabpl gen-version ${DRONE_TAG} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - |- @@ -1530,13 +1571,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss @@ -1544,7 +1585,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend - commands: - yarn run prettier:check @@ -1555,19 +1596,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -1575,7 +1616,7 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-frontend trigger: event: @@ -1624,7 +1665,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1637,7 +1678,7 @@ steps: - ./bin/grabpl verify-version ${DRONE_TAG} - ./bin/grabpl gen-version ${DRONE_TAG} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - apt-get update @@ -1652,7 +1693,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: postgres-integration-tests - commands: - apt-get update @@ -1667,7 +1708,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: mysql-integration-tests trigger: event: @@ -1708,7 +1749,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1767,7 +1808,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1782,7 +1823,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -1802,7 +1843,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} @@ -1812,32 +1853,31 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps - --edition enterprise --no-pull-enterprise ${DRONE_TAG} + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition + enterprise --no-pull-enterprise ${DRONE_TAG} depends_on: - initialize environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign - --signing-admin + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -1858,7 +1898,7 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: ensure-cuetsified - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} @@ -1868,7 +1908,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend-enterprise2 - commands: - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} @@ -1889,14 +1929,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: copy-packages-for-docker - commands: - ./bin/grabpl build-docker --edition enterprise --shouldSave @@ -1933,7 +1973,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: grafana-server - commands: - apt-get install -y netcat @@ -2045,7 +2085,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 --src-bucket "$${PRERELEASE_BUCKET}" @@ -2107,7 +2147,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2122,7 +2162,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2142,7 +2182,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - |- @@ -2156,13 +2196,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition enterprise @@ -2170,7 +2210,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend - commands: - yarn run prettier:check @@ -2181,19 +2221,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition enterprise depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend - commands: - ./bin/grabpl integration-tests --edition enterprise depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -2201,7 +2241,7 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-frontend - commands: - ./bin/grabpl lint-backend --edition enterprise2 @@ -2209,19 +2249,19 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend-enterprise2 - commands: - ./bin/grabpl test-backend --edition enterprise2 depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-enterprise2 - commands: - ./bin/grabpl integration-tests --edition enterprise2 depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration-enterprise2 trigger: event: @@ -2280,7 +2320,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2295,7 +2335,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2315,7 +2355,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - apt-get update @@ -2330,7 +2370,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: postgres-integration-tests - commands: - apt-get update @@ -2345,7 +2385,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -2354,7 +2394,7 @@ steps: - initialize environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -2363,7 +2403,7 @@ steps: - initialize environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: memcached-integration-tests trigger: event: @@ -2408,7 +2448,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2483,7 +2523,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2561,7 +2601,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2622,7 +2662,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2701,7 +2741,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2763,7 +2803,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2799,7 +2839,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2842,11 +2882,11 @@ steps: - ./bin/grabpl verify-version ${DRONE_TAG} - ./bin/grabpl gen-version ${DRONE_TAG} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2868,7 +2908,7 @@ steps: environment: NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: release-npm-packages trigger: event: @@ -2894,7 +2934,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2957,7 +2997,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2969,7 +3009,7 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -2977,31 +3017,31 @@ steps: depends_on: - initialize environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + --no-pull-enterprise depends_on: - initialize environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -3022,7 +3062,7 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: ensure-cuetsified - commands: - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise @@ -3042,14 +3082,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: copy-packages-for-docker - commands: - ./bin/grabpl build-docker --edition oss --shouldSave @@ -3085,7 +3125,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: grafana-server - commands: - apt-get install -y netcat @@ -3162,7 +3202,7 @@ steps: - build-frontend environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-storybook - commands: - ./bin/grabpl upload-cdn --edition oss --src-bucket "grafana-static-assets" @@ -3216,7 +3256,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3228,7 +3268,7 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - |- @@ -3242,13 +3282,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss @@ -3256,7 +3296,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend - commands: - yarn run prettier:check @@ -3267,19 +3307,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -3287,7 +3327,7 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-frontend trigger: ref: @@ -3330,7 +3370,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3342,7 +3382,7 @@ steps: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - apt-get update @@ -3357,7 +3397,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: postgres-integration-tests - commands: - apt-get update @@ -3372,7 +3412,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: mysql-integration-tests trigger: ref: @@ -3407,7 +3447,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3455,7 +3495,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3470,7 +3510,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3486,7 +3526,7 @@ steps: depends_on: - clone-enterprise environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -3494,32 +3534,31 @@ steps: depends_on: - initialize environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition enterprise --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} + --no-pull-enterprise depends_on: - initialize environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign - --signing-admin + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -3540,7 +3579,7 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: ensure-cuetsified - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} @@ -3548,7 +3587,7 @@ steps: depends_on: - initialize environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-backend-enterprise2 - commands: - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -3569,14 +3608,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: copy-packages-for-docker - commands: - ./bin/grabpl build-docker --edition enterprise --shouldSave @@ -3613,7 +3652,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: grafana-server - commands: - apt-get install -y netcat @@ -3690,7 +3729,7 @@ steps: - build-frontend environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: build-storybook - commands: - ./bin/grabpl upload-cdn --edition enterprise --src-bucket "grafana-static-assets" @@ -3733,7 +3772,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 --src-bucket "grafana-static-assets" @@ -3788,7 +3827,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3803,7 +3842,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3819,7 +3858,7 @@ steps: depends_on: - clone-enterprise environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - |- @@ -3833,13 +3872,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition enterprise @@ -3847,7 +3886,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend - commands: - yarn run prettier:check @@ -3858,19 +3897,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition enterprise depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend - commands: - ./bin/grabpl integration-tests --edition enterprise depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -3878,7 +3917,7 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-frontend - commands: - ./bin/grabpl lint-backend --edition enterprise2 @@ -3886,19 +3925,19 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: lint-backend-enterprise2 - commands: - ./bin/grabpl test-backend --edition enterprise2 depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-enterprise2 - commands: - ./bin/grabpl integration-tests --edition enterprise2 depends_on: - initialize - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: test-backend-integration-enterprise2 trigger: ref: @@ -3951,7 +3990,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3966,7 +4005,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3982,7 +4021,7 @@ steps: depends_on: - clone-enterprise environment: {} - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: initialize - commands: - apt-get update @@ -3997,7 +4036,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: postgres-integration-tests - commands: - apt-get update @@ -4012,7 +4051,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -4021,7 +4060,7 @@ steps: - initialize environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -4030,7 +4069,7 @@ steps: - initialize environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.4.9 + image: grafana/build-container:1.5.1 name: memcached-integration-tests trigger: ref: @@ -4069,7 +4108,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4266,6 +4305,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 703b9a44a073ab768a774029cee2c3fc7c5fe7b7e01495e18f048bc7d1f007b0 +hmac: d9de3f45c31338ed1936f253e8a1f3390822468daeb3395bc368dced68068519 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495abdd9d94..fee3c1ec2b4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -99,6 +99,7 @@ go.sum @grafana/backend-platform /public/app/core/components/TimePicker @grafana/grafana-bi-squad /public/app/core/components/Layers @grafana/grafana-edge-squad /public/app/features/canvas/ @grafana/grafana-edge-squad +/public/app/features/comments/ @grafana/grafana-edge-squad /public/app/features/dimensions/ @grafana/grafana-edge-squad /public/app/features/geo/ @grafana/grafana-edge-squad /public/app/features/live/ @grafana/grafana-edge-squad diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 29271b76d72..ef70d6fa713 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -17,6 +17,7 @@ "d3-scale-chromatic", // we should bump this once we move to esm modules "execa", // we should bump this once we move to esm modules "history", // we should bump this together with react-router-dom + "react-hook-form", // due to us exposing these hooks via @grafana/ui form components bumping can break plugins "react-icons", // jaeger-ui-components is being refactored to use @grafana/ui icons instead "react-router-dom", // we should bump this together with history "slate", diff --git a/Dockerfile b/Dockerfile index 618c0fa1e88..20aa6b5b3e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.17.6-alpine3.15 as go-builder +FROM golang:1.17.7-alpine3.15 as go-builder RUN apk add --no-cache gcc g++ make diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index db3794d1bb1..4c3f2325a8f 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -21,7 +21,7 @@ COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.17.6 AS go-builder +FROM golang:1.17.7 AS go-builder WORKDIR /src/grafana diff --git a/babel.config.json b/babel.config.json index 4de74eb504a..28e9f48eeed 100644 --- a/babel.config.json +++ b/babel.config.json @@ -33,6 +33,9 @@ "allowDeclareFields": true } ], + // added to mitigate https://github.com/babel/babel/issues/14289 + // package (and following line) can be removed once the issue is fixed and released + "@babel/plugin-proposal-class-properties", ["@babel/plugin-proposal-object-rest-spread", { "loose": true }], "@babel/plugin-transform-react-constant-elements", "@babel/plugin-proposal-nullish-coalescing-operator", diff --git a/conf/defaults.ini b/conf/defaults.ini index 738b33889ac..c87e9b467fb 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -403,6 +403,9 @@ oauth_auto_login = false # OAuth state max age cookie duration in seconds. Defaults to 600 seconds. oauth_state_cookie_max_age = 600 +# Skip forced assignment of OrgID 1 or 'auto_assign_org_id' for social logins +oauth_skip_org_role_update_sync = false + # limit of api_key seconds to live before expiration api_key_max_seconds_to_live = -1 diff --git a/conf/sample.ini b/conf/sample.ini index 759fd56af35..96f19917bf1 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -397,6 +397,9 @@ # OAuth state max age cookie duration in seconds. Defaults to 600 seconds. ;oauth_state_cookie_max_age = 600 +# Skip forced assignment of OrgID 1 or 'auto_assign_org_id' for social logins +;oauth_skip_org_role_update_sync = false + # limit of api_key seconds to live before expiration ;api_key_max_seconds_to_live = -1 diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index a215513c568..b6f3498eaf3 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -24,7 +24,7 @@ services: MYSQL_PASSWORD: password command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all, --max-connections=1001] ports: - - 3306 + - "3306:3306" healthcheck: test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] timeout: 10s @@ -47,7 +47,7 @@ services: # POSTGRES_USER: grafana # POSTGRES_PASSWORD: password # ports: - # - 5432 + # - "5432:5432" # healthcheck: # test: ["CMD-SHELL", "pg_isready -d grafana -U grafana"] # timeout: 10s @@ -66,13 +66,9 @@ services: - GF_DATABASE_TYPE=mysql - GF_DATABASE_HOST=db:3306 - GF_DATABASE_MAX_OPEN_CONN=300 - - GF_SESSION_PROVIDER=mysql - - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true # - GF_DATABASE_TYPE=postgres # - GF_DATABASE_HOST=db:5432 # - GF_DATABASE_SSL_MODE=disable - # - GF_SESSION_PROVIDER=postgres - # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable - GF_SERVER_ROUTER_LOGGING=true - GF_LOG_CONSOLE_FORMAT=json - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 1600c0420b5..9440a278a46 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -766,6 +766,12 @@ This setting is ignored if multiple OAuth providers are configured. Default is ` How many seconds the OAuth state cookie lives before being deleted. Default is `600` (seconds) Administrators can increase this if they experience OAuth login state mismatch errors. +### oauth_skip_org_role_update_sync + +Skip forced assignment of OrgID `1` or `auto_assign_org_id` for external logins. Default is `false`. +Use this setting to distribute users with external login to multiple organizations. +Otherwise, the users' organization would get reset on every new login, for example, via AzureAD. + ### api_key_max_seconds_to_live Limit of API key seconds to live before expiration. Default is -1 (unlimited). diff --git a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md index a41915e1660..254a9a0d26b 100644 --- a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md +++ b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md @@ -7,7 +7,7 @@ weight = 450 # Mute timings -A mute timing is a recurring interval of time when no new notifications for a policy are generated or sent. are sent for a policy. Use them to prevent alerts from firing a specific and reoccurring period, for example, a regular maintenance period. +A mute timing is a recurring interval of time when no new notifications for a policy are generated or sent. Use them to prevent alerts from firing a specific and reoccurring period, for example, a regular maintenance period. Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. diff --git a/docs/sources/datasources/alertmanager.md b/docs/sources/datasources/alertmanager.md index f3bcc5505c7..68422acc56c 100644 --- a/docs/sources/datasources/alertmanager.md +++ b/docs/sources/datasources/alertmanager.md @@ -26,7 +26,7 @@ apiVersion: 1 datasources: - name: Alertmanager type: alertmanager - url: http://localhost:9090 + url: http://localhost:9093 access: proxy jsonData: implementation: 'prometheus' # alternatively 'cortex' diff --git a/docs/sources/datasources/jaeger.md b/docs/sources/datasources/jaeger.md index 0f4509aa46c..f0b686cd6d8 100644 --- a/docs/sources/datasources/jaeger.md +++ b/docs/sources/datasources/jaeger.md @@ -32,6 +32,7 @@ This is a configuration for the [trace to logs feature]({{< relref "../explore/t - **Data source -** Target data source. - **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. +- **Map tag names -** When enabled, allows configuring how Jaeger tag names map to Loki label names. For example, map `service.name` to `service`. - **Span start time shift -** Shift in the start time for the Loki query based on the span start time. In order to extend to the past, you need to use a negative value. Use time interval units like 5s, 1m, 3h. The default is 0. - **Span end time shift -** Shift in the end time for the Loki query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. - **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. @@ -147,12 +148,15 @@ datasources: tracesToLogs: # Field with internal link pointing to a Loki data source in Grafana. # datasourceUid value must match the `datasourceUid` value of the Loki data source. - datasourceUid: loki - tags: - - cluster - - hostname - - namespace - - pod + datasourceUid: 'loki' + tags: ['job', 'instance', 'pod', 'namespace'] + mappedTags: [{ key: 'service.name', value: 'service' }] + mapTagNamesEnabled: false + spanStartTimeShift: '1h' + spanEndTimeShift: '1h' + filterByTraceID: false + filterBySpanID: false + lokiSearch: true secureJsonData: basicAuthPassword: my_password ``` diff --git a/docs/sources/datasources/tempo.md b/docs/sources/datasources/tempo.md index eeb5ad5d77e..0a688d7f2d5 100644 --- a/docs/sources/datasources/tempo.md +++ b/docs/sources/datasources/tempo.md @@ -31,6 +31,7 @@ This is a configuration for the [trace to logs feature]({{< relref "../explore/t - **Data source -** Target data source. - **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. +- **Map tag names -** When enabled, allows configuring how Tempo tag names map to Loki label names. For example, map `service.name` to `service`. - **Span start time shift -** A shift in the start time for the Loki query based on the start time for the span. To extend the time to the past, use a negative value. You can use time units, for example, 5s, 1m, 3h. The default is 0. - **Span end time shift -** Shift in the end time for the Loki query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. - **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. @@ -167,6 +168,8 @@ datasources: tracesToLogs: datasourceUid: 'loki' tags: ['job', 'instance', 'pod', 'namespace'] + mappedTags: [{ key: 'service.name', value: 'service' }] + mapTagNamesEnabled: false spanStartTimeShift: '1h' spanEndTimeShift: '1h' filterByTraceID: false diff --git a/docs/sources/datasources/zipkin.md b/docs/sources/datasources/zipkin.md index 1b1f4b6df86..ed530afa4ec 100644 --- a/docs/sources/datasources/zipkin.md +++ b/docs/sources/datasources/zipkin.md @@ -32,6 +32,7 @@ This is a configuration for the [trace to logs feature]({{< relref "../explore/t - **Data source -** Target data source. - **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. +- **Map tag names -** When enabled, allows configuring how Zipkin tag names map to Loki label names. For example, map `service.name` to `service`. - **Span start time shift -** Shift in the start time for the Loki query based on the span start time. In order to extend to the past, you need to use a negative value. Use time interval units like 5s, 1m, 3h. The default is 0. - **Span end time shift -** Shift in the end time for the Loki query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. - **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. diff --git a/docs/sources/developers/_index.md b/docs/sources/developers/_index.md index 2d27bc4e9fb..bd4e72187a8 100644 --- a/docs/sources/developers/_index.md +++ b/docs/sources/developers/_index.md @@ -6,4 +6,10 @@ weight = 190 # Developers -This section of the documentation contains pages with resources for Grafana developers. +This section of the documentation contains pages with resources for Grafana developers: + +- [Grafana Contribution Guide - learn how you can contribute your skills to make Grafana even better.]({{< relref "contribute.md" >}}) +- [How to build a plugin]({{< relref "plugins" >}}) +- [Grafana Tutorials - step-by-step guides that help you make the most of Grafana](https://grafana.com/tutorials/) +- [Grafana Community Forums - get technical support for open source Grafana, Loki, and Tempo.](https://community.grafana.com) +- [Grafana design system - library of reusable Grafana components and guidelines that help you with contribution and plugin development](https://developers.grafana.com) diff --git a/docs/sources/enterprise/enterprise-encryption/_index.md b/docs/sources/enterprise/enterprise-encryption/_index.md index ca4a52640a3..3110e1dea52 100644 --- a/docs/sources/enterprise/enterprise-encryption/_index.md +++ b/docs/sources/enterprise/enterprise-encryption/_index.md @@ -16,10 +16,10 @@ You can choose to encrypt secrets stored in the Grafana database using a key fro Grafana integrates with the following key management systems: -- [AWS KMS]({{< relref "/using-aws-kms-to-encrypt-database-secrets.md" >}}) -- [Azure Key Vault]({{< relref "/using-azure-key-vault-to-encrypt-database-secrets.md" >}}) -- [Google Cloud KMS]({{< relref "/using-google-cloud-kms-to-encrypt-database-secrets.md" >}}) -- [Hashicorp Key Vault]({{< relref "/using-hashicorp-key-vault-to-encrypt-database-secrets.md" >}}) +- [AWS KMS]({{< relref "using-aws-kms-to-encrypt-database-secrets.md" >}}) +- [Azure Key Vault]({{< relref "using-azure-key-vault-to-encrypt-database-secrets.md" >}}) +- [Google Cloud KMS]({{< relref "using-google-cloud-kms-to-encrypt-database-secrets.md" >}}) +- [Hashicorp Key Vault]({{< relref "using-hashicorp-key-vault-to-encrypt-database-secrets.md" >}}) Refer to [Database encryption]({{< relref "../../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database. diff --git a/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md index 3005656c87c..b09bd2d582b 100644 --- a/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md +++ b/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md @@ -5,9 +5,9 @@ keywords = ["grafana", "Hashicorp Vault integration"] weight = 3 +++ -# Using Google Cloud KMS to encrypt database secrets +# Using Hashicorp Vault to encrypt database secrets -You can use an encryption key from Google Cloud Key Management Service to encrypt secrets in the Grafana database. +You can use an encryption key from Hashicorp Vault to encrypt secrets in the Grafana database. **Prerequisites:** diff --git a/docs/sources/http_api/folder_dashboard_search.md b/docs/sources/http_api/folder_dashboard_search.md index 987665b9545..93bd9132d86 100644 --- a/docs/sources/http_api/folder_dashboard_search.md +++ b/docs/sources/http_api/folder_dashboard_search.md @@ -19,7 +19,7 @@ Query parameters: - **dashboardIds** – List of dashboard id's to search for - **folderIds** – List of folder id's to search in for dashboards - **starred** – Flag indicating if only starred Dashboards should be returned -- **limit** – Limit the number of returned results (max 5000) +- **limit** – Limit the number of returned results (max is 5000; default is 1000) - **page** – Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size. Only available in Grafana v6.2+. **Example request for retrieving folders and dashboards of the general folder**: diff --git a/docs/sources/http_api/query_history.md b/docs/sources/http_api/query_history.md index f360f90ea37..2b5833fa2fb 100644 --- a/docs/sources/http_api/query_history.md +++ b/docs/sources/http_api/query_history.md @@ -77,7 +77,7 @@ Status codes: - **400** - Errors (invalid JSON, missing or invalid fields) - **500** – Unable to add query to the database -### Delete query from Query history by UID +## Delete query from Query history by UID `DELETE /api/query-history/:uid` @@ -107,10 +107,9 @@ Content-Type: application/json Status codes: - **200** – OK -- **404** - Query in query history not found - **500** – Unable to delete query from the database -### Update comment of query in Query history by UID +## Update comment of query in Query history by UID `PATCH /api/query-history/:uid` @@ -165,3 +164,99 @@ Status codes: - **200** – OK - **400** - Errors (invalid JSON, missing or invalid fields) - **500** – Unable to update comment of query in the database + +## Star query in Query history + +`POST /api/query-history/star/:uid` + +Stars query in query history. + +**Example request:** + +```http +POST /api/query-history/star/P8zM2I1nz HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response:** + +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "result": { + "uid": "P8zM2I1nz", + "datasourceUid": "PE1C5CBDA0504A6A3", + "createdBy": 1, + "createdAt": 1643630762, + "starred": false, + "comment": "Debugging query", + "queries": [ + { + "refId": "A", + "key": "Q-87fed8e3-62ba-4eb2-8d2a-4129979bb4de-0", + "scenarioId": "csv_content", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + } + } + ] + } +} +``` + +Status codes: + +- **200** – OK +- **500** – Unable to star query in the database + +## Unstar query in Query history + +`DELETE /api/query-history/star/:uid` + +Removes stars from query in query history. + +**Example request:** + +```http +DELETE /api/query-history/star/P8zM2I1nz HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response:** + +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "result": { + "uid": "P8zM2I1nz", + "datasourceUid": "PE1C5CBDA0504A6A3", + "createdBy": 1, + "createdAt": 1643630762, + "starred": false, + "comment": "Debugging query", + "queries": [ + { + "refId": "A", + "key": "Q-87fed8e3-62ba-4eb2-8d2a-4129979bb4de-0", + "scenarioId": "csv_content", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + } + } + ] + } +} +``` + +Status codes: + +- **200** – OK +- **500** – Unable to unstar query in the database diff --git a/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index bf6782e2d64..39c87b73448 100644 --- a/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -5,7 +5,7 @@ e2e.scenario({ itName: 'Tests dashboard links and variables in links', addScenarioDataSource: false, addScenarioDashBoard: false, - skipScenario: false, + skipScenario: true, // Skipped because it was causing many failures in main. scenario: () => { e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); e2e() diff --git a/go.mod b/go.mod index de2b68f0c21..19c0d32aa94 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/gorilla/websocket v1.4.2 github.com/gosimple/slug v1.9.0 github.com/grafana/cuetsy v0.0.0-20211119211437-8c25464cc9bf - github.com/grafana/grafana-aws-sdk v0.10.0 + github.com/grafana/grafana-aws-sdk v0.10.1 github.com/grafana/grafana-plugin-sdk-go v0.126.0 github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 diff --git a/go.sum b/go.sum index 9f1d3c03590..67d93cc8c4c 100644 --- a/go.sum +++ b/go.sum @@ -1245,6 +1245,8 @@ github.com/grafana/grafana-aws-sdk v0.9.1 h1:jMZlsLsWnqOwLt2UNcLUsJ2z6289hLYlscK github.com/grafana/grafana-aws-sdk v0.9.1/go.mod h1:6KaQ8uUD4KpXr/b7bAC7zbfSXTVOiTk4XhIrwkGWn4w= github.com/grafana/grafana-aws-sdk v0.10.0 h1:q7+mJtT/vsU5InDN57yM+BJ2z1kJDf1W4WwWPEZ0Cxw= github.com/grafana/grafana-aws-sdk v0.10.0/go.mod h1:vFIOHEnY1u5nY0/tge1IHQjPuG6DRKr2ISf/HikUdjE= +github.com/grafana/grafana-aws-sdk v0.10.1 h1:Ksguhjx6EuGLN/5Oc7oZoxuDReJ5RxIH99yqSMpLGUs= +github.com/grafana/grafana-aws-sdk v0.10.1/go.mod h1:vFIOHEnY1u5nY0/tge1IHQjPuG6DRKr2ISf/HikUdjE= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 h1:2ud7NNM7LrGPO4x0NFR8qLq68CqI4SmB7I2yRN2w9oE= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE= github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M= diff --git a/package.json b/package.json index 3a96b5bac55..ba20522e9b9 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "packages:publishDev": "lerna publish from-package --contents dist --dist-tag dev --yes --registry http://grafana-npm.local:4873 --force-publish=*", "packages:typecheck": "lerna run typecheck", "packages:clean": "lerna run clean", - "precommit": "betterer precommit && yarn run lint-staged", + "precommit": "yarn run lint-staged", "prettier:check": "prettier --check --list-different=false --loglevel=warn \"**/*.{scss,md,mdx}\"", "prettier:write": "prettier --list-different \"**/*.{scss,md,mdx}\" --write", "start": "yarn themes:generate && yarn dev --watch", @@ -59,6 +59,7 @@ }, "lint-staged": { "*.{js,ts,tsx}": [ + "betterer precommit", "eslint --ext .js,.tsx,.ts --cache --fix" ], "*.{json,scss,md,mdx}": [ @@ -70,6 +71,7 @@ }, "devDependencies": { "@babel/core": "7.17.0", + "@babel/plugin-proposal-class-properties": "7.16.7", "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7", "@babel/plugin-proposal-object-rest-spread": "7.17.3", "@babel/plugin-proposal-optional-chaining": "7.16.7", @@ -153,8 +155,8 @@ "@types/testing-library__react-hooks": "^3.2.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "8.3.4", - "@typescript-eslint/eslint-plugin": "5.11.0", - "@typescript-eslint/parser": "5.11.0", + "@typescript-eslint/eslint-plugin": "5.12.1", + "@typescript-eslint/parser": "5.12.1", "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", "autoprefixer": "10.4.2", "axios": "0.26.0", @@ -225,7 +227,7 @@ "ts-node": "10.5.0", "typescript": "4.4.4", "wait-on": "6.0.0", - "webpack": "5.68.0", + "webpack": "5.69.1", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.9.2", "webpack-dev-server": "4.7.4", @@ -255,7 +257,7 @@ "@react-aria/dialog": "3.1.4", "@react-aria/focus": "3.5.0", "@react-aria/interactions": "3.8.1", - "@react-aria/menu": "3.3.0", + "@react-aria/menu": "3.4.1", "@react-aria/overlays": "3.7.3", "@react-aria/utils": "3.11.0", "@react-stately/collections": "3.3.4", @@ -308,7 +310,7 @@ "lezer-tree": "0.13.2", "lodash": "4.17.21", "logfmt": "^1.3.2", - "lru-cache": "7.3.1", + "lru-cache": "7.4.0", "memoize-one": "6.0.0", "moment": "2.29.1", "moment-timezone": "0.5.34", @@ -320,7 +322,7 @@ "ol": "6.12.0", "papaparse": "5.3.1", "pluralize": "^8.0.0", - "prismjs": "1.26.0", + "prismjs": "1.27.0", "prop-types": "15.8.1", "rc-cascader": "3.2.1", "rc-drawer": "4.4.3", @@ -334,7 +336,7 @@ "react-draggable": "4.4.4", "react-grid-layout": "1.3.3", "react-highlight-words": "0.17.0", - "react-hook-form": "7.27.0", + "react-hook-form": "7.5.3", "react-inlinesvg": "2.3.0", "react-loadable": "5.5.0", "react-moveable": "0.30.3", @@ -380,7 +382,7 @@ "@rushstack/node-core-library": "3.45.0", "@rushstack/rig-package": "0.3.7", "@rushstack/ts-command-line": "4.10.6", - "@storybook/react/webpack": "5.68.0" + "@storybook/react/webpack": "5.69.1" }, "workspaces": { "packages": [ diff --git a/packages/grafana-data/src/text/markdown.ts b/packages/grafana-data/src/text/markdown.ts index a87d0f0bb54..5335d47145d 100644 --- a/packages/grafana-data/src/text/markdown.ts +++ b/packages/grafana-data/src/text/markdown.ts @@ -5,6 +5,7 @@ let hasInitialized = false; export interface RenderMarkdownOptions { noSanitize?: boolean; + breaks?: boolean; } const markdownOptions = { @@ -13,6 +14,7 @@ const markdownOptions = { smartLists: true, smartypants: false, xhtml: false, + breaks: false, }; export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): string { @@ -21,7 +23,15 @@ export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): s hasInitialized = true; } - const html = marked(str || ''); + let opts = undefined; + if (options?.breaks) { + opts = { + ...markdownOptions, + breaks: true, + }; + } + const html = marked(str || '', opts); + if (options?.noSanitize) { return html; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 95bb511c0e9..ef0242c0389 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -42,5 +42,7 @@ export interface FeatureToggles { validatedQueries?: boolean; swaggerUi?: boolean; featureHighlights?: boolean; + dashboardComments?: boolean; + annotationComments?: boolean; migrationLocking?: boolean; } diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 781a6610270..66b1fe81737 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -22,6 +22,7 @@ export interface NavModelItem { highlightText?: string; highlightId?: string; tabSuffix?: ComponentType<{ className?: string }>; + hideFromNavbar?: boolean; } export enum NavSection { diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 0b574b21fd7..18f138f6b27 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -41,7 +41,7 @@ "rollup-plugin-copy": "3.4.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", - "webpack": "5.68.0" + "webpack": "5.69.1" }, "types": "src/index.ts", "dependencies": { diff --git a/packages/grafana-runtime/src/utils/queryResponse.test.ts b/packages/grafana-runtime/src/utils/queryResponse.test.ts index 0f64a32a1bd..36bdcdf953e 100644 --- a/packages/grafana-runtime/src/utils/queryResponse.test.ts +++ b/packages/grafana-runtime/src/utils/queryResponse.test.ts @@ -1,6 +1,6 @@ import { DataQuery, toDataFrameDTO, DataFrame } from '@grafana/data'; import { FetchError, FetchResponse } from 'src/services'; -import { BackendDataSourceResponse, toDataQueryResponse, toTestingStatus } from './queryResponse'; +import { BackendDataSourceResponse, cachedResponseNotice, toDataQueryResponse, toTestingStatus } from './queryResponse'; const resp = { data: { @@ -277,6 +277,55 @@ describe('Query Response parser', () => { expect(ids).toEqual(['A', 'B']); }); + describe('Cache notice', () => { + let resp: any; + + beforeEach(() => { + resp = { + url: '', + type: 'basic', + config: { url: '' }, + status: 200, + statusText: 'OK', + ok: true, + redirected: false, + headers: new Headers(), + data: { + results: { + A: { frames: [{ schema: { fields: [] } }] }, + }, + }, + }; + }); + + test('adds notice for responses with X-Cache: HIT header', () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + resp.headers.set('X-Cache', 'HIT'); + expect(toDataQueryResponse(resp, queries).data[0].meta.notices).toStrictEqual([cachedResponseNotice]); + }); + + test('does not remove existing notices', () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + resp.headers.set('X-Cache', 'HIT'); + resp.data.results.A.frames[0].schema.meta = { notices: [{ severity: 'info', text: 'Example' }] }; + expect(toDataQueryResponse(resp, queries).data[0].meta.notices).toStrictEqual([ + { severity: 'info', text: 'Example' }, + cachedResponseNotice, + ]); + }); + + test('does not add notice for responses with X-Cache: MISS header', () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + resp.headers.set('X-Cache', 'MISS'); + expect(toDataQueryResponse(resp, queries).data[0].meta?.notices).toBeUndefined(); + }); + + test('does not add notice for responses without X-Cache header', () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + expect(toDataQueryResponse(resp, queries).data[0].meta?.notices).toBeUndefined(); + }); + }); + test('resultWithError', () => { // Generated from: // qdr.Responses[q.GetRefID()] = backend.DataResponse{ diff --git a/packages/grafana-runtime/src/utils/queryResponse.ts b/packages/grafana-runtime/src/utils/queryResponse.ts index 55c12a20703..748efa4d57d 100644 --- a/packages/grafana-runtime/src/utils/queryResponse.ts +++ b/packages/grafana-runtime/src/utils/queryResponse.ts @@ -12,10 +12,13 @@ import { DataQuery, DataFrameJSON, dataFrameFromJSON, + QueryResultMetaNotice, } from '@grafana/data'; import { FetchError, FetchResponse } from '../services'; import { toDataQueryError } from './toDataQueryError'; +export const cachedResponseNotice: QueryResultMetaNotice = { severity: 'info', text: 'Cached response' }; + /** * Single response object from a backend data source. Properties are optional but response should contain at least * an error or a some data (but can contain both). Main way to send data is with dataframes attribute as series and @@ -62,6 +65,7 @@ export function toDataQueryResponse( if ((res as FetchResponse).data?.results) { const results = (res as FetchResponse).data.results; const refIDs = queries?.length ? queries.map((q) => q.refId) : Object.keys(results); + const cachedResponse = isCachedResponse(res as FetchResponse); const data: DataResponse[] = []; for (const refId of refIDs) { @@ -85,7 +89,10 @@ export function toDataQueryResponse( } if (dr.frames?.length) { - for (const js of dr.frames) { + for (let js of dr.frames) { + if (cachedResponse) { + js = addCacheNotice(js); + } const df = dataFrameFromJSON(js); if (!df.refId) { df.refId = dr.refId; @@ -128,6 +135,28 @@ export function toDataQueryResponse( return rsp; } +function isCachedResponse(res: FetchResponse): boolean { + const headers = res?.headers; + if (!headers || !headers.get) { + return false; + } + return headers.get('X-Cache') === 'HIT'; +} + +function addCacheNotice(frame: DataFrameJSON): DataFrameJSON { + return { + ...frame, + schema: { + ...frame.schema, + fields: [...(frame.schema?.fields ?? [])], + meta: { + ...frame.schema?.meta, + notices: [...(frame.schema?.meta?.notices ?? []), cachedResponseNotice], + }, + }, + }; +} + /** * Data sources using api/ds/query to test data sources can use this function to * handle errors and convert them to TestingStatus object. diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 7d6935c56cf..321fadb1443 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -42,7 +42,7 @@ "@react-aria/button": "3.3.4", "@react-aria/dialog": "3.1.4", "@react-aria/focus": "3.5.0", - "@react-aria/menu": "3.3.0", + "@react-aria/menu": "3.4.1", "@react-aria/overlays": "3.7.3", "@react-stately/menu": "3.2.3", "@sentry/browser": "6.17.4", @@ -61,7 +61,7 @@ "moment": "2.29.1", "monaco-editor": "^0.31.1", "ol": "6.12.0", - "prismjs": "1.26.0", + "prismjs": "1.27.0", "rc-cascader": "3.2.1", "rc-drawer": "4.4.3", "rc-slider": "9.7.5", @@ -74,7 +74,7 @@ "react-dom": "17.0.2", "react-dropzone": "11.5.1", "react-highlight-words": "0.17.0", - "react-hook-form": "7.27.0", + "react-hook-form": "7.5.3", "react-inlinesvg": "2.3.0", "react-popper": "2.2.5", "react-popper-tooltip": "^4.3.1", @@ -175,7 +175,7 @@ "terser-webpack-plugin": "5.3.1", "ts-loader": "8.0.11", "typescript": "4.4.4", - "webpack": "5.68.0", + "webpack": "5.69.1", "webpack-filter-warnings-plugin": "1.2.1" }, "types": "src/index.ts" diff --git a/packages/grafana-ui/src/components/Modal/Modal.test.tsx b/packages/grafana-ui/src/components/Modal/Modal.test.tsx index 4001fbfeee5..f629c8fb54d 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.test.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.test.tsx @@ -1,27 +1,24 @@ import React from 'react'; -import { mount } from 'enzyme'; import { Modal } from './Modal'; +import { render, screen } from '@testing-library/react'; describe('Modal', () => { - it('renders without error', () => { - mount(); - }); - it('renders nothing by default or when isOpen is false', () => { - const wrapper = mount(); - expect(wrapper.html()).toBe(null); + render(); - wrapper.setProps({ ...wrapper.props(), isOpen: false }); - expect(wrapper.html()).toBe(null); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('renders correct contents', () => { - const wrapper = mount( - -
Content
+ render( + +
Content
); - expect(wrapper.find('div#modal-content').length).toBe(1); - expect(wrapper.contains('Some Title')).toBeTruthy(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByLabelText('Some Title')).toBeInTheDocument(); + + expect(screen.getByTestId('modal-content')).toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index f053d665b59..a159a2825ec 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,7 +1,9 @@ import { cx } from '@emotion/css'; import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer } from '@react-aria/overlays'; -import React, { PropsWithChildren, useCallback, useEffect } from 'react'; +import { useDialog } from '@react-aria/dialog'; + +import { OverlayContainer, useOverlay } from '@react-aria/overlays'; +import React, { PropsWithChildren, useRef } from 'react'; import { useTheme2 } from '../../themes'; import { IconName } from '../../types'; @@ -39,33 +41,24 @@ export function Modal(props: PropsWithChildren) { closeOnBackdropClick = true, className, contentClassName, - onDismiss: propsOnDismiss, + onDismiss, onClickBackdrop, trapFocus = true, } = props; const theme = useTheme2(); const styles = getModalStyles(theme); - const onDismiss = useCallback(() => { - if (propsOnDismiss) { - propsOnDismiss(); - } - }, [propsOnDismiss]); - useEffect(() => { - const onEscKey = (ev: KeyboardEvent) => { - if (ev.key === 'Esc' || ev.key === 'Escape') { - onDismiss(); - } - }; - if (isOpen && closeOnEscape) { - document.addEventListener('keydown', onEscKey, false); - } else { - document.removeEventListener('keydown', onEscKey, false); - } - return () => { - document.removeEventListener('keydown', onEscKey, false); - }; - }, [closeOnEscape, isOpen, onDismiss]); + const ref = useRef(null); + + // Handle interacting outside the dialog and pressing + // the Escape key to close the modal. + const { overlayProps, underlayProps } = useOverlay( + { isKeyboardDismissDisabled: closeOnEscape, isOpen, onClose: onDismiss }, + ref + ); + + // Get props for the dialog and its title + const { dialogProps, titleProps } = useDialog({}, ref); if (!isOpen) { return null; @@ -78,16 +71,17 @@ export function Modal(props: PropsWithChildren) {
- {/* - tabIndex=-1 is needed here to support highlighting text within the modal when using FocusScope - see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668 - */} -
+
- {typeof title === 'string' && } - {typeof title !== 'string' && title} + {typeof title === 'string' && } + { + // FIXME: custom title components won't get an accessible title. + // Do we really want to support them or shall we just limit this ModalTabsHeader? + typeof title !== 'string' && title + }
@@ -130,11 +124,12 @@ function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; Modal.ButtonRow = ModalButtonRow; interface DefaultModalHeaderProps { + id?: string; title: string; icon?: IconName; iconTooltip?: string; } -function DefaultModalHeader({ icon, iconTooltip, title }: DefaultModalHeaderProps): JSX.Element { - return ; +function DefaultModalHeader({ icon, iconTooltip, title, id }: DefaultModalHeaderProps): JSX.Element { + return ; } diff --git a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx index 4873b394c73..6d5c2c1acaf 100644 --- a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx +++ b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx @@ -5,6 +5,7 @@ import { useStyles2 } from '../../themes'; interface Props { title: string; + id?: string; /** @deprecated */ icon?: IconName; /** @deprecated */ @@ -12,12 +13,14 @@ interface Props { } /** @internal */ -export const ModalHeader: React.FC = ({ icon, iconTooltip, title, children }) => { +export const ModalHeader: React.FC = ({ icon, iconTooltip, title, children, id }) => { const styles = useStyles2(getModalStyles); return ( <> -

{title}

+

+ {title} +

{children} ); diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index c212194bb57..2ceeaa18023 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -69,6 +69,7 @@ export class RefreshPicker extends PureComponent { return ( [ + 'anchor', 'angle-double-down', 'angle-double-right', 'angle-double-up', @@ -50,6 +51,7 @@ export const getAvailableIcons = () => 'cog', 'columns', 'comment-alt', + 'comment-alt-message', 'comment-alt-share', 'comments-alt', 'compass', diff --git a/pkg/api/api.go b/pkg/api/api.go index 535cca95ab7..8f352f87300 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -204,7 +204,7 @@ func (hs *HTTPServer) registerRoutes() { // org information available to all users. apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - orgRoute.Get("/", authorize(reqSignedIn, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(GetCurrentOrg)) + orgRoute.Get("/", authorize(reqSignedIn, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.GetCurrentOrg)) orgRoute.Get("/quotas", authorize(reqSignedIn, ac.EvalPermission(ActionOrgsQuotasRead)), routing.Wrap(hs.GetCurrentOrgQuotas)) }) @@ -243,7 +243,7 @@ func (hs *HTTPServer) registerRoutes() { // orgs (admin routes) apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) { userIDScope := ac.Scope("users", "id", ac.Parameter(":userId")) - orgsRoute.Get("/", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(GetOrgByID)) + orgsRoute.Get("/", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.GetOrgByID)) orgsRoute.Put("/", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(hs.UpdateOrg)) orgsRoute.Put("/address", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(hs.UpdateOrgAddress)) orgsRoute.Delete("/", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsDelete)), routing.Wrap(hs.DeleteOrgByID)) @@ -462,6 +462,11 @@ func (hs *HTTPServer) registerRoutes() { // short urls apiRoute.Post("/short-urls", routing.Wrap(hs.createShortURL)) + + apiRoute.Group("/comments", func(commentRoute routing.RouteRegister) { + commentRoute.Post("/get", routing.Wrap(hs.commentsGet)) + commentRoute.Post("/create", routing.Wrap(hs.commentsCreate)) + }) }, reqSignedIn) // admin api diff --git a/pkg/api/comments.go b/pkg/api/comments.go new file mode 100644 index 00000000000..d84d4a8cd8f --- /dev/null +++ b/pkg/api/comments.go @@ -0,0 +1,49 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/comments" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" +) + +func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response { + cmd := comments.GetCmd{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + items, err := hs.commentsService.Get(c.Req.Context(), c.OrgId, c.SignedInUser, cmd) + if err != nil { + if errors.Is(err, comments.ErrPermissionDenied) { + return response.Error(http.StatusForbidden, "permission denied", err) + } + return response.Error(http.StatusInternalServerError, "internal error", err) + } + return response.JSON(200, util.DynMap{ + "comments": items, + }) +} + +func (hs *HTTPServer) commentsCreate(c *models.ReqContext) response.Response { + cmd := comments.CreateCmd{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + if c.SignedInUser.UserId == 0 && !c.SignedInUser.HasRole(models.ROLE_ADMIN) { + return response.Error(http.StatusForbidden, "admin role required", nil) + } + comment, err := hs.commentsService.Create(c.Req.Context(), c.OrgId, c.SignedInUser, cmd) + if err != nil { + if errors.Is(err, comments.ErrPermissionDenied) { + return response.Error(http.StatusForbidden, "permission denied", err) + } + return response.Error(http.StatusInternalServerError, "internal error", err) + } + return response.JSON(200, util.DynMap{ + "comment": comment, + }) +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 01f87d54b0a..36fa2f632bc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -93,7 +93,7 @@ func newTestLive(t *testing.T) *live.GrafanaLive { nil, &usagestats.UsageStatsMock{T: t}, nil, - features) + features, nil) require.NoError(t, err) return gLive } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 729bf865099..2b4308078a7 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -30,6 +30,7 @@ import ( acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/cleanup" + "github.com/grafana/grafana/pkg/services/comments" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" @@ -134,6 +135,7 @@ type HTTPServer struct { dashboardProvisioningService dashboards.DashboardProvisioningService folderService dashboards.FolderService DatasourcePermissionsService DatasourcePermissionsService + commentsService *comments.Service AlertNotificationService *alerting.AlertNotificationService DashboardsnapshotsService *dashboardsnapshots.Service } @@ -166,7 +168,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService, datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, - dashboardsnapshotsService *dashboardsnapshots.Service, + dashboardsnapshotsService *dashboardsnapshots.Service, commentsService *comments.Service, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -231,6 +233,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dashboardProvisioningService: dashboardProvisioningService, folderService: folderService, DatasourcePermissionsService: datasourcePermissionsService, + commentsService: commentsService, teamPermissionsService: permissionsServices.GetTeamService(), AlertNotificationService: alertNotificationService, DashboardsnapshotsService: dashboardsnapshotsService, diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 82d1e76fbbb..501c0ad6095 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -11,6 +11,8 @@ import ( "net/http" "net/url" + "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -20,7 +22,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" - "golang.org/x/oauth2" ) var ( @@ -131,7 +132,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) response.Response { return nil } - hashedState := hashStatecode(state, provider.ClientSecret) + hashedState := hs.hashStatecode(state, provider.ClientSecret) cookies.WriteCookie(ctx.Resp, OauthStateCookieName, hashedState, hs.Cfg.OAuthCookieMaxAge, hs.CookieOptionsFromCfg) if provider.HostedDomain != "" { opts = append(opts, oauth2.SetAuthURLParam("hd", provider.HostedDomain)) @@ -154,7 +155,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) response.Response { return nil } - queryState := hashStatecode(ctx.Query("state"), provider.ClientSecret) + queryState := hs.hashStatecode(ctx.Query("state"), provider.ClientSecret) oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState) if cookieState != queryState { hs.handleOAuthLoginError(ctx, loginInfo, LoginError{ @@ -233,7 +234,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) response.Response { return nil } - loginInfo.ExternalUser = *buildExternalUserInfo(token, userInfo, name) + loginInfo.ExternalUser = *hs.buildExternalUserInfo(token, userInfo, name) loginInfo.User, err = hs.SyncUser(ctx, &loginInfo.ExternalUser, connect) if err != nil { hs.handleOAuthLoginErrorWithRedirect(ctx, loginInfo, err) @@ -264,7 +265,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) response.Response { } // buildExternalUserInfo returns a ExternalUserInfo struct from OAuth user profile -func buildExternalUserInfo(token *oauth2.Token, userInfo *social.BasicUserInfo, name string) *models.ExternalUserInfo { +func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *social.BasicUserInfo, name string) *models.ExternalUserInfo { oauthLogger.Debug("Building external user info from OAuth user info") extUser := &models.ExternalUserInfo{ @@ -278,13 +279,13 @@ func buildExternalUserInfo(token *oauth2.Token, userInfo *social.BasicUserInfo, Groups: userInfo.Groups, } - if userInfo.Role != "" { + if userInfo.Role != "" && !hs.Cfg.OAuthSkipOrgRoleUpdateSync { rt := models.RoleType(userInfo.Role) if rt.IsValid() { // The user will be assigned a role in either the auto-assigned organization or in the default one var orgID int64 - if setting.AutoAssignOrg && setting.AutoAssignOrgId > 0 { - orgID = int64(setting.AutoAssignOrgId) + if hs.Cfg.AutoAssignOrg && hs.Cfg.AutoAssignOrgId > 0 { + orgID = int64(hs.Cfg.AutoAssignOrgId) plog.Debug("The user has a role assignment and organization membership is auto-assigned", "role", userInfo.Role, "orgId", orgID) } else { @@ -327,8 +328,8 @@ func (hs *HTTPServer) SyncUser( return cmd.Result, nil } -func hashStatecode(code, seed string) string { - hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed)) +func (hs *HTTPServer) hashStatecode(code, seed string) string { + hashBytes := sha256.Sum256([]byte(code + hs.Cfg.SecretKey + seed)) return hex.EncodeToString(hashBytes[:]) } diff --git a/pkg/api/org.go b/pkg/api/org.go index fa2b4ce944f..21abb8fe5f0 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -18,17 +18,17 @@ import ( ) // GET /api/org -func GetCurrentOrg(c *models.ReqContext) response.Response { - return getOrgHelper(c.Req.Context(), c.OrgId) +func (hs *HTTPServer) GetCurrentOrg(c *models.ReqContext) response.Response { + return hs.getOrgHelper(c.Req.Context(), c.OrgId) } // GET /api/orgs/:orgId -func GetOrgByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgByID(c *models.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "orgId is invalid", err) } - return getOrgHelper(c.Req.Context(), orgId) + return hs.getOrgHelper(c.Req.Context(), orgId) } // GET /api/orgs/name/:name @@ -57,10 +57,10 @@ func (hs *HTTPServer) GetOrgByName(c *models.ReqContext) response.Response { return response.JSON(200, &result) } -func getOrgHelper(ctx context.Context, orgID int64) response.Response { +func (hs *HTTPServer) getOrgHelper(ctx context.Context, orgID int64) response.Response { query := models.GetOrgByIdQuery{Id: orgID} - if err := sqlstore.GetOrgById(ctx, &query); err != nil { + if err := hs.SQLStore.GetOrgById(ctx, &query); err != nil { if errors.Is(err, models.ErrOrgNotFound) { return response.Error(404, "Organization not found", err) } diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go index 0d97599c491..780e9d306e5 100644 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go @@ -3,7 +3,6 @@ package secretsmigrations import ( "context" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/runner" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/services/featuremgmt" diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go index 067a5d10d36..257259ad2a7 100644 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go @@ -4,9 +4,9 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/runner" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -17,69 +17,123 @@ import ( "xorm.io/xorm" ) -func (s simpleSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { +func (s simpleSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) { var rows []struct { Id int - Secret string + Secret []byte } if err := sess.Table(s.tableName).Select(fmt.Sprintf("id, %s as secret", s.columnName)).Find(&rows); err != nil { - return err + logger.Warn("Could not find any secret to re-encrypt", "table", s.tableName) + return } + var anyFailure bool + for _, row := range rows { if len(row.Secret) == 0 { continue } - var ( - err error - decoded = []byte(row.Secret) - ) - - if s.isBase64Encoded { - decoded, err = base64.StdEncoding.DecodeString(row.Secret) - if err != nil { - return err - } - } - - decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) + decrypted, err := secretsSrv.Decrypt(context.Background(), row.Secret) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue } encrypted, err := secretsSrv.EncryptWithDBSession(context.Background(), decrypted, secrets.WithoutScope(), sess) if err != nil { - return err + anyFailure = true + logger.Warn("Could not encrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue } - encoded := string(encrypted) - if s.isBase64Encoded { - encoded = base64.StdEncoding.EncodeToString(encrypted) - } - - updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) - if _, err := sess.Exec(updateSQL, encoded, row.Id); err != nil { - return err + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ?, updated = ? WHERE id = ?", s.tableName, s.columnName) + if _, err = sess.Exec(updateSQL, encrypted, nowInUTC(), row.Id); err != nil { + anyFailure = true + logger.Warn("Could not update secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue } } - logger.Infof("Column %s from %s has been re-encrypted successfully\n", s.columnName, s.tableName) - - return nil + if anyFailure { + logger.Warn(fmt.Sprintf("Column %s from %s has been re-encrypted with errors", s.columnName, s.tableName)) + } else { + logger.Info(fmt.Sprintf("Column %s from %s has been re-encrypted successfully", s.columnName, s.tableName)) + } } -func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { +func (s b64Secret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) { + var rows []struct { + Id int + Secret string + } + + if err := sess.Table(s.tableName).Select(fmt.Sprintf("id, %s as secret", s.columnName)).Find(&rows); err != nil { + logger.Warn("Could not find any secret to re-encrypt", "table", s.tableName) + return + } + + var anyFailure bool + + for _, row := range rows { + if len(row.Secret) == 0 { + continue + } + + decoded, err := base64.StdEncoding.DecodeString(row.Secret) + if err != nil { + anyFailure = true + logger.Warn("Could not decode base64-encoded secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue + } + + decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) + if err != nil { + anyFailure = true + logger.Warn("Could not decrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue + } + + encrypted, err := secretsSrv.EncryptWithDBSession(context.Background(), decrypted, secrets.WithoutScope(), sess) + if err != nil { + anyFailure = true + logger.Warn("Could not encrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue + } + + encoded := base64.StdEncoding.EncodeToString(encrypted) + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) + _, err = sess.Exec(updateSQL, encoded, row.Id) + + if err != nil { + anyFailure = true + logger.Warn("Could not update secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) + continue + } + } + + if anyFailure { + logger.Warn(fmt.Sprintf("Column %s from %s has been re-encrypted with errors", s.columnName, s.tableName)) + } else { + logger.Info(fmt.Sprintf("Column %s from %s has been re-encrypted successfully", s.columnName, s.tableName)) + } +} + +func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) { var rows []struct { Id int SecureJsonData map[string][]byte } if err := sess.Table(s.tableName).Cols("id", "secure_json_data").Find(&rows); err != nil { - return err + logger.Warn("Could not find any secret to re-encrypt", "table", s.tableName) + return } + var anyFailure bool + for _, row := range rows { if len(row.SecureJsonData) == 0 { continue @@ -87,29 +141,38 @@ func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Ses decrypted, err := secretsSrv.DecryptJsonData(context.Background(), row.SecureJsonData) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decrypt secrets while re-encrypting them", "table", s.tableName, "id", row.Id, "error", err) + continue } - var toUpdate struct { + toUpdate := struct { SecureJsonData map[string][]byte - } + Updated string + }{Updated: nowInUTC()} toUpdate.SecureJsonData, err = secretsSrv.EncryptJsonDataWithDBSession(context.Background(), decrypted, secrets.WithoutScope(), sess) if err != nil { - return err + anyFailure = true + logger.Warn("Could not re-encrypt secrets", "table", s.tableName, "id", row.Id, "error", err) + continue } if _, err := sess.Table(s.tableName).Where("id = ?", row.Id).Update(toUpdate); err != nil { - return err + anyFailure = true + logger.Warn("Could not update secrets while re-encrypting them", "table", s.tableName, "id", row.Id, "error", err) + continue } } - logger.Infof("Secure json data from %s has been re-encrypted successfully\n", s.tableName) - - return nil + if anyFailure { + logger.Warn(fmt.Sprintf("Secure json data secrets from %s have been re-encrypted with errors", s.tableName)) + } else { + logger.Info(fmt.Sprintf("Secure json data secrets from %s have been re-encrypted successfully", s.tableName)) + } } -func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { +func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) { var results []struct { Id int AlertmanagerConfiguration string @@ -117,14 +180,19 @@ func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm selectSQL := "SELECT id, alertmanager_configuration FROM alert_configuration" if err := sess.SQL(selectSQL).Find(&results); err != nil { - return err + logger.Warn("Could not find any alert_configuration secret to re-encrypt") + return } + var anyFailure bool + for _, result := range results { result := result postableUserConfig, err := notifier.Load([]byte(result.AlertmanagerConfiguration)) if err != nil { - return err + anyFailure = true + logger.Warn("Could not load alert_configuration while re-encrypting it", "id", result.Id, "error", err) + continue } for _, receiver := range postableUserConfig.AlertmanagerConfig.Receivers { @@ -132,17 +200,23 @@ func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm for k, v := range gmr.SecureSettings { decoded, err := base64.StdEncoding.DecodeString(v) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decode base64-encoded alert_configuration secret", "id", result.Id, "key", k, "error", err) + continue } decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decrypt alert_configuration secret", "id", result.Id, "key", k, "error", err) + continue } reencrypted, err := secretsSrv.EncryptWithDBSession(context.Background(), decrypted, secrets.WithoutScope(), sess) if err != nil { - return err + anyFailure = true + logger.Warn("Could not re-encrypt alert_configuration secret", "id", result.Id, "key", k, "error", err) + continue } gmr.SecureSettings[k] = base64.StdEncoding.EncodeToString(reencrypted) @@ -152,18 +226,24 @@ func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm marshalled, err := json.Marshal(postableUserConfig) if err != nil { - return err + anyFailure = true + logger.Warn("Could not marshal alert_configuration while re-encrypting it", "id", result.Id, "error", err) + continue } result.AlertmanagerConfiguration = string(marshalled) if _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result); err != nil { - return err + anyFailure = true + logger.Warn("Could not update alert_configuration secret while re-encrypting it", "id", result.Id, "error", err) + continue } } - logger.Info("Alerting secrets has been re-encrypted successfully\n") - - return nil + if anyFailure { + logger.Warn("Alerting configuration secrets have been re-encrypted with errors") + } else { + logger.Info("Alerting configuration secrets have been re-encrypted successfully") + } } func ReEncryptSecrets(_ utils.CommandLine, runner runner.Runner) error { @@ -173,22 +253,27 @@ func ReEncryptSecrets(_ utils.CommandLine, runner runner.Runner) error { } toMigrate := []interface { - reencrypt(*manager.SecretsService, *xorm.Session) error + reencrypt(*manager.SecretsService, *xorm.Session) }{ - simpleSecret{tableName: "dashboard_snapshot", columnName: "dashboard_encrypted", isBase64Encoded: false}, - simpleSecret{tableName: "user_auth", columnName: "o_auth_access_token", isBase64Encoded: true}, - simpleSecret{tableName: "user_auth", columnName: "o_auth_refresh_token", isBase64Encoded: true}, - simpleSecret{tableName: "user_auth", columnName: "o_auth_token_type", isBase64Encoded: true}, + simpleSecret{tableName: "dashboard_snapshot", columnName: "dashboard_encrypted"}, + b64Secret{simpleSecret{tableName: "user_auth", columnName: "o_auth_access_token"}}, + b64Secret{simpleSecret{tableName: "user_auth", columnName: "o_auth_refresh_token"}}, + b64Secret{simpleSecret{tableName: "user_auth", columnName: "o_auth_token_type"}}, jsonSecret{tableName: "data_source"}, jsonSecret{tableName: "plugin_setting"}, alertingSecret{}, } - return runner.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - for _, m := range toMigrate { - if err := m.reencrypt(runner.SecretsService, sess.Session); err != nil { - return err + return runner.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) (err error) { + defer func() { + if r := recover(); r != nil { + err = errors.New(fmt.Sprint(r)) + logger.Error("Secrets re-encryption failed, rolling back transaction...", "error", err) } + }() + + for _, m := range toMigrate { + m.reencrypt(runner.SecretsService, sess.Session) } return nil diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go index 619cbfb8f6e..fc17c10aaed 100644 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go @@ -4,14 +4,13 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" - "github.com/grafana/grafana/pkg/services/featuremgmt" - - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/runner" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/services/encryption" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -23,14 +22,15 @@ func (s simpleSecret) rollback( encryptionSrv encryption.Internal, sess *xorm.Session, secretKey string, -) error { +) (anyFailure bool) { var rows []struct { Id int - Secret string + Secret []byte } if err := sess.Table(s.tableName).Select(fmt.Sprintf("id, %s as secret", s.columnName)).Find(&rows); err != nil { - return err + logger.Warn("Could not find any secret to roll back", "table", s.tableName) + return true } for _, row := range rows { @@ -38,42 +38,95 @@ func (s simpleSecret) rollback( continue } - var ( - err error - decoded = []byte(row.Secret) - ) - - if s.isBase64Encoded { - decoded, err = base64.StdEncoding.DecodeString(row.Secret) - if err != nil { - return err - } - } - - decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) + decrypted, err := secretsSrv.Decrypt(context.Background(), row.Secret) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decrypt secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue } encrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey) if err != nil { - return err + anyFailure = true + logger.Warn("Could not encrypt secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue } - encoded := string(encrypted) - if s.isBase64Encoded { - encoded = base64.StdEncoding.EncodeToString(encrypted) - } - - updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) - if _, err := sess.Exec(updateSQL, encoded, row.Id); err != nil { - return err + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ?, updated = ? WHERE id = ?", s.tableName, s.columnName) + if _, err = sess.Exec(updateSQL, encrypted, nowInUTC(), row.Id); err != nil { + anyFailure = true + logger.Warn("Could not update secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue } } - logger.Infof("Column %s from %s have been rolled back successfully\n", s.columnName, s.tableName) + if anyFailure { + logger.Warn(fmt.Sprintf("Column %s from %s has been rolled back with errors", s.columnName, s.tableName)) + } else { + logger.Info(fmt.Sprintf("Column %s from %s has been rolled back successfully", s.columnName, s.tableName)) + } - return nil + return anyFailure +} + +func (s b64Secret) rollback( + secretsSrv *manager.SecretsService, + encryptionSrv encryption.Internal, + sess *xorm.Session, + secretKey string, +) (anyFailure bool) { + var rows []struct { + Id int + Secret string + } + + if err := sess.Table(s.tableName).Select(fmt.Sprintf("id, %s as secret", s.columnName)).Find(&rows); err != nil { + logger.Warn("Could not find any secret to roll back", "table", s.tableName) + return true + } + + for _, row := range rows { + if len(row.Secret) == 0 { + continue + } + + decoded, err := base64.StdEncoding.DecodeString(row.Secret) + if err != nil { + anyFailure = true + logger.Warn("Could not decode base64-encoded secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue + } + + decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) + if err != nil { + anyFailure = true + logger.Warn("Could not decrypt secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue + } + + encrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey) + if err != nil { + anyFailure = true + logger.Warn("Could not encrypt secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue + } + + encoded := base64.StdEncoding.EncodeToString(encrypted) + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) + if _, err := sess.Exec(updateSQL, encoded, row.Id); err != nil { + anyFailure = true + logger.Warn("Could not update secret while rolling it back", "table", s.tableName, "id", row.Id, "error", err) + continue + } + } + + if anyFailure { + logger.Warn(fmt.Sprintf("Column %s from %s has been rolled back with errors", s.columnName, s.tableName)) + } else { + logger.Info(fmt.Sprintf("Column %s from %s has been rolled back successfully", s.columnName, s.tableName)) + } + + return anyFailure } func (s jsonSecret) rollback( @@ -81,14 +134,15 @@ func (s jsonSecret) rollback( encryptionSrv encryption.Internal, sess *xorm.Session, secretKey string, -) error { +) (anyFailure bool) { var rows []struct { Id int SecureJsonData map[string][]byte } if err := sess.Table(s.tableName).Cols("id", "secure_json_data").Find(&rows); err != nil { - return err + logger.Warn("Could not find any secret to roll back", "table", s.tableName) + return true } for _, row := range rows { @@ -98,26 +152,35 @@ func (s jsonSecret) rollback( decrypted, err := secretsSrv.DecryptJsonData(context.Background(), row.SecureJsonData) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decrypt secrets while rolling them back", "table", s.tableName, "id", row.Id, "error", err) + continue } - var toUpdate struct { + toUpdate := struct { SecureJsonData map[string][]byte - } + Updated string + }{Updated: nowInUTC()} toUpdate.SecureJsonData, err = encryptionSrv.EncryptJsonData(context.Background(), decrypted, secretKey) if err != nil { - return err + logger.Warn("Could not re-encrypt secrets while rolling them back", "table", s.tableName, "id", row.Id, "error", err) + continue } if _, err := sess.Table(s.tableName).Where("id = ?", row.Id).Update(toUpdate); err != nil { - return err + logger.Warn("Could not update secrets while rolling them back", "table", s.tableName, "id", row.Id, "error", err) + continue } } - logger.Infof("Secure json data from %s have been rolled back successfully\n", s.tableName) + if anyFailure { + logger.Warn(fmt.Sprintf("Secure json data secrets from %s have been rolled back with errors", s.tableName)) + } else { + logger.Info(fmt.Sprintf("Secure json data secrets from %s have been rolled back successfully", s.tableName)) + } - return nil + return anyFailure } func (s alertingSecret) rollback( @@ -125,7 +188,7 @@ func (s alertingSecret) rollback( encryptionSrv encryption.Internal, sess *xorm.Session, secretKey string, -) error { +) (anyFailure bool) { var results []struct { Id int AlertmanagerConfiguration string @@ -133,14 +196,17 @@ func (s alertingSecret) rollback( selectSQL := "SELECT id, alertmanager_configuration FROM alert_configuration" if err := sess.SQL(selectSQL).Find(&results); err != nil { - return err + logger.Warn("Could not find any alert_configuration secret to roll back") + return true } for _, result := range results { result := result postableUserConfig, err := notifier.Load([]byte(result.AlertmanagerConfiguration)) if err != nil { - return err + anyFailure = true + logger.Warn("Could not load configuration (alert_configuration with id: %d) while rolling it back", result.Id, err) + continue } for _, receiver := range postableUserConfig.AlertmanagerConfig.Receivers { @@ -148,17 +214,23 @@ func (s alertingSecret) rollback( for k, v := range gmr.SecureSettings { decoded, err := base64.StdEncoding.DecodeString(v) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decode base64-encoded secret (alert_configuration with id: %d, key)", k, result.Id, err) + continue } decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) if err != nil { - return err + anyFailure = true + logger.Warn("Could not decrypt secret (alert_configuration with id: %d, key)", k, result.Id, err) + continue } reencrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey) if err != nil { - return err + anyFailure = true + logger.Warn("Could not re-encrypt secret (alert_configuration with id: %d, key)", k, result.Id, err) + continue } gmr.SecureSettings[k] = base64.StdEncoding.EncodeToString(reencrypted) @@ -168,18 +240,26 @@ func (s alertingSecret) rollback( marshalled, err := json.Marshal(postableUserConfig) if err != nil { - return err + anyFailure = true + logger.Warn("Could not marshal configuration (alert_configuration with id: %d) while rolling it back", result.Id, err) + continue } result.AlertmanagerConfiguration = string(marshalled) if _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result); err != nil { - return err + anyFailure = true + logger.Warn("Could not update secret (alert_configuration with id: %d) while rolling it back", result.Id, err) + continue } } - logger.Info("Alerting secrets have rolled re-encrypted successfully\n") + if anyFailure { + logger.Warn("Alerting configuration secrets have been rolled back with errors") + } else { + logger.Info("Alerting configuration secrets have been rolled back successfully") + } - return nil + return anyFailure } func RollBackSecrets(_ utils.CommandLine, runner runner.Runner) error { @@ -188,31 +268,41 @@ func RollBackSecrets(_ utils.CommandLine, runner runner.Runner) error { return nil } - toMigrate := []interface { - rollback(*manager.SecretsService, encryption.Internal, *xorm.Session, string) error + toRollback := []interface { + rollback(*manager.SecretsService, encryption.Internal, *xorm.Session, string) bool }{ - simpleSecret{tableName: "dashboard_snapshot", columnName: "dashboard_encrypted", isBase64Encoded: false}, - simpleSecret{tableName: "user_auth", columnName: "o_auth_access_token", isBase64Encoded: true}, - simpleSecret{tableName: "user_auth", columnName: "o_auth_refresh_token", isBase64Encoded: true}, - simpleSecret{tableName: "user_auth", columnName: "o_auth_token_type", isBase64Encoded: true}, + simpleSecret{tableName: "dashboard_snapshot", columnName: "dashboard_encrypted"}, + b64Secret{simpleSecret{tableName: "user_auth", columnName: "o_auth_access_token"}}, + b64Secret{simpleSecret{tableName: "user_auth", columnName: "o_auth_refresh_token"}}, + b64Secret{simpleSecret{tableName: "user_auth", columnName: "o_auth_token_type"}}, jsonSecret{tableName: "data_source"}, jsonSecret{tableName: "plugin_setting"}, alertingSecret{}, } - return runner.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - for _, m := range toMigrate { - if err := m.rollback( - runner.SecretsService, - runner.EncryptionService, - sess.Session, - runner.Cfg.SecretKey); err != nil { - return err + return runner.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) (err error) { + defer func() { + if r := recover(); r != nil { + err = errors.New(fmt.Sprint(r)) + logger.Error("Secrets roll back failed, rolling back transaction...", "error", err) + } + }() + + var anyFailure bool + + for _, r := range toRollback { + if failed := r.rollback(runner.SecretsService, runner.EncryptionService, sess.Session, runner.Cfg.SecretKey); failed { + anyFailure = true } } - if _, err := sess.Exec("DELETE FROM data_keys"); err != nil { - logger.Warn("Error while cleaning up data keys table...", "err", err) + if anyFailure { + logger.Warn("Some errors happened, not cleaning up data keys table...") + return nil + } + + if _, sqlErr := sess.Exec("DELETE FROM data_keys"); sqlErr != nil { + logger.Warn("Error while cleaning up data keys table...", "error", sqlErr) } return nil diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/secretsmigrations.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/secretsmigrations.go new file mode 100644 index 00000000000..a75e2e9b329 --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/secretsmigrations.go @@ -0,0 +1,28 @@ +package secretsmigrations + +import ( + "time" + + "github.com/grafana/grafana/pkg/infra/log" +) + +type simpleSecret struct { + tableName string + columnName string +} + +type b64Secret struct { + simpleSecret +} + +type jsonSecret struct { + tableName string +} + +type alertingSecret struct{} + +func nowInUTC() string { + return time.Now().UTC().Format("2006-01-02 15:04:05") +} + +var logger = log.New("secrets.migrations") diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/types.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/types.go deleted file mode 100644 index b272983f26d..00000000000 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/types.go +++ /dev/null @@ -1,13 +0,0 @@ -package secretsmigrations - -type simpleSecret struct { - tableName string - columnName string - isBase64Encoded bool -} - -type jsonSecret struct { - tableName string -} - -type alertingSecret struct{} diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index a7059f94d06..5fc3c80079c 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -147,6 +147,8 @@ type OrgUserDTO struct { Login string `json:"login"` Role string `json:"role"` LastSeenAt time.Time `json:"lastSeenAt"` + Updated time.Time `json:"-"` + Created time.Time `json:"-"` LastSeenAtAge string `json:"lastSeenAtAge"` AccessControl map[string]bool `json:"accessControl,omitempty"` } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 8f57ceb3a29..586c86db521 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" + "github.com/grafana/grafana/pkg/services/comments" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/dashboardimport" dashboardimportservice "github.com/grafana/grafana/pkg/services/dashboardimport/service" @@ -211,6 +212,7 @@ var wireBasicSet = wire.NewSet( dashboardimportservice.ProvideService, wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)), plugindashboards.ProvideService, + comments.ProvideService, ) var wireSet = wire.NewSet( diff --git a/pkg/services/comments/commentmodel/events.go b/pkg/services/comments/commentmodel/events.go new file mode 100644 index 00000000000..5e047c24757 --- /dev/null +++ b/pkg/services/comments/commentmodel/events.go @@ -0,0 +1,13 @@ +package commentmodel + +type EventType string + +const ( + EventCommentCreated EventType = "commentCreated" +) + +// Event represents comment event structure. +type Event struct { + Event EventType `json:"event"` + CommentCreated *CommentDto `json:"commentCreated"` +} diff --git a/pkg/services/comments/commentmodel/models.go b/pkg/services/comments/commentmodel/models.go new file mode 100644 index 00000000000..64526d124f2 --- /dev/null +++ b/pkg/services/comments/commentmodel/models.go @@ -0,0 +1,105 @@ +package commentmodel + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" +) + +const ( + // ObjectTypeOrg is reserved for future use for per-org comments. + ObjectTypeOrg = "org" + // ObjectTypeDashboard used for dashboard-wide comments. + ObjectTypeDashboard = "dashboard" + // ObjectTypeAnnotation used for annotation comments. + ObjectTypeAnnotation = "annotation" +) + +var RegisteredObjectTypes = map[string]struct{}{ + ObjectTypeOrg: {}, + ObjectTypeDashboard: {}, + ObjectTypeAnnotation: {}, +} + +type CommentGroup struct { + Id int64 + OrgId int64 + ObjectType string + ObjectId string + Settings Settings + + Created int64 + Updated int64 +} + +func (i CommentGroup) TableName() string { + return "comment_group" +} + +type Settings struct { +} + +var ( + _ driver.Valuer = Settings{} + _ sql.Scanner = &Settings{} +) + +func (s Settings) Value() (driver.Value, error) { + d, err := json.Marshal(s) + if err != nil { + return nil, err + } + return string(d), nil +} + +func (s *Settings) Scan(value interface{}) error { + switch v := value.(type) { + case string: + return json.Unmarshal([]byte(v), &s) + case []uint8: + return json.Unmarshal(v, &s) + default: + return fmt.Errorf("type assertion on scan failed: got %T", value) + } +} + +type Comment struct { + Id int64 + GroupId int64 + UserId int64 + Content string + + Created int64 + Updated int64 +} + +type CommentUser struct { + Id int64 `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` +} + +type CommentDto struct { + Id int64 `json:"id"` + UserId int64 `json:"userId"` + Content string `json:"content"` + Created int64 `json:"created"` + User *CommentUser `json:"user,omitempty"` +} + +func (i Comment) ToDTO(user *CommentUser) *CommentDto { + return &CommentDto{ + Id: i.Id, + UserId: i.UserId, + Content: i.Content, + Created: i.Created, + User: user, + } +} + +func (i Comment) TableName() string { + return "comment" +} diff --git a/pkg/services/comments/commentmodel/permissions.go b/pkg/services/comments/commentmodel/permissions.go new file mode 100644 index 00000000000..c35f927538c --- /dev/null +++ b/pkg/services/comments/commentmodel/permissions.go @@ -0,0 +1,132 @@ +package commentmodel + +import ( + "context" + "strconv" + + "github.com/grafana/grafana/pkg/services/sqlstore" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/guardian" +) + +type PermissionChecker struct { + sqlStore *sqlstore.SQLStore + features featuremgmt.FeatureToggles +} + +func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles) *PermissionChecker { + return &PermissionChecker{sqlStore: sqlStore, features: features} +} + +func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*models.Dashboard, error) { + query := models.GetDashboardQuery{Uid: uid, OrgId: orgID} + if err := c.sqlStore.GetDashboard(ctx, &query); err != nil { + return nil, err + } + return query.Result, nil +} + +func (c *PermissionChecker) getDashboardById(ctx context.Context, orgID int64, id int64) (*models.Dashboard, error) { + query := models.GetDashboardQuery{Id: id, OrgId: orgID} + if err := c.sqlStore.GetDashboard(ctx, &query); err != nil { + return nil, err + } + return query.Result, nil +} + +func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int64, signedInUser *models.SignedInUser, objectType string, objectID string) (bool, error) { + switch objectType { + case ObjectTypeOrg: + return false, nil + case ObjectTypeDashboard: + if !c.features.IsEnabled(featuremgmt.FlagDashboardComments) { + return false, nil + } + dash, err := c.getDashboardByUid(ctx, orgId, objectID) + if err != nil { + return false, err + } + guard := guardian.New(ctx, dash.Id, orgId, signedInUser) + if ok, err := guard.CanView(); err != nil || !ok { + return false, nil + } + case ObjectTypeAnnotation: + if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { + return false, nil + } + repo := annotations.GetRepository() + annotationID, err := strconv.ParseInt(objectID, 10, 64) + if err != nil { + return false, nil + } + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId}) + if err != nil || len(items) != 1 { + return false, nil + } + dashboardID := items[0].DashboardId + if dashboardID == 0 { + return false, nil + } + dash, err := c.getDashboardById(ctx, orgId, dashboardID) + if err != nil { + return false, err + } + guard := guardian.New(ctx, dash.Id, orgId, signedInUser) + if ok, err := guard.CanView(); err != nil || !ok { + return false, nil + } + default: + return false, nil + } + return true, nil +} + +func (c *PermissionChecker) CheckWritePermissions(ctx context.Context, orgId int64, signedInUser *models.SignedInUser, objectType string, objectID string) (bool, error) { + switch objectType { + case ObjectTypeOrg: + return false, nil + case ObjectTypeDashboard: + if !c.features.IsEnabled(featuremgmt.FlagDashboardComments) { + return false, nil + } + dash, err := c.getDashboardByUid(ctx, orgId, objectID) + if err != nil { + return false, err + } + guard := guardian.New(ctx, dash.Id, orgId, signedInUser) + if ok, err := guard.CanEdit(); err != nil || !ok { + return false, nil + } + case ObjectTypeAnnotation: + if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { + return false, nil + } + repo := annotations.GetRepository() + annotationID, err := strconv.ParseInt(objectID, 10, 64) + if err != nil { + return false, nil + } + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId}) + if err != nil || len(items) != 1 { + return false, nil + } + dashboardID := items[0].DashboardId + if dashboardID == 0 { + return false, nil + } + dash, err := c.getDashboardById(ctx, orgId, dashboardID) + if err != nil { + return false, nil + } + guard := guardian.New(ctx, dash.Id, orgId, signedInUser) + if ok, err := guard.CanEdit(); err != nil || !ok { + return false, nil + } + default: + return false, nil + } + return true, nil +} diff --git a/pkg/services/comments/handlers.go b/pkg/services/comments/handlers.go new file mode 100644 index 00000000000..8773f19b4ff --- /dev/null +++ b/pkg/services/comments/handlers.go @@ -0,0 +1,164 @@ +package comments + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/comments/commentmodel" +) + +func commentsToDto(items []*commentmodel.Comment, userMap map[int64]*commentmodel.CommentUser) []*commentmodel.CommentDto { + result := make([]*commentmodel.CommentDto, 0, len(items)) + for _, m := range items { + result = append(result, commentToDto(m, userMap)) + } + return result +} + +func commentToDto(comment *commentmodel.Comment, userMap map[int64]*commentmodel.CommentUser) *commentmodel.CommentDto { + var u *commentmodel.CommentUser + if comment.UserId > 0 { + var ok bool + u, ok = userMap[comment.UserId] + if !ok { + // TODO: handle this gracefully? + u = &commentmodel.CommentUser{ + Id: comment.UserId, + } + } + } + return comment.ToDTO(u) +} + +func searchUserToCommentUser(searchUser *models.UserSearchHitDTO) *commentmodel.CommentUser { + if searchUser == nil { + return nil + } + return &commentmodel.CommentUser{ + Id: searchUser.Id, + Name: searchUser.Name, + Login: searchUser.Login, + Email: searchUser.Email, + AvatarUrl: dtos.GetGravatarUrl(searchUser.Email), + } +} + +type UserIDFilter struct { + userIDs []int64 +} + +func NewIDFilter(userIDs []int64) models.Filter { + return &UserIDFilter{userIDs: userIDs} +} + +func (a *UserIDFilter) WhereCondition() *models.WhereCondition { + return nil +} + +func (a *UserIDFilter) JoinCondition() *models.JoinCondition { + return nil +} + +func (a *UserIDFilter) InCondition() *models.InCondition { + return &models.InCondition{ + Condition: "u.id", + Params: a.userIDs, + } +} + +type GetCmd struct { + ObjectType string `json:"objectType"` + ObjectID string `json:"objectId"` + Limit uint `json:"limit"` + BeforeId int64 `json:"beforeId"` +} + +type CreateCmd struct { + ObjectType string `json:"objectType"` + ObjectID string `json:"objectId"` + Content string `json:"content"` +} + +var ErrPermissionDenied = errors.New("permission denied") + +func (s *Service) Create(ctx context.Context, orgID int64, signedInUser *models.SignedInUser, cmd CreateCmd) (*commentmodel.CommentDto, error) { + ok, err := s.permissions.CheckWritePermissions(ctx, orgID, signedInUser, cmd.ObjectType, cmd.ObjectID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrPermissionDenied + } + + userMap := make(map[int64]*commentmodel.CommentUser, 1) + if signedInUser.UserId > 0 { + userMap[signedInUser.UserId] = &commentmodel.CommentUser{ + Id: signedInUser.UserId, + Name: signedInUser.Name, + Login: signedInUser.Login, + Email: signedInUser.Email, + AvatarUrl: dtos.GetGravatarUrl(signedInUser.Email), + } + } + + m, err := s.storage.Create(ctx, orgID, cmd.ObjectType, cmd.ObjectID, signedInUser.UserId, cmd.Content) + if err != nil { + return nil, err + } + mDto := commentToDto(m, userMap) + e := commentmodel.Event{ + Event: commentmodel.EventCommentCreated, + CommentCreated: mDto, + } + eventJSON, _ := json.Marshal(e) + _ = s.live.Publish(orgID, fmt.Sprintf("grafana/comment/%s/%s", cmd.ObjectType, cmd.ObjectID), eventJSON) + return mDto, nil +} + +func (s *Service) Get(ctx context.Context, orgID int64, signedInUser *models.SignedInUser, cmd GetCmd) ([]*commentmodel.CommentDto, error) { + ok, err := s.permissions.CheckReadPermissions(ctx, orgID, signedInUser, cmd.ObjectType, cmd.ObjectID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrPermissionDenied + } + + messages, err := s.storage.Get(ctx, orgID, cmd.ObjectType, cmd.ObjectID, GetFilter{ + Limit: cmd.Limit, + BeforeID: cmd.BeforeId, + }) + if err != nil { + return nil, err + } + + userIds := make([]int64, 0, len(messages)) + for _, m := range messages { + if m.UserId <= 0 { + continue + } + userIds = append(userIds, m.UserId) + } + + // NOTE: probably replace with comment and user table join. + query := &models.SearchUsersQuery{Query: "", Filters: []models.Filter{NewIDFilter(userIds)}, Page: 0, Limit: len(userIds)} + if err := s.sqlStore.SearchUsers(ctx, query); err != nil { + return nil, err + } + + userMap := make(map[int64]*commentmodel.CommentUser, len(query.Result.Users)) + for _, v := range query.Result.Users { + userMap[v.Id] = searchUserToCommentUser(v) + } + + result := commentsToDto(messages, userMap) + sort.Slice(result, func(i, j int) bool { + return result[i].Id < result[j].Id + }) + return result, nil +} diff --git a/pkg/services/comments/service.go b/pkg/services/comments/service.go new file mode 100644 index 00000000000..c2797fdaa87 --- /dev/null +++ b/pkg/services/comments/service.go @@ -0,0 +1,38 @@ +package comments + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/comments/commentmodel" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/live" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +type Service struct { + cfg *setting.Cfg + live *live.GrafanaLive + sqlStore *sqlstore.SQLStore + storage Storage + permissions *commentmodel.PermissionChecker +} + +func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles) *Service { + s := &Service{ + cfg: cfg, + live: live, + sqlStore: store, + storage: &sqlStorage{ + sql: store, + }, + permissions: commentmodel.NewPermissionChecker(store, features), + } + return s +} + +// Run Service. +func (s *Service) Run(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() +} diff --git a/pkg/services/comments/sql_storage.go b/pkg/services/comments/sql_storage.go new file mode 100644 index 00000000000..6a95c225912 --- /dev/null +++ b/pkg/services/comments/sql_storage.go @@ -0,0 +1,116 @@ +package comments + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/comments/commentmodel" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +type sqlStorage struct { + sql *sqlstore.SQLStore +} + +func checkObjectType(contentType string) bool { + _, ok := commentmodel.RegisteredObjectTypes[contentType] + return ok +} + +func checkObjectID(objectID string) bool { + return objectID != "" +} + +func (s *sqlStorage) Create(ctx context.Context, orgID int64, objectType string, objectID string, userID int64, content string) (*commentmodel.Comment, error) { + if !checkObjectType(objectType) { + return nil, errUnknownObjectType + } + if !checkObjectID(objectID) { + return nil, errEmptyObjectID + } + if content == "" { + return nil, errEmptyContent + } + + var result *commentmodel.Comment + + return result, s.sql.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + group := commentmodel.CommentGroup{ + OrgId: orgID, + ObjectType: objectType, + ObjectId: objectID, + } + has, err := dbSession.Get(&group) + if err != nil { + return err + } + + nowUnix := time.Now().Unix() + + groupID := group.Id + if !has { + group.Created = nowUnix + group.Updated = nowUnix + group.Settings = commentmodel.Settings{} + _, err = dbSession.Insert(&group) + if err != nil { + return err + } + groupID = group.Id + } + message := commentmodel.Comment{ + GroupId: groupID, + UserId: userID, + Content: content, + Created: nowUnix, + Updated: nowUnix, + } + _, err = dbSession.Insert(&message) + if err != nil { + return err + } + result = &message + return nil + }) +} + +const maxLimit = 300 + +func (s *sqlStorage) Get(ctx context.Context, orgID int64, objectType string, objectID string, filter GetFilter) ([]*commentmodel.Comment, error) { + if !checkObjectType(objectType) { + return nil, errUnknownObjectType + } + if !checkObjectID(objectID) { + return nil, errEmptyObjectID + } + + var result []*commentmodel.Comment + + limit := 100 + if filter.Limit > 0 { + limit = int(filter.Limit) + if limit > maxLimit { + limit = maxLimit + } + } + + return result, s.sql.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + group := commentmodel.CommentGroup{ + OrgId: orgID, + ObjectType: objectType, + ObjectId: objectID, + } + has, err := dbSession.Get(&group) + if err != nil { + return err + } + if !has { + return nil + } + clause := dbSession.Where("group_id=?", group.Id) + if filter.BeforeID > 0 { + clause.Where("id < ?", filter.BeforeID) + } + return clause.OrderBy("id desc").Limit(limit).Find(&result) + }) +} diff --git a/pkg/services/comments/sql_storage_test.go b/pkg/services/comments/sql_storage_test.go new file mode 100644 index 00000000000..ddfcec06434 --- /dev/null +++ b/pkg/services/comments/sql_storage_test.go @@ -0,0 +1,76 @@ +package comments + +import ( + "context" + "strconv" + "testing" + + "github.com/grafana/grafana/pkg/services/comments/commentmodel" + "github.com/grafana/grafana/pkg/services/sqlstore" + + "github.com/stretchr/testify/require" +) + +func createSqlStorage(t *testing.T) Storage { + t.Helper() + sqlStore := sqlstore.InitTestDB(t) + return &sqlStorage{ + sql: sqlStore, + } +} + +func TestSqlStorage(t *testing.T) { + s := createSqlStorage(t) + ctx := context.Background() + items, err := s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{}) + require.NoError(t, err) + require.Len(t, items, 0) + + numComments := 10 + + for i := 0; i < numComments; i++ { + comment, err := s.Create(ctx, 1, commentmodel.ObjectTypeOrg, "2", 1, "test"+strconv.Itoa(i)) + require.NoError(t, err) + require.NotNil(t, comment) + require.True(t, comment.Id > 0) + } + + items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{}) + require.NoError(t, err) + require.Len(t, items, 10) + require.Equal(t, "test9", items[0].Content) + require.Equal(t, "test0", items[9].Content) + require.Equal(t, int64(1), items[0].UserId) + require.NotZero(t, items[0].Created) + require.NotZero(t, items[0].Updated) + + // Same object, but another content type. + items, err = s.Get(ctx, 1, commentmodel.ObjectTypeDashboard, "2", GetFilter{}) + require.NoError(t, err) + require.Len(t, items, 0) + + // Now test filtering. + items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{ + Limit: 5, + }) + require.NoError(t, err) + require.Len(t, items, 5) + require.Equal(t, "test9", items[0].Content) + require.Equal(t, "test5", items[4].Content) + + items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{ + Limit: 5, + BeforeID: items[4].Id, + }) + require.NoError(t, err) + require.Len(t, items, 5) + require.Equal(t, "test4", items[0].Content) + require.Equal(t, "test0", items[4].Content) + + items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{ + Limit: 5, + BeforeID: items[4].Id, + }) + require.NoError(t, err) + require.Len(t, items, 0) +} diff --git a/pkg/services/comments/storage.go b/pkg/services/comments/storage.go new file mode 100644 index 00000000000..dd9216cb64b --- /dev/null +++ b/pkg/services/comments/storage.go @@ -0,0 +1,24 @@ +package comments + +import ( + "context" + "errors" + + "github.com/grafana/grafana/pkg/services/comments/commentmodel" +) + +type GetFilter struct { + Limit uint + BeforeID int64 +} + +var ( + errUnknownObjectType = errors.New("unknown object type") + errEmptyObjectID = errors.New("empty object id") + errEmptyContent = errors.New("empty comment content") +) + +type Storage interface { + Get(ctx context.Context, orgID int64, objectType string, objectID string, filter GetFilter) ([]*commentmodel.Comment, error) + Create(ctx context.Context, orgID int64, objectType string, objectID string, userID int64, content string) (*commentmodel.Comment, error) +} diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index f3cca22bfe4..da1273ffa9f 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -26,6 +26,7 @@ type DashboardProvisioningService interface { GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) UnprovisionDashboard(ctx context.Context, dashboardID int64) error DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error + DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error } //go:generate mockery --name Store --structname FakeDashboardStore --output database --outpkg database --filename database_mock.go @@ -41,6 +42,7 @@ type Store interface { SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error + DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error // SaveAlerts saves dashboard alerts. SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error UnprovisionDashboard(ctx context.Context, id int64) error diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go index 46ae5a09745..92da6bb4ef5 100644 --- a/pkg/services/dashboards/dashboard_provisioning_mock.go +++ b/pkg/services/dashboards/dashboard_provisioning_mock.go @@ -14,6 +14,20 @@ type FakeDashboardProvisioning struct { mock.Mock } +// DeleteOrphanedProvisionedDashboards provides a mock function with given fields: ctx, cmd +func (_m *FakeDashboardProvisioning) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { + ret := _m.Called(ctx, cmd) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.DeleteOrphanedProvisionedDashboardsCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteProvisionedDashboard provides a mock function with given fields: ctx, dashboardID, orgID func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error { ret := _m.Called(ctx, dashboardID, orgID) diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 9d6331879cb..307641e981e 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -42,3 +42,6 @@ func (s *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId func (s *FakeDashboardService) GetProvisionedDashboardDataByDashboardID(id int64) (*models.DashboardProvisioning, error) { return s.ProvisionedDashData, nil } +func (s *FakeDashboardService) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { + return nil +} diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 60b0b44d5f1..611935c2ab3 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -2,6 +2,7 @@ package database import ( "context" + "errors" "fmt" "time" @@ -195,6 +196,31 @@ func (d *DashboardStore) UnprovisionDashboard(ctx context.Context, id int64) err }) } +func (d *DashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { + return d.sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + var result []*models.DashboardProvisioning + + convertedReaderNames := make([]interface{}, len(cmd.ReaderNames)) + for index, readerName := range cmd.ReaderNames { + convertedReaderNames[index] = readerName + } + + err := sess.NotIn("name", convertedReaderNames...).Find(&result) + if err != nil { + return err + } + + for _, deleteDashCommand := range result { + err := d.sqlStore.DeleteDashboard(ctx, &models.DeleteDashboardCommand{Id: deleteDashCommand.DashboardId}) + if err != nil && !errors.Is(err, models.ErrDashboardNotFound) { + return err + } + } + + return nil + }) +} + func getExistingDashboardByIdOrUidForUpdate(sess *sqlstore.DBSession, dash *models.Dashboard, dialect migrator.Dialect, overwrite bool) (bool, error) { dashWithIdExists := false isParentFolderChanged := false diff --git a/pkg/services/dashboards/database/database_mock.go b/pkg/services/dashboards/database/database_mock.go index c8b154f970f..8091583a48c 100644 --- a/pkg/services/dashboards/database/database_mock.go +++ b/pkg/services/dashboards/database/database_mock.go @@ -15,6 +15,20 @@ type FakeDashboardStore struct { mock.Mock } +// DeleteOrphanedProvisionedDashboards provides a mock function with given fields: ctx, cmd +func (_m *FakeDashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { + ret := _m.Called(ctx, cmd) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.DeleteOrphanedProvisionedDashboardsCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // GetFolderByTitle provides a mock function with given fields: orgID, title func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { ret := _m.Called(orgID, title) diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index bec37866108..968252d8ce3 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -5,10 +5,11 @@ package database import ( "context" - "github.com/grafana/grafana/pkg/services/sqlstore" "testing" "time" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -82,7 +83,7 @@ func TestDashboardProvisioningTest(t *testing.T) { require.NotNil(t, query.Result) deleteCmd := &models.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: []string{"default"}} - require.Nil(t, sqlStore.DeleteOrphanedProvisionedDashboards(context.Background(), deleteCmd)) + require.Nil(t, dashboardStore.DeleteOrphanedProvisionedDashboards(context.Background(), deleteCmd)) query = &models.GetDashboardsQuery{DashboardIds: []int64{dash.Id, anotherDash.Id}} err = sqlStore.GetDashboards(context.Background(), query) diff --git a/pkg/services/dashboards/manager/dashboard_service.go b/pkg/services/dashboards/manager/dashboard_service.go index 759582dfcdd..93457ffa7bc 100644 --- a/pkg/services/dashboards/manager/dashboard_service.go +++ b/pkg/services/dashboards/manager/dashboard_service.go @@ -135,6 +135,10 @@ func (dr *DashboardServiceImpl) UpdateDashboardACL(ctx context.Context, uid int6 return dr.dashboardStore.UpdateDashboardACL(ctx, uid, items) } +func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { + return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd) +} + var validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { extractor := alerting.NewDashAlertExtractor(dash, dash.OrgId, user) return extractor.ValidateAlerts(ctx) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d95d6b385c1..8aba04eda6a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -142,6 +142,16 @@ var ( Description: "Highlight Enterprise features", State: FeatureStateStable, }, + { + Name: "dashboardComments", + Description: "Enable dashboard-wide comments", + State: FeatureStateAlpha, + }, + { + Name: "annotationComments", + Description: "Enable annotation comments", + State: FeatureStateAlpha, + }, { Name: "migrationLocking", Description: "Lock database during migrations", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7c54af1412e..70f7b58c3eb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -107,6 +107,14 @@ const ( // Highlight Enterprise features FlagFeatureHighlights = "featureHighlights" + // FlagDashboardComments + // Enable dashboard-wide comments + FlagDashboardComments = "dashboardComments" + + // FlagAnnotationComments + // Enable annotation comments + FlagAnnotationComments = "annotationComments" + // FlagMigrationLocking // Lock database during migrations FlagMigrationLocking = "migrationLocking" diff --git a/pkg/services/kmsproviders/kmsproviders.go b/pkg/services/kmsproviders/kmsproviders.go index 6de895f5847..c5baf11a344 100644 --- a/pkg/services/kmsproviders/kmsproviders.go +++ b/pkg/services/kmsproviders/kmsproviders.go @@ -17,3 +17,11 @@ const ( type Service interface { Provide() (map[secrets.ProviderID]secrets.Provider, error) } + +func NormalizeProviderID(id secrets.ProviderID) secrets.ProviderID { + if id == Legacy { + return Default + } + + return id +} diff --git a/pkg/services/live/features/comment.go b/pkg/services/live/features/comment.go new file mode 100644 index 00000000000..98b30867830 --- /dev/null +++ b/pkg/services/live/features/comment.go @@ -0,0 +1,48 @@ +package features + +import ( + "context" + "strings" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/comments/commentmodel" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +// CommentHandler manages all the `grafana/comment/*` channels. +type CommentHandler struct { + permissionChecker *commentmodel.PermissionChecker +} + +func NewCommentHandler(permissionChecker *commentmodel.PermissionChecker) *CommentHandler { + return &CommentHandler{permissionChecker: permissionChecker} +} + +// GetHandlerForPath called on init. +func (h *CommentHandler) GetHandlerForPath(_ string) (models.ChannelHandler, error) { + return h, nil // all chats share the same handler +} + +// OnSubscribe handles subscription to comment group channel. +func (h *CommentHandler) OnSubscribe(ctx context.Context, user *models.SignedInUser, e models.SubscribeEvent) (models.SubscribeReply, backend.SubscribeStreamStatus, error) { + parts := strings.Split(e.Path, "/") + if len(parts) != 2 { + return models.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil + } + objectType := parts[0] + objectID := parts[1] + ok, err := h.permissionChecker.CheckReadPermissions(ctx, user.OrgId, user, objectType, objectID) + if err != nil { + return models.SubscribeReply{}, 0, err + } + if !ok { + return models.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil + } + return models.SubscribeReply{}, backend.SubscribeStreamStatusOK, nil +} + +// OnPublish is not used for comments. +func (h *CommentHandler) OnPublish(_ context.Context, _ *models.SignedInUser, _ models.PublishEvent) (models.PublishReply, backend.PublishStreamStatus, error) { + return models.PublishReply{}, backend.PublishStreamStatusPermissionDenied, nil +} diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index f61b36ffc75..270def38838 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -13,19 +13,10 @@ import ( "sync" "time" - jsoniter "github.com/json-iterator/go" - - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/query" - - "github.com/centrifugal/centrifuge" - "github.com/go-redis/redis/v8" - "github.com/gobwas/glob" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/live" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -33,7 +24,9 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/plugincontext" + "github.com/grafana/grafana/pkg/services/comments/commentmodel" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/live/database" "github.com/grafana/grafana/pkg/services/live/features" "github.com/grafana/grafana/pkg/services/live/livecontext" @@ -44,11 +37,19 @@ import ( "github.com/grafana/grafana/pkg/services/live/pushws" "github.com/grafana/grafana/pkg/services/live/runstream" "github.com/grafana/grafana/pkg/services/live/survey" + "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" + + "github.com/centrifugal/centrifuge" + "github.com/go-redis/redis/v8" + "github.com/gobwas/glob" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/live" + jsoniter "github.com/json-iterator/go" "golang.org/x/sync/errgroup" ) @@ -68,7 +69,8 @@ type CoreGrafanaScope struct { func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, routeRegister routing.RouteRegister, pluginStore plugins.Store, cacheService *localcache.CacheService, dataSourceCache datasources.CacheService, sqlStore *sqlstore.SQLStore, secretsService secrets.Service, - usageStatsService usagestats.Service, queryDataService *query.Service, toggles featuremgmt.FeatureToggles) (*GrafanaLive, error) { + usageStatsService usagestats.Service, queryDataService *query.Service, toggles featuremgmt.FeatureToggles, + bus bus.Bus) (*GrafanaLive, error) { g := &GrafanaLive{ Cfg: cfg, Features: toggles, @@ -80,6 +82,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r SQLStore: sqlStore, SecretsService: secretsService, queryDataService: queryDataService, + bus: bus, channels: make(map[string]models.ChannelHandler), GrafanaScope: CoreGrafanaScope{ Features: make(map[string]models.ChannelHandlerFactory), @@ -238,6 +241,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r g.GrafanaScope.Dashboards = dash g.GrafanaScope.Features["dashboard"] = dash g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage) + g.GrafanaScope.Features["comment"] = features.NewCommentHandler(commentmodel.NewPermissionChecker(g.SQLStore, g.Features)) g.surveyCaller = survey.NewCaller(managedStreamRunner, node) err = g.surveyCaller.SetupHandlers() @@ -402,6 +406,7 @@ type GrafanaLive struct { SecretsService secrets.Service pluginStore plugins.Store queryDataService *query.Service + bus bus.Bus node *centrifuge.Node surveyCaller *survey.Caller @@ -933,6 +938,7 @@ func (g *GrafanaLive) handleDatasourceScope(ctx context.Context, user *models.Si // Publish sends the data to the channel without checking permissions etc. func (g *GrafanaLive) Publish(orgID int64, channel string, data []byte) error { + logger.Debug("publish into channel", "channel", channel, "orgId", orgID, "data", string(data)) _, err := g.node.Publish(orgchannel.PrependOrgID(orgID, channel), data) return err } diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index 5fd0079b1fe..f161e2a9365 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -48,7 +48,7 @@ func (s *AuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *m } authInfoQuery := &models.GetAuthInfoQuery{UserId: userQuery.Result.Id} - if err := s.bus.Dispatch(ctx, authInfoQuery); err != nil { + if err := s.GetAuthInfo(ctx, authInfoQuery); err != nil { return err } diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index a3ea2c7e3a8..3f513e79ad5 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -95,7 +95,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { api.RegisterRulerApiEndpoints(NewForkedRuler( api.DatasourceCache, NewLotexRuler(proxy, logger), - &RulerSrv{DatasourceCache: api.DatasourceCache, QuotaService: api.QuotaService, scheduleService: api.Schedule, store: api.RuleStore, log: logger}, + &RulerSrv{DatasourceCache: api.DatasourceCache, QuotaService: api.QuotaService, scheduleService: api.Schedule, store: api.RuleStore, log: logger, cfg: &api.Cfg.UnifiedAlerting}, ), m) api.RegisterTestingApiEndpoints(NewForkedTestingApi( &TestingApiSrv{ diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index eb8177bd27f..23bef6da32b 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "fmt" "net/http" @@ -9,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/common/model" @@ -29,8 +31,13 @@ type RulerSrv struct { QuotaService *quota.QuotaService scheduleService schedule.ScheduleService log log.Logger + cfg *setting.UnifiedAlertingSettings } +var ( + errQuotaReached = errors.New("quota has been exceeded") +) + func (srv RulerSrv) RouteDeleteNamespaceRulesConfig(c *models.ReqContext) response.Response { namespaceTitle := web.Params(c.Req)[":Namespace"] namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgId, c.SignedInUser, true) @@ -243,62 +250,77 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf return toNamespaceErrorResponse(err) } - //TODO: Should this belong in alerting-api? - if ruleGroupConfig.Name == "" { - return ErrResp(http.StatusBadRequest, errors.New("rule group name is not valid"), "") + rules, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.OrgId, namespace, conditionValidator(c, srv.DatasourceCache), srv.cfg) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "") } - alertRuleUIDs := make(map[string]struct{}) - for _, r := range ruleGroupConfig.Rules { - cond := ngmodels.Condition{ - Condition: r.GrafanaManagedAlert.Condition, - OrgID: c.SignedInUser.OrgId, - Data: r.GrafanaManagedAlert.Data, - } - if err := validateCondition(c.Req.Context(), cond, c.SignedInUser, c.SkipCache, srv.DatasourceCache); err != nil { - return ErrResp(http.StatusBadRequest, err, "failed to validate alert rule %q", r.GrafanaManagedAlert.Title) - } - if r.GrafanaManagedAlert.UID != "" { - _, ok := alertRuleUIDs[r.GrafanaManagedAlert.UID] - if ok { - return ErrResp(http.StatusBadRequest, fmt.Errorf("conflicting UID %q found", r.GrafanaManagedAlert.UID), "failed to validate alert rule %q", r.GrafanaManagedAlert.Title) - } - alertRuleUIDs[r.GrafanaManagedAlert.UID] = struct{}{} - } - } + return srv.updateAlertRulesInGroup(c, namespace, ruleGroupConfig.Name, rules) +} - numOfNewRules := len(ruleGroupConfig.Rules) - len(alertRuleUIDs) - if numOfNewRules > 0 { - // quotas are checked in advanced - // that is acceptable under the assumption that there will be only one alert rule under the rule group - // alternatively we should check the quotas after the rule group update - // and rollback the transaction in case of violation - limitReached, err := srv.QuotaService.QuotaReached(c, "alert_rule") +func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *models.Folder, groupName string, rules []*ngmodels.AlertRule) response.Response { + // TODO add create rules authz logic + + var changes *RuleChanges = nil + err := srv.store.InTransaction(c.Req.Context(), func(tranCtx context.Context) error { + var err error + changes, err = calculateChanges(tranCtx, srv.store, c.SignedInUser.OrgId, namespace, groupName, rules) if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to get quota") + return err } - if limitReached { - return ErrResp(http.StatusForbidden, errors.New("quota reached"), "") - } - } - if err := srv.store.UpdateRuleGroup(c.Req.Context(), store.UpdateRuleGroupCmd{ - OrgID: c.SignedInUser.OrgId, - NamespaceUID: namespace.Uid, - RuleGroupConfig: ruleGroupConfig, - }); err != nil { + // TODO add update/delete authz logic + err = srv.store.UpsertAlertRules(tranCtx, changes.Upsert) + if err != nil { + return fmt.Errorf("failed to add or update rules: %w", err) + } + + for _, rule := range changes.Delete { + if err = srv.store.DeleteAlertRuleByUID(tranCtx, c.SignedInUser.OrgId, rule.UID); err != nil { + return fmt.Errorf("failed to delete rule %d with UID %s: %w", rule.ID, rule.UID, err) + } + } + + if changes.newRules > 0 { + limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{ + OrgId: c.OrgId, + UserId: c.UserId, + }) // alert rule is table name + if err != nil { + return fmt.Errorf("failed to get alert rules quota: %w", err) + } + if limitReached { + return errQuotaReached + } + } + return nil + }) + + if err != nil { if errors.Is(err, ngmodels.ErrAlertRuleNotFound) { return ErrResp(http.StatusNotFound, err, "failed to update rule group") } else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) { return ErrResp(http.StatusBadRequest, err, "failed to update rule group") + } else if errors.Is(err, errQuotaReached) { + return ErrResp(http.StatusForbidden, err, "") } return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") } - for uid := range alertRuleUIDs { - srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{ + // TODO uncomment when rules that are not changed will be filter out from the upsert list. + // for _, rule := range changes.Upsert { + // if rule.Existing != nil { + // srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{ + // OrgID: c.SignedInUser.OrgId, + // UID: rule.Existing.UID, + // }) + // } + // } + + for _, rule := range changes.Delete { + srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{ OrgID: c.SignedInUser.OrgId, - UID: uid, + UID: rule.UID, }) } @@ -341,3 +363,81 @@ func toNamespaceErrorResponse(err error) response.Response { } return apierrors.ToFolderErrorResponse(err) } + +type RuleChanges struct { + newRules int + Upsert []store.UpsertRule + Delete []*ngmodels.AlertRule +} + +// calculateChanges calculates the difference between rules in the group in the database and the submitted rules. If a submitted rule has UID it tries to find it in the database (in other groups). +// returns a list of rules that need to be added, updated and deleted. Deleted considered rules in the database that belong to the group but do not exist in the list of submitted rules. +func calculateChanges(ctx context.Context, ruleStore store.RuleStore, orgId int64, namespace *models.Folder, ruleGroupName string, submittedRules []*ngmodels.AlertRule) (*RuleChanges, error) { + q := &ngmodels.ListRuleGroupAlertRulesQuery{ + OrgID: orgId, + NamespaceUID: namespace.Uid, + RuleGroup: ruleGroupName, + } + if err := ruleStore.GetRuleGroupAlertRules(ctx, q); err != nil { + return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", ruleGroupName, err) + } + existingGroupRules := q.Result + + existingGroupRulesUIDs := make(map[string]*ngmodels.AlertRule, len(existingGroupRules)) + for _, r := range existingGroupRules { + existingGroupRulesUIDs[r.UID] = r + } + + upsert := make([]store.UpsertRule, 0, len(submittedRules)) + toDelete := make([]*ngmodels.AlertRule, 0, len(submittedRules)) + newRules := 0 + for _, r := range submittedRules { + var existing *ngmodels.AlertRule = nil + + if r.UID != "" { + if existingGroupRule, ok := existingGroupRulesUIDs[r.UID]; ok { + existing = existingGroupRule + // remove the rule from existingGroupRulesUIDs + delete(existingGroupRulesUIDs, r.UID) + } else { + // Rule can be from other group or namespace + q := &ngmodels.GetAlertRuleByUIDQuery{OrgID: orgId, UID: r.UID} + if err := ruleStore.GetAlertRuleByUID(ctx, q); err != nil || q.Result == nil { + // if rule has UID then it is considered an update. Therefore, fail if there is no rule to update + if errors.Is(err, ngmodels.ErrAlertRuleNotFound) || q.Result == nil && err == nil { + return nil, fmt.Errorf("failed to update rule with UID %s because %w", r.UID, ngmodels.ErrAlertRuleNotFound) + } + return nil, fmt.Errorf("failed to query database for an alert rule with UID %s: %w", r.UID, err) + } + existing = q.Result + } + } + + if existing == nil { + upsert = append(upsert, store.UpsertRule{ + Existing: nil, + New: *r, + }) + newRules++ + continue + } + + ngmodels.PatchPartialAlertRule(existing, r) + // TODO diff between patched and existing, as well as between submitted + upsert = append(upsert, store.UpsertRule{ + Existing: existing, + New: *r, + }) + continue + } + + for _, rule := range existingGroupRulesUIDs { + toDelete = append(toDelete, rule) + } + + return &RuleChanges{ + Upsert: upsert, + Delete: toDelete, + newRules: newRules, + }, nil +} diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go new file mode 100644 index 00000000000..b26a799d8a6 --- /dev/null +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -0,0 +1,279 @@ +package api + +import ( + "context" + "errors" + "math/rand" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/util" +) + +func TestCalculateChanges(t *testing.T) { + orgId := rand.Int63() + + t.Run("detects alerts that need to be added", func(t *testing.T) { + fakeStore := store.NewFakeRuleStore(t) + + namespace := randFolder() + groupName := util.GenerateShortUID() + submitted := models.GenerateAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)) + + changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, submitted) + require.NoError(t, err) + + require.Equal(t, changes.newRules, len(submitted)) + require.Empty(t, changes.Delete) + require.Len(t, changes.Upsert, len(submitted)) + for _, rule := range changes.Upsert { + require.Nil(t, rule.Existing) + } + + opts := []cmp.Option{ + cmp.FilterPath(func(path cmp.Path) bool { + return path.String() == "Data.modelProps" + }, cmp.Ignore()), + } + + outerloop: + for _, expected := range submitted { + for _, rule := range changes.Upsert { + if cmp.Equal(*expected, rule.New, opts...) { + continue outerloop + } + } + require.Fail(t, "changes did not contain rule that was submitted") + } + }) + + t.Run("detects alerts that need to be deleted", func(t *testing.T) { + namespace := randFolder() + groupName := util.GenerateShortUID() + inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), withGroup(groupName), withNamespace(namespace))) + + fakeStore := store.NewFakeRuleStore(t) + fakeStore.PutRule(context.Background(), inDatabase...) + + changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, make([]*models.AlertRule, 0)) + require.NoError(t, err) + + require.Equal(t, 0, changes.newRules) + require.Len(t, changes.Upsert, 0) + require.Len(t, changes.Delete, len(inDatabaseMap)) + for _, toDelete := range changes.Delete { + require.Contains(t, inDatabaseMap, toDelete.UID) + db := inDatabaseMap[toDelete.UID] + require.Equal(t, db, toDelete) + } + }) + + t.Run("should detect alerts that needs to be updated", func(t *testing.T) { + namespace := randFolder() + groupName := util.GenerateShortUID() + inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), withGroup(groupName), withNamespace(namespace))) + submittedMap, submitted := models.GenerateUniqueAlertRules(len(inDatabase), models.AlertRuleGen(simulateSubmitted, withOrgID(orgId), withGroup(groupName), withNamespace(namespace), withUIDs(inDatabaseMap))) + + fakeStore := store.NewFakeRuleStore(t) + fakeStore.PutRule(context.Background(), inDatabase...) + + changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, submitted) + require.NoError(t, err) + + require.Len(t, changes.Upsert, len(inDatabase)) + for _, upsert := range changes.Upsert { + require.NotNil(t, upsert.Existing) + require.Equal(t, upsert.Existing.UID, upsert.New.UID) + require.Equal(t, inDatabaseMap[upsert.Existing.UID], upsert.Existing) + require.Equal(t, *submittedMap[upsert.Existing.UID], upsert.New) + } + require.Len(t, changes.Delete, 0) + require.Equal(t, 0, changes.newRules) + }) + + t.Run("should patch rule with UID specified by existing rule", func(t *testing.T) { + testCases := []struct { + name string + mutator func(r *models.AlertRule) + }{ + { + name: "title is empty", + mutator: func(r *models.AlertRule) { + r.Title = "" + }, + }, + { + name: "condition and data are empty", + mutator: func(r *models.AlertRule) { + r.Condition = "" + r.Data = nil + }, + }, + { + name: "ExecErrState is empty", + mutator: func(r *models.AlertRule) { + r.ExecErrState = "" + }, + }, + { + name: "NoDataState is empty", + mutator: func(r *models.AlertRule) { + r.NoDataState = "" + }, + }, + { + name: "For is 0", + mutator: func(r *models.AlertRule) { + r.For = 0 + }, + }, + } + + dbRule := models.AlertRuleGen(withOrgID(orgId))() + + fakeStore := store.NewFakeRuleStore(t) + fakeStore.PutRule(context.Background(), dbRule) + + namespace := randFolder() + groupName := util.GenerateShortUID() + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + expected := models.AlertRuleGen(simulateSubmitted, testCase.mutator)() + expected.UID = dbRule.UID + submitted := *expected + changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*models.AlertRule{&submitted}) + require.NoError(t, err) + require.Len(t, changes.Upsert, 1) + ch := changes.Upsert[0] + require.Equal(t, ch.Existing, dbRule) + fixed := *expected + models.PatchPartialAlertRule(dbRule, &fixed) + require.Equal(t, fixed, ch.New) + }) + } + }) + + t.Run("should be able to find alerts by UID in other group/namespace", func(t *testing.T) { + inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withOrgID(orgId))) + + fakeStore := store.NewFakeRuleStore(t) + fakeStore.PutRule(context.Background(), inDatabase...) + + namespace := randFolder() + groupName := util.GenerateShortUID() + submittedMap, submitted := models.GenerateUniqueAlertRules(rand.Intn(len(inDatabase)-5)+5, models.AlertRuleGen(simulateSubmitted, withOrgID(orgId), withGroup(groupName), withNamespace(namespace), withUIDs(inDatabaseMap))) + + changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, submitted) + require.NoError(t, err) + + require.Len(t, changes.Delete, 0) + require.Equal(t, 0, changes.newRules) + require.Len(t, changes.Upsert, len(submitted)) + for _, upsert := range changes.Upsert { + require.NotNil(t, upsert.Existing) + require.Equal(t, upsert.Existing.UID, upsert.New.UID) + require.Equal(t, inDatabaseMap[upsert.Existing.UID], upsert.Existing) + require.Equal(t, *submittedMap[upsert.Existing.UID], upsert.New) + } + }) + + t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) { + fakeStore := store.NewFakeRuleStore(t) + + namespace := randFolder() + groupName := util.GenerateShortUID() + submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)() + require.NotEqual(t, "", submitted.UID) + + _, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*models.AlertRule{submitted}) + require.Error(t, err) + }) + + t.Run("should fail if cannot fetch current rules in the group", func(t *testing.T) { + fakeStore := store.NewFakeRuleStore(t) + expectedErr := errors.New("TEST ERROR") + fakeStore.Hook = func(cmd interface{}) error { + switch cmd.(type) { + case models.ListRuleGroupAlertRulesQuery: + return expectedErr + } + return nil + } + + namespace := randFolder() + groupName := util.GenerateShortUID() + submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)() + + _, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*models.AlertRule{submitted}) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("should fail if cannot fetch rule by UID", func(t *testing.T) { + fakeStore := store.NewFakeRuleStore(t) + expectedErr := errors.New("TEST ERROR") + fakeStore.Hook = func(cmd interface{}) error { + switch cmd.(type) { + case models.GetAlertRuleByUIDQuery: + return expectedErr + } + return nil + } + + namespace := randFolder() + groupName := util.GenerateShortUID() + submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)() + + _, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*models.AlertRule{submitted}) + require.Error(t, err, expectedErr) + }) +} + +func withOrgID(orgId int64) func(rule *models.AlertRule) { + return func(rule *models.AlertRule) { + rule.OrgID = orgId + } +} + +func withGroup(groupName string) func(rule *models.AlertRule) { + return func(rule *models.AlertRule) { + rule.RuleGroup = groupName + } +} + +func withNamespace(namespace *models2.Folder) func(rule *models.AlertRule) { + return func(rule *models.AlertRule) { + rule.NamespaceUID = namespace.Uid + } +} + +// simulateSubmitted resets some fields of the structure that are not populated by API model to model conversion +func simulateSubmitted(rule *models.AlertRule) { + rule.ID = 0 + rule.Version = 0 + rule.Updated = time.Time{} +} + +func withoutUID(rule *models.AlertRule) { + rule.UID = "" +} + +func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) { + unused := make([]string, 0, len(uids)) + for s := range uids { + unused = append(unused, s) + } + return func(rule *models.AlertRule) { + if len(unused) == 0 { + return + } + rule.UID = unused[0] + unused = unused[1:] + } +} diff --git a/pkg/services/ngalert/api/api_ruler_validation.go b/pkg/services/ngalert/api/api_ruler_validation.go new file mode 100644 index 00000000000..5eed7bb8001 --- /dev/null +++ b/pkg/services/ngalert/api/api_ruler_validation.go @@ -0,0 +1,183 @@ +package api + +import ( + "errors" + "fmt" + "strconv" + "time" + + "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" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/setting" +) + +// validateRuleNode validates API model (definitions.PostableExtendedRuleNode) and converts it to models.AlertRule +func validateRuleNode( + ruleNode *apimodels.PostableExtendedRuleNode, + groupName string, + interval time.Duration, + orgId int64, + namespace *models.Folder, + conditionValidator func(ngmodels.Condition) error, + cfg *setting.UnifiedAlertingSettings) (*ngmodels.AlertRule, error) { + intervalSeconds := int64(interval.Seconds()) + + baseIntervalSeconds := int64(cfg.BaseInterval.Seconds()) + + if interval <= 0 { + return nil, fmt.Errorf("rule evaluation interval must be positive duration that is multiple of the base interval %d seconds", baseIntervalSeconds) + } + + if intervalSeconds%baseIntervalSeconds != 0 { + return nil, fmt.Errorf("rule evaluation interval %d should be multiple of the base interval of %d seconds", int64(interval.Seconds()), baseIntervalSeconds) + } + + if ruleNode.GrafanaManagedAlert == nil { + return nil, fmt.Errorf("not Grafana managed alert rule") + } + + // if UID is specified then we can accept partial model. Therefore, some validation can be skipped as it will be patched later + canPatch := ruleNode.GrafanaManagedAlert.UID != "" + + if ruleNode.GrafanaManagedAlert.Title == "" && !canPatch { + return nil, errors.New("alert rule title cannot be empty") + } + + if len(ruleNode.GrafanaManagedAlert.Title) > store.AlertRuleMaxTitleLength { + return nil, fmt.Errorf("alert rule title is too long. Max length is %d", store.AlertRuleMaxTitleLength) + } + + noDataState := ngmodels.NoData + if ruleNode.GrafanaManagedAlert.NoDataState == "" && canPatch { + noDataState = "" + } + + if ruleNode.GrafanaManagedAlert.NoDataState != "" { + var err error + noDataState, err = ngmodels.NoDataStateFromString(string(ruleNode.GrafanaManagedAlert.NoDataState)) + if err != nil { + return nil, err + } + } + + errorState := ngmodels.AlertingErrState + + if ruleNode.GrafanaManagedAlert.ExecErrState == "" && canPatch { + errorState = "" + } + + if ruleNode.GrafanaManagedAlert.ExecErrState != "" { + var err error + errorState, err = ngmodels.ErrStateFromString(string(ruleNode.GrafanaManagedAlert.ExecErrState)) + if err != nil { + return nil, err + } + } + + if len(ruleNode.GrafanaManagedAlert.Data) == 0 { + if canPatch { + if ruleNode.GrafanaManagedAlert.Condition != "" { + return nil, fmt.Errorf("%w: query is not specified by condition is. You must specify both query and condition to update existing alert rule", ngmodels.ErrAlertRuleFailedValidation) + } + } else { + return nil, fmt.Errorf("%w: no queries or expressions are found", ngmodels.ErrAlertRuleFailedValidation) + } + } + + if len(ruleNode.GrafanaManagedAlert.Data) != 0 { + cond := ngmodels.Condition{ + Condition: ruleNode.GrafanaManagedAlert.Condition, + OrgID: orgId, + Data: ruleNode.GrafanaManagedAlert.Data, + } + if err := conditionValidator(cond); err != nil { + return nil, fmt.Errorf("failed to validate condition of alert rule %s: %w", ruleNode.GrafanaManagedAlert.Title, err) + } + } + + newAlertRule := ngmodels.AlertRule{ + OrgID: orgId, + Title: ruleNode.GrafanaManagedAlert.Title, + Condition: ruleNode.GrafanaManagedAlert.Condition, + Data: ruleNode.GrafanaManagedAlert.Data, + UID: ruleNode.GrafanaManagedAlert.UID, + IntervalSeconds: intervalSeconds, + NamespaceUID: namespace.Uid, + RuleGroup: groupName, + NoDataState: noDataState, + ExecErrState: errorState, + } + + if ruleNode.ApiRuleNode != nil { + newAlertRule.For = time.Duration(ruleNode.ApiRuleNode.For) + newAlertRule.Annotations = ruleNode.ApiRuleNode.Annotations + newAlertRule.Labels = ruleNode.ApiRuleNode.Labels + + dashUID := ruleNode.ApiRuleNode.Annotations[ngmodels.DashboardUIDAnnotation] + panelID := ruleNode.ApiRuleNode.Annotations[ngmodels.PanelIDAnnotation] + + if dashUID != "" && panelID == "" || dashUID == "" && panelID != "" { + return nil, fmt.Errorf("both annotations %s and %s must be specified", ngmodels.DashboardUIDAnnotation, ngmodels.PanelIDAnnotation) + } + + if dashUID != "" { + panelIDValue, err := strconv.ParseInt(panelID, 10, 64) + if err != nil { + return nil, fmt.Errorf("annotation %s must be a valid integer Panel ID", ngmodels.PanelIDAnnotation) + } + newAlertRule.DashboardUID = &dashUID + newAlertRule.PanelID = &panelIDValue + } + } + + return &newAlertRule, nil +} + +// validateRuleGroup validates API model (definitions.PostableRuleGroupConfig) and converts it to a collection of models.AlertRule. +// Returns a slice that contains all rules described by API model or error if either group specification or an alert definition is not valid. +func validateRuleGroup( + ruleGroupConfig *apimodels.PostableRuleGroupConfig, + orgId int64, + namespace *models.Folder, + conditionValidator func(ngmodels.Condition) error, + cfg *setting.UnifiedAlertingSettings) ([]*ngmodels.AlertRule, error) { + if ruleGroupConfig.Name == "" { + return nil, errors.New("rule group name cannot be empty") + } + + if len(ruleGroupConfig.Name) > store.AlertRuleMaxRuleGroupNameLength { + return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength) + } + + interval := time.Duration(ruleGroupConfig.Interval) + if interval == 0 { + // if group interval is 0 (undefined) then we automatically fall back to the default interval + interval = cfg.DefaultRuleEvaluationInterval + } + + if interval < 0 || int64(interval.Seconds())%int64(cfg.BaseInterval.Seconds()) != 0 { + return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(cfg.BaseInterval.Seconds())) + } + + // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval + + result := make([]*ngmodels.AlertRule, 0, len(ruleGroupConfig.Rules)) + uids := make(map[string]int, cap(result)) + for idx := range ruleGroupConfig.Rules { + rule, err := validateRuleNode(&ruleGroupConfig.Rules[idx], ruleGroupConfig.Name, interval, orgId, namespace, conditionValidator, cfg) + // TODO do not stop on the first failure but return all failures + if err != nil { + return nil, fmt.Errorf("invalid rule specification at index [%d]: %w", idx, err) + } + if rule.UID != "" { + if existingIdx, ok := uids[rule.UID]; ok { + return nil, fmt.Errorf("rule [%d] has UID %s that is already assigned to another rule at index %d", idx, rule.UID, existingIdx) + } + uids[rule.UID] = idx + } + result = append(result, rule) + } + return result, nil +} diff --git a/pkg/services/ngalert/api/api_ruler_validation_test.go b/pkg/services/ngalert/api/api_ruler_validation_test.go new file mode 100644 index 00000000000..b2fe8116509 --- /dev/null +++ b/pkg/services/ngalert/api/api_ruler_validation_test.go @@ -0,0 +1,716 @@ +package api + +import ( + "errors" + "fmt" + "strconv" + "testing" + "time" + + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "golang.org/x/exp/rand" + + models2 "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +var allNoData = []apimodels.NoDataState{ + apimodels.OK, + apimodels.NoData, + apimodels.Alerting, +} + +var allExecError = []apimodels.ExecutionErrorState{ + apimodels.ErrorErrState, + apimodels.AlertingErrState, +} + +func config(t *testing.T) *setting.UnifiedAlertingSettings { + t.Helper() + baseInterval := time.Duration(rand.Intn(99)+1) * time.Second + result := &setting.UnifiedAlertingSettings{ + BaseInterval: baseInterval, + DefaultRuleEvaluationInterval: baseInterval * time.Duration(rand.Intn(9)+1), + } + t.Logf("Config Base interval is [%v]", result.BaseInterval) + return result +} + +func validRule() apimodels.PostableExtendedRuleNode { + return apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: model.Duration(rand.Int63n(1000)), + Labels: map[string]string{ + "test-label": "data", + }, + Annotations: map[string]string{ + "test-annotation": "data", + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("TEST-ALERT-%d", rand.Int63()), + Condition: "A", + Data: []models.AlertQuery{ + { + RefID: "A", + QueryType: "TEST", + RelativeTimeRange: models.RelativeTimeRange{ + From: 10, + To: 0, + }, + DatasourceUID: "DATASOURCE_TEST", + Model: nil, + }, + }, + UID: util.GenerateShortUID(), + NoDataState: allNoData[rand.Intn(len(allNoData)-1)], + ExecErrState: allExecError[rand.Intn(len(allExecError)-1)], + }, + } +} + +func validGroup(cfg *setting.UnifiedAlertingSettings, rules ...apimodels.PostableExtendedRuleNode) apimodels.PostableRuleGroupConfig { + return apimodels.PostableRuleGroupConfig{ + Name: "TEST-ALERTS-" + util.GenerateShortUID(), + Interval: model.Duration(cfg.BaseInterval * time.Duration(rand.Int63n(10))), + Rules: rules, + } +} + +func randFolder() *models2.Folder { + return &models2.Folder{ + Id: rand.Int63(), + Uid: util.GenerateShortUID(), + Title: "TEST-FOLDER-" + util.GenerateShortUID(), + Url: "", + Version: 0, + Created: time.Time{}, + Updated: time.Time{}, + UpdatedBy: 0, + CreatedBy: 0, + HasAcl: false, + } +} + +func TestValidateRuleGroup(t *testing.T) { + orgId := rand.Int63() + folder := randFolder() + + rules := make([]apimodels.PostableExtendedRuleNode, 0, rand.Intn(4)+1) + for i := 0; i < cap(rules); i++ { + rules = append(rules, validRule()) + } + cfg := config(t) + + t.Run("should validate struct and rules", func(t *testing.T) { + g := validGroup(cfg, rules...) + conditionValidations := 0 + alerts, err := validateRuleGroup(&g, orgId, folder, func(condition models.Condition) error { + conditionValidations++ + return nil + }, cfg) + require.NoError(t, err) + require.Len(t, alerts, len(rules)) + require.Equal(t, len(rules), conditionValidations) + }) + t.Run("should default to default interval from config if group interval is 0", func(t *testing.T) { + g := validGroup(cfg, rules...) + g.Interval = 0 + alerts, err := validateRuleGroup(&g, orgId, folder, func(condition models.Condition) error { + return nil + }, cfg) + require.NoError(t, err) + for _, alert := range alerts { + require.Equal(t, int64(cfg.DefaultRuleEvaluationInterval.Seconds()), alert.IntervalSeconds) + } + }) +} + +func TestValidateRuleGroupFailures(t *testing.T) { + orgId := rand.Int63() + folder := randFolder() + cfg := config(t) + + testCases := []struct { + name string + group func() *apimodels.PostableRuleGroupConfig + assert func(t *testing.T, apiModel *apimodels.PostableRuleGroupConfig, err error) + }{ + { + name: "fail if title is empty", + group: func() *apimodels.PostableRuleGroupConfig { + g := validGroup(cfg) + g.Name = "" + return &g + }, + }, + { + name: "fail if title is too long", + group: func() *apimodels.PostableRuleGroupConfig { + g := validGroup(cfg) + for len(g.Name) < store.AlertRuleMaxRuleGroupNameLength { + g.Name += g.Name + } + return &g + }, + }, + { + name: "fail if interval is negative", + group: func() *apimodels.PostableRuleGroupConfig { + g := validGroup(cfg) + g.Interval = model.Duration(-(rand.Int63n(1000) + 1)) + return &g + }, + }, + { + name: "fail if interval is not aligned with base interval", + group: func() *apimodels.PostableRuleGroupConfig { + g := validGroup(cfg) + g.Interval = model.Duration(cfg.BaseInterval + time.Duration(rand.Intn(10)+1)*time.Second) + return &g + }, + }, + { + name: "fail if two rules have same UID", + group: func() *apimodels.PostableRuleGroupConfig { + r1 := validRule() + r2 := validRule() + uid := util.GenerateShortUID() + r1.GrafanaManagedAlert.UID = uid + r2.GrafanaManagedAlert.UID = uid + g := validGroup(cfg, r1, r2) + return &g + }, + assert: func(t *testing.T, apiModel *apimodels.PostableRuleGroupConfig, err error) { + require.Contains(t, err.Error(), apiModel.Rules[0].GrafanaManagedAlert.UID) + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + g := testCase.group() + _, err := validateRuleGroup(g, orgId, folder, func(condition models.Condition) error { + return nil + }, cfg) + require.Error(t, err) + if testCase.assert != nil { + testCase.assert(t, g, err) + } + }) + } +} + +func TestValidateRuleNode_NoUID(t *testing.T) { + orgId := rand.Int63() + folder := randFolder() + name := util.GenerateShortUID() + var cfg = config(t) + interval := cfg.BaseInterval * time.Duration(rand.Int63n(10)+1) + + testCases := []struct { + name string + rule func() *apimodels.PostableExtendedRuleNode + assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, rule *models.AlertRule) + }{ + { + name: "coverts api model to AlertRule", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, int64(0), alert.ID) + require.Equal(t, orgId, alert.OrgID) + require.Equal(t, api.GrafanaManagedAlert.Title, alert.Title) + require.Equal(t, api.GrafanaManagedAlert.Condition, alert.Condition) + require.Equal(t, api.GrafanaManagedAlert.Data, alert.Data) + require.Equal(t, time.Time{}, alert.Updated) + require.Equal(t, int64(interval.Seconds()), alert.IntervalSeconds) + require.Equal(t, int64(0), alert.Version) + require.Equal(t, api.GrafanaManagedAlert.UID, alert.UID) + require.Equal(t, folder.Uid, alert.NamespaceUID) + require.Nil(t, alert.DashboardUID) + require.Nil(t, alert.PanelID) + require.Equal(t, name, alert.RuleGroup) + require.Equal(t, models.NoDataState(api.GrafanaManagedAlert.NoDataState), alert.NoDataState) + require.Equal(t, models.ExecutionErrorState(api.GrafanaManagedAlert.ExecErrState), alert.ExecErrState) + require.Equal(t, time.Duration(api.ApiRuleNode.For), alert.For) + require.Equal(t, api.ApiRuleNode.Annotations, alert.Annotations) + require.Equal(t, api.ApiRuleNode.Labels, alert.Labels) + }, + }, + { + name: "coverts api without ApiRuleNode", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode = nil + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, time.Duration(0), alert.For) + require.Nil(t, alert.Annotations) + require.Nil(t, alert.Labels) + }, + }, + { + name: "defaults to NoData if NoDataState is empty", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.NoDataState = "" + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, models.NoData, alert.NoDataState) + }, + }, + { + name: "defaults to Alerting if ExecErrState is empty", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.ExecErrState = "" + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, models.AlertingErrState, alert.ExecErrState) + }, + }, + { + name: "extracts Dashboard UID and Panel Id from annotations", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.DashboardUIDAnnotation: util.GenerateShortUID(), + models.PanelIDAnnotation: strconv.Itoa(rand.Int()), + } + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, api.ApiRuleNode.Annotations[models.DashboardUIDAnnotation], *alert.DashboardUID) + panelId, err := strconv.Atoi(api.ApiRuleNode.Annotations[models.PanelIDAnnotation]) + require.NoError(t, err) + require.Equal(t, int64(panelId), *alert.PanelID) + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := testCase.rule() + r.GrafanaManagedAlert.UID = "" + + alert, err := validateRuleNode(r, name, interval, orgId, folder, func(condition models.Condition) error { + return nil + }, cfg) + require.NoError(t, err) + testCase.assert(t, r, alert) + }) + } + + t.Run("accepts empty group name", func(t *testing.T) { + r := validRule() + alert, err := validateRuleNode(&r, "", interval, orgId, folder, func(condition models.Condition) error { + return nil + }, cfg) + require.NoError(t, err) + require.Equal(t, "", alert.RuleGroup) + }) +} + +func TestValidateRuleNodeFailures_NoUID(t *testing.T) { + orgId := rand.Int63() + folder := randFolder() + cfg := config(t) + successValidation := func(condition models.Condition) error { + return nil + } + + testCases := []struct { + name string + interval *time.Duration + rule func() *apimodels.PostableExtendedRuleNode + conditionValidation func(condition models.Condition) error + assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error) + allowedIfNoUId bool + }{ + { + name: "fail if GrafanaManagedAlert is not specified", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert = nil + return &r + }, + }, + { + name: "fail if title is empty", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Title = "" + return &r + }, + }, + { + name: "fail if title is too long", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + for len(r.GrafanaManagedAlert.Title) < store.AlertRuleMaxTitleLength { + r.GrafanaManagedAlert.Title += r.GrafanaManagedAlert.Title + } + return &r + }, + }, + { + name: "fail if NoDataState is not known", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.NoDataState = apimodels.NoDataState(util.GenerateShortUID()) + return &r + }, + }, + { + name: "fail if ExecErrState is not known", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.ExecErrState = apimodels.ExecutionErrorState(util.GenerateShortUID()) + return &r + }, + }, + { + name: "fail if there are not data (nil)", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Data = nil + return &r + }, + }, + { + name: "fail if there are not data (empty)", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Data = make([]models.AlertQuery, 0, 1) + return &r + }, + }, + { + name: "fail if validator function returns error", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + return &r + }, + conditionValidation: func(condition models.Condition) error { + return errors.New("BAD alert condition") + }, + }, + { + name: "fail if Dashboard UID is specified but not Panel ID", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.DashboardUIDAnnotation: util.GenerateShortUID(), + } + return &r + }, + }, + { + name: "fail if Dashboard UID is specified and Panel ID is NaN", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.DashboardUIDAnnotation: util.GenerateShortUID(), + models.PanelIDAnnotation: util.GenerateShortUID(), + } + return &r + }, + }, + { + name: "fail if PanelID is specified but not Dashboard UID ", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.PanelIDAnnotation: "0", + } + return &r + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := testCase.rule() + if r.GrafanaManagedAlert != nil { + r.GrafanaManagedAlert.UID = "" + } + f := successValidation + if testCase.conditionValidation != nil { + f = testCase.conditionValidation + } + + interval := cfg.BaseInterval + if testCase.interval != nil { + interval = *testCase.interval + } + + _, err := validateRuleNode(r, "", interval, orgId, folder, f, cfg) + require.Error(t, err) + if testCase.assert != nil { + testCase.assert(t, r, err) + } + }) + } +} + +func TestValidateRuleNode_UID(t *testing.T) { + orgId := rand.Int63() + folder := randFolder() + name := util.GenerateShortUID() + var cfg = config(t) + interval := cfg.BaseInterval * time.Duration(rand.Int63n(10)+1) + + testCases := []struct { + name string + rule func() *apimodels.PostableExtendedRuleNode + assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, rule *models.AlertRule) + }{ + { + name: "use empty Title", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Title = "" + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, "", alert.Title) + }, + }, + { + name: "use empty NoData if NoDataState is empty", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.NoDataState = "" + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, models.NoDataState(""), alert.NoDataState) + }, + }, + { + name: "use empty Alerting if ExecErrState is empty", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.ExecErrState = "" + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, models.ExecutionErrorState(""), alert.ExecErrState) + }, + }, + { + name: "use empty Condition and Data if they are empty", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Condition = "" + r.GrafanaManagedAlert.Data = nil + if rand.Int63()%2 == 0 { + r.GrafanaManagedAlert.Data = make([]models.AlertQuery, 0) + } + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, "", alert.Condition) + require.Len(t, alert.Data, 0) + }, + }, + { + name: "extracts Dashboard UID and Panel Id from annotations", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.DashboardUIDAnnotation: util.GenerateShortUID(), + models.PanelIDAnnotation: strconv.Itoa(rand.Int()), + } + return &r + }, + assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { + require.Equal(t, api.ApiRuleNode.Annotations[models.DashboardUIDAnnotation], *alert.DashboardUID) + panelId, err := strconv.Atoi(api.ApiRuleNode.Annotations[models.PanelIDAnnotation]) + require.NoError(t, err) + require.Equal(t, int64(panelId), *alert.PanelID) + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := testCase.rule() + alert, err := validateRuleNode(r, name, interval, orgId, folder, func(condition models.Condition) error { + return nil + }, cfg) + require.NoError(t, err) + testCase.assert(t, r, alert) + }) + } + + t.Run("accepts empty group name", func(t *testing.T) { + r := validRule() + alert, err := validateRuleNode(&r, "", interval, orgId, folder, func(condition models.Condition) error { + return nil + }, cfg) + require.NoError(t, err) + require.Equal(t, "", alert.RuleGroup) + }) +} + +func TestValidateRuleNodeFailures_UID(t *testing.T) { + orgId := rand.Int63() + folder := randFolder() + cfg := config(t) + successValidation := func(condition models.Condition) error { + return nil + } + + testCases := []struct { + name string + interval *time.Duration + rule func() *apimodels.PostableExtendedRuleNode + conditionValidation func(condition models.Condition) error + assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error) + }{ + { + name: "fail if GrafanaManagedAlert is not specified", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert = nil + return &r + }, + }, + { + name: "fail if title is too long", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + for len(r.GrafanaManagedAlert.Title) < store.AlertRuleMaxTitleLength { + r.GrafanaManagedAlert.Title += r.GrafanaManagedAlert.Title + } + return &r + }, + }, + { + name: "fail if there are not data (nil) but condition is set", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Data = nil + r.GrafanaManagedAlert.Condition = "A" + return &r + }, + }, + { + name: "fail if there are not data (empty) but condition is set", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.GrafanaManagedAlert.Data = make([]models.AlertQuery, 0, 1) + r.GrafanaManagedAlert.Condition = "A" + return &r + }, + }, + { + name: "fail if validator function returns error", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + return &r + }, + conditionValidation: func(condition models.Condition) error { + return errors.New("BAD alert condition") + }, + }, + { + name: "fail if Dashboard UID is specified but not Panel ID", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.DashboardUIDAnnotation: util.GenerateShortUID(), + } + return &r + }, + }, + { + name: "fail if Dashboard UID is specified and Panel ID is NaN", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.DashboardUIDAnnotation: util.GenerateShortUID(), + models.PanelIDAnnotation: util.GenerateShortUID(), + } + return &r + }, + }, + { + name: "fail if PanelID is specified but not Dashboard UID ", + rule: func() *apimodels.PostableExtendedRuleNode { + r := validRule() + r.ApiRuleNode.Annotations = map[string]string{ + models.PanelIDAnnotation: "0", + } + return &r + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := testCase.rule() + f := successValidation + if testCase.conditionValidation != nil { + f = testCase.conditionValidation + } + + interval := cfg.BaseInterval + if testCase.interval != nil { + interval = *testCase.interval + } + + _, err := validateRuleNode(r, "", interval, orgId, folder, f, cfg) + require.Error(t, err) + if testCase.assert != nil { + testCase.assert(t, r, err) + } + }) + } +} + +func TestValidateRuleNodeIntervalFailures(t *testing.T) { + cfg := config(t) + + testCases := []struct { + name string + interval time.Duration + }{ + { + name: "fail if interval is negative", + interval: -time.Duration(rand.Int63n(10)+1) * time.Second, + }, + { + name: "fail if interval is 0", + interval: 0, + }, + { + name: "fail if interval is not multiple of base interval", + interval: cfg.BaseInterval + time.Duration(rand.Int63n(int64(cfg.BaseInterval.Seconds())-2)+1)*time.Second, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := validRule() + f := func(condition models.Condition) error { + return nil + } + + _, err := validateRuleNode(&r, util.GenerateShortUID(), testCase.interval, rand.Int63(), randFolder(), f, cfg) + require.Error(t, err) + }) + } +} diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index 206f504e467..186af0d99e1 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -202,6 +202,13 @@ func validateCondition(ctx context.Context, c ngmodels.Condition, user *models.S return nil } +// conditionValidator returns a curried validateCondition that accepts only condition +func conditionValidator(c *models.ReqContext, cache datasources.CacheService) func(ngmodels.Condition) error { + return func(condition ngmodels.Condition) error { + return validateCondition(c.Req.Context(), condition, c.SignedInUser, c.SkipCache, cache) + } +} + func validateQueriesAndExpressions(ctx context.Context, data []ngmodels.AlertQuery, user *models.SignedInUser, skipCache bool, datasourceCache datasources.CacheService) (map[string]struct{}, error) { refIDs := make(map[string]struct{}) if len(data) == 0 { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index dd717b3c358..4e59f55c6d6 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -27,6 +27,19 @@ func (noDataState NoDataState) String() string { return string(noDataState) } +func NoDataStateFromString(state string) (NoDataState, error) { + switch state { + case string(Alerting): + return Alerting, nil + case string(NoData): + return NoData, nil + case string(OK): + return OK, nil + default: + return "", fmt.Errorf("unknown NoData state option %s", state) + } +} + const ( Alerting NoDataState = "Alerting" NoData NoDataState = "NoData" @@ -39,9 +52,23 @@ func (executionErrorState ExecutionErrorState) String() string { return string(executionErrorState) } +func ErrStateFromString(opt string) (ExecutionErrorState, error) { + switch opt { + case string(Alerting): + return AlertingErrState, nil + case string(ErrorErrState): + return ErrorErrState, nil + case string(OkErrState): + return OkErrState, nil + default: + return "", fmt.Errorf("unknown Error state option %s", opt) + } +} + const ( AlertingErrState ExecutionErrorState = "Alerting" ErrorErrState ExecutionErrorState = "Error" + OkErrState ExecutionErrorState = "OK" ) const ( @@ -218,3 +245,37 @@ func (c Condition) IsValid() bool { // TODO search for refIDs in QueriesAndExpressions return len(c.Data) != 0 } + +// PatchPartialAlertRule patches `ruleToPatch` by `existingRule` following the rule that if a field of `ruleToPatch` is empty or has the default value, it is populated by the value of the corresponding field from `existingRule`. +// There are several exceptions: +// 1. Following fields are not patched and therefore will be ignored: AlertRule.ID, AlertRule.OrgID, AlertRule.Updated, AlertRule.Version, AlertRule.UID, AlertRule.DashboardUID, AlertRule.PanelID, AlertRule.Annotations and AlertRule.Labels +// 2. There are fields that are patched together: +// - AlertRule.Condition and AlertRule.Data +// If either of the pair is specified, neither is patched. +func PatchPartialAlertRule(existingRule *AlertRule, ruleToPatch *AlertRule) { + if ruleToPatch.Title == "" { + ruleToPatch.Title = existingRule.Title + } + if ruleToPatch.Condition == "" || len(ruleToPatch.Data) == 0 { + ruleToPatch.Condition = existingRule.Condition + ruleToPatch.Data = existingRule.Data + } + if ruleToPatch.IntervalSeconds == 0 { + ruleToPatch.IntervalSeconds = existingRule.IntervalSeconds + } + if ruleToPatch.NamespaceUID == "" { + ruleToPatch.NamespaceUID = existingRule.NamespaceUID + } + if ruleToPatch.RuleGroup == "" { + ruleToPatch.RuleGroup = existingRule.RuleGroup + } + if ruleToPatch.ExecErrState == "" { + ruleToPatch.ExecErrState = existingRule.ExecErrState + } + if ruleToPatch.NoDataState == "" { + ruleToPatch.NoDataState = existingRule.NoDataState + } + if ruleToPatch.For == 0 { + ruleToPatch.For = existingRule.For + } +} diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go new file mode 100644 index 00000000000..a5106fb7063 --- /dev/null +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -0,0 +1,227 @@ +package models + +import ( + "math/rand" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/util" +) + +func TestNoDataStateFromString(t *testing.T) { + allKnownNoDataStates := [...]NoDataState{ + Alerting, + NoData, + OK, + } + + t.Run("should parse known values", func(t *testing.T) { + for _, state := range allKnownNoDataStates { + stateStr := string(state) + actual, err := NoDataStateFromString(stateStr) + require.NoErrorf(t, err, "failed to parse a known state [%s]", stateStr) + require.Equal(t, state, actual) + } + }) + + t.Run("should fail to parse in different case", func(t *testing.T) { + for _, state := range allKnownNoDataStates { + stateStr := strings.ToLower(string(state)) + actual, err := NoDataStateFromString(stateStr) + require.Errorf(t, err, "expected error for input value [%s]", stateStr) + require.Equal(t, NoDataState(""), actual) + } + }) + + t.Run("should fail to parse unknown values", func(t *testing.T) { + input := util.GenerateShortUID() + actual, err := NoDataStateFromString(input) + require.Errorf(t, err, "expected error for input value [%s]", input) + require.Equal(t, NoDataState(""), actual) + }) +} + +func TestErrStateFromString(t *testing.T) { + allKnownErrStates := [...]ExecutionErrorState{ + AlertingErrState, + ErrorErrState, + OkErrState, + } + + t.Run("should parse known values", func(t *testing.T) { + for _, state := range allKnownErrStates { + stateStr := string(state) + actual, err := ErrStateFromString(stateStr) + require.NoErrorf(t, err, "failed to parse a known state [%s]", stateStr) + require.Equal(t, state, actual) + } + }) + + t.Run("should fail to parse in different case", func(t *testing.T) { + for _, state := range allKnownErrStates { + stateStr := strings.ToLower(string(state)) + actual, err := ErrStateFromString(stateStr) + require.Errorf(t, err, "expected error for input value [%s]", stateStr) + require.Equal(t, ExecutionErrorState(""), actual) + } + }) + + t.Run("should fail to parse unknown values", func(t *testing.T) { + input := util.GenerateShortUID() + actual, err := ErrStateFromString(input) + require.Errorf(t, err, "expected error for input value [%s]", input) + require.Equal(t, ExecutionErrorState(""), actual) + }) +} + +func TestPatchPartialAlertRule(t *testing.T) { + t.Run("patches", func(t *testing.T) { + testCases := []struct { + name string + mutator func(r *AlertRule) + }{ + { + name: "title is empty", + mutator: func(r *AlertRule) { + r.Title = "" + }, + }, + { + name: "condition and data are empty", + mutator: func(r *AlertRule) { + r.Condition = "" + r.Data = nil + }, + }, + { + name: "ExecErrState is empty", + mutator: func(r *AlertRule) { + r.ExecErrState = "" + }, + }, + { + name: "NoDataState is empty", + mutator: func(r *AlertRule) { + r.NoDataState = "" + }, + }, + { + name: "For is 0", + mutator: func(r *AlertRule) { + r.For = 0 + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var existing *AlertRule + for { + existing = AlertRuleGen(func(rule *AlertRule) { + rule.For = time.Duration(rand.Int63n(1000) + 1) + })() + cloned := *existing + testCase.mutator(&cloned) + if !cmp.Equal(*existing, cloned, cmp.FilterPath(func(path cmp.Path) bool { + return path.String() == "Data.modelProps" + }, cmp.Ignore())) { + break + } + } + patch := *existing + testCase.mutator(&patch) + + require.NotEqual(t, *existing, patch) + PatchPartialAlertRule(existing, &patch) + require.Equal(t, *existing, patch) + }) + } + }) + + t.Run("does not patch", func(t *testing.T) { + testCases := []struct { + name string + mutator func(r *AlertRule) + }{ + { + name: "ID", + mutator: func(r *AlertRule) { + r.ID = 0 + }, + }, + { + name: "OrgID", + mutator: func(r *AlertRule) { + r.OrgID = 0 + }, + }, + { + name: "Updated", + mutator: func(r *AlertRule) { + r.Updated = time.Time{} + }, + }, + { + name: "Version", + mutator: func(r *AlertRule) { + r.Version = 0 + }, + }, + { + name: "UID", + mutator: func(r *AlertRule) { + r.UID = "" + }, + }, + { + name: "DashboardUID", + mutator: func(r *AlertRule) { + r.DashboardUID = nil + }, + }, + { + name: "PanelID", + mutator: func(r *AlertRule) { + r.PanelID = nil + }, + }, + { + name: "Annotations", + mutator: func(r *AlertRule) { + r.Annotations = nil + }, + }, + { + name: "Labels", + mutator: func(r *AlertRule) { + r.Labels = nil + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var existing *AlertRule + for { + existing = AlertRuleGen()() + cloned := *existing + // make sure the generated rule does not match the mutated one + testCase.mutator(&cloned) + if !cmp.Equal(*existing, cloned, cmp.FilterPath(func(path cmp.Path) bool { + return path.String() == "Data.modelProps" + }, cmp.Ignore())) { + break + } + } + patch := *existing + testCase.mutator(&patch) + PatchPartialAlertRule(existing, &patch) + require.NotEqual(t, *existing, patch) + }) + } + }) +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go new file mode 100644 index 00000000000..3428086fc6a --- /dev/null +++ b/pkg/services/ngalert/models/testing.go @@ -0,0 +1,127 @@ +package models + +import ( + "encoding/json" + "math/rand" + "time" + + "github.com/grafana/grafana/pkg/util" +) + +// AlertRuleGen provides a factory function that generates a random AlertRule. +// The mutators arguments allows changing fields of the resulting structure +func AlertRuleGen(mutators ...func(*AlertRule)) func() *AlertRule { + return func() *AlertRule { + randNoDataState := func() NoDataState { + s := [...]NoDataState{ + Alerting, + NoData, + OK, + } + return s[rand.Intn(len(s)-1)] + } + + randErrState := func() ExecutionErrorState { + s := [...]ExecutionErrorState{ + AlertingErrState, + ErrorErrState, + OkErrState, + } + return s[rand.Intn(len(s)-1)] + } + + interval := (rand.Int63n(6) + 1) * 10 + forInterval := time.Duration(interval*rand.Int63n(6)) * time.Second + + var annotations map[string]string = nil + if rand.Int63()%2 == 0 { + qty := rand.Intn(5) + annotations = make(map[string]string, qty) + for i := 0; i < qty; i++ { + annotations[util.GenerateShortUID()] = util.GenerateShortUID() + } + } + var labels map[string]string = nil + if rand.Int63()%2 == 0 { + qty := rand.Intn(5) + labels = make(map[string]string, qty) + for i := 0; i < qty; i++ { + labels[util.GenerateShortUID()] = util.GenerateShortUID() + } + } + + var dashUID *string = nil + var panelID *int64 = nil + if rand.Int63()%2 == 0 { + d := util.GenerateShortUID() + dashUID = &d + p := rand.Int63() + panelID = &p + } + + rule := &AlertRule{ + ID: rand.Int63(), + OrgID: rand.Int63(), + Title: "TEST-ALERT-" + util.GenerateShortUID(), + Condition: "A", + Data: []AlertQuery{ + { + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "datasourceUid": "-100", + "type":"math", + "expression":"2 + 1 < 1" + }`), + RelativeTimeRange: RelativeTimeRange{ + From: Duration(5 * time.Hour), + To: Duration(3 * time.Hour), + }, + RefID: "A", + }}, + Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), + IntervalSeconds: rand.Int63n(60) + 1, + Version: rand.Int63(), + UID: util.GenerateShortUID(), + NamespaceUID: util.GenerateShortUID(), + DashboardUID: dashUID, + PanelID: panelID, + RuleGroup: "TEST-GROUP-" + util.GenerateShortUID(), + NoDataState: randNoDataState(), + ExecErrState: randErrState(), + For: forInterval, + Annotations: annotations, + Labels: labels, + } + + for _, mutator := range mutators { + mutator(rule) + } + return rule + } +} + +// GenerateUniqueAlertRules generates many random alert rules and makes sure that they have unique UID. +// It returns a tuple where first element is a map where keys are UID of alert rule and the second element is a slice of the same rules +func GenerateUniqueAlertRules(count int, f func() *AlertRule) (map[string]*AlertRule, []*AlertRule) { + uIDs := make(map[string]*AlertRule, count) + result := make([]*AlertRule, 0, count) + for len(result) < count { + rule := f() + if _, ok := uIDs[rule.UID]; ok { + continue + } + result = append(result, rule) + uIDs[rule.UID] = rule + } + return uIDs, result +} + +// GenerateAlertRules generates many random alert rules. Does not guarantee that rules are unique (by UID) +func GenerateAlertRules(count int, f func() *AlertRule) []*AlertRule { + result := make([]*AlertRule, 0, count) + for len(result) < count { + rule := f() + result = append(result, rule) + } + return result +} diff --git a/pkg/services/ngalert/notifier/available_channels.go b/pkg/services/ngalert/notifier/available_channels.go index 5a5e8eeffaa..521548e53b5 100644 --- a/pkg/services/ngalert/notifier/available_channels.go +++ b/pkg/services/ngalert/notifier/available_channels.go @@ -459,6 +459,14 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { PropertyName: "url", Secure: true, }, + { // New in 8.4. + Label: "Endpoint URL", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Description: "Optionally provide a custom Slack message API endpoint for non-webhook requests, default is https://slack.com/api/chat.postMessage", + Placeholder: "Slack endpoint url", + PropertyName: "endpointUrl", + }, { // New in 8.0. Label: "Title", Element: alerting.ElementTypeInput, diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 66713312ed8..e77ef66afec 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -52,9 +52,11 @@ func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} } + endpointURL := model.Settings.Get("endpointUrl").MustString(SlackAPIEndpoint) + slackURL := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString()) if slackURL == "" { - slackURL = SlackAPIEndpoint + slackURL = endpointURL } apiURL, err := url.Parse(slackURL) if err != nil { @@ -62,7 +64,7 @@ func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn } recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString()) - if recipient == "" && apiURL.String() == SlackAPIEndpoint { + if recipient == "" && apiURL.String() == endpointURL { return nil, receiverInitError{Cfg: *model, Reason: "recipient must be specified when using the Slack chat API", } diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index ce6eef5bba8..c30b87bf64b 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -160,6 +160,42 @@ func TestSlackNotifier(t *testing.T) { }`, expInitError: `failed to validate receiver "slack_testing" of type "slack": recipient must be specified when using the Slack chat API`, }, + { + name: "Custom endpoint url", + settings: `{ + "token": "1234", + "recipient": "#testchannel", + "endpointUrl": "https://slack-custom.com/api/", + "icon_emoji": ":emoji:" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, + }, + expMsg: &slackMessage{ + Channel: "#testchannel", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/assets/img/fav32.png", + Color: "#D63232", + Ts: 0, + }, + }, + }, + expMsgError: nil, + }, } for _, c := range cases { @@ -196,6 +232,12 @@ func TestSlackNotifier(t *testing.T) { _ = request.Body.Close() }() + url := settingsJSON.Get("url").MustString() + if len(url) == 0 { + endpointUrl := settingsJSON.Get("endpointUrl").MustString(SlackAPIEndpoint) + require.Equal(t, endpointUrl, request.URL.String()) + } + b, err := io.ReadAll(request.Body) require.NoError(t, err) body = string(b) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index dead82108a1..c3d2fe61cff 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -2,9 +2,7 @@ package store import ( "context" - "errors" "fmt" - "strconv" "strings" "time" @@ -50,7 +48,7 @@ type RuleStore interface { GetNamespaceByTitle(context.Context, string, int64, *models.SignedInUser, bool) (*models.Folder, error) GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error UpsertAlertRules(ctx context.Context, rule []UpsertRule) error - UpdateRuleGroup(ctx context.Context, cmd UpdateRuleGroupCmd) error + InTransaction(ctx context.Context, f func(ctx context.Context) error) error } func getAlertRuleByUID(sess *sqlstore.DBSession, alertRuleUID string, orgID int64) (*ngmodels.AlertRule, error) { @@ -188,18 +186,6 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro newRules := make([]ngmodels.AlertRule, 0, len(rules)) ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules)) for _, r := range rules { - if r.Existing == nil && r.New.UID != "" { - // check by UID - existingAlertRule, err := getAlertRuleByUID(sess, r.New.UID, r.New.OrgID) - if err != nil { - if errors.Is(err, ngmodels.ErrAlertRuleNotFound) { - return fmt.Errorf("failed to get alert rule %s: %w", r.New.UID, err) - } - return err - } - r.Existing = existingAlertRule - } - var parentVersion int64 switch r.Existing { case nil: // new rule @@ -208,23 +194,8 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.New.Title, err) } r.New.UID = uid - - if r.New.IntervalSeconds == 0 { - r.New.IntervalSeconds = int64(st.DefaultInterval.Seconds()) - } - r.New.Version = 1 - if r.New.NoDataState == "" { - // set default no data state - r.New.NoDataState = ngmodels.NoData - } - - if r.New.ExecErrState == "" { - // set default error state - r.New.ExecErrState = ngmodels.AlertingErrState - } - if err := st.validateAlertRule(r.New); err != nil { return err } @@ -232,37 +203,11 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro if err := (&r.New).PreSave(TimeNow); err != nil { return err } - newRules = append(newRules, r.New) default: - // explicitly set the existing properties if missing - // do not rely on xorm - if r.New.Title == "" { - r.New.Title = r.Existing.Title - } - - if r.New.Condition == "" { - r.New.Condition = r.Existing.Condition - } - - if len(r.New.Data) == 0 { - r.New.Data = r.Existing.Data - } - r.New.ID = r.Existing.ID - r.New.OrgID = r.Existing.OrgID - r.New.NamespaceUID = r.Existing.NamespaceUID - r.New.RuleGroup = r.Existing.RuleGroup r.New.Version = r.Existing.Version + 1 - if r.New.ExecErrState == "" { - r.New.ExecErrState = r.Existing.ExecErrState - } - - if r.New.NoDataState == "" { - r.New.NoDataState = r.Existing.NoDataState - } - if err := st.validateAlertRule(r.New); err != nil { return err } @@ -273,9 +218,11 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro // no way to update multiple rules at once if _, err := sess.ID(r.Existing.ID).AllCols().Update(r.New); err != nil { - return fmt.Errorf("failed to update rule %s: %w", r.New.Title, err) + if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { + return ngmodels.ErrAlertRuleUniqueConstraintViolation + } + return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err) } - parentVersion = r.Existing.Version } @@ -301,6 +248,9 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro if len(newRules) > 0 { if _, err := sess.Insert(&newRules); err != nil { + if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { + return ngmodels.ErrAlertRuleUniqueConstraintViolation + } return fmt.Errorf("failed to create new rules: %w", err) } } @@ -506,100 +456,6 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error { return nil } -// UpdateRuleGroup creates new rules and updates and/or deletes existing rules -func (st DBstore) UpdateRuleGroup(ctx context.Context, cmd UpdateRuleGroupCmd) error { - return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { - ruleGroup := cmd.RuleGroupConfig.Name - q := &ngmodels.ListRuleGroupAlertRulesQuery{ - OrgID: cmd.OrgID, - NamespaceUID: cmd.NamespaceUID, - RuleGroup: ruleGroup, - } - if err := st.GetRuleGroupAlertRules(ctx, q); err != nil { - return err - } - existingGroupRules := q.Result - - existingGroupRulesUIDs := make(map[string]ngmodels.AlertRule, len(existingGroupRules)) - for _, r := range existingGroupRules { - existingGroupRulesUIDs[r.UID] = *r - } - - upsertRules := make([]UpsertRule, 0) - for _, r := range cmd.RuleGroupConfig.Rules { - if r.GrafanaManagedAlert == nil { - continue - } - - newAlertRule := ngmodels.AlertRule{ - OrgID: cmd.OrgID, - Title: r.GrafanaManagedAlert.Title, - Condition: r.GrafanaManagedAlert.Condition, - Data: r.GrafanaManagedAlert.Data, - UID: r.GrafanaManagedAlert.UID, - IntervalSeconds: int64(time.Duration(cmd.RuleGroupConfig.Interval).Seconds()), - NamespaceUID: cmd.NamespaceUID, - RuleGroup: ruleGroup, - NoDataState: ngmodels.NoDataState(r.GrafanaManagedAlert.NoDataState), - ExecErrState: ngmodels.ExecutionErrorState(r.GrafanaManagedAlert.ExecErrState), - } - - if r.ApiRuleNode != nil { - newAlertRule.For = time.Duration(r.ApiRuleNode.For) - newAlertRule.Annotations = r.ApiRuleNode.Annotations - newAlertRule.Labels = r.ApiRuleNode.Labels - } - - if s := newAlertRule.Annotations[ngmodels.DashboardUIDAnnotation]; s != "" { - newAlertRule.DashboardUID = &s - } - - if s := newAlertRule.Annotations[ngmodels.PanelIDAnnotation]; s != "" { - panelID, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return fmt.Errorf("the %s annotation does not contain a valid Panel ID: %w", ngmodels.PanelIDAnnotation, err) - } - newAlertRule.PanelID = &panelID - } - - upsertRule := UpsertRule{ - New: newAlertRule, - } - - if existingGroupRule, ok := existingGroupRulesUIDs[r.GrafanaManagedAlert.UID]; ok { - upsertRule.Existing = &existingGroupRule - // remove the rule from existingGroupRulesUIDs - delete(existingGroupRulesUIDs, r.GrafanaManagedAlert.UID) - } - upsertRules = append(upsertRules, upsertRule) - } - - if err := st.UpsertAlertRules(ctx, upsertRules); err != nil { - if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { - return ngmodels.ErrAlertRuleUniqueConstraintViolation - } - return err - } - - // delete instances for rules that will not be removed - for _, rule := range existingGroupRules { - if _, ok := existingGroupRulesUIDs[rule.UID]; !ok { - if err := st.DeleteAlertInstancesByRuleUID(ctx, cmd.OrgID, rule.UID); err != nil { - return err - } - } - } - - // delete the remaining rules - for ruleUID := range existingGroupRulesUIDs { - if err := st.DeleteAlertRuleByUID(ctx, cmd.OrgID, ruleUID); err != nil { - return err - } - } - return nil - }) -} - func (st DBstore) GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error { return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { var ruleGroups [][]string @@ -646,3 +502,7 @@ WHERE org_id = ?` return nil }) } + +func (st *DBstore) InTransaction(ctx context.Context, f func(ctx context.Context) error) error { + return st.SQLStore.InTransaction(ctx, f) +} diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index d2391da0fac..d2cdef6a14a 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -42,11 +42,32 @@ type FakeRuleStore struct { } // PutRule puts the rule in the Rules map. If there are existing rule in the same namespace, they will be overwritten -func (f *FakeRuleStore) PutRule(_ context.Context, r *models.AlertRule) { +func (f *FakeRuleStore) PutRule(_ context.Context, rules ...*models.AlertRule) { f.mtx.Lock() defer f.mtx.Unlock() - f.Rules[r.OrgID][r.RuleGroup][r.NamespaceUID] = []*models.AlertRule{ - r, +mainloop: + for _, r := range rules { + rgs, ok := f.Rules[r.OrgID] + if !ok { + f.Rules[r.OrgID] = map[string]map[string][]*models.AlertRule{} + } + + rg, ok := rgs[r.RuleGroup] + if !ok { + f.Rules[r.OrgID][r.RuleGroup] = map[string][]*models.AlertRule{} + } + + _, ok = rg[r.NamespaceUID] + if !ok { + f.Rules[r.OrgID][r.RuleGroup][r.NamespaceUID] = []*models.AlertRule{} + } + for idx, rulePtr := range f.Rules[r.OrgID][r.RuleGroup][r.NamespaceUID] { + if rulePtr.UID == r.UID { + f.Rules[r.OrgID][r.RuleGroup][r.NamespaceUID][idx] = r + continue mainloop + } + } + f.Rules[r.OrgID][r.RuleGroup][r.NamespaceUID] = append(f.Rules[r.OrgID][r.RuleGroup][r.NamespaceUID], r) } } @@ -256,6 +277,10 @@ func (f *FakeRuleStore) UpdateRuleGroup(_ context.Context, cmd UpdateRuleGroupCm return nil } +func (f *FakeRuleStore) InTransaction(ctx context.Context, fn func(c context.Context) error) error { + return fn(ctx) +} + type FakeInstanceStore struct { mtx sync.Mutex RecordedOps []interface{} diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index b0af3a96e34..d8ab0e11a85 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -12,7 +12,6 @@ import ( databasestore "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/ngalert" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -23,7 +22,6 @@ import ( "github.com/grafana/grafana/pkg/util" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/common/model" "github.com/stretchr/testify/require" ) @@ -59,36 +57,33 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * // CreateTestAlertRule creates a dummy alert definition to be used by the tests. func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64) *models.AlertRule { ruleGroup := fmt.Sprintf("ruleGroup-%s", util.GenerateShortUID()) - err := dbstore.UpdateRuleGroup(ctx, store.UpdateRuleGroupCmd{ - OrgID: orgID, - NamespaceUID: "namespace", - RuleGroupConfig: apimodels.PostableRuleGroupConfig{ - Name: ruleGroup, - Interval: model.Duration(time.Duration(intervalSeconds) * time.Second), - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: fmt.Sprintf("an alert definition %s", util.GenerateShortUID()), - Condition: "A", - Data: []models.AlertQuery{ - { - Model: json.RawMessage(`{ + err := dbstore.UpsertAlertRules(ctx, []store.UpsertRule{ + { + New: models.AlertRule{ + ID: 0, + OrgID: orgID, + Title: fmt.Sprintf("an alert definition %s", util.GenerateShortUID()), + Condition: "A", + Data: []models.AlertQuery{ + { + Model: json.RawMessage(`{ "datasourceUid": "-100", "type":"math", "expression":"2 + 2 > 1" }`), - RelativeTimeRange: models.RelativeTimeRange{ - From: models.Duration(5 * time.Hour), - To: models.Duration(3 * time.Hour), - }, - RefID: "A", - }, + RelativeTimeRange: models.RelativeTimeRange{ + From: models.Duration(5 * time.Hour), + To: models.Duration(3 * time.Hour), }, + RefID: "A", }, }, + Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + IntervalSeconds: intervalSeconds, + NamespaceUID: "namespace", + RuleGroup: ruleGroup, + NoDataState: models.NoData, + ExecErrState: models.AlertingErrState, }, }, }) @@ -107,38 +102,3 @@ func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBsto t.Logf("alert definition: %v with title: %q interval: %d created", rule.GetKey(), rule.Title, rule.IntervalSeconds) return rule } - -// updateTestAlertRule update a dummy alert definition to be used by the tests. -func UpdateTestAlertRuleIntervalSeconds(t *testing.T, ctx context.Context, dbstore *store.DBstore, existingRule *models.AlertRule, intervalSeconds int64) *models.AlertRule { - cmd := store.UpdateRuleGroupCmd{ - OrgID: 1, - NamespaceUID: "namespace", - RuleGroupConfig: apimodels.PostableRuleGroupConfig{ - Name: existingRule.RuleGroup, - Interval: model.Duration(time.Duration(intervalSeconds) * time.Second), - Rules: []apimodels.PostableExtendedRuleNode{ - { - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: existingRule.UID, - }, - }, - }, - }, - } - - err := dbstore.UpdateRuleGroup(ctx, cmd) - require.NoError(t, err) - - q := models.ListRuleGroupAlertRulesQuery{ - OrgID: 1, - NamespaceUID: "namespace", - RuleGroup: existingRule.RuleGroup, - } - err = dbstore.GetRuleGroupAlertRules(ctx, &q) - require.NoError(t, err) - require.NotEmpty(t, q.Result) - - rule := q.Result[0] - t.Logf("alert definition: %v with title: %s and interval: %d created", rule.GetKey(), rule.Title, rule.IntervalSeconds) - return rule -} diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index d6dc0c3a2cc..ab65d1f8539 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -14,8 +14,9 @@ import ( ) type configReader struct { - path string - log log.Logger + path string + log log.Logger + orgStore utils.OrgStore } func (cr *configReader) parseConfigs(file os.FileInfo) ([]*config, error) { @@ -93,7 +94,7 @@ func (cr *configReader) readConfig(ctx context.Context) ([]*config, error) { dashboard.OrgID = 1 } - if err := utils.CheckOrgExists(ctx, dashboard.OrgID); err != nil { + if err := utils.CheckOrgExists(ctx, cr.orgStore, dashboard.OrgID); err != nil { return nil, fmt.Errorf("failed to provision dashboards with %q reader: %w", dashboard.Name, err) } diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index 1af40e82853..7d04ae7d2d6 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -25,10 +25,10 @@ var ( func TestDashboardsAsConfig(t *testing.T) { t.Run("Dashboards as configuration", func(t *testing.T) { logger := log.New("test-logger") - sqlstore.InitTestDB(t) + store := sqlstore.InitTestDB(t) t.Run("Should fail if orgs don't exist in the database", func(t *testing.T) { - cfgProvider := configReader{path: appliedDefaults, log: logger} + cfgProvider := configReader{path: appliedDefaults, log: logger, orgStore: store} _, err := cfgProvider.readConfig(context.Background()) require.Error(t, err) assert.True(t, errors.Is(err, models.ErrOrgNotFound)) @@ -41,7 +41,7 @@ func TestDashboardsAsConfig(t *testing.T) { } t.Run("default values should be applied", func(t *testing.T) { - cfgProvider := configReader{path: appliedDefaults, log: logger} + cfgProvider := configReader{path: appliedDefaults, log: logger, orgStore: store} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -52,7 +52,7 @@ func TestDashboardsAsConfig(t *testing.T) { t.Run("Can read config file version 1 format", func(t *testing.T) { _ = os.Setenv("TEST_VAR", "general") - cfgProvider := configReader{path: simpleDashboardConfig, log: logger} + cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgStore: store} cfg, err := cfgProvider.readConfig(context.Background()) _ = os.Unsetenv("TEST_VAR") require.NoError(t, err) @@ -61,7 +61,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Can read config file in version 0 format", func(t *testing.T) { - cfgProvider := configReader{path: oldVersion, log: logger} + cfgProvider := configReader{path: oldVersion, log: logger, orgStore: store} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -69,7 +69,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Should skip invalid path", func(t *testing.T) { - cfgProvider := configReader{path: "/invalid-directory", log: logger} + cfgProvider := configReader{path: "/invalid-directory", log: logger, orgStore: store} cfg, err := cfgProvider.readConfig(context.Background()) if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -79,7 +79,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Should skip broken config files", func(t *testing.T) { - cfgProvider := configReader{path: brokenConfigs, log: logger} + cfgProvider := configReader{path: brokenConfigs, log: logger, orgStore: store} cfg, err := cfgProvider.readConfig(context.Background()) if err != nil { t.Fatalf("readConfig return an error %v", err) diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 660d33fa8bb..e34c3a0b25e 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -5,10 +5,10 @@ import ( "fmt" "os" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -23,7 +23,7 @@ type DashboardProvisioner interface { } // DashboardProvisionerFactory creates DashboardProvisioners based on input -type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService) (DashboardProvisioner, error) +type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, utils.OrgStore) (DashboardProvisioner, error) // Provisioner is responsible for syncing dashboard from disk to Grafana's database. type Provisioner struct { @@ -31,18 +31,19 @@ type Provisioner struct { fileReaders []*FileReader configs []*config duplicateValidator duplicateValidator + provisioner dashboards.DashboardProvisioningService } // New returns a new DashboardProvisioner -func New(ctx context.Context, configDirectory string, service dashboards.DashboardProvisioningService) (DashboardProvisioner, error) { +func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgStore utils.OrgStore) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") - cfgReader := &configReader{path: configDirectory, log: logger} + cfgReader := &configReader{path: configDirectory, log: logger, orgStore: orgStore} configs, err := cfgReader.readConfig(ctx) if err != nil { return nil, errutil.Wrap("Failed to read dashboards config", err) } - fileReaders, err := getFileReaders(configs, logger, service) + fileReaders, err := getFileReaders(configs, logger, provisioner) if err != nil { return nil, errutil.Wrap("Failed to initialize file readers", err) } @@ -52,6 +53,7 @@ func New(ctx context.Context, configDirectory string, service dashboards.Dashboa fileReaders: fileReaders, configs: configs, duplicateValidator: newDuplicateValidator(logger, fileReaders), + provisioner: provisioner, } return d, nil @@ -84,7 +86,7 @@ func (provider *Provisioner) CleanUpOrphanedDashboards(ctx context.Context) { currentReaders[index] = reader.Cfg.Name } - if err := bus.Dispatch(ctx, &models.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: currentReaders}); err != nil { + if err := provider.provisioner.DeleteOrphanedProvisionedDashboards(ctx, &models.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: currentReaders}); err != nil { provider.log.Warn("Failed to delete orphaned provisioned dashboards", "err", err) } } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 2b84b13dc62..59755ecd14e 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -138,7 +138,7 @@ func (fr *FileReader) isDatabaseAccessRestricted() bool { // storeDashboardsInFolder saves dashboards from the filesystem on disk to the folder from config func (fr *FileReader) storeDashboardsInFolder(ctx context.Context, filesFoundOnDisk map[string]os.FileInfo, dashboardRefs map[string]*models.DashboardProvisioning, usageTracker *usageTracker) error { - folderID, err := getOrCreateFolderID(ctx, fr.Cfg, fr.dashboardProvisioningService, fr.Cfg.Folder) + folderID, err := fr.getOrCreateFolderID(ctx, fr.Cfg, fr.dashboardProvisioningService, fr.Cfg.Folder) if err != nil && !errors.Is(err, ErrFolderNameMissing) { return err } @@ -168,7 +168,7 @@ func (fr *FileReader) storeDashboardsInFoldersFromFileStructure(ctx context.Cont folderName = filepath.Base(dashboardsFolder) } - folderID, err := getOrCreateFolderID(ctx, fr.Cfg, fr.dashboardProvisioningService, folderName) + folderID, err := fr.getOrCreateFolderID(ctx, fr.Cfg, fr.dashboardProvisioningService, folderName) if err != nil && !errors.Is(err, ErrFolderNameMissing) { return fmt.Errorf("can't provision folder %q from file system structure: %w", folderName, err) } @@ -290,7 +290,7 @@ func getProvisionedDashboardsByPath(service dashboards.DashboardProvisioningServ return byPath, nil } -func getOrCreateFolderID(ctx context.Context, cfg *config, service dashboards.DashboardProvisioningService, folderName string) (int64, error) { +func (fr *FileReader) getOrCreateFolderID(ctx context.Context, cfg *config, service dashboards.DashboardProvisioningService, folderName string) (int64, error) { if folderName == "" { return 0, ErrFolderNameMissing } diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 810b3a8d284..1c2def3a342 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -364,8 +364,10 @@ func TestDashboardFileReader(t *testing.T) { "folder": defaultDashboards, }, } + r, err := NewDashboardFileReader(cfg, logger, nil) + require.NoError(t, err) - _, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) + _, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) require.Equal(t, err, ErrFolderNameMissing) }) @@ -380,9 +382,12 @@ func TestDashboardFileReader(t *testing.T) { "folder": defaultDashboards, }, } + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once() - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() - _, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) + r, err := NewDashboardFileReader(cfg, logger, nil) + require.NoError(t, err) + + _, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) require.NoError(t, err) }) diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index 040e4699b45..e571ca2dbfe 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -29,18 +29,18 @@ func TestDuplicatesValidator(t *testing.T) { Type: "file", OrgID: 1, Folder: "", - Options: map[string]interface{}{}, + Options: map[string]interface{}{"path": dashboardContainingUID}, } logger := log.New("test.logger") t.Run("Duplicates validator should collect info about duplicate UIDs and titles within folders", func(t *testing.T) { const folderName = "duplicates-validator-folder" - - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3) - fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(2) - fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) - - folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) + r, err := NewDashboardFileReader(cfg, logger, nil) + require.NoError(t, err) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(6) + fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(4) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5) + folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) require.NoError(t, err) identity := dashboardIdentity{folderID: folderID, title: "Grafana"} @@ -89,12 +89,9 @@ func TestDuplicatesValidator(t *testing.T) { t.Run("Duplicates validator should not collect info about duplicate UIDs and titles within folders for different orgs", func(t *testing.T) { const folderName = "duplicates-validator-folder" - - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3) - fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(2) - fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) - - folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) + r, err := NewDashboardFileReader(cfg, logger, nil) + require.NoError(t, err) + folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) require.NoError(t, err) identity := dashboardIdentity{folderID: folderID, title: "Grafana"} @@ -154,7 +151,7 @@ func TestDuplicatesValidator(t *testing.T) { t.Run("Duplicates validator should restrict write access only for readers with duplicates", func(t *testing.T) { fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5) fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(3) - fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(6) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5) cfg1 := &config{ Name: "first", Type: "file", OrgID: 1, Folder: "duplicates-validator-folder", @@ -194,7 +191,9 @@ func TestDuplicatesValidator(t *testing.T) { duplicates := duplicateValidator.getDuplicates() - folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg1.Folder) + r, err := NewDashboardFileReader(cfg, logger, nil) + require.NoError(t, err) + folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg1.Folder) require.NoError(t, err) identity := dashboardIdentity{folderID: folderID, title: "Grafana"} @@ -209,7 +208,9 @@ func TestDuplicatesValidator(t *testing.T) { sort.Strings(titleUsageReaders) require.Equal(t, []string{"first"}, titleUsageReaders) - folderID, err = getOrCreateFolderID(context.Background(), cfg3, fakeService, cfg3.Folder) + r, err = NewDashboardFileReader(cfg3, logger, nil) + require.NoError(t, err) + folderID, err = r.getOrCreateFolderID(context.Background(), cfg3, fakeService, cfg3.Folder) require.NoError(t, err) identity = dashboardIdentity{folderID: folderID, title: "Grafana"} diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 243c2f726ab..eb1bb03e9cc 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -16,7 +16,8 @@ import ( ) type configReader struct { - log log.Logger + log log.Logger + orgStore utils.OrgStore } func (cr *configReader) readConfig(ctx context.Context, path string) ([]*configs, error) { @@ -129,7 +130,7 @@ func (cr *configReader) validateDefaultUniqueness(ctx context.Context, datasourc } func (cr *configReader) validateAccessAndOrgID(ctx context.Context, ds *upsertDataSourceFromConfig) error { - if err := utils.CheckOrgExists(ctx, ds.OrgID); err != nil { + if err := utils.CheckOrgExists(ctx, cr.orgStore, ds.OrgID); err != nil { return err } diff --git a/pkg/services/provisioning/datasources/config_reader_test.go b/pkg/services/provisioning/datasources/config_reader_test.go index 242d97cc9c4..fb0811f540b 100644 --- a/pkg/services/provisioning/datasources/config_reader_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -5,7 +5,6 @@ import ( "os" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" @@ -26,173 +25,133 @@ var ( multipleOrgsWithDefault = "testdata/multiple-org-default" withoutDefaults = "testdata/appliedDefaults" invalidAccess = "testdata/invalid-access" - - fakeRepo *fakeRepository ) func TestDatasourceAsConfig(t *testing.T) { - setup := func() { - fakeRepo = &fakeRepository{} - bus.ClearBusHandlers() - bus.AddHandler("test", mockDelete) - bus.AddHandler("test", mockInsert) - bus.AddHandler("test", mockUpdate) - bus.AddHandler("test", mockGet) - bus.AddHandler("test", mockGetOrg) - } + t.Run("when some values missing should apply default on insert", func(t *testing.T) { + store := &spyStore{} + orgStore := &mockOrgStore{ExpectedOrg: &models.Org{Id: 1}} + dc := newDatasourceProvisioner(logger, store, orgStore) + err := dc.applyChanges(context.Background(), withoutDefaults) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } - t.Run("when some values missing", func(t *testing.T) { - t.Run("should apply default on insert", func(t *testing.T) { - setup() - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), withoutDefaults) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } + require.Equal(t, len(store.inserted), 1) + require.Equal(t, store.inserted[0].OrgId, int64(1)) + require.Equal(t, store.inserted[0].Access, models.DsAccess("proxy")) + require.Equal(t, store.inserted[0].Name, "My datasource name") + require.Equal(t, store.inserted[0].Uid, "P2AD1F727255C56BA") + }) - require.Equal(t, len(fakeRepo.inserted), 1) - require.Equal(t, fakeRepo.inserted[0].OrgId, int64(1)) - require.Equal(t, fakeRepo.inserted[0].Access, models.DsAccess("proxy")) - require.Equal(t, fakeRepo.inserted[0].Name, "My datasource name") - require.Equal(t, fakeRepo.inserted[0].Uid, "P2AD1F727255C56BA") - }) + t.Run("when some values missing should not change UID when updates", func(t *testing.T) { + store := &spyStore{ + items: []*models.DataSource{{Name: "My datasource name", OrgId: 1, Id: 1, Uid: util.GenerateShortUID()}}, + } + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) + err := dc.applyChanges(context.Background(), withoutDefaults) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } - t.Run("should not change UID when updates", func(t *testing.T) { - setup() - - fakeRepo.loadAll = []*models.DataSource{ - {Name: "My datasource name", OrgId: 1, Id: 1, Uid: util.GenerateShortUID()}, - } - - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), withoutDefaults) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - - require.Equal(t, len(fakeRepo.deleted), 0) - require.Equal(t, len(fakeRepo.inserted), 0) - require.Equal(t, len(fakeRepo.updated), 1) - require.Equal(t, "", fakeRepo.updated[0].Uid) // XORM will not update the field if its value is default - }) + require.Equal(t, len(store.deleted), 0) + require.Equal(t, len(store.inserted), 0) + require.Equal(t, len(store.updated), 1) + require.Equal(t, "", store.updated[0].Uid) // XORM will not update the field if its value is default }) t.Run("no datasource in database", func(t *testing.T) { - setup() - dc := newDatasourceProvisioner(logger) + store := &spyStore{} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) err := dc.applyChanges(context.Background(), twoDatasourcesConfig) if err != nil { t.Fatalf("applyChanges return an error %v", err) } - require.Equal(t, len(fakeRepo.deleted), 0) - require.Equal(t, len(fakeRepo.inserted), 2) - require.Equal(t, len(fakeRepo.updated), 0) + require.Equal(t, len(store.deleted), 0) + require.Equal(t, len(store.inserted), 2) + require.Equal(t, len(store.updated), 0) }) - t.Run("One datasource in database with same name", func(t *testing.T) { - setup() - fakeRepo.loadAll = []*models.DataSource{ - {Name: "Graphite", OrgId: 1, Id: 1}, + t.Run("One datasource in database with same name should update one datasource", func(t *testing.T) { + store := &spyStore{items: []*models.DataSource{{Name: "Graphite", OrgId: 1, Id: 1}}} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) + err := dc.applyChanges(context.Background(), twoDatasourcesConfig) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) } - t.Run("should update one datasource", func(t *testing.T) { - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), twoDatasourcesConfig) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - - require.Equal(t, len(fakeRepo.deleted), 0) - require.Equal(t, len(fakeRepo.inserted), 1) - require.Equal(t, len(fakeRepo.updated), 1) - }) + require.Equal(t, len(store.deleted), 0) + require.Equal(t, len(store.inserted), 1) + require.Equal(t, len(store.updated), 1) }) - t.Run("Two datasources with is_default", func(t *testing.T) { - setup() - dc := newDatasourceProvisioner(logger) + t.Run("Two datasources with is_default should raise error", func(t *testing.T) { + store := &spyStore{} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) err := dc.applyChanges(context.Background(), doubleDatasourcesConfig) - t.Run("should raise error", func(t *testing.T) { require.Equal(t, err, ErrInvalidConfigToManyDefault) }) + require.Equal(t, err, ErrInvalidConfigToManyDefault) }) - t.Run("Multiple datasources in different organizations with isDefault in each organization", func(t *testing.T) { - setup() - dc := newDatasourceProvisioner(logger) + t.Run("Multiple datasources in different organizations with isDefault in each organization should not raise error", func(t *testing.T) { + store := &spyStore{} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) err := dc.applyChanges(context.Background(), multipleOrgsWithDefault) - t.Run("should not raise error", func(t *testing.T) { - require.NoError(t, err) - require.Equal(t, len(fakeRepo.inserted), 4) - require.True(t, fakeRepo.inserted[0].IsDefault) - require.Equal(t, fakeRepo.inserted[0].OrgId, int64(1)) - require.True(t, fakeRepo.inserted[2].IsDefault) - require.Equal(t, fakeRepo.inserted[2].OrgId, int64(2)) - }) + require.NoError(t, err) + require.Equal(t, len(store.inserted), 4) + require.True(t, store.inserted[0].IsDefault) + require.Equal(t, store.inserted[0].OrgId, int64(1)) + require.True(t, store.inserted[2].IsDefault) + require.Equal(t, store.inserted[2].OrgId, int64(2)) }) - t.Run("Remove one datasource", func(t *testing.T) { - setup() - t.Run("Remove one datasource", func(t *testing.T) { - fakeRepo.loadAll = []*models.DataSource{} + t.Run("Remove one datasource should have removed old datasource", func(t *testing.T) { + store := &spyStore{} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) + err := dc.applyChanges(context.Background(), deleteOneDatasource) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } - t.Run("should have removed old datasource", func(t *testing.T) { - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), deleteOneDatasource) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - - require.Equal(t, 1, len(fakeRepo.deleted)) - // should have set OrgID to 1 - require.Equal(t, fakeRepo.deleted[0].OrgID, int64(1)) - require.Equal(t, 0, len(fakeRepo.inserted)) - require.Equal(t, len(fakeRepo.updated), 0) - }) - }) + require.Equal(t, 1, len(store.deleted)) + // should have set OrgID to 1 + require.Equal(t, store.deleted[0].OrgID, int64(1)) + require.Equal(t, 0, len(store.inserted)) + require.Equal(t, len(store.updated), 0) }) - t.Run("Two configured datasource and purge others ", func(t *testing.T) { - setup() - t.Run("two other datasources in database", func(t *testing.T) { - fakeRepo.loadAll = []*models.DataSource{ - {Name: "old-graphite", OrgId: 1, Id: 1}, - {Name: "old-graphite2", OrgId: 1, Id: 2}, - } + t.Run("Two configured datasource and purge others", func(t *testing.T) { + store := &spyStore{items: []*models.DataSource{{Name: "old-graphite", OrgId: 1, Id: 1}, {Name: "old-graphite2", OrgId: 1, Id: 2}}} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) + err := dc.applyChanges(context.Background(), twoDatasourcesConfigPurgeOthers) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } - t.Run("should have two new datasources", func(t *testing.T) { - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), twoDatasourcesConfigPurgeOthers) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - - require.Equal(t, len(fakeRepo.deleted), 2) - require.Equal(t, len(fakeRepo.inserted), 2) - require.Equal(t, len(fakeRepo.updated), 0) - }) - }) + require.Equal(t, len(store.deleted), 2) + require.Equal(t, len(store.inserted), 2) + require.Equal(t, len(store.updated), 0) }) t.Run("Two configured datasource and purge others = false", func(t *testing.T) { - setup() - t.Run("two other datasources in database", func(t *testing.T) { - fakeRepo.loadAll = []*models.DataSource{ - {Name: "Graphite", OrgId: 1, Id: 1}, - {Name: "old-graphite2", OrgId: 1, Id: 2}, - } + store := &spyStore{items: []*models.DataSource{{Name: "Graphite", OrgId: 1, Id: 1}, {Name: "old-graphite2", OrgId: 1, Id: 2}}} + orgStore := &mockOrgStore{} + dc := newDatasourceProvisioner(logger, store, orgStore) + err := dc.applyChanges(context.Background(), twoDatasourcesConfig) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } - t.Run("should have two new datasources", func(t *testing.T) { - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), twoDatasourcesConfig) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - - require.Equal(t, len(fakeRepo.deleted), 0) - require.Equal(t, len(fakeRepo.inserted), 1) - require.Equal(t, len(fakeRepo.updated), 1) - }) - }) + require.Equal(t, len(store.deleted), 0) + require.Equal(t, len(store.inserted), 1) + require.Equal(t, len(store.updated), 1) }) t.Run("broken yaml should return error", func(t *testing.T) { @@ -202,14 +161,14 @@ func TestDatasourceAsConfig(t *testing.T) { }) t.Run("invalid access should warn about invalid value and return 'proxy'", func(t *testing.T) { - reader := &configReader{log: logger} + reader := &configReader{log: logger, orgStore: &mockOrgStore{}} configs, err := reader.readConfig(context.Background(), invalidAccess) require.NoError(t, err) require.Equal(t, configs[0].Datasources[0].Access, models.DS_ACCESS_PROXY) }) t.Run("skip invalid directory", func(t *testing.T) { - cfgProvider := &configReader{log: log.New("test logger")} + cfgProvider := &configReader{log: log.New("test logger"), orgStore: &mockOrgStore{}} cfg, err := cfgProvider.readConfig(context.Background(), "./invalid-directory") if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -220,7 +179,7 @@ func TestDatasourceAsConfig(t *testing.T) { t.Run("can read all properties from version 1", func(t *testing.T) { _ = os.Setenv("TEST_VAR", "name") - cfgProvider := &configReader{log: log.New("test logger")} + cfgProvider := &configReader{log: log.New("test logger"), orgStore: &mockOrgStore{}} cfg, err := cfgProvider.readConfig(context.Background(), allProperties) _ = os.Unsetenv("TEST_VAR") if err != nil { @@ -249,7 +208,7 @@ func TestDatasourceAsConfig(t *testing.T) { }) t.Run("can read all properties from version 0", func(t *testing.T) { - cfgProvider := &configReader{log: log.New("test logger")} + cfgProvider := &configReader{log: log.New("test logger"), orgStore: &mockOrgStore{}} cfg, err := cfgProvider.readConfig(context.Background(), versionZero) if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -308,40 +267,41 @@ func validateDatasourceV1(t *testing.T, dsCfg *configs) { require.Equal(t, ds.UID, "test_uid") } -type fakeRepository struct { +type mockOrgStore struct{ ExpectedOrg *models.Org } + +func (m *mockOrgStore) GetOrgById(c context.Context, cmd *models.GetOrgByIdQuery) error { + cmd.Result = m.ExpectedOrg + return nil +} + +type spyStore struct { inserted []*models.AddDataSourceCommand deleted []*models.DeleteDataSourceCommand updated []*models.UpdateDataSourceCommand - - loadAll []*models.DataSource + items []*models.DataSource } -func mockDelete(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { - fakeRepo.deleted = append(fakeRepo.deleted, cmd) - return nil -} - -func mockUpdate(ctx context.Context, cmd *models.UpdateDataSourceCommand) error { - fakeRepo.updated = append(fakeRepo.updated, cmd) - return nil -} - -func mockInsert(ctx context.Context, cmd *models.AddDataSourceCommand) error { - fakeRepo.inserted = append(fakeRepo.inserted, cmd) - return nil -} - -func mockGet(ctx context.Context, cmd *models.GetDataSourceQuery) error { - for _, v := range fakeRepo.loadAll { - if cmd.Name == v.Name && cmd.OrgId == v.OrgId { - cmd.Result = v +func (s *spyStore) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error { + for _, v := range s.items { + if query.Name == v.Name && query.OrgId == v.OrgId { + query.Result = v return nil } } - return models.ErrDataSourceNotFound } -func mockGetOrg(ctx context.Context, _ *models.GetOrgByIdQuery) error { +func (s *spyStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { + s.deleted = append(s.deleted, cmd) + return nil +} + +func (s *spyStore) AddDataSource(ctx context.Context, cmd *models.AddDataSourceCommand) error { + s.inserted = append(s.inserted, cmd) + return nil +} + +func (s *spyStore) UpdateDataSource(ctx context.Context, cmd *models.UpdateDataSourceCommand) error { + s.updated = append(s.updated, cmd) return nil } diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 329192343a4..2845b09d2e6 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -4,13 +4,19 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/models" ) +type Store interface { + GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error + AddDataSource(ctx context.Context, cmd *models.AddDataSourceCommand) error + UpdateDataSource(ctx context.Context, cmd *models.UpdateDataSourceCommand) error + DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error +} + var ( // ErrInvalidConfigToManyDefault indicates that multiple datasource in the provisioning files // contains more than one datasource marked as default. @@ -19,8 +25,8 @@ var ( // Provision scans a directory for provisioning config files // and provisions the datasource in those files. -func Provision(ctx context.Context, configDirectory string) error { - dc := newDatasourceProvisioner(log.New("provisioning.datasources")) +func Provision(ctx context.Context, configDirectory string, store Store, orgStore utils.OrgStore) error { + dc := newDatasourceProvisioner(log.New("provisioning.datasources"), store, orgStore) return dc.applyChanges(ctx, configDirectory) } @@ -29,12 +35,14 @@ func Provision(ctx context.Context, configDirectory string) error { type DatasourceProvisioner struct { log log.Logger cfgProvider *configReader + store Store } -func newDatasourceProvisioner(log log.Logger) DatasourceProvisioner { +func newDatasourceProvisioner(log log.Logger, store Store, orgStore utils.OrgStore) DatasourceProvisioner { return DatasourceProvisioner{ log: log, - cfgProvider: &configReader{log: log}, + cfgProvider: &configReader{log: log, orgStore: orgStore}, + store: store, } } @@ -45,7 +53,7 @@ func (dc *DatasourceProvisioner) apply(ctx context.Context, cfg *configs) error for _, ds := range cfg.Datasources { cmd := &models.GetDataSourceQuery{OrgId: ds.OrgID, Name: ds.Name} - err := bus.Dispatch(ctx, cmd) + err := dc.store.GetDataSource(ctx, cmd) if err != nil && !errors.Is(err, models.ErrDataSourceNotFound) { return err } @@ -53,13 +61,13 @@ func (dc *DatasourceProvisioner) apply(ctx context.Context, cfg *configs) error if errors.Is(err, models.ErrDataSourceNotFound) { insertCmd := createInsertCommand(ds) dc.log.Info("inserting datasource from configuration ", "name", insertCmd.Name, "uid", insertCmd.Uid) - if err := bus.Dispatch(ctx, insertCmd); err != nil { + if err := dc.store.AddDataSource(ctx, insertCmd); err != nil { return err } } else { updateCmd := createUpdateCommand(ds, cmd.Result.Id) dc.log.Debug("updating datasource from configuration", "name", updateCmd.Name, "uid", updateCmd.Uid) - if err := bus.Dispatch(ctx, updateCmd); err != nil { + if err := dc.store.UpdateDataSource(ctx, updateCmd); err != nil { return err } } @@ -86,7 +94,7 @@ func (dc *DatasourceProvisioner) applyChanges(ctx context.Context, configPath st func (dc *DatasourceProvisioner) deleteDatasources(ctx context.Context, dsToDelete []*deleteDatasourceConfig) error { for _, ds := range dsToDelete { cmd := &models.DeleteDataSourceCommand{OrgID: ds.OrgID, Name: ds.Name} - if err := bus.Dispatch(ctx, cmd); err != nil { + if err := dc.store.DeleteDataSource(ctx, cmd); err != nil { return err } diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go index dff39292842..3195837c72b 100644 --- a/pkg/services/provisioning/notifiers/alert_notifications.go +++ b/pkg/services/provisioning/notifiers/alert_notifications.go @@ -1,7 +1,6 @@ package notifiers import ( - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/encryption" @@ -9,9 +8,18 @@ import ( "golang.org/x/net/context" ) +type Store interface { + GetOrgById(c context.Context, cmd *models.GetOrgByIdQuery) error + GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error + GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) error + DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error + CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) error + UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) error +} + // Provision alert notifiers -func Provision(ctx context.Context, configDirectory string, encryptionService encryption.Internal, notificationService *notifications.NotificationService) error { - dc := newNotificationProvisioner(encryptionService, notificationService, log.New("provisioning.notifiers")) +func Provision(ctx context.Context, configDirectory string, store Store, encryptionService encryption.Internal, notificationService *notifications.NotificationService) error { + dc := newNotificationProvisioner(store, encryptionService, notificationService, log.New("provisioning.notifiers")) return dc.applyChanges(ctx, configDirectory) } @@ -19,15 +27,18 @@ func Provision(ctx context.Context, configDirectory string, encryptionService en type NotificationProvisioner struct { log log.Logger cfgProvider *configReader + store Store } -func newNotificationProvisioner(encryptionService encryption.Internal, notifiationService *notifications.NotificationService, log log.Logger) NotificationProvisioner { +func newNotificationProvisioner(store Store, encryptionService encryption.Internal, notifiationService *notifications.NotificationService, log log.Logger) NotificationProvisioner { return NotificationProvisioner{ - log: log, + log: log, + store: store, cfgProvider: &configReader{ encryptionService: encryptionService, notificationService: notifiationService, log: log, + orgStore: store, }, } } @@ -50,7 +61,7 @@ func (dc *NotificationProvisioner) deleteNotifications(ctx context.Context, noti if notification.OrgID == 0 && notification.OrgName != "" { getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName} - if err := bus.Dispatch(ctx, getOrg); err != nil { + if err := dc.store.GetOrgByNameHandler(ctx, getOrg); err != nil { return err } notification.OrgID = getOrg.Result.Id @@ -60,13 +71,13 @@ func (dc *NotificationProvisioner) deleteNotifications(ctx context.Context, noti getNotification := &models.GetAlertNotificationsWithUidQuery{Uid: notification.UID, OrgId: notification.OrgID} - if err := bus.Dispatch(ctx, getNotification); err != nil { + if err := dc.store.GetAlertNotificationsWithUid(ctx, getNotification); err != nil { return err } if getNotification.Result != nil { cmd := &models.DeleteAlertNotificationWithUidCommand{Uid: getNotification.Result.Uid, OrgId: getNotification.OrgId} - if err := bus.Dispatch(ctx, cmd); err != nil { + if err := dc.store.DeleteAlertNotificationWithUid(ctx, cmd); err != nil { return err } } @@ -79,7 +90,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif for _, notification := range notificationToMerge { if notification.OrgID == 0 && notification.OrgName != "" { getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName} - if err := bus.Dispatch(ctx, getOrg); err != nil { + if err := dc.store.GetOrgByNameHandler(ctx, getOrg); err != nil { return err } notification.OrgID = getOrg.Result.Id @@ -88,7 +99,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif } cmd := &models.GetAlertNotificationsWithUidQuery{OrgId: notification.OrgID, Uid: notification.UID} - err := bus.Dispatch(ctx, cmd) + err := dc.store.GetAlertNotificationsWithUid(ctx, cmd) if err != nil { return err } @@ -108,7 +119,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif SendReminder: notification.SendReminder, } - if err := bus.Dispatch(ctx, insertCmd); err != nil { + if err := dc.store.CreateAlertNotificationCommand(ctx, insertCmd); err != nil { return err } } else { @@ -126,7 +137,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif SendReminder: notification.SendReminder, } - if err := bus.Dispatch(ctx, updateCmd); err != nil { + if err := dc.store.UpdateAlertNotificationWithUid(ctx, updateCmd); err != nil { return err } } diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go index f5680db8cc3..b502c3872e7 100644 --- a/pkg/services/provisioning/notifiers/config_reader.go +++ b/pkg/services/provisioning/notifiers/config_reader.go @@ -21,6 +21,7 @@ import ( type configReader struct { encryptionService encryption.Internal notificationService *notifications.NotificationService + orgStore utils.OrgStore log log.Logger } @@ -93,7 +94,7 @@ func (cr *configReader) checkOrgIDAndOrgName(ctx context.Context, notifications notification.OrgID = 0 } } else { - if err := utils.CheckOrgExists(ctx, notification.OrgID); err != nil { + if err := utils.CheckOrgExists(ctx, cr.orgStore, notification.OrgID); err != nil { return fmt.Errorf("failed to provision %q notification: %w", notification.Name, err) } } diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go index 984421265ce..7e8a0dd760b 100644 --- a/pkg/services/provisioning/notifiers/config_reader_test.go +++ b/pkg/services/provisioning/notifiers/config_reader_test.go @@ -62,6 +62,7 @@ func TestNotificationAsConfig(t *testing.T) { setup() _ = os.Setenv("TEST_VAR", "default") cfgProvider := &configReader{ + orgStore: sqlStore, encryptionService: ossencryption.ProvideService(), log: log.New("test logger"), } @@ -139,7 +140,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("One configured notification", func(t *testing.T) { t.Run("no notification in database", func(t *testing.T) { setup() - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), twoNotificationsConfig) if err != nil { @@ -170,7 +171,7 @@ func TestNotificationAsConfig(t *testing.T) { require.Equal(t, len(notificationsQuery.Result), 1) t.Run("should update one notification", func(t *testing.T) { - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err = dc.applyChanges(context.Background(), twoNotificationsConfig) if err != nil { t.Fatalf("applyChanges return an error %v", err) @@ -194,7 +195,7 @@ func TestNotificationAsConfig(t *testing.T) { }) t.Run("Two notifications with is_default", func(t *testing.T) { setup() - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), doubleNotificationsConfig) t.Run("should both be inserted", func(t *testing.T) { require.NoError(t, err) @@ -237,7 +238,7 @@ func TestNotificationAsConfig(t *testing.T) { require.Equal(t, len(notificationsQuery.Result), 2) t.Run("should have two new notifications", func(t *testing.T) { - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), twoNotificationsConfig) if err != nil { t.Fatalf("applyChanges return an error %v", err) @@ -254,11 +255,11 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Can read correct properties with orgName instead of orgId", func(t *testing.T) { setup() existingOrg1 := models.GetOrgByNameQuery{Name: "Main Org. 1"} - err := sqlstore.GetOrgByName(context.Background(), &existingOrg1) + err := sqlStore.GetOrgByNameHandler(context.Background(), &existingOrg1) require.NoError(t, err) require.NotNil(t, existingOrg1.Result) existingOrg2 := models.GetOrgByNameQuery{Name: "Main Org. 2"} - err = sqlstore.GetOrgByName(context.Background(), &existingOrg2) + err = sqlStore.GetOrgByNameHandler(context.Background(), &existingOrg2) require.NoError(t, err) require.NotNil(t, existingOrg2.Result) @@ -271,7 +272,7 @@ func TestNotificationAsConfig(t *testing.T) { err = sqlStore.CreateAlertNotificationCommand(context.Background(), &existingNotificationCmd) require.NoError(t, err) - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err = dc.applyChanges(context.Background(), correctPropertiesWithOrgName) if err != nil { t.Fatalf("applyChanges return an error %v", err) @@ -290,7 +291,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Config doesn't contain required field", func(t *testing.T) { setup() - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), noRequiredFields) require.NotNil(t, err) @@ -304,7 +305,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Empty yaml file", func(t *testing.T) { t.Run("should have not changed repo", func(t *testing.T) { setup() - dc := newNotificationProvisioner(ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), emptyFile) if err != nil { t.Fatalf("applyChanges return an error %v", err) @@ -318,6 +319,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Broken yaml should return error", func(t *testing.T) { reader := &configReader{ + orgStore: sqlStore, encryptionService: ossencryption.ProvideService(), log: log.New("test logger"), } @@ -328,6 +330,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Skip invalid directory", func(t *testing.T) { cfgProvider := &configReader{ + orgStore: sqlStore, encryptionService: ossencryption.ProvideService(), log: log.New("test logger"), } @@ -341,6 +344,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Unknown notifier should return error", func(t *testing.T) { cfgProvider := &configReader{ + orgStore: sqlStore, encryptionService: ossencryption.ProvideService(), log: log.New("test logger"), } @@ -351,6 +355,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Read incorrect properties", func(t *testing.T) { cfgProvider := &configReader{ + orgStore: sqlStore, encryptionService: ossencryption.ProvideService(), log: log.New("test logger"), } @@ -363,7 +368,7 @@ func TestNotificationAsConfig(t *testing.T) { func setupBusHandlers(sqlStore *sqlstore.SQLStore) { bus.AddHandler("getOrg", func(ctx context.Context, q *models.GetOrgByNameQuery) error { - return sqlstore.GetOrgByName(ctx, q) + return sqlStore.GetOrgByNameHandler(ctx, q) }) bus.AddHandler("getAlertNotifications", func(ctx context.Context, q *models.GetAlertNotificationsWithUidQuery) error { diff --git a/pkg/services/provisioning/plugins/mocks/Store.go b/pkg/services/provisioning/plugins/mocks/Store.go new file mode 100644 index 00000000000..c32d458b1b4 --- /dev/null +++ b/pkg/services/provisioning/plugins/mocks/Store.go @@ -0,0 +1,57 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/grafana/grafana/pkg/models" + mock "github.com/stretchr/testify/mock" +) + +// Store is an autogenerated mock type for the Store type +type Store struct { + mock.Mock +} + +// GetOrgByNameHandler provides a mock function with given fields: ctx, query +func (_m *Store) GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error { + ret := _m.Called(ctx, query) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.GetOrgByNameQuery) error); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetPluginSettingById provides a mock function with given fields: ctx, query +func (_m *Store) GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { + ret := _m.Called(ctx, query) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.GetPluginSettingByIdQuery) error); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdatePluginSetting provides a mock function with given fields: ctx, cmd +func (_m *Store) UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error { + ret := _m.Called(ctx, cmd) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdatePluginSettingCmd) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/services/provisioning/plugins/plugin_provisioner.go b/pkg/services/provisioning/plugins/plugin_provisioner.go index 0093f0baddd..d2ae059f0e3 100644 --- a/pkg/services/provisioning/plugins/plugin_provisioner.go +++ b/pkg/services/provisioning/plugins/plugin_provisioner.go @@ -4,19 +4,25 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ) +type Store interface { + GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error + GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error + UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error +} + // Provision scans a directory for provisioning config files // and provisions the app in those files. -func Provision(ctx context.Context, configDirectory string, pluginStore plugins.Store) error { +func Provision(ctx context.Context, configDirectory string, store Store, pluginStore plugins.Store) error { logger := log.New("provisioning.plugins") ap := PluginProvisioner{ log: logger, cfgProvider: newConfigReader(logger, pluginStore), + store: store, } return ap.applyChanges(ctx, configDirectory) } @@ -26,13 +32,14 @@ func Provision(ctx context.Context, configDirectory string, pluginStore plugins. type PluginProvisioner struct { log log.Logger cfgProvider configReader + store Store } func (ap *PluginProvisioner) apply(ctx context.Context, cfg *pluginsAsConfig) error { for _, app := range cfg.Apps { if app.OrgID == 0 && app.OrgName != "" { getOrgQuery := &models.GetOrgByNameQuery{Name: app.OrgName} - if err := bus.Dispatch(ctx, getOrgQuery); err != nil { + if err := ap.store.GetOrgByNameHandler(ctx, getOrgQuery); err != nil { return err } app.OrgID = getOrgQuery.Result.Id @@ -41,7 +48,7 @@ func (ap *PluginProvisioner) apply(ctx context.Context, cfg *pluginsAsConfig) er } query := &models.GetPluginSettingByIdQuery{OrgId: app.OrgID, PluginId: app.PluginID} - err := bus.Dispatch(ctx, query) + err := ap.store.GetPluginSettingById(ctx, query) if err != nil { if !errors.Is(err, models.ErrPluginSettingNotFound) { return err @@ -60,7 +67,7 @@ func (ap *PluginProvisioner) apply(ctx context.Context, cfg *pluginsAsConfig) er SecureJsonData: app.SecureJSONData, PluginVersion: app.PluginVersion, } - if err := bus.Dispatch(ctx, cmd); err != nil { + if err := ap.store.UpdatePluginSetting(ctx, cmd); err != nil { return err } } diff --git a/pkg/services/provisioning/plugins/plugin_provisioner_test.go b/pkg/services/provisioning/plugins/plugin_provisioner_test.go index 98a30436cb5..3c3dff3c8fb 100644 --- a/pkg/services/provisioning/plugins/plugin_provisioner_test.go +++ b/pkg/services/provisioning/plugins/plugin_provisioner_test.go @@ -5,7 +5,6 @@ import ( "errors" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/stretchr/testify/require" @@ -21,32 +20,6 @@ func TestPluginProvisioner(t *testing.T) { }) t.Run("Should apply configurations", func(t *testing.T) { - bus.AddHandler("test", func(ctx context.Context, query *models.GetOrgByNameQuery) error { - if query.Name == "Org 4" { - query.Result = &models.Org{Id: 4} - } - - return nil - }) - - bus.AddHandler("test", func(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { - if query.PluginId == "test-plugin" && query.OrgId == 2 { - query.Result = &models.PluginSetting{ - PluginVersion: "2.0.1", - } - return nil - } - - return models.ErrPluginSettingNotFound - }) - - sentCommands := []*models.UpdatePluginSettingCmd{} - - bus.AddHandler("test", func(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error { - sentCommands = append(sentCommands, cmd) - return nil - }) - cfg := []*pluginsAsConfig{ { Apps: []*appFromConfig{ @@ -58,11 +31,12 @@ func TestPluginProvisioner(t *testing.T) { }, } reader := &testConfigReader{result: cfg} - ap := PluginProvisioner{log: log.New("test"), cfgProvider: reader} + store := &mockStore{} + ap := PluginProvisioner{log: log.New("test"), cfgProvider: reader, store: store} err := ap.applyChanges(context.Background(), "") require.NoError(t, err) - require.Len(t, sentCommands, 4) + require.Len(t, store.sentCommands, 4) testCases := []struct { ExpectedPluginID string @@ -77,7 +51,7 @@ func TestPluginProvisioner(t *testing.T) { } for index, tc := range testCases { - cmd := sentCommands[index] + cmd := store.sentCommands[index] require.NotNil(t, cmd) require.Equal(t, tc.ExpectedPluginID, cmd.PluginId) require.Equal(t, tc.ExpectedOrgID, cmd.OrgId) @@ -95,3 +69,30 @@ type testConfigReader struct { func (tcr *testConfigReader) readConfig(ctx context.Context, path string) ([]*pluginsAsConfig, error) { return tcr.result, tcr.err } + +type mockStore struct { + sentCommands []*models.UpdatePluginSettingCmd +} + +func (m *mockStore) GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error { + if query.Name == "Org 4" { + query.Result = &models.Org{Id: 4} + } + return nil +} + +func (m *mockStore) GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { + if query.PluginId == "test-plugin" && query.OrgId == 2 { + query.Result = &models.PluginSetting{ + PluginVersion: "2.0.1", + } + return nil + } + + return models.ErrPluginSettingNotFound +} + +func (m *mockStore) UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error { + m.sentCommands = append(m.sentCommands, cmd) + return nil +} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 711b90b7962..2621fac3eb8 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning/datasources" "github.com/grafana/grafana/pkg/services/provisioning/notifiers" "github.com/grafana/grafana/pkg/services/provisioning/plugins" + "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" @@ -26,6 +27,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p ) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, + SQLStore: sqlStore, pluginStore: pluginStore, EncryptionService: encryptionService, NotificationService: notificatonService, @@ -64,9 +66,9 @@ func NewProvisioningServiceImpl() *ProvisioningServiceImpl { // Used for testing purposes func newProvisioningServiceImpl( newDashboardProvisioner dashboards.DashboardProvisionerFactory, - provisionNotifiers func(context.Context, string, encryption.Internal, *notifications.NotificationService) error, - provisionDatasources func(context.Context, string) error, - provisionPlugins func(context.Context, string, plugifaces.Store) error, + provisionNotifiers func(context.Context, string, notifiers.Store, encryption.Internal, *notifications.NotificationService) error, + provisionDatasources func(context.Context, string, datasources.Store, utils.OrgStore) error, + provisionPlugins func(context.Context, string, plugins.Store, plugifaces.Store) error, ) *ProvisioningServiceImpl { return &ProvisioningServiceImpl{ log: log.New("provisioning"), @@ -87,9 +89,9 @@ type ProvisioningServiceImpl struct { pollingCtxCancel context.CancelFunc newDashboardProvisioner dashboards.DashboardProvisionerFactory dashboardProvisioner dashboards.DashboardProvisioner - provisionNotifiers func(context.Context, string, encryption.Internal, *notifications.NotificationService) error - provisionDatasources func(context.Context, string) error - provisionPlugins func(context.Context, string, plugifaces.Store) error + provisionNotifiers func(context.Context, string, notifiers.Store, encryption.Internal, *notifications.NotificationService) error + provisionDatasources func(context.Context, string, datasources.Store, utils.OrgStore) error + provisionPlugins func(context.Context, string, plugins.Store, plugifaces.Store) error mutex sync.Mutex dashboardService dashboardservice.DashboardProvisioningService } @@ -144,7 +146,7 @@ func (ps *ProvisioningServiceImpl) Run(ctx context.Context) error { func (ps *ProvisioningServiceImpl) ProvisionDatasources(ctx context.Context) error { datasourcePath := filepath.Join(ps.Cfg.ProvisioningPath, "datasources") - if err := ps.provisionDatasources(ctx, datasourcePath); err != nil { + if err := ps.provisionDatasources(ctx, datasourcePath, ps.SQLStore, ps.SQLStore); err != nil { err = errutil.Wrap("Datasource provisioning error", err) ps.log.Error("Failed to provision data sources", "error", err) return err @@ -154,7 +156,7 @@ func (ps *ProvisioningServiceImpl) ProvisionDatasources(ctx context.Context) err func (ps *ProvisioningServiceImpl) ProvisionPlugins(ctx context.Context) error { appPath := filepath.Join(ps.Cfg.ProvisioningPath, "plugins") - if err := ps.provisionPlugins(ctx, appPath, ps.pluginStore); err != nil { + if err := ps.provisionPlugins(ctx, appPath, ps.SQLStore, ps.pluginStore); err != nil { err = errutil.Wrap("app provisioning error", err) ps.log.Error("Failed to provision plugins", "error", err) return err @@ -164,7 +166,7 @@ func (ps *ProvisioningServiceImpl) ProvisionPlugins(ctx context.Context) error { func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) error { alertNotificationsPath := filepath.Join(ps.Cfg.ProvisioningPath, "notifiers") - if err := ps.provisionNotifiers(ctx, alertNotificationsPath, ps.EncryptionService, ps.NotificationService); err != nil { + if err := ps.provisionNotifiers(ctx, alertNotificationsPath, ps.SQLStore, ps.EncryptionService, ps.NotificationService); err != nil { err = errutil.Wrap("Alert notification provisioning error", err) ps.log.Error("Failed to provision alert notifications", "error", err) return err @@ -174,7 +176,7 @@ func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) e func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error { dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards") - dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService) + dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService, ps.SQLStore) if err != nil { return errutil.Wrap("Failed to create provisioner", err) } diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index 8bf248f97fa..4493d6569dd 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -8,6 +8,7 @@ import ( dashboardstore "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" + "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" ) @@ -92,7 +93,7 @@ func setup() *serviceTestStruct { } serviceTest.service = newProvisioningServiceImpl( - func(context.Context, string, dashboardstore.DashboardProvisioningService) (dashboards.DashboardProvisioner, error) { + func(context.Context, string, dashboardstore.DashboardProvisioningService, utils.OrgStore) (dashboards.DashboardProvisioner, error) { return serviceTest.mock, nil }, nil, diff --git a/pkg/services/provisioning/utils/utils.go b/pkg/services/provisioning/utils/utils.go index 13c270c5442..18f74408b52 100644 --- a/pkg/services/provisioning/utils/utils.go +++ b/pkg/services/provisioning/utils/utils.go @@ -5,13 +5,16 @@ import ( "errors" "fmt" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func CheckOrgExists(ctx context.Context, orgID int64) error { +type OrgStore interface { + GetOrgById(context.Context, *models.GetOrgByIdQuery) error +} + +func CheckOrgExists(ctx context.Context, store OrgStore, orgID int64) error { query := models.GetOrgByIdQuery{Id: orgID} - if err := bus.Dispatch(ctx, &query); err != nil { + if err := store.GetOrgById(ctx, &query); err != nil { if errors.Is(err, models.ErrOrgNotFound) { return err } diff --git a/pkg/services/provisioning/utils/utils_test.go b/pkg/services/provisioning/utils/utils_test.go index 33b5710bb45..d4b585bf785 100644 --- a/pkg/services/provisioning/utils/utils_test.go +++ b/pkg/services/provisioning/utils/utils_test.go @@ -1,32 +1 @@ package utils - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore" - - "github.com/stretchr/testify/require" -) - -func TestCheckOrgExists(t *testing.T) { - t.Run("with default org in database", func(t *testing.T) { - sqlstore.InitTestDB(t) - - defaultOrg := models.CreateOrgCommand{Name: "Main Org."} - - err := sqlstore.CreateOrg(context.Background(), &defaultOrg) - require.NoError(t, err) - - t.Run("default org exists", func(t *testing.T) { - err := CheckOrgExists(context.Background(), defaultOrg.Result.Id) - require.NoError(t, err) - }) - - t.Run("other org doesn't exist", func(t *testing.T) { - err := CheckOrgExists(context.Background(), defaultOrg.Result.Id+1) - require.Equal(t, err, models.ErrOrgNotFound) - }) - }) -} diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go index 76c76cd0094..2a1104c75ee 100644 --- a/pkg/services/queryhistory/api.go +++ b/pkg/services/queryhistory/api.go @@ -15,6 +15,8 @@ func (s *QueryHistoryService) registerAPIEndpoints() { s.RouteRegister.Group("/api/query-history", func(entities routing.RouteRegister) { entities.Post("/", middleware.ReqSignedIn, routing.Wrap(s.createHandler)) entities.Delete("/:uid", middleware.ReqSignedIn, routing.Wrap(s.deleteHandler)) + entities.Post("/star/:uid", middleware.ReqSignedIn, routing.Wrap(s.starHandler)) + entities.Delete("/star/:uid", middleware.ReqSignedIn, routing.Wrap(s.unstarHandler)) entities.Patch("/:uid", middleware.ReqSignedIn, routing.Wrap(s.patchCommentHandler)) }) } @@ -35,7 +37,7 @@ func (s *QueryHistoryService) createHandler(c *models.ReqContext) response.Respo func (s *QueryHistoryService) deleteHandler(c *models.ReqContext) response.Response { queryUID := web.Params(c.Req)[":uid"] - if !util.IsValidShortUID(queryUID) { + if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } @@ -52,7 +54,7 @@ func (s *QueryHistoryService) deleteHandler(c *models.ReqContext) response.Respo func (s *QueryHistoryService) patchCommentHandler(c *models.ReqContext) response.Response { queryUID := web.Params(c.Req)[":uid"] - if !util.IsValidShortUID(queryUID) { + if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } @@ -68,3 +70,31 @@ func (s *QueryHistoryService) patchCommentHandler(c *models.ReqContext) response return response.JSON(http.StatusOK, QueryHistoryResponse{Result: query}) } + +func (s *QueryHistoryService) starHandler(c *models.ReqContext) response.Response { + queryUID := web.Params(c.Req)[":uid"] + if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { + return response.Error(http.StatusNotFound, "Query in query history not found", nil) + } + + query, err := s.StarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) + } + + return response.JSON(http.StatusOK, QueryHistoryResponse{Result: query}) +} + +func (s *QueryHistoryService) unstarHandler(c *models.ReqContext) response.Response { + queryUID := web.Params(c.Req)[":uid"] + if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { + return response.Error(http.StatusNotFound, "Query in query history not found", nil) + } + + query, err := s.UnstarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to unstar query in query history", err) + } + + return response.JSON(http.StatusOK, QueryHistoryResponse{Result: query}) +} diff --git a/pkg/services/queryhistory/database.go b/pkg/services/queryhistory/database.go index c8d6f4dc3d9..c926286a3f7 100644 --- a/pkg/services/queryhistory/database.go +++ b/pkg/services/queryhistory/database.go @@ -43,13 +43,24 @@ func (s QueryHistoryService) createQuery(ctx context.Context, user *models.Signe func (s QueryHistoryService) deleteQuery(ctx context.Context, user *models.SignedInUser, UID string) (int64, error) { var queryID int64 - err := s.SQLStore.WithDbSession(ctx, func(session *sqlstore.DBSession) error { + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *sqlstore.DBSession) error { + // Try to unstar the query first + _, err := session.Table("query_history_star").Where("user_id = ? AND query_uid = ?", user.UserId, UID).Delete(QueryHistoryStar{}) + if err != nil { + s.log.Error("Failed to unstar query while deleting it from query history", "query", UID, "user", user.UserId, "error", err) + } + + // Then delete it id, err := session.Where("org_id = ? AND created_by = ? AND uid = ?", user.OrgId, user.UserId, UID).Delete(QueryHistory{}) + if err != nil { + return err + } if id == 0 { return ErrQueryNotFound } + queryID = id - return err + return nil }) return queryID, err @@ -57,6 +68,8 @@ func (s QueryHistoryService) deleteQuery(ctx context.Context, user *models.Signe func (s QueryHistoryService) patchQueryComment(ctx context.Context, user *models.SignedInUser, UID string, cmd PatchQueryCommentInQueryHistoryCommand) (QueryHistoryDTO, error) { var queryHistory QueryHistory + var isStarred bool + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *sqlstore.DBSession) error { exists, err := session.Where("org_id = ? AND created_by = ? AND uid = ?", user.OrgId, user.UserId, UID).Get(&queryHistory) if err != nil { @@ -72,6 +85,11 @@ func (s QueryHistoryService) patchQueryComment(ctx context.Context, user *models return err } + starred, err := session.Table("query_history_star").Where("user_id = ? AND query_uid = ?", user.UserId, UID).Exist() + if err != nil { + return err + } + isStarred = starred return nil }) @@ -86,7 +104,98 @@ func (s QueryHistoryService) patchQueryComment(ctx context.Context, user *models CreatedAt: queryHistory.CreatedAt, Comment: queryHistory.Comment, Queries: queryHistory.Queries, - Starred: false, + Starred: isStarred, + } + + return dto, nil +} + +func (s QueryHistoryService) starQuery(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) { + var queryHistory QueryHistory + var isStarred bool + + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *sqlstore.DBSession) error { + // Check if query exists as we want to star only existing queries + exists, err := session.Table("query_history").Where("org_id = ? AND created_by = ? AND uid = ?", user.OrgId, user.UserId, UID).Get(&queryHistory) + if err != nil { + return err + } + if !exists { + return ErrQueryNotFound + } + + // If query exists then star it + queryHistoryStar := QueryHistoryStar{ + UserID: user.UserId, + QueryUID: UID, + } + + _, err = session.Insert(&queryHistoryStar) + if err != nil { + if s.SQLStore.Dialect.IsUniqueConstraintViolation(err) { + return ErrQueryAlreadyStarred + } + return err + } + + isStarred = true + return nil + }) + + if err != nil { + return QueryHistoryDTO{}, err + } + + dto := QueryHistoryDTO{ + UID: queryHistory.UID, + DatasourceUID: queryHistory.DatasourceUID, + CreatedBy: queryHistory.CreatedBy, + CreatedAt: queryHistory.CreatedAt, + Comment: queryHistory.Comment, + Queries: queryHistory.Queries, + Starred: isStarred, + } + + return dto, nil +} + +func (s QueryHistoryService) unstarQuery(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) { + var queryHistory QueryHistory + var isStarred bool + + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *sqlstore.DBSession) error { + exists, err := session.Table("query_history").Where("org_id = ? AND created_by = ? AND uid = ?", user.OrgId, user.UserId, UID).Get(&queryHistory) + if err != nil { + return err + } + if !exists { + return ErrQueryNotFound + } + + id, err := session.Table("query_history_star").Where("user_id = ? AND query_uid = ?", user.UserId, UID).Delete(QueryHistoryStar{}) + if id == 0 { + return ErrStarredQueryNotFound + } + if err != nil { + return err + } + + isStarred = false + return nil + }) + + if err != nil { + return QueryHistoryDTO{}, err + } + + dto := QueryHistoryDTO{ + UID: queryHistory.UID, + DatasourceUID: queryHistory.DatasourceUID, + CreatedBy: queryHistory.CreatedBy, + CreatedAt: queryHistory.CreatedAt, + Comment: queryHistory.Comment, + Queries: queryHistory.Queries, + Starred: isStarred, } return dto, nil diff --git a/pkg/services/queryhistory/models.go b/pkg/services/queryhistory/models.go index f313a8bae1a..dd42fe8218e 100644 --- a/pkg/services/queryhistory/models.go +++ b/pkg/services/queryhistory/models.go @@ -7,7 +7,9 @@ import ( ) var ( - ErrQueryNotFound = errors.New("query in query history not found") + ErrQueryNotFound = errors.New("query in query history not found") + ErrStarredQueryNotFound = errors.New("starred query not found") + ErrQueryAlreadyStarred = errors.New("query was already starred") ) type QueryHistory struct { @@ -21,6 +23,12 @@ type QueryHistory struct { Queries *simplejson.Json } +type QueryHistoryStar struct { + ID int64 `xorm:"pk autoincr 'id'"` + QueryUID string `xorm:"query_uid"` + UserID int64 `xorm:"user_id"` +} + type CreateQueryInQueryHistoryCommand struct { DatasourceUID string `json:"datasourceUid"` Queries *simplejson.Json `json:"queries"` diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go index 71332ae2bd1..40b31836259 100644 --- a/pkg/services/queryhistory/queryhistory.go +++ b/pkg/services/queryhistory/queryhistory.go @@ -30,6 +30,8 @@ type Service interface { CreateQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, cmd CreateQueryInQueryHistoryCommand) (QueryHistoryDTO, error) DeleteQueryFromQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (int64, error) PatchQueryCommentInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string, cmd PatchQueryCommentInQueryHistoryCommand) (QueryHistoryDTO, error) + StarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) + UnstarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) } type QueryHistoryService struct { @@ -50,3 +52,11 @@ func (s QueryHistoryService) DeleteQueryFromQueryHistory(ctx context.Context, us func (s QueryHistoryService) PatchQueryCommentInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string, cmd PatchQueryCommentInQueryHistoryCommand) (QueryHistoryDTO, error) { return s.patchQueryComment(ctx, user, UID, cmd) } + +func (s QueryHistoryService) StarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) { + return s.starQuery(ctx, user, UID) +} + +func (s QueryHistoryService) UnstarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) { + return s.unstarQuery(ctx, user, UID) +} diff --git a/pkg/services/queryhistory/queryhistory_delete_test.go b/pkg/services/queryhistory/queryhistory_delete_test.go index 181cf1f3430..4e1ac3dc60b 100644 --- a/pkg/services/queryhistory/queryhistory_delete_test.go +++ b/pkg/services/queryhistory/queryhistory_delete_test.go @@ -1,8 +1,10 @@ package queryhistory import ( + "context" "testing" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/require" ) @@ -20,4 +22,22 @@ func TestDeleteQueryFromQueryHistory(t *testing.T) { resp := sc.service.deleteHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) }) + + testScenarioWithQueryInQueryHistory(t, "When users tries to delete query in query history that exists, it should also unstar it and succeed", + func(t *testing.T, sc scenarioContext) { + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) + // Star added query + sc.service.starHandler(sc.reqContext) + // Then delete it + resp := sc.service.deleteHandler(sc.reqContext) + // Check if query is still in query_history_star table + err := sc.sqlStore.WithDbSession(context.Background(), func(dbSession *sqlstore.DBSession) error { + exists, err := dbSession.Table("query_history_star").Where("user_id = ? AND query_uid = ?", sc.reqContext.SignedInUser.UserId, sc.initialResult.Result.UID).Exist() + require.NoError(t, err) + require.Equal(t, false, exists) + return err + }) + require.NoError(t, err) + require.Equal(t, 200, resp.Status()) + }) } diff --git a/pkg/services/queryhistory/queryhistory_star_test.go b/pkg/services/queryhistory/queryhistory_star_test.go new file mode 100644 index 00000000000..f74fb120a7f --- /dev/null +++ b/pkg/services/queryhistory/queryhistory_star_test.go @@ -0,0 +1,31 @@ +package queryhistory + +import ( + "testing" + + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" +) + +func TestStarQueryInQueryHistory(t *testing.T) { + testScenarioWithQueryInQueryHistory(t, "When users tries to star query in query history that does not exists, it should fail", + func(t *testing.T, sc scenarioContext) { + resp := sc.service.starHandler(sc.reqContext) + require.Equal(t, 500, resp.Status()) + }) + + testScenarioWithQueryInQueryHistory(t, "When users tries to star query in query history that exists, it should succeed", + func(t *testing.T, sc scenarioContext) { + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) + resp := sc.service.starHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + }) + + testScenarioWithQueryInQueryHistory(t, "When users tries to star query that is already starred, it should fail", + func(t *testing.T, sc scenarioContext) { + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) + sc.service.starHandler(sc.reqContext) + resp := sc.service.starHandler(sc.reqContext) + require.Equal(t, 500, resp.Status()) + }) +} diff --git a/pkg/services/queryhistory/queryhistory_unstar_test.go b/pkg/services/queryhistory/queryhistory_unstar_test.go new file mode 100644 index 00000000000..6ef52c03eec --- /dev/null +++ b/pkg/services/queryhistory/queryhistory_unstar_test.go @@ -0,0 +1,33 @@ +package queryhistory + +import ( + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" +) + +func TestUnstarQueryInQueryHistory(t *testing.T) { + testScenarioWithQueryInQueryHistory(t, "When users tries to unstar query in query history that does not exists, it should fail", + func(t *testing.T, sc scenarioContext) { + resp := sc.service.starHandler(sc.reqContext) + require.Equal(t, 500, resp.Status()) + }) + + testScenarioWithQueryInQueryHistory(t, "When users tries to unstar starred query in query history, it should succeed", + func(t *testing.T, sc scenarioContext) { + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) + sc.service.starHandler(sc.reqContext) + resp := sc.service.unstarHandler(sc.reqContext) + fmt.Println(resp) + require.Equal(t, 200, resp.Status()) + }) + + testScenarioWithQueryInQueryHistory(t, "When users tries to unstar query in query history that is not starred, it should fail", + func(t *testing.T, sc scenarioContext) { + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) + resp := sc.service.unstarHandler(sc.reqContext) + require.Equal(t, 500, resp.Status()) + }) +} diff --git a/pkg/services/secrets/database/database.go b/pkg/services/secrets/database/database.go index 19a5e509e93..c4c0aeb4016 100644 --- a/pkg/services/secrets/database/database.go +++ b/pkg/services/secrets/database/database.go @@ -6,6 +6,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/kmsproviders" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/sqlstore" "xorm.io/xorm" @@ -100,26 +101,50 @@ func (ss *SecretsStoreImpl) ReEncryptDataKeys( } for _, k := range keys { - provider, ok := providers[k.Provider] + provider, ok := providers[kmsproviders.NormalizeProviderID(k.Provider)] if !ok { - return fmt.Errorf("could not find encryption provider '%s'", k.Provider) + ss.log.Warn( + "Could not find provider to re-encrypt data encryption key", + "key_id", k.Name, + "provider", k.Provider, + ) + continue } decrypted, err := provider.Decrypt(ctx, k.EncryptedData) if err != nil { - return err + ss.log.Warn( + "Error while decrypting data encryption key to re-encrypt it", + "key_id", k.Name, + "provider", k.Provider, + "err", err, + ) + continue } // Updating current data key by re-encrypting it with current provider. // Accessing the current provider within providers map should be safe. k.Provider = currProvider + k.Updated = time.Now() k.EncryptedData, err = providers[currProvider].Encrypt(ctx, decrypted) if err != nil { - return err + ss.log.Warn( + "Error while re-encrypting data encryption key", + "key_id", k.Name, + "provider", k.Provider, + "err", err, + ) + continue } if _, err := sess.Table(dataKeysTable).Where("name = ?", k.Name).Update(k); err != nil { - return err + ss.log.Warn( + "Error while re-encrypting data encryption key", + "key_id", k.Name, + "provider", k.Provider, + "err", err, + ) + continue } } diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index b31f0c7f73b..f10e3da9eb2 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -48,7 +48,7 @@ func ProvideSecretsService( logger := log.New("secrets") enabled := features.IsEnabled(featuremgmt.FlagEnvelopeEncryption) - currentProviderID := normalizeProviderID(secrets.ProviderID( + currentProviderID := kmsproviders.NormalizeProviderID(secrets.ProviderID( settings.KeyValue("security", "encryption_provider").MustString(kmsproviders.Default), )) @@ -79,14 +79,6 @@ func ProvideSecretsService( return s, nil } -func normalizeProviderID(id secrets.ProviderID) secrets.ProviderID { - if id == kmsproviders.Legacy { - return kmsproviders.Default - } - - return id -} - func (s *SecretsService) registerUsageMetrics() { s.usageStats.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { usageMetrics := make(map[string]interface{}) @@ -330,7 +322,7 @@ func (s *SecretsService) dataKey(ctx context.Context, name string) ([]byte, erro } // 2. decrypt data key - provider, exists := s.providers[normalizeProviderID(dataKey.Provider)] + provider, exists := s.providers[kmsproviders.NormalizeProviderID(dataKey.Provider)] if !exists { return nil, fmt.Errorf("could not find encryption provider '%s'", dataKey.Provider) } diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 1d26c54ce01..7fba7661d61 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -70,13 +70,15 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( auth := acmiddleware.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) - serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount)) + serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin, + accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount)) + serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, + accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount)) serviceAccountsRoute.Patch("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.updateServiceAccount)) serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount)) serviceAccountsRoute.Post("/upgradeall", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.UpgradeServiceAccounts)) serviceAccountsRoute.Post("/convert/:keyId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.ConvertToServiceAccount)) - serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount)) serviceAccountsRoute.Get("/:serviceAccountId/tokens", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.ListTokens)) serviceAccountsRoute.Post("/:serviceAccountId/tokens", auth(middleware.ReqOrgAdmin, @@ -92,6 +94,8 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "Bad request data", err) } + cmd.OrgID = c.OrgId + user, err := api.service.CreateServiceAccount(c.Req.Context(), &cmd) switch { case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound): @@ -192,6 +196,9 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re return response.Error(http.StatusInternalServerError, "Failed to retrieve service account", err) } } + + serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name) + return response.JSON(http.StatusOK, serviceAccount) } diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 593a23b03c3..0b0da8e5d0b 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -177,12 +177,16 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o if len(query.Result) != 1 { return nil, serviceaccounts.ErrServiceAccountNotFound } + saProfile := &serviceaccounts.ServiceAccountProfileDTO{ - Id: query.Result[0].UserId, - Name: query.Result[0].Name, - Login: query.Result[0].Login, + Id: query.Result[0].UserId, + Name: query.Result[0].Name, + Login: query.Result[0].Login, + OrgId: query.Result[0].OrgId, + UpdatedAt: query.Result[0].Updated, + CreatedAt: query.Result[0].Created, } - return saProfile, err + return saProfile, nil } func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go index cc46473caa3..f79e0bcddbe 100644 --- a/pkg/services/sqlstore/dashboard_provisioning.go +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -1,43 +1 @@ package sqlstore - -import ( - "context" - "errors" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/models" -) - -func (ss *SQLStore) addDashboardProvisioningQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.DeleteOrphanedProvisionedDashboards) -} - -type DashboardExtras struct { - Id int64 - DashboardId int64 - Key string - Value string -} - -func (ss *SQLStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { - var result []*models.DashboardProvisioning - - convertedReaderNames := make([]interface{}, len(cmd.ReaderNames)) - for index, readerName := range cmd.ReaderNames { - convertedReaderNames[index] = readerName - } - - err := x.NotIn("name", convertedReaderNames...).Find(&result) - if err != nil { - return err - } - - for _, deleteDashCommand := range result { - err := ss.DeleteDashboard(ctx, &models.DeleteDashboardCommand{Id: deleteDashCommand.DashboardId}) - if err != nil && !errors.Is(err, models.ErrDashboardNotFound) { - return err - } - } - - return nil -} diff --git a/pkg/services/sqlstore/migrations/comments_migrations.go b/pkg/services/sqlstore/migrations/comments_migrations.go new file mode 100644 index 00000000000..2c92b8d756e --- /dev/null +++ b/pkg/services/sqlstore/migrations/comments_migrations.go @@ -0,0 +1,46 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addCommentGroupMigrations(mg *Migrator) { + commentGroupTable := Table{ + Name: "comment_group", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: DB_BigInt, Nullable: false}, + {Name: "object_type", Type: DB_NVarchar, Length: 10, Nullable: false}, + {Name: "object_id", Type: DB_NVarchar, Length: 128, Nullable: false}, + {Name: "settings", Type: DB_MediumText, Nullable: false}, + {Name: "created", Type: DB_Int, Nullable: false}, + {Name: "updated", Type: DB_Int, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"org_id", "object_type", "object_id"}, Type: UniqueIndex}, + }, + } + mg.AddMigration("create comment group table", NewAddTableMigration(commentGroupTable)) + mg.AddMigration("add index comment_group.org_id_object_type_object_id", NewAddIndexMigration(commentGroupTable, commentGroupTable.Indices[0])) +} + +func addCommentMigrations(mg *Migrator) { + commentTable := Table{ + Name: "comment", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "group_id", Type: DB_BigInt, Nullable: false}, + {Name: "user_id", Type: DB_BigInt, Nullable: false}, + {Name: "content", Type: DB_MediumText, Nullable: false}, + {Name: "created", Type: DB_Int, Nullable: false}, + {Name: "updated", Type: DB_Int, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"group_id"}, Type: IndexType}, + {Cols: []string{"created"}, Type: IndexType}, + }, + } + mg.AddMigration("create comment table", NewAddTableMigration(commentTable)) + mg.AddMigration("add index comment.group_id", NewAddIndexMigration(commentTable, commentTable.Indices[0])) + mg.AddMigration("add index comment.created", NewAddIndexMigration(commentTable, commentTable.Indices[1])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index e3e66a46cc4..bad451879d8 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -78,6 +78,14 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { accesscontrol.AddTeamMembershipMigrations(mg) } } + addQueryHistoryStarMigrations(mg) + + if mg.Cfg != nil && mg.Cfg.IsFeatureToggleEnabled != nil { + if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagDashboardComments) || mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAnnotationComments) { + addCommentGroupMigrations(mg) + addCommentMigrations(mg) + } + } } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 83a26cc52df..96218fb3c93 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -155,6 +155,11 @@ func TestMigrationLock(t *testing.T) { func TestMigratorLocking(t *testing.T) { dbType := getDBType() testDB := getTestDB(t, dbType) + // skip for SQLite for now since it occasionally fails for not clear reason + // anyway starting migrations concurretly for the same migrator is impossible use case + if dbType == SQLite { + t.Skip() + } x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -181,7 +186,7 @@ func TestMigratorLocking(t *testing.T) { }) } }) - assert.Equal(t, int64(1), errorNum) + assert.Equal(t, int64(1), atomic.LoadInt64(&errorNum)) } func TestDatabaseLocking(t *testing.T) { diff --git a/pkg/services/sqlstore/migrations/query_history_star_mig.go b/pkg/services/sqlstore/migrations/query_history_star_mig.go new file mode 100644 index 00000000000..e4ebe662c2a --- /dev/null +++ b/pkg/services/sqlstore/migrations/query_history_star_mig.go @@ -0,0 +1,23 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addQueryHistoryStarMigrations(mg *Migrator) { + queryHistoryStarV1 := Table{ + Name: "query_history_star", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "query_uid", Type: DB_NVarchar, Length: 40, Nullable: false}, + {Name: "user_id", Type: DB_Int, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"user_id", "query_uid"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create query_history_star table v1", NewAddTableMigration(queryHistoryStarV1)) + + mg.AddMigration("add index query_history.user_id-query_uid", NewAddIndexMigration(queryHistoryStarV1, queryHistoryStarV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 58387874d51..61b19b0a53c 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -13,14 +13,16 @@ type OrgListResponse []struct { Response error } type SQLStoreMock struct { - LastGetAlertsQuery *models.GetAlertsQuery - LatestUserId int64 + LastGetAlertsQuery *models.GetAlertsQuery + LatestUserId int64 + ExpectedUser *models.User ExpectedDatasource *models.DataSource ExpectedAlert *models.Alert ExpectedPluginSetting *models.PluginSetting ExpectedDashboard *models.Dashboard ExpectedDashboards []*models.Dashboard + ExpectedDashboardVersion *models.DashboardVersion ExpectedDashboardVersions []*models.DashboardVersion ExpectedDashboardAclInfoList []*models.DashboardAclInfoDTO ExpectedUserOrgList []*models.UserOrgDTO @@ -92,10 +94,19 @@ func (m *SQLStoreMock) SearchDashboardSnapshots(query *models.GetDashboardSnapsh return m.ExpectedError } +func (m *SQLStoreMock) GetOrgById(ctx context.Context, cmd *models.GetOrgByIdQuery) error { + return m.ExpectedError +} + func (m *SQLStoreMock) GetOrgByName(name string) (*models.Org, error) { return m.ExpectedOrg, m.ExpectedError } +func (m *SQLStoreMock) GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error { + query.Result = m.ExpectedOrg + return m.ExpectedError +} + func (m *SQLStoreMock) CreateOrgWithMember(name string, userID int64) (models.Org, error) { return *m.ExpectedOrg, nil } diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index a373b81fe5b..e8e538913b1 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -16,11 +16,11 @@ import ( const MainOrgName = "Main Org." func (ss *SQLStore) addOrgQueryAndCommandHandlers() { - bus.AddHandler("sql", GetOrgById) + bus.AddHandler("sql", ss.GetOrgById) bus.AddHandler("sql", CreateOrg) bus.AddHandler("sql", ss.UpdateOrg) bus.AddHandler("sql", ss.UpdateOrgAddress) - bus.AddHandler("sql", GetOrgByName) + bus.AddHandler("sql", ss.GetOrgByNameHandler) bus.AddHandler("sql", ss.SearchOrgs) bus.AddHandler("sql", ss.DeleteOrg) } @@ -48,7 +48,7 @@ func (ss *SQLStore) SearchOrgs(ctx context.Context, query *models.SearchOrgsQuer return err } -func GetOrgById(ctx context.Context, query *models.GetOrgByIdQuery) error { +func (ss *SQLStore) GetOrgById(ctx context.Context, query *models.GetOrgByIdQuery) error { var org models.Org exists, err := x.Id(query.Id).Get(&org) if err != nil { @@ -63,7 +63,7 @@ func GetOrgById(ctx context.Context, query *models.GetOrgByIdQuery) error { return nil } -func GetOrgByName(ctx context.Context, query *models.GetOrgByNameQuery) error { +func (ss *SQLStore) GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error { var org models.Org exists, err := x.Where("name=?", query.Name).Get(&org) if err != nil { diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 0af6765da50..e967fe62f52 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -149,6 +149,8 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu "user.login", "org_user.role", "user.last_seen_at", + "user.created", + "user.updated", ) sess.Asc("user.email", "user.login") diff --git a/pkg/services/sqlstore/searchstore/builder.go b/pkg/services/sqlstore/searchstore/builder.go index 6f084b62ba7..c27451bd7e7 100644 --- a/pkg/services/sqlstore/searchstore/builder.go +++ b/pkg/services/sqlstore/searchstore/builder.go @@ -115,15 +115,31 @@ func (b *Builder) applyFilters() (ordering string) { b.params = append(b.params, whereParams...) } - if len(groups) > 0 { - b.sql.WriteString(fmt.Sprintf(" GROUP BY %s", strings.Join(groups, ", "))) - b.params = append(b.params, groupParams...) - } - if len(orders) < 1 { orders = append(orders, TitleSorter{}.OrderBy()) } + if len(groups) > 0 { + cols := make([]string, 0, len(orders)+len(groups)) + for _, o := range orders { + o := strings.TrimSuffix(o, " DESC") + o = strings.TrimSuffix(o, " ASC") + exists := false + for _, g := range groups { + if g == o { + exists = true + break + } + } + if !exists { + cols = append(cols, o) + } + } + cols = append(cols, groups...) + b.sql.WriteString(fmt.Sprintf(" GROUP BY %s", strings.Join(cols, ", "))) + b.params = append(b.params, groupParams...) + } + orderBy := fmt.Sprintf(" ORDER BY %s", strings.Join(orders, ", ")) b.sql.WriteString(orderBy) diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index b83ee8cbba1..0c50d28161c 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -130,7 +130,6 @@ func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, b bus. ss.addPlaylistQueryAndCommandHandlers() ss.addLoginAttemptQueryAndCommandHandlers() ss.addTeamQueryAndCommandHandlers() - ss.addDashboardProvisioningQueryAndCommandHandlers() ss.addOrgQueryAndCommandHandlers() bus.AddHandler("sql", ss.GetDBHealthQuery) @@ -465,6 +464,7 @@ type InitTestDBOpt struct { var featuresEnabledDuringTests = []string{ featuremgmt.FlagDashboardPreviews, + featuremgmt.FlagDashboardComments, } // InitTestDBWithMigration initializes the test DB given custom migrations. diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index bd0a67c778d..5a8087559df 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -79,7 +79,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { // get 1st user's organisation getOrgByIdQuery := &models.GetOrgByIdQuery{Id: users[0].OrgId} - err := GetOrgById(context.Background(), getOrgByIdQuery) + err := sqlStore.GetOrgById(context.Background(), getOrgByIdQuery) require.NoError(t, err) org := getOrgByIdQuery.Result @@ -103,7 +103,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { // get 2nd user's organisation getOrgByIdQuery = &models.GetOrgByIdQuery{Id: users[1].OrgId} - err = GetOrgById(context.Background(), getOrgByIdQuery) + err = sqlStore.GetOrgById(context.Background(), getOrgByIdQuery) require.NoError(t, err) org = getOrgByIdQuery.Result diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 100b3e75f21..82c6f104820 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -24,7 +24,8 @@ type Store interface { UpdateOrg(ctx context.Context, cmd *models.UpdateOrgCommand) error UpdateOrgAddress(ctx context.Context, cmd *models.UpdateOrgAddressCommand) error DeleteOrg(ctx context.Context, cmd *models.DeleteOrgCommand) error - DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error + GetOrgById(context.Context, *models.GetOrgByIdQuery) error + GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error CloneUserToServiceAccount(ctx context.Context, siUser *models.SignedInUser) (*models.User, error) diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 47da1eafb4a..394648217eb 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -618,6 +618,10 @@ func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSigned return err } +func (ss *SQLStore) SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { + return SearchUsers(ctx, query) +} + func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { query.Result = models.SearchUserQueryResult{ Users: make([]*models.UserSearchHitDTO, 0), diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 51639e6748c..3728affc65a 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -394,9 +394,10 @@ type Cfg struct { DefaultTheme string HomePage string - AutoAssignOrg bool - AutoAssignOrgId int - AutoAssignOrgRole string + AutoAssignOrg bool + AutoAssignOrgId int + AutoAssignOrgRole string + OAuthSkipOrgRoleUpdateSync bool // ExpressionsEnabled specifies whether expressions are enabled. ExpressionsEnabled bool @@ -1252,6 +1253,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false) cfg.OAuthCookieMaxAge = auth.Key("oauth_state_cookie_max_age").MustInt(600) SignoutRedirectUrl = valueAsString(auth, "signout_redirect_url", "") + cfg.OAuthSkipOrgRoleUpdateSync = auth.Key("oauth_skip_org_role_update_sync").MustBool(false) // SigV4 SigV4AuthEnabled = auth.Key("sigv4_auth_enabled").MustBool(false) diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 5a0d21fe0a6..ff38d29ebdf 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -908,7 +908,7 @@ func TestAlertRuleCRUD(t *testing.T) { Data: []ngmodels.AlertQuery{}, }, }, - expectedResponse: `{"message": "failed to update rule group: invalid alert rule: no queries or expressions are found"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found"}`, }, { desc: "alert rule with empty title", @@ -938,7 +938,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "failed to update rule group: invalid alert rule: title is empty"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title cannot be empty"}`, }, { desc: "alert rule with too long name", @@ -968,7 +968,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "failed to update rule group: invalid alert rule: name length should not be greater than 190"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title is too long. Max length is 190"}`, }, { desc: "alert rule with too long rulegroup", @@ -998,7 +998,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "failed to update rule group: invalid alert rule: rule group name length should not be greater than 190"}`, + expectedResponse: `{"message": "rule group name is too long. Max length is 190"}`, }, { desc: "alert rule with invalid interval", @@ -1029,7 +1029,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "failed to update rule group: invalid alert rule: interval (1s) should be non-zero and divided exactly by scheduler interval: 10s"}`, + expectedResponse: `{"message": "rule evaluation interval (1 second) should be positive number that is multiple of the base interval of 10 seconds"}`, }, { desc: "alert rule with unknown datasource", @@ -1059,7 +1059,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "failed to validate alert rule \"AlwaysFiring\": invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: invalid query A: data source not found: unknown"}`, }, { desc: "alert rule with invalid condition", @@ -1089,7 +1089,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "failed to validate alert rule \"AlwaysFiring\": condition B not found in any query or expression: it should be one of: [A]"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: condition B not found in any query or expression: it should be one of: [A]"}`, }, } @@ -1379,7 +1379,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) - require.JSONEq(t, `{"message": "failed to update rule group: failed to get alert rule unknown: could not find alert rule"}`, string(b)) + require.JSONEq(t, `{"message": "failed to update rule group: failed to update rule with UID unknown because could not find alert rule"}`, string(b)) // let's make sure that rule definitions are not affected by the failed POST request. u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1498,7 +1498,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - require.JSONEq(t, fmt.Sprintf(`{"message": "failed to validate alert rule \"AlwaysAlerting\": conflicting UID \"%s\" found"}`, ruleUID), string(b)) + require.JSONEq(t, fmt.Sprintf(`{"message": "rule [1] has UID %s that is already assigned to another rule at index 0"}`, ruleUID), string(b)) // let's make sure that rule definitions are not affected by the failed POST request. u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1847,6 +1847,7 @@ func TestAlertRuleCRUD(t *testing.T) { "rules":[ { "expr":"", + "for": "30s", "grafana_alert":{ "id":1, "orgId":1, @@ -2115,7 +2116,7 @@ func TestQuota(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) - require.JSONEq(t, `{"message": "quota reached"}`, string(b)) + require.JSONEq(t, `{"message": "quota has been exceeded"}`, string(b)) }) t.Run("when quota limit exceed updating existing rule should succeed", func(t *testing.T) { @@ -2193,6 +2194,7 @@ func TestQuota(t *testing.T) { "rules":[ { "expr":"", + "for": "2m", "grafana_alert":{ "id":1, "orgId":1, diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index 03061c78008..3a53f2a1bfa 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -1,6 +1,7 @@ package alerting import ( + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -11,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/tests/testinfra" ) @@ -46,1654 +48,9 @@ func TestAvailableChannels(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) - require.JSONEq(t, expAvailableChannelJsonOutput, string(b)) -} -var expAvailableChannelJsonOutput = ` -[ - { - "type": "dingding", - "name": "DingDing", - "heading": "DingDing settings", - "description": "Sends HTTP POST request to DingDing", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Message Type", - "description": "", - "placeholder": "", - "propertyName": "msgType", - "selectOptions": [ - { - "value": "link", - "label": "Link" - }, - { - "value": "actionCard", - "label": "ActionCard" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "kafka", - "name": "Kafka REST Proxy", - "heading": "Kafka settings", - "description": "Sends notifications to Kafka Rest Proxy", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Kafka REST Proxy", - "description": "", - "placeholder": "http://localhost:8082", - "propertyName": "kafkaRestProxy", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Topic", - "description": "", - "placeholder": "topic1", - "propertyName": "kafkaTopic", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "email", - "name": "Email", - "heading": "Email settings", - "description": "Sends notifications using Grafana server configured SMTP settings", - "info": "", - "options": [ - { - "element": "checkbox", - "inputType": "", - "label": "Single email", - "description": "Send a single email to all recipients", - "placeholder": "", - "propertyName": "singleEmail", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Addresses", - "description": "You can enter multiple email addresses using a \";\" separator", - "placeholder": "", - "propertyName": "addresses", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "Optional message to include with the email. You can use template variables", - "placeholder": "", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "pagerduty", - "name": "PagerDuty", - "heading": "PagerDuty settings", - "description": "Sends notifications to PagerDuty", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Integration Key", - "description": "", - "placeholder": "Pagerduty Integration Key", - "propertyName": "integrationKey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "select", - "inputType": "", - "label": "Severity", - "description": "", - "placeholder": "", - "propertyName": "severity", - "selectOptions": [ - { - "value": "critical", - "label": "Critical" - }, - { - "value": "error", - "label": "Error" - }, - { - "value": "warning", - "label": "Warning" - }, - { - "value": "info", - "label": "Info" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Class", - "description": "The class/type of the event, for example 'ping failure' or 'cpu load'", - "placeholder": "", - "propertyName": "class", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Component", - "description": "Component of the source machine that is responsible for the event, for example mysql or eth0", - "placeholder": "Grafana", - "propertyName": "component", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Group", - "description": "Logical grouping of components of a service, for example 'app-stack'", - "placeholder": "", - "propertyName": "group", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Summary", - "description": "You can use templates for summary", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "summary", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "victorops", - "name": "VictorOps", - "heading": "VictorOps settings", - "description": "Sends notifications to VictorOps", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "VictorOps url", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Message Type", - "description": "", - "placeholder": "", - "propertyName": "messageType", - "selectOptions": [ - { - "value": "CRITICAL", - "label": "CRITICAL" - }, - { - "value": "WARNING", - "label": "WARNING" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "pushover", - "name": "Pushover", - "description": "Sends HTTP POST request to the Pushover API", - "heading": "Pushover settings", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "API Token", - "description": "", - "placeholder": "Application token", - "propertyName": "apiToken", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "User key(s)", - "description": "", - "placeholder": "comma-separated list", - "propertyName": "userKey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Device(s) (optional)", - "description": "", - "placeholder": "comma-separated list; leave empty to send to all devices", - "propertyName": "device", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Alerting priority", - "description": "", - "placeholder": "", - "propertyName": "priority", - "selectOptions": [ - { - "value": "2", - "label": "Emergency" - }, - { - "value": "1", - "label": "High" - }, - { - "value": "0", - "label": "Normal" - }, - { - "value": "-1", - "label": "Low" - }, - { - "value": "-2", - "label": "Lowest" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "OK priority", - "description": "", - "placeholder": "", - "propertyName": "okPriority", - "selectOptions": [ - { - "value": "2", - "label": "Emergency" - }, - { - "value": "1", - "label": "High" - }, - { - "value": "0", - "label": "Normal" - }, - { - "value": "-1", - "label": "Low" - }, - { - "value": "-2", - "label": "Lowest" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Retry (Only used for Emergency Priority)", - "description": "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.", - "placeholder": "minimum 30 seconds", - "propertyName": "retry", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Expire (Only used for Emergency Priority)", - "description": "How many seconds the alerting or OK notification will continue to be retried.", - "placeholder": "maximum 86400 seconds", - "propertyName": "expire", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Alerting sound", - "description": "", - "placeholder": "", - "propertyName": "sound", - "selectOptions": [ - { - "value": "default", - "label": "Default" - }, - { - "value": "pushover", - "label": "Pushover" - }, - { - "value": "bike", - "label": "Bike" - }, - { - "value": "bugle", - "label": "Bugle" - }, - { - "value": "cashregister", - "label": "Cashregister" - }, - { - "value": "classical", - "label": "Classical" - }, - { - "value": "cosmic", - "label": "Cosmic" - }, - { - "value": "falling", - "label": "Falling" - }, - { - "value": "gamelan", - "label": "Gamelan" - }, - { - "value": "incoming", - "label": "Incoming" - }, - { - "value": "intermission", - "label": "Intermission" - }, - { - "value": "magic", - "label": "Magic" - }, - { - "value": "mechanical", - "label": "Mechanical" - }, - { - "value": "pianobar", - "label": "Pianobar" - }, - { - "value": "siren", - "label": "Siren" - }, - { - "value": "spacealarm", - "label": "Spacealarm" - }, - { - "value": "tugboat", - "label": "Tugboat" - }, - { - "value": "alien", - "label": "Alien" - }, - { - "value": "climb", - "label": "Climb" - }, - { - "value": "persistent", - "label": "Persistent" - }, - { - "value": "echo", - "label": "Echo" - }, - { - "value": "updown", - "label": "Updown" - }, - { - "value": "none", - "label": "None" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "OK sound", - "description": "", - "placeholder": "", - "propertyName": "okSound", - "selectOptions": [ - { - "value": "default", - "label": "Default" - }, - { - "value": "pushover", - "label": "Pushover" - }, - { - "value": "bike", - "label": "Bike" - }, - { - "value": "bugle", - "label": "Bugle" - }, - { - "value": "cashregister", - "label": "Cashregister" - }, - { - "value": "classical", - "label": "Classical" - }, - { - "value": "cosmic", - "label": "Cosmic" - }, - { - "value": "falling", - "label": "Falling" - }, - { - "value": "gamelan", - "label": "Gamelan" - }, - { - "value": "incoming", - "label": "Incoming" - }, - { - "value": "intermission", - "label": "Intermission" - }, - { - "value": "magic", - "label": "Magic" - }, - { - "value": "mechanical", - "label": "Mechanical" - }, - { - "value": "pianobar", - "label": "Pianobar" - }, - { - "value": "siren", - "label": "Siren" - }, - { - "value": "spacealarm", - "label": "Spacealarm" - }, - { - "value": "tugboat", - "label": "Tugboat" - }, - { - "value": "alien", - "label": "Alien" - }, - { - "value": "climb", - "label": "Climb" - }, - { - "value": "persistent", - "label": "Persistent" - }, - { - "value": "echo", - "label": "Echo" - }, - { - "value": "updown", - "label": "Updown" - }, - { - "value": "none", - "label": "None" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "slack", - "name": "Slack", - "heading": "Slack settings", - "description": "Sends notifications to Slack", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Recipient", - "description": "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook", - "placeholder": "", - "propertyName": "recipient", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Token", - "description": "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook", - "placeholder": "", - "propertyName": "token", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Username", - "description": "Set the username for the bot's message", - "placeholder": "", - "propertyName": "username", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Icon emoji", - "description": "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.", - "placeholder": "", - "propertyName": "icon_emoji", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Icon URL", - "description": "Provide a URL to an image to use as the icon for the bot's message", - "placeholder": "", - "propertyName": "icon_url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Mention Users", - "description": "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)", - "placeholder": "", - "propertyName": "mentionUsers", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Mention Groups", - "description": "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)", - "placeholder": "", - "propertyName": "mentionGroups", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Mention Channel", - "description": "Mention whole channel or just active members when notifying", - "placeholder": "", - "propertyName": "mentionChannel", - "selectOptions": [ - { - "value": "", - "label": "Disabled" - }, - { - "value": "here", - "label": "Every active channel member" - }, - { - "value": "channel", - "label": "Every channel member" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Webhook URL", - "description": "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary", - "placeholder": "Slack incoming webhook URL", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Title", - "description": "Templated title of the slack message", - "placeholder": "{{ template \"slack.default.title\" . }}", - "propertyName": "title", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Text Body", - "description": "Body of the slack message", - "placeholder": "{{ template \"slack.default.text\" . }}", - "propertyName": "text", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "sensugo", - "name": "Sensu Go", - "description": "Sends HTTP POST request to a Sensu Go API", - "heading": "Sensu Go Settings", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Backend URL", - "description": "", - "placeholder": "http://sensu-api.local:8080", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "password", - "label": "API Key", - "description": "API key to auth to Sensu Go backend", - "placeholder": "", - "propertyName": "apikey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Proxy entity name", - "description": "", - "placeholder": "default", - "propertyName": "entity", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Check name", - "description": "", - "placeholder": "default", - "propertyName": "check", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Handler", - "description": "", - "placeholder": "", - "propertyName": "handler", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Namespace", - "description": "", - "placeholder": "default", - "propertyName": "namespace", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "teams", - "name": "Microsoft Teams", - "heading": "Teams settings", - "description": "Sends notifications using Incoming Webhook connector to Microsoft Teams", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "URL", - "description": "", - "placeholder": "Teams incoming webhook url", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "telegram", - "name": "Telegram", - "heading": "Telegram API settings", - "description": "Sends notifications to Telegram", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "BOT API Token", - "description": "", - "placeholder": "Telegram BOT API Token", - "propertyName": "bottoken", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Chat ID", - "description": "Integer Telegram Chat Identifier", - "placeholder": "", - "propertyName": "chatid", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "webhook", - "name": "webhook", - "heading": "Webhook settings", - "description": "Sends HTTP POST request to a URL", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Http Method", - "description": "", - "placeholder": "", - "propertyName": "httpMethod", - "selectOptions": [ - { - "value": "POST", - "label": "POST" - }, - { - "value": "PUT", - "label": "PUT" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Username", - "description": "", - "placeholder": "", - "propertyName": "username", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "password", - "label": "Password", - "description": "", - "placeholder": "", - "propertyName": "password", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Max Alerts", - "description": "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", - "placeholder": "", - "propertyName": "maxAlerts", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "wecom", - "name": "WeCom", - "heading": "WeCom settings", - "description": "Send alerts generated by Grafana to WeCom", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "Custom WeCom message. You can use template variables.", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "prometheus-alertmanager", - "name": "Alertmanager", - "heading": "Alertmanager Settings", - "description": "Sends notifications to Alertmanager", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "URL", - "description": "", - "placeholder": "http://localhost:9093", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Basic Auth User", - "description": "", - "placeholder": "", - "propertyName": "basicAuthUser", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "password", - "label": "Basic Auth Password", - "description": "", - "placeholder": "", - "propertyName": "basicAuthPassword", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - } - ] - }, - { - "type": "discord", - "name": "Discord", - "heading": "Discord settings", - "description": "Sends notifications to Discord", - "info": "", - "options": [ - { - "label": "Message Content", - "description": "Mention a group using @ or a user using <@ID> when notifying in a channel", - "element": "input", - "inputType": "text", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "label": "Webhook URL", - "description": "", - "element": "input", - "inputType": "text", - "placeholder": "Discord webhook URL", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "label": "Avatar URL", - "description": "", - "element": "input", - "inputType": "text", - "placeholder": "", - "propertyName": "avatar_url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "checkbox", - "inputType": "", - "label": "Use Discord's Webhook Username", - "description": "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", - "placeholder": "", - "propertyName": "use_discord_username", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "googlechat", - "name": "Google Hangouts Chat", - "heading": "Google Hangouts Chat settings", - "description": "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message format", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "Google Hangouts Chat incoming webhook url", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "LINE", - "name": "LINE", - "heading": "LINE notify settings", - "description": "Send notifications to LINE notify", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Token", - "description": "", - "placeholder": "LINE notify token key", - "propertyName": "token", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - } - ] - }, - { - "type": "threema", - "name": "Threema Gateway", - "heading": "Threema Gateway settings", - "description": "Sends notifications to Threema using Threema Gateway (Basic IDs)", - "info": "Notifications can be configured for any Threema Gateway ID of type \"Basic\". End-to-End IDs are not currently supported.The Threema Gateway ID can be set up at https://gateway.threema.ch/.", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Gateway ID", - "description": "Your 8 character Threema Gateway Basic ID (starting with a *).", - "placeholder": "*3MAGWID", - "propertyName": "gateway_id", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "\\*[0-9A-Z]{7}", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Recipient ID", - "description": "The 8 character Threema ID that should receive the alerts.", - "placeholder": "YOUR3MID", - "propertyName": "recipient_id", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "[0-9A-Z]{8}", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "API Secret", - "description": "Your Threema Gateway API secret.", - "placeholder": "", - "propertyName": "api_secret", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - } - ] - }, - { - "type": "opsgenie", - "name": "OpsGenie", - "heading": "OpsGenie settings", - "description": "Sends notifications to OpsGenie", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "API Key", - "description": "", - "placeholder": "OpsGenie API Key", - "propertyName": "apiKey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Alert API Url", - "description": "", - "placeholder": "https://api.opsgenie.com/v2/alerts", - "propertyName": "apiUrl", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "checkbox", - "inputType": "", - "label": "Auto close incidents", - "description": "Automatically close alerts in OpsGenie once the alert goes back to ok.", - "placeholder": "", - "propertyName": "autoClose", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "checkbox", - "inputType": "", - "label": "Override priority", - "description": "Allow the alert priority to be set using the og_priority annotation", - "placeholder": "", - "propertyName": "overridePriority", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Send notification tags as", - "description": "Send the common annotations to Opsgenie as either Extra Properties, Tags or both", - "placeholder": "", - "propertyName": "sendTagsAs", - "selectOptions": [ - { - "value": "tags", - "label": "Tags" - }, - { - "value": "details", - "label": "Extra Properties" - }, - { - "value": "both", - "label": "Tags & Extra Properties" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - } -] -` + expNotifiers := notifier.GetAvailableNotifiers() + expJson, err := json.Marshal(expNotifiers) + require.NoError(t, err) + require.Equal(t, string(expJson), string(b)) +} diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 011443c546b..bce301c230c 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -10,6 +10,10 @@ import ( "testing" "time" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" @@ -17,9 +21,6 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestPrometheusRules(t *testing.T) { @@ -208,7 +209,7 @@ func TestPrometheusRules(t *testing.T) { require.NoError(t, err) assert.Equal(t, 400, resp.StatusCode) - require.JSONEq(t, `{"message": "failed to update rule group: invalid alert rule: cannot have Panel ID without a Dashboard UID"}`, string(b)) + require.JSONEq(t, `{"message": "invalid rule specification at index [0]: both annotations __dashboardUid__ and __panelId__ must be specified"}`, string(b)) } // Now, let's see how this looks like. diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index c3576ac77c6..3e59d152ddd 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -10,6 +10,10 @@ import ( "testing" "time" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" @@ -17,9 +21,6 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestAlertRulePermissions(t *testing.T) { @@ -432,7 +433,7 @@ func TestAlertRuleConflictingTitle(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - require.JSONEq(t, `{"message": "failed to update rule group: a conflicting alert rule is found: rule title under the same organisation and folder should be unique"}`, string(b)) + require.JSONEq(t, `{"message": "failed to update rule group: failed to add or update rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique"}`, string(b)) }) t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) { diff --git a/pkg/tests/api/metrics/api_metrics_test.go b/pkg/tests/api/metrics/api_metrics_test.go deleted file mode 100644 index c23ad995701..00000000000 --- a/pkg/tests/api/metrics/api_metrics_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package metrics - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "testing" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" - "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch" - - cwapi "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQueryCloudWatchMetrics(t *testing.T) { - grafDir, cfgPath := testinfra.CreateGrafDir(t) - - addr, sqlStore := testinfra.StartGrafana(t, grafDir, cfgPath) - setUpDatabase(t, sqlStore, "metrics") - - origNewCWClient := cloudwatch.NewCWClient - t.Cleanup(func() { - cloudwatch.NewCWClient = origNewCWClient - }) - var client cloudwatch.FakeCWClient - cloudwatch.NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { - return client - } - - t.Run("Custom metrics", func(t *testing.T) { - client = cloudwatch.FakeCWClient{ - Metrics: []*cwapi.Metric{ - { - MetricName: aws.String("Test_MetricName"), - Dimensions: []*cwapi.Dimension{ - { - Name: aws.String("Test_DimensionName"), - }, - }, - }, - }, - } - result := getCWMetrics(t, 1, addr) - - type suggestData struct { - Text string - Value string - Label string - } - expect := []suggestData{ - {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"}, - } - actual := []suggestData{} - err := json.Unmarshal(result, &actual) - require.NoError(t, err) - assert.Equal(t, expect, actual) - }) -} - -func TestQueryCloudWatchLogs(t *testing.T) { - grafDir, cfgPath := testinfra.CreateGrafDir(t) - addr, store := testinfra.StartGrafana(t, grafDir, cfgPath) - setUpDatabase(t, store, "logs") - - origNewCWLogsClient := cloudwatch.NewCWLogsClient - t.Cleanup(func() { - cloudwatch.NewCWLogsClient = origNewCWLogsClient - }) - - var client cloudwatch.FakeCWLogsClient - cloudwatch.NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return client - } - - t.Run("Describe log groups", func(t *testing.T) { - client = cloudwatch.FakeCWLogsClient{} - - req := dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.NewFromAny(map[string]interface{}{ - "type": "logAction", - "subtype": "DescribeLogGroups", - "region": "us-east-1", - "datasourceId": 1, - }), - }, - } - tr := makeCWRequest(t, req, addr) - - dataFrames := data.Frames{ - &data.Frame{ - Name: "logGroups", - RefID: "A", - Fields: []*data.Field{ - data.NewField("logGroupName", nil, []*string{}), - }, - }, - } - - expect := backend.NewQueryDataResponse() - expect.Responses["A"] = backend.DataResponse{ - Frames: dataFrames, - } - assert.Equal(t, *expect, tr) - }) -} - -func getCWMetrics(t *testing.T, datasourceId int, addr string) []byte { - t.Helper() - - u := fmt.Sprintf("http://%s/api/datasources/%v/resources/metrics?region=us-east-1&namespace=custom", addr, datasourceId) - t.Logf("Making GET request to %s", u) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - require.NotNil(t, resp) - t.Cleanup(func() { - err := resp.Body.Close() - assert.NoError(t, err) - }) - - buf := bytes.Buffer{} - _, err = io.Copy(&buf, resp.Body) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - - return buf.Bytes() -} - -func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.QueryDataResponse { - t.Helper() - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - err := enc.Encode(&req) - require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ds/query", addr) - t.Logf("Making POST request to %s", u) - // nolint:gosec - resp, err := http.Post(u, "application/json", &buf) - require.NoError(t, err) - require.NotNil(t, resp) - t.Cleanup(func() { - err := resp.Body.Close() - assert.NoError(t, err) - }) - - buf = bytes.Buffer{} - _, err = io.Copy(&buf, resp.Body) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - - var tr backend.QueryDataResponse - err = json.Unmarshal(buf.Bytes(), &tr) - require.NoError(t, err) - - return tr -} - -func setUpDatabase(t *testing.T, store *sqlstore.SQLStore, uid string) { - t.Helper() - - err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - _, err := sess.Insert(&models.DataSource{ - Id: 1, - Uid: uid, - // This will be the ID of the main org - OrgId: 2, - Name: "Test", - Type: "cloudwatch", - Created: time.Now(), - Updated: time.Now(), - }) - return err - }) - require.NoError(t, err) - - // Make sure changes are synced with other goroutines - err = store.Sync() - require.NoError(t, err) -} diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 2f924899e08..9317e29b080 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -166,11 +166,10 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes if len(point.Value.DistributionValue.BucketCounts) == 0 { continue } - maxKey := 0 for i := 0; i < len(point.Value.DistributionValue.BucketCounts); i++ { value, err := strconv.ParseFloat(point.Value.DistributionValue.BucketCounts[i], 64) if err != nil { - continue + return err } if _, ok := buckets[i]; !ok { // set lower bounds @@ -197,38 +196,9 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes ExecutedQueryString: executedQueryString, }, } - - if maxKey < i { - maxKey = i - } } buckets[i].AppendRow(point.Interval.EndTime, value) } - for i := 0; i < maxKey; i++ { - if _, ok := buckets[i]; !ok { - bucketBound := calcBucketBound(point.Value.DistributionValue.BucketOptions, i) - additionalLabels := data.Labels{"bucket": bucketBound} - timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []time.Time{}) - valueField := data.NewField(data.TimeSeriesValueFieldName, nil, []float64{}) - frameName := formatLegendKeys(series.Metric.Type, defaultMetricName, seriesLabels, - additionalLabels, timeSeriesFilter) - valueField.Name = frameName - valueField.Labels = seriesLabels - setDisplayNameAsFieldName(valueField) - - buckets[i] = &data.Frame{ - Name: frameName, - RefID: timeSeriesFilter.RefID, - Fields: []*data.Field{ - timeField, - valueField, - }, - Meta: &data.FrameMeta{ - ExecutedQueryString: executedQueryString, - }, - } - } - } } for i := 0; i < len(buckets); i++ { buckets[i].Meta.Custom = customFrameMeta diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index f653e811e81..6ff264ff3da 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -68,7 +68,9 @@ func (m *mySQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - + if timeRange.From.UTC().Unix() < 0 { + return fmt.Sprintf("%s BETWEEN DATE_ADD(FROM_UNIXTIME(0), INTERVAL %d SECOND) AND FROM_UNIXTIME(%d)", args[0], timeRange.From.UTC().Unix(), timeRange.To.UTC().Unix()), nil + } return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], timeRange.From.UTC().Unix(), timeRange.To.UTC().Unix()), nil case "__timeFrom": return fmt.Sprintf("FROM_UNIXTIME(%d)", timeRange.From.UTC().Unix()), nil diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 543993731de..86b8912d59e 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -129,7 +129,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") require.Nil(t, err) - require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN DATE_ADD(FROM_UNIXTIME(0), INTERVAL %d SECOND) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) }) t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { @@ -152,7 +152,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") require.Nil(t, err) - require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN DATE_ADD(FROM_UNIXTIME(0), INTERVAL %d SECOND) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) }) t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 046b76668f0..4fe53b56801 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -2,6 +2,7 @@ import React, { PureComponent } from 'react'; import appEvents from 'app/core/app_events'; import AppNotificationItem from './AppNotificationItem'; import { notifyApp, clearAppNotification } from 'app/core/actions'; +import { selectAll } from 'app/core/reducers/appNotification'; import { StoreState } from 'app/types'; import { @@ -16,7 +17,7 @@ import { VerticalGroup } from '@grafana/ui'; export interface OwnProps {} const mapStateToProps = (state: StoreState, props: OwnProps) => ({ - appNotifications: state.appNotifications.appNotifications, + appNotifications: selectAll(state.appNotifications), }); const mapDispatchToProps = { diff --git a/public/app/core/components/NavBar/NavBarItem.tsx b/public/app/core/components/NavBar/NavBarItem.tsx index 67efefbf768..cf91ade17a4 100644 --- a/public/app/core/components/NavBar/NavBarItem.tsx +++ b/public/app/core/components/NavBar/NavBarItem.tsx @@ -33,7 +33,9 @@ const NavBarItem = ({ const { i18n } = useLingui(); const theme = useTheme2(); const menuItems = link.children ?? []; - const menuItemsSorted = reverseMenuDirection ? menuItems.reverse() : menuItems; + + // Spreading `menuItems` here as otherwise we'd be mutating props + const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems; const filteredItems = menuItemsSorted .filter((item) => !item.hideFromMenu) .map((i) => ({ ...i, menuItemType: NavMenuItemType.Item })); diff --git a/public/app/core/components/NavBar/NavBarMenu.test.tsx b/public/app/core/components/NavBar/NavBarMenu.test.tsx index 08f1acd6c39..10e4569e239 100644 --- a/public/app/core/components/NavBar/NavBarMenu.test.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { NavModelItem } from '@grafana/data'; -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { NavModelItem } from '@grafana/data'; +import { render } from 'test/redux-rtl'; import { NavBarMenu } from './NavBarMenu'; describe('NavBarMenu', () => { diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index ae35bde4235..f4c6823f4c3 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -6,6 +6,9 @@ import { useDialog } from '@react-aria/dialog'; import { useOverlay } from '@react-aria/overlays'; import { css } from '@emotion/css'; import { NavBarMenuItem } from './NavBarMenuItem'; +import { useDispatch } from 'react-redux'; +import { togglePin } from 'app/core/reducers/navBarTree'; +import { getConfig } from 'app/core/config'; export interface Props { activeItem?: NavModelItem; @@ -14,6 +17,11 @@ export interface Props { } export function NavBarMenu({ activeItem, navItems, onClose }: Props) { + const dispatch = useDispatch(); + const toggleItemPin = (id: string) => { + dispatch(togglePin({ id })); + }; + const theme = useTheme2(); const styles = getStyles(theme); const ref = useRef(null); @@ -27,6 +35,7 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) { ref ); + const newNavigationEnabled = getConfig().featureToggles.newNavigation; return (
@@ -37,8 +46,8 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {