Merge branch 'main' of github.com:grafana/grafana into task/45304-integrate-alerts-with-folders
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -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**:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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{}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
@@ -29,8 +31,13 @@ type RulerSrv struct {
|
||||
QuotaService *quota.QuotaService
|
||||
scheduleService schedule.ScheduleService
|
||||
log log.Logger
|
||||
cfg *setting.UnifiedAlertingSettings
|
||||
}
|
||||
|
||||
var (
|
||||
errQuotaReached = errors.New("quota has been exceeded")
|
||||
)
|
||||
|
||||
func (srv RulerSrv) RouteDeleteNamespaceRulesConfig(c *models.ReqContext) response.Response {
|
||||
namespaceTitle := web.Params(c.Req)[":Namespace"]
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgId, c.SignedInUser, true)
|
||||
@@ -243,62 +250,77 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
||||
//TODO: Should this belong in alerting-api?
|
||||
if ruleGroupConfig.Name == "" {
|
||||
return ErrResp(http.StatusBadRequest, errors.New("rule group name is not valid"), "")
|
||||
rules, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.OrgId, namespace, conditionValidator(c, srv.DatasourceCache), srv.cfg)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
alertRuleUIDs := make(map[string]struct{})
|
||||
for _, r := range ruleGroupConfig.Rules {
|
||||
cond := ngmodels.Condition{
|
||||
Condition: r.GrafanaManagedAlert.Condition,
|
||||
OrgID: c.SignedInUser.OrgId,
|
||||
Data: r.GrafanaManagedAlert.Data,
|
||||
}
|
||||
if err := validateCondition(c.Req.Context(), cond, c.SignedInUser, c.SkipCache, srv.DatasourceCache); err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "failed to validate alert rule %q", r.GrafanaManagedAlert.Title)
|
||||
}
|
||||
if r.GrafanaManagedAlert.UID != "" {
|
||||
_, ok := alertRuleUIDs[r.GrafanaManagedAlert.UID]
|
||||
if ok {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("conflicting UID %q found", r.GrafanaManagedAlert.UID), "failed to validate alert rule %q", r.GrafanaManagedAlert.Title)
|
||||
}
|
||||
alertRuleUIDs[r.GrafanaManagedAlert.UID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return srv.updateAlertRulesInGroup(c, namespace, ruleGroupConfig.Name, rules)
|
||||
}
|
||||
|
||||
numOfNewRules := len(ruleGroupConfig.Rules) - len(alertRuleUIDs)
|
||||
if numOfNewRules > 0 {
|
||||
// quotas are checked in advanced
|
||||
// that is acceptable under the assumption that there will be only one alert rule under the rule group
|
||||
// alternatively we should check the quotas after the rule group update
|
||||
// and rollback the transaction in case of violation
|
||||
limitReached, err := srv.QuotaService.QuotaReached(c, "alert_rule")
|
||||
func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *models.Folder, groupName string, rules []*ngmodels.AlertRule) response.Response {
|
||||
// TODO add create rules authz logic
|
||||
|
||||
var changes *RuleChanges = nil
|
||||
err := srv.store.InTransaction(c.Req.Context(), func(tranCtx context.Context) error {
|
||||
var err error
|
||||
changes, err = calculateChanges(tranCtx, srv.store, c.SignedInUser.OrgId, namespace, groupName, rules)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to get quota")
|
||||
return err
|
||||
}
|
||||
if limitReached {
|
||||
return ErrResp(http.StatusForbidden, errors.New("quota reached"), "")
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.store.UpdateRuleGroup(c.Req.Context(), store.UpdateRuleGroupCmd{
|
||||
OrgID: c.SignedInUser.OrgId,
|
||||
NamespaceUID: namespace.Uid,
|
||||
RuleGroupConfig: ruleGroupConfig,
|
||||
}); err != nil {
|
||||
// TODO add update/delete authz logic
|
||||
err = srv.store.UpsertAlertRules(tranCtx, changes.Upsert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add or update rules: %w", err)
|
||||
}
|
||||
|
||||
for _, rule := range changes.Delete {
|
||||
if err = srv.store.DeleteAlertRuleByUID(tranCtx, c.SignedInUser.OrgId, rule.UID); err != nil {
|
||||
return fmt.Errorf("failed to delete rule %d with UID %s: %w", rule.ID, rule.UID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if changes.newRules > 0 {
|
||||
limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{
|
||||
OrgId: c.OrgId,
|
||||
UserId: c.UserId,
|
||||
}) // alert rule is table name
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get alert rules quota: %w", err)
|
||||
}
|
||||
if limitReached {
|
||||
return errQuotaReached
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
|
||||
return ErrResp(http.StatusNotFound, err, "failed to update rule group")
|
||||
} else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) {
|
||||
return ErrResp(http.StatusBadRequest, err, "failed to update rule group")
|
||||
} else if errors.Is(err, errQuotaReached) {
|
||||
return ErrResp(http.StatusForbidden, err, "")
|
||||
}
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to update rule group")
|
||||
}
|
||||
|
||||
for uid := range alertRuleUIDs {
|
||||
srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{
|
||||
// TODO uncomment when rules that are not changed will be filter out from the upsert list.
|
||||
// for _, rule := range changes.Upsert {
|
||||
// if rule.Existing != nil {
|
||||
// srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{
|
||||
// OrgID: c.SignedInUser.OrgId,
|
||||
// UID: rule.Existing.UID,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
for _, rule := range changes.Delete {
|
||||
srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{
|
||||
OrgID: c.SignedInUser.OrgId,
|
||||
UID: uid,
|
||||
UID: rule.UID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -341,3 +363,81 @@ func toNamespaceErrorResponse(err error) response.Response {
|
||||
}
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
|
||||
type RuleChanges struct {
|
||||
newRules int
|
||||
Upsert []store.UpsertRule
|
||||
Delete []*ngmodels.AlertRule
|
||||
}
|
||||
|
||||
// calculateChanges calculates the difference between rules in the group in the database and the submitted rules. If a submitted rule has UID it tries to find it in the database (in other groups).
|
||||
// returns a list of rules that need to be added, updated and deleted. Deleted considered rules in the database that belong to the group but do not exist in the list of submitted rules.
|
||||
func calculateChanges(ctx context.Context, ruleStore store.RuleStore, orgId int64, namespace *models.Folder, ruleGroupName string, submittedRules []*ngmodels.AlertRule) (*RuleChanges, error) {
|
||||
q := &ngmodels.ListRuleGroupAlertRulesQuery{
|
||||
OrgID: orgId,
|
||||
NamespaceUID: namespace.Uid,
|
||||
RuleGroup: ruleGroupName,
|
||||
}
|
||||
if err := ruleStore.GetRuleGroupAlertRules(ctx, q); err != nil {
|
||||
return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", ruleGroupName, err)
|
||||
}
|
||||
existingGroupRules := q.Result
|
||||
|
||||
existingGroupRulesUIDs := make(map[string]*ngmodels.AlertRule, len(existingGroupRules))
|
||||
for _, r := range existingGroupRules {
|
||||
existingGroupRulesUIDs[r.UID] = r
|
||||
}
|
||||
|
||||
upsert := make([]store.UpsertRule, 0, len(submittedRules))
|
||||
toDelete := make([]*ngmodels.AlertRule, 0, len(submittedRules))
|
||||
newRules := 0
|
||||
for _, r := range submittedRules {
|
||||
var existing *ngmodels.AlertRule = nil
|
||||
|
||||
if r.UID != "" {
|
||||
if existingGroupRule, ok := existingGroupRulesUIDs[r.UID]; ok {
|
||||
existing = existingGroupRule
|
||||
// remove the rule from existingGroupRulesUIDs
|
||||
delete(existingGroupRulesUIDs, r.UID)
|
||||
} else {
|
||||
// Rule can be from other group or namespace
|
||||
q := &ngmodels.GetAlertRuleByUIDQuery{OrgID: orgId, UID: r.UID}
|
||||
if err := ruleStore.GetAlertRuleByUID(ctx, q); err != nil || q.Result == nil {
|
||||
// if rule has UID then it is considered an update. Therefore, fail if there is no rule to update
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) || q.Result == nil && err == nil {
|
||||
return nil, fmt.Errorf("failed to update rule with UID %s because %w", r.UID, ngmodels.ErrAlertRuleNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query database for an alert rule with UID %s: %w", r.UID, err)
|
||||
}
|
||||
existing = q.Result
|
||||
}
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
upsert = append(upsert, store.UpsertRule{
|
||||
Existing: nil,
|
||||
New: *r,
|
||||
})
|
||||
newRules++
|
||||
continue
|
||||
}
|
||||
|
||||
ngmodels.PatchPartialAlertRule(existing, r)
|
||||
// TODO diff between patched and existing, as well as between submitted
|
||||
upsert = append(upsert, store.UpsertRule{
|
||||
Existing: existing,
|
||||
New: *r,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rule := range existingGroupRulesUIDs {
|
||||
toDelete = append(toDelete, rule)
|
||||
}
|
||||
|
||||
return &RuleChanges{
|
||||
Upsert: upsert,
|
||||
Delete: toDelete,
|
||||
newRules: newRules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user