Merge branch 'main' of github.com:grafana/grafana into task/45304-integrate-alerts-with-folders

This commit is contained in:
Konrad Lalik
2022-02-24 09:12:19 +01:00
214 changed files with 7668 additions and 3672 deletions
-3
View File
@@ -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"]
],
+1 -1
View File
@@ -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() + \
+262 -223
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
+1
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3
View File
@@ -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",
+3
View File
@@ -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
+3
View File
@@ -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
+2 -6
View File
@@ -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
@@ -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).
@@ -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.
+1 -1
View File
@@ -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'
+10 -6
View File
@@ -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
```
+3
View File
@@ -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
+1
View File
@@ -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.
+7 -1
View File
@@ -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)
@@ -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.
@@ -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:**
@@ -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**:
+98 -3
View File
@@ -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
@@ -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()
+1 -1
View File
@@ -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
+2
View File
@@ -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=
+11 -9
View File
@@ -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": [
+11 -1
View File
@@ -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;
}
@@ -42,5 +42,7 @@ export interface FeatureToggles {
validatedQueries?: boolean;
swaggerUi?: boolean;
featureHighlights?: boolean;
dashboardComments?: boolean;
annotationComments?: boolean;
migrationLocking?: boolean;
}
@@ -22,6 +22,7 @@ export interface NavModelItem {
highlightText?: string;
highlightId?: string;
tabSuffix?: ComponentType<{ className?: string }>;
hideFromNavbar?: boolean;
}
export enum NavSection {
+1 -1
View File
@@ -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": {
@@ -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{
@@ -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<BackendDataSourceResponse | undefined>): 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.
+4 -4
View File
@@ -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"
@@ -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(<Modal title={'Some Title'} isOpen={true} />);
});
it('renders nothing by default or when isOpen is false', () => {
const wrapper = mount(<Modal title={'Some Title'} />);
expect(wrapper.html()).toBe(null);
render(<Modal title="Some Title" />);
wrapper.setProps({ ...wrapper.props(), isOpen: false });
expect(wrapper.html()).toBe(null);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders correct contents', () => {
const wrapper = mount(
<Modal title={'Some Title'} isOpen={true}>
<div id={'modal-content'}>Content</div>
render(
<Modal title="Some Title" isOpen>
<div data-testid="modal-content">Content</div>
</Modal>
);
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();
});
});
@@ -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<Props>) {
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<HTMLDivElement>(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<Props>) {
<div
className={styles.modalBackdrop}
onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)}
{...underlayProps}
/>
<FocusScope contain={trapFocus} autoFocus restoreFocus>
{/*
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
*/}
<div tabIndex={-1} className={cx(styles.modal, className)}>
<div className={cx(styles.modal, className)} ref={ref} {...overlayProps} {...dialogProps}>
<div className={headerClass}>
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} />}
{typeof title !== 'string' && title}
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} id={titleProps.id} />}
{
// 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
}
<div className={styles.modalHeaderClose}>
<IconButton aria-label="Close dialogue" surface="header" name="times" size="xl" onClick={onDismiss} />
</div>
@@ -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 <ModalHeader icon={icon} iconTooltip={iconTooltip} title={title} />;
function DefaultModalHeader({ icon, iconTooltip, title, id }: DefaultModalHeaderProps): JSX.Element {
return <ModalHeader icon={icon} iconTooltip={iconTooltip} title={title} id={id} />;
}
@@ -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<Props> = ({ icon, iconTooltip, title, children }) => {
export const ModalHeader: React.FC<Props> = ({ icon, iconTooltip, title, children, id }) => {
const styles = useStyles2(getModalStyles);
return (
<>
<h2 className={styles.modalHeaderTitle}>{title}</h2>
<h2 className={styles.modalHeaderTitle} id={id}>
{title}
</h2>
{children}
</>
);
@@ -69,6 +69,7 @@ export class RefreshPicker extends PureComponent<Props> {
return (
<ButtonGroup className="refresh-picker">
<ToolbarButton
aria-label={text}
tooltip={tooltip}
onClick={onRefresh}
variant={variant}
+2
View File
@@ -5,6 +5,7 @@ export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
export const getAvailableIcons = () =>
[
'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',
+7 -2
View File
@@ -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
+49
View File
@@ -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,
})
}
+1 -1
View File
@@ -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
}
+4 -1
View File
@@ -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,
+11 -10
View File
@@ -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[:])
}
+6 -6
View File
@@ -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)
}
@@ -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"
@@ -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
@@ -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
@@ -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")
@@ -1,13 +0,0 @@
package secretsmigrations
type simpleSecret struct {
tableName string
columnName string
isBase64Encoded bool
}
type jsonSecret struct {
tableName string
}
type alertingSecret struct{}
+2
View File
@@ -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"`
}
+2
View File
@@ -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(
@@ -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"`
}
@@ -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"
}
@@ -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
}
+164
View File
@@ -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
}
+38
View File
@@ -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()
}
+116
View File
@@ -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)
})
}
+76
View File
@@ -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)
}
+24
View File
@@ -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)
}
+2
View File
@@ -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
@@ -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)
@@ -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
}
@@ -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
@@ -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)
@@ -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)
@@ -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)
+10
View File
@@ -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",
+8
View File
@@ -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"
@@ -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
}
+48
View File
@@ -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
}
+17 -11
View File
@@ -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
}
@@ -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
}
+1 -1
View File
@@ -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{
+141 -41
View File
@@ -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", &quota.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
}
+279
View File
@@ -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:]
}
}
@@ -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
}
@@ -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)
})
}
}
+7
View File
@@ -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 {
+61
View File
@@ -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
}
}
@@ -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)
})
}
})
}
+127
View File
@@ -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
}
@@ -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,
@@ -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",
}
@@ -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)
+12 -152
View File
@@ -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)
}
+28 -3
View File
@@ -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{}
+20 -60
View File
@@ -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
}
@@ -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)
}
@@ -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)
@@ -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)
}
}
@@ -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
}
@@ -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)
})
@@ -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"}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -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)
}
}
@@ -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 {
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More