Compare commits

..

1 Commits

Author SHA1 Message Date
Gareth Dawson e80ef1a3e5 Tempo: Fix search default qeury 2026-01-12 20:11:24 +09:00
237 changed files with 1201 additions and 9557 deletions
@@ -14,9 +14,6 @@ outputs:
frontend:
description: Whether the frontend or self has changed in any way
value: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}
frontend-packages:
description: Whether any frontend packages have changed
value: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}
e2e:
description: Whether the e2e tests or self have changed in any way
value: ${{ steps.changed-files.outputs.e2e_any_changed == 'true' ||
@@ -100,12 +97,6 @@ runs:
- '.yarn/**'
- 'apps/dashboard/pkg/migration/**'
- '${{ inputs.self }}'
frontend_packages:
- '.github/actions/checkout/**'
- '.github/actions/change-detection/**'
- 'packages/**'
- './scripts/validate-npm-packages.sh'
- '${{ inputs.self }}'
e2e:
- 'e2e/**'
- 'e2e-playwright/**'
@@ -162,8 +153,6 @@ runs:
echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}"
echo "Frontend: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}"
echo "Frontend packages: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.frontend_packages_all_changed_files }}"
echo "E2E: ${{ steps.changed-files.outputs.e2e_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.e2e_all_changed_files }}"
echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}"
+2 -2
View File
@@ -4,8 +4,8 @@ description: Sets up a node.js environment with presets for the Grafana reposito
runs:
using: "composite"
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
cache-dependency-path: 'yarn.lock'
+30 -36
View File
@@ -17,7 +17,6 @@ jobs:
outputs:
changed: ${{ steps.detect-changes.outputs.frontend }}
prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }}
changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }}
steps:
- uses: actions/checkout@v5
with:
@@ -43,8 +42,11 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- run: yarn install --immutable --check-cache
- run: yarn run prettier:check
- run: yarn run lint
@@ -61,8 +63,11 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Enterprise
uses: ./.github/actions/setup-enterprise
with:
@@ -84,8 +89,11 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- run: yarn install --immutable --check-cache
- run: yarn run typecheck
lint-frontend-typecheck-enterprise:
@@ -101,8 +109,11 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Enterprise
uses: ./.github/actions/setup-enterprise
with:
@@ -122,8 +133,11 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- run: yarn install --immutable --check-cache
- name: Generate API clients
run: |
@@ -150,8 +164,11 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Enterprise
uses: ./.github/actions/setup-enterprise
with:
@@ -170,26 +187,3 @@ jobs:
echo "${uncommited_error_message}"
exit 1
fi
lint-frontend-packed-packages:
needs: detect-changes
permissions:
contents: read
id-token: write
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.changed-frontend-packages == 'true'
name: Verify packed frontend packages
runs-on: ubuntu-latest
steps:
- name: Checkout build commit
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Install dependencies
run: yarn install --immutable
- name: Build and pack packages
run: |
yarn run packages:build
yarn run packages:pack
- name: Validate packages
run: ./scripts/validate-npm-packages.sh
@@ -852,194 +852,6 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -1102,24 +914,6 @@
"name": "panel-6"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -879,200 +879,6 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -1167,32 +973,6 @@
"name": "panel-6"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 8,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -711,146 +711,6 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 18
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 18
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -711,146 +711,6 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 18
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 18
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -879,200 +879,6 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -1135,24 +941,6 @@
"name": "panel-6"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -711,146 +711,6 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 0,
"y": 6
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 8,
"y": 6
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -711,146 +711,6 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 0,
"y": 6
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 8,
"y": 6
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -852,194 +852,6 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -1134,32 +946,6 @@
"name": "panel-6"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 8,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -1195,36 +1195,16 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer
// getPanelDatasource determines the panel-level datasource for V1.
// Returns:
// - Mixed datasource reference if queries use different datasources
// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels)
// - Dashboard datasource reference if a single query uses Dashboard datasource
// - First query's datasource if all queries use the same datasource
// - nil if no queries exist
// Compares based on V2 input without runtime resolution:
// - If query has explicit datasource.uid → use that UID and type
// - Else → use query.Kind as type (empty UID)
func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} {
const sharedDashboardQuery = "-- Dashboard --"
if len(queries) == 0 {
return nil
}
// Count how many queries use Dashboard datasource
// Multiple dashboard queries need mixed mode because they fetch from different panels
// which may have different underlying datasources
dashboardDsQueryCount := 0
for _, query := range queries {
if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery {
dashboardDsQueryCount++
}
}
if dashboardDsQueryCount > 1 {
return map[string]interface{}{
"type": "mixed",
"uid": "-- Mixed --",
}
}
var firstUID, firstType string
var hasFirst bool
@@ -1259,16 +1239,6 @@ func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[stri
}
}
// Handle case when a single query uses Dashboard datasource.
// This is needed for the frontend to properly activate and fetch data from source panels.
// See DashboardDatasourceBehaviour.tsx for more details.
if firstUID == sharedDashboardQuery {
return map[string]interface{}{
"type": "datasource",
"uid": sharedDashboardQuery,
}
}
// Not mixed - return the first query's datasource so the panel has a datasource set.
// This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource
// to resolve the datasource, and if undefined, it falls back to the default datasource
@@ -32,7 +32,7 @@ type ConnectionSecure struct {
// Token is the reference of the token used to act as the Connection.
// This value is stored securely and cannot be read back
Token common.InlineSecureValue `json:"token,omitzero,omitempty"`
Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"`
}
func (v ConnectionSecure) IsZero() bool {
@@ -320,7 +320,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.Reference
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
},
},
"token": {
"webhook": {
SchemaProps: spec.SchemaProps{
Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back",
Default: map[string]interface{}{},
@@ -22,6 +22,7 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs
@@ -1,24 +0,0 @@
package connection
import (
"context"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter
type Connection interface {
// Validate ensures the resource _looks_ correct.
// It should be called before trying to upsert a resource into the Kubernetes API server.
// This is not an indication that the connection information works, just that they are reasonably configured.
Validate(ctx context.Context) error
// Mutate performs in place mutation of the underneath resource.
Mutate(context.Context) error
// GenerateRepositoryToken generates a repository-scoped access token.
// For GitHub connections, this creates an installation token using the GitHub App credentials.
// The repo parameter specifies the repository name the token should be scoped to.
GenerateRepositoryToken(ctx context.Context, repo *provisioning.Repository) (common.RawSecureValue, error)
}
@@ -1,189 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package connection
import (
context "context"
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
mock "github.com/stretchr/testify/mock"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// MockConnection is an autogenerated mock type for the Connection type
type MockConnection struct {
mock.Mock
}
type MockConnection_Expecter struct {
mock *mock.Mock
}
func (_m *MockConnection) EXPECT() *MockConnection_Expecter {
return &MockConnection_Expecter{mock: &_m.Mock}
}
// GenerateRepositoryToken provides a mock function with given fields: ctx, repo
func (_m *MockConnection) GenerateRepositoryToken(ctx context.Context, repo *v0alpha1.Repository) (commonv0alpha1.RawSecureValue, error) {
ret := _m.Called(ctx, repo)
if len(ret) == 0 {
panic("no return value specified for GenerateRepositoryToken")
}
var r0 commonv0alpha1.RawSecureValue
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) (commonv0alpha1.RawSecureValue, error)); ok {
return rf(ctx, repo)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) commonv0alpha1.RawSecureValue); ok {
r0 = rf(ctx, repo)
} else {
r0 = ret.Get(0).(commonv0alpha1.RawSecureValue)
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Repository) error); ok {
r1 = rf(ctx, repo)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GenerateRepositoryToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateRepositoryToken'
type MockConnection_GenerateRepositoryToken_Call struct {
*mock.Call
}
// GenerateRepositoryToken is a helper method to define mock.On call
// - ctx context.Context
// - repo *v0alpha1.Repository
func (_e *MockConnection_Expecter) GenerateRepositoryToken(ctx interface{}, repo interface{}) *MockConnection_GenerateRepositoryToken_Call {
return &MockConnection_GenerateRepositoryToken_Call{Call: _e.mock.On("GenerateRepositoryToken", ctx, repo)}
}
func (_c *MockConnection_GenerateRepositoryToken_Call) Run(run func(ctx context.Context, repo *v0alpha1.Repository)) *MockConnection_GenerateRepositoryToken_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository))
})
return _c
}
func (_c *MockConnection_GenerateRepositoryToken_Call) Return(_a0 commonv0alpha1.RawSecureValue, _a1 error) *MockConnection_GenerateRepositoryToken_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GenerateRepositoryToken_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository) (commonv0alpha1.RawSecureValue, error)) *MockConnection_GenerateRepositoryToken_Call {
_c.Call.Return(run)
return _c
}
// Mutate provides a mock function with given fields: _a0
func (_m *MockConnection) Mutate(_a0 context.Context) error {
ret := _m.Called(_a0)
if len(ret) == 0 {
panic("no return value specified for Mutate")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate'
type MockConnection_Mutate_Call struct {
*mock.Call
}
// Mutate is a helper method to define mock.On call
// - _a0 context.Context
func (_e *MockConnection_Expecter) Mutate(_a0 interface{}) *MockConnection_Mutate_Call {
return &MockConnection_Mutate_Call{Call: _e.mock.On("Mutate", _a0)}
}
func (_c *MockConnection_Mutate_Call) Run(run func(_a0 context.Context)) *MockConnection_Mutate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockConnection_Mutate_Call) Return(_a0 error) *MockConnection_Mutate_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Mutate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Mutate_Call {
_c.Call.Return(run)
return _c
}
// Validate provides a mock function with given fields: ctx
func (_m *MockConnection) Validate(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for Validate")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
type MockConnection_Validate_Call struct {
*mock.Call
}
// Validate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockConnection_Expecter) Validate(ctx interface{}) *MockConnection_Validate_Call {
return &MockConnection_Validate_Call{Call: _e.mock.On("Validate", ctx)}
}
func (_c *MockConnection_Validate_Call) Run(run func(ctx context.Context)) *MockConnection_Validate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockConnection_Validate_Call) Return(_a0 error) *MockConnection_Validate_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Validate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Validate_Call {
_c.Call.Return(run)
return _c
}
// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockConnection(t interface {
mock.TestingT
Cleanup(func())
}) *MockConnection {
mock := &MockConnection{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,141 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package connection
import (
context "context"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockExtra is an autogenerated mock type for the Extra type
type MockExtra struct {
mock.Mock
}
type MockExtra_Expecter struct {
mock *mock.Mock
}
func (_m *MockExtra) EXPECT() *MockExtra_Expecter {
return &MockExtra_Expecter{mock: &_m.Mock}
}
// Build provides a mock function with given fields: ctx, r
func (_m *MockExtra) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) {
ret := _m.Called(ctx, r)
if len(ret) == 0 {
panic("no return value specified for Build")
}
var r0 Connection
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok {
return rf(ctx, r)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok {
r0 = rf(ctx, r)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Connection)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok {
r1 = rf(ctx, r)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockExtra_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
type MockExtra_Build_Call struct {
*mock.Call
}
// Build is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Connection
func (_e *MockExtra_Expecter) Build(ctx interface{}, r interface{}) *MockExtra_Build_Call {
return &MockExtra_Build_Call{Call: _e.mock.On("Build", ctx, r)}
}
func (_c *MockExtra_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockExtra_Build_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Connection))
})
return _c
}
func (_c *MockExtra_Build_Call) Return(_a0 Connection, _a1 error) *MockExtra_Build_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockExtra_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockExtra_Build_Call {
_c.Call.Return(run)
return _c
}
// Type provides a mock function with no fields
func (_m *MockExtra) Type() v0alpha1.ConnectionType {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Type")
}
var r0 v0alpha1.ConnectionType
if rf, ok := ret.Get(0).(func() v0alpha1.ConnectionType); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(v0alpha1.ConnectionType)
}
return r0
}
// MockExtra_Type_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Type'
type MockExtra_Type_Call struct {
*mock.Call
}
// Type is a helper method to define mock.On call
func (_e *MockExtra_Expecter) Type() *MockExtra_Type_Call {
return &MockExtra_Type_Call{Call: _e.mock.On("Type")}
}
func (_c *MockExtra_Type_Call) Run(run func()) *MockExtra_Type_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockExtra_Type_Call) Return(_a0 v0alpha1.ConnectionType) *MockExtra_Type_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockExtra_Type_Call) RunAndReturn(run func() v0alpha1.ConnectionType) *MockExtra_Type_Call {
_c.Call.Return(run)
return _c
}
// NewMockExtra creates a new instance of MockExtra. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockExtra(t interface {
mock.TestingT
Cleanup(func())
}) *MockExtra {
mock := &MockExtra{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,75 +0,0 @@
package connection
import (
"context"
"fmt"
"sort"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
//go:generate mockery --name=Extra --structname=MockExtra --inpackage --filename=extra_mock.go --with-expecter
type Extra interface {
Type() provisioning.ConnectionType
Build(ctx context.Context, r *provisioning.Connection) (Connection, error)
}
//go:generate mockery --name=Factory --structname=MockFactory --inpackage --filename=factory_mock.go --with-expecter
type Factory interface {
Types() []provisioning.ConnectionType
Build(ctx context.Context, r *provisioning.Connection) (Connection, error)
}
type factory struct {
extras map[provisioning.ConnectionType]Extra
enabled map[provisioning.ConnectionType]struct{}
}
func ProvideFactory(enabled map[provisioning.ConnectionType]struct{}, extras []Extra) (Factory, error) {
f := &factory{
enabled: enabled,
extras: make(map[provisioning.ConnectionType]Extra, len(extras)),
}
for _, e := range extras {
if _, exists := f.extras[e.Type()]; exists {
return nil, fmt.Errorf("connection type %q is already registered", e.Type())
}
f.extras[e.Type()] = e
}
return f, nil
}
func (f *factory) Types() []provisioning.ConnectionType {
var types []provisioning.ConnectionType
for t := range f.enabled {
if _, exists := f.extras[t]; exists {
types = append(types, t)
}
}
sort.Slice(types, func(i, j int) bool {
return string(types[i]) < string(types[j])
})
return types
}
func (f *factory) Build(ctx context.Context, c *provisioning.Connection) (Connection, error) {
for _, e := range f.extras {
if e.Type() == c.Spec.Type {
if _, enabled := f.enabled[e.Type()]; !enabled {
return nil, fmt.Errorf("connection type %q is not enabled", e.Type())
}
return e.Build(ctx, c)
}
}
return nil, fmt.Errorf("connection type %q is not supported", c.Spec.Type)
}
var (
_ Factory = (*factory)(nil)
)
@@ -1,143 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package connection
import (
context "context"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockFactory is an autogenerated mock type for the Factory type
type MockFactory struct {
mock.Mock
}
type MockFactory_Expecter struct {
mock *mock.Mock
}
func (_m *MockFactory) EXPECT() *MockFactory_Expecter {
return &MockFactory_Expecter{mock: &_m.Mock}
}
// Build provides a mock function with given fields: ctx, r
func (_m *MockFactory) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) {
ret := _m.Called(ctx, r)
if len(ret) == 0 {
panic("no return value specified for Build")
}
var r0 Connection
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok {
return rf(ctx, r)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok {
r0 = rf(ctx, r)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Connection)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok {
r1 = rf(ctx, r)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockFactory_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
type MockFactory_Build_Call struct {
*mock.Call
}
// Build is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Connection
func (_e *MockFactory_Expecter) Build(ctx interface{}, r interface{}) *MockFactory_Build_Call {
return &MockFactory_Build_Call{Call: _e.mock.On("Build", ctx, r)}
}
func (_c *MockFactory_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockFactory_Build_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Connection))
})
return _c
}
func (_c *MockFactory_Build_Call) Return(_a0 Connection, _a1 error) *MockFactory_Build_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockFactory_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockFactory_Build_Call {
_c.Call.Return(run)
return _c
}
// Types provides a mock function with no fields
func (_m *MockFactory) Types() []v0alpha1.ConnectionType {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Types")
}
var r0 []v0alpha1.ConnectionType
if rf, ok := ret.Get(0).(func() []v0alpha1.ConnectionType); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]v0alpha1.ConnectionType)
}
}
return r0
}
// MockFactory_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types'
type MockFactory_Types_Call struct {
*mock.Call
}
// Types is a helper method to define mock.On call
func (_e *MockFactory_Expecter) Types() *MockFactory_Types_Call {
return &MockFactory_Types_Call{Call: _e.mock.On("Types")}
}
func (_c *MockFactory_Types_Call) Run(run func()) *MockFactory_Types_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockFactory_Types_Call) Return(_a0 []v0alpha1.ConnectionType) *MockFactory_Types_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockFactory_Types_Call) RunAndReturn(run func() []v0alpha1.ConnectionType) *MockFactory_Types_Call {
_c.Call.Return(run)
return _c
}
// NewMockFactory creates a new instance of MockFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockFactory(t interface {
mock.TestingT
Cleanup(func())
}) *MockFactory {
mock := &MockFactory{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,324 +0,0 @@
package connection_test
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestProvideFactory(t *testing.T) {
tests := []struct {
name string
setupExtras func(t *testing.T) []connection.Extra
enabled map[provisioning.ConnectionType]struct{}
wantErr bool
validateError func(t *testing.T, err error)
}{
{
name: "should create factory with valid extras",
setupExtras: func(t *testing.T) []connection.Extra {
extra1 := connection.NewMockExtra(t)
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
extra2 := connection.NewMockExtra(t)
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
return []connection.Extra{extra1, extra2}
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
provisioning.GitlabConnectionType: {},
},
wantErr: false,
},
{
name: "should return error when duplicate connection types",
setupExtras: func(t *testing.T) []connection.Extra {
extra1 := connection.NewMockExtra(t)
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
extra2 := connection.NewMockExtra(t)
extra2.EXPECT().Type().Return(provisioning.GithubConnectionType)
return []connection.Extra{extra1, extra2}
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "connection type \"github\" is already registered")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
extras := tt.setupExtras(t)
factory, err := connection.ProvideFactory(tt.enabled, extras)
if tt.wantErr {
require.Error(t, err)
assert.Nil(t, factory)
if tt.validateError != nil {
tt.validateError(t, err)
}
} else {
require.NoError(t, err)
require.NotNil(t, factory)
}
})
}
}
func TestFactory_Types(t *testing.T) {
tests := []struct {
name string
extraTypes []provisioning.ConnectionType
enabled map[provisioning.ConnectionType]struct{}
expectedLen int
expectedList []provisioning.ConnectionType
checkSorted bool
}{
{
name: "should return only enabled types that have extras",
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
provisioning.GitlabConnectionType: {},
},
expectedLen: 2,
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
},
{
name: "should return sorted list of types",
extraTypes: []provisioning.ConnectionType{provisioning.GitlabConnectionType, provisioning.GithubConnectionType},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
provisioning.GitlabConnectionType: {},
},
expectedLen: 2,
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
checkSorted: true,
},
{
name: "should return empty list when no types are enabled",
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType},
enabled: map[provisioning.ConnectionType]struct{}{},
expectedLen: 0,
expectedList: []provisioning.ConnectionType{},
},
{
name: "should not return types that are enabled but have no extras",
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
provisioning.GitlabConnectionType: {},
},
expectedLen: 1,
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType},
},
{
name: "should not return types that have extras but are not enabled",
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
expectedLen: 1,
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType},
},
{
name: "should return empty list when no extras are provided",
extraTypes: []provisioning.ConnectionType{},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
expectedLen: 0,
expectedList: []provisioning.ConnectionType{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup extras based on the types specified
extras := make([]connection.Extra, 0, len(tt.extraTypes))
for _, connType := range tt.extraTypes {
extra := connection.NewMockExtra(t)
extra.EXPECT().Type().Return(connType)
extras = append(extras, extra)
}
factory, err := connection.ProvideFactory(tt.enabled, extras)
require.NoError(t, err)
types := factory.Types()
assert.Len(t, types, tt.expectedLen)
if tt.checkSorted {
// Verify exact order: github should come before gitlab alphabetically
assert.Equal(t, tt.expectedList, types)
} else {
// Just verify the types are present
for _, expectedType := range tt.expectedList {
assert.Contains(t, types, expectedType)
}
}
})
}
}
func TestFactory_Build(t *testing.T) {
tests := []struct {
name string
connectionType provisioning.ConnectionType
setupExtras func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error)
enabled map[provisioning.ConnectionType]struct{}
wantErr bool
validateError func(t *testing.T, err error)
}{
{
name: "should successfully build connection when type is enabled and has extra",
connectionType: provisioning.GithubConnectionType,
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
mockConnection := connection.NewMockConnection(t)
extra := connection.NewMockExtra(t)
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
extra.EXPECT().Build(ctx, &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
},
}).Return(mockConnection, nil)
return []connection.Extra{extra}, mockConnection, nil
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
wantErr: false,
},
{
name: "should return error when type is not enabled",
connectionType: provisioning.GitlabConnectionType,
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
extra := connection.NewMockExtra(t)
extra.EXPECT().Type().Return(provisioning.GitlabConnectionType)
return []connection.Extra{extra}, nil, nil
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled")
},
},
{
name: "should return error when type is not supported",
connectionType: provisioning.GitlabConnectionType,
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
extra := connection.NewMockExtra(t)
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
return []connection.Extra{extra}, nil, nil
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported")
},
},
{
name: "should pass through errors from extra.Build()",
connectionType: provisioning.GithubConnectionType,
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
buildErr := errors.New("build error")
extra := connection.NewMockExtra(t)
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
extra.EXPECT().Build(ctx, &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
},
}).Return(nil, buildErr)
return []connection.Extra{extra}, nil, buildErr
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Equal(t, "build error", err.Error())
},
},
{
name: "should build with multiple extras registered",
connectionType: provisioning.GitlabConnectionType,
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
mockConnection := connection.NewMockConnection(t)
extra1 := connection.NewMockExtra(t)
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
extra2 := connection.NewMockExtra(t)
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
extra2.EXPECT().Build(ctx, &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
},
}).Return(mockConnection, nil)
return []connection.Extra{extra1, extra2}, mockConnection, nil
},
enabled: map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
provisioning.GitlabConnectionType: {},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
extras, expectedConnection, _ := tt.setupExtras(t, ctx)
factory, err := connection.ProvideFactory(tt.enabled, extras)
require.NoError(t, err)
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: tt.connectionType,
},
}
result, err := factory.Build(ctx, conn)
if tt.wantErr {
require.Error(t, err)
assert.Nil(t, result)
if tt.validateError != nil {
tt.validateError(t, err)
}
} else {
require.NoError(t, err)
assert.Equal(t, expectedConnection, result)
}
})
}
}
@@ -1,126 +0,0 @@
package github
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/google/go-github/v70/github"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
// API errors that we need to convey after parsing real GH errors (or faking them).
var (
//lint:ignore ST1005 this is not punctuation
ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
)
//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter
type Client interface {
// Apps and installations
GetApp(ctx context.Context) (App, error)
GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error)
CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error)
}
// App represents a Github App.
type App struct {
// ID represents the GH app ID.
ID int64
// Slug represents the GH app slug.
Slug string
// Owner represents the GH account/org owning the app
Owner string
}
// AppInstallation represents a Github App Installation.
type AppInstallation struct {
// ID represents the GH installation ID.
ID int64
// Whether the installation is enabled or not.
Enabled bool
}
// InstallationToken represents a Github App Installation Access Token.
type InstallationToken struct {
// Token is the access token value.
Token string
// ExpiresAt is the expiration time of the token.
ExpiresAt string
}
type githubClient struct {
gh *github.Client
}
func NewClient(client *github.Client) Client {
return &githubClient{client}
}
// GetApp gets the app by using the given token.
func (r *githubClient) GetApp(ctx context.Context) (App, error) {
app, _, err := r.gh.Apps.Get(ctx, "")
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return App{}, ErrServiceUnavailable
}
return App{}, err
}
return App{
ID: app.GetID(),
Slug: app.GetSlug(),
Owner: app.GetOwner().GetLogin(),
}, nil
}
// GetAppInstallation gets the installation of the app related to the given token.
func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) {
id, err := strconv.Atoi(installationID)
if err != nil {
return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID)
}
installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id))
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return AppInstallation{}, ErrServiceUnavailable
}
return AppInstallation{}, err
}
return AppInstallation{
ID: installation.GetID(),
Enabled: installation.GetSuspendedAt().IsZero(),
}, nil
}
// CreateInstallationAccessToken creates an installation access token scoped to a specific repository.
func (r *githubClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
id, err := strconv.Atoi(installationID)
if err != nil {
return InstallationToken{}, fmt.Errorf("invalid installation ID: %s", installationID)
}
opts := &github.InstallationTokenOptions{
Repositories: []string{repo},
}
token, _, err := r.gh.Apps.CreateInstallationToken(ctx, int64(id), opts)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return InstallationToken{}, ErrServiceUnavailable
}
return InstallationToken{}, err
}
return InstallationToken{
Token: token.GetToken(),
ExpiresAt: token.GetExpiresAt().String(),
}, nil
}
@@ -1,207 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockClient is an autogenerated mock type for the Client type
type MockClient struct {
mock.Mock
}
type MockClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// CreateInstallationAccessToken provides a mock function with given fields: ctx, installationID, repo
func (_m *MockClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
ret := _m.Called(ctx, installationID, repo)
if len(ret) == 0 {
panic("no return value specified for CreateInstallationAccessToken")
}
var r0 InstallationToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (InstallationToken, error)); ok {
return rf(ctx, installationID, repo)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) InstallationToken); ok {
r0 = rf(ctx, installationID, repo)
} else {
r0 = ret.Get(0).(InstallationToken)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, installationID, repo)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_CreateInstallationAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateInstallationAccessToken'
type MockClient_CreateInstallationAccessToken_Call struct {
*mock.Call
}
// CreateInstallationAccessToken is a helper method to define mock.On call
// - ctx context.Context
// - installationID string
// - repo string
func (_e *MockClient_Expecter) CreateInstallationAccessToken(ctx interface{}, installationID interface{}, repo interface{}) *MockClient_CreateInstallationAccessToken_Call {
return &MockClient_CreateInstallationAccessToken_Call{Call: _e.mock.On("CreateInstallationAccessToken", ctx, installationID, repo)}
}
func (_c *MockClient_CreateInstallationAccessToken_Call) Run(run func(ctx context.Context, installationID string, repo string)) *MockClient_CreateInstallationAccessToken_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockClient_CreateInstallationAccessToken_Call) Return(_a0 InstallationToken, _a1 error) *MockClient_CreateInstallationAccessToken_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_CreateInstallationAccessToken_Call) RunAndReturn(run func(context.Context, string, string) (InstallationToken, error)) *MockClient_CreateInstallationAccessToken_Call {
_c.Call.Return(run)
return _c
}
// GetApp provides a mock function with given fields: ctx
func (_m *MockClient) GetApp(ctx context.Context) (App, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for GetApp")
}
var r0 App
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) (App, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) App); ok {
r0 = rf(ctx)
} else {
r0 = ret.Get(0).(App)
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApp'
type MockClient_GetApp_Call struct {
*mock.Call
}
// GetApp is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockClient_Expecter) GetApp(ctx interface{}) *MockClient_GetApp_Call {
return &MockClient_GetApp_Call{Call: _e.mock.On("GetApp", ctx)}
}
func (_c *MockClient_GetApp_Call) Run(run func(ctx context.Context)) *MockClient_GetApp_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockClient_GetApp_Call) Return(_a0 App, _a1 error) *MockClient_GetApp_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_GetApp_Call) RunAndReturn(run func(context.Context) (App, error)) *MockClient_GetApp_Call {
_c.Call.Return(run)
return _c
}
// GetAppInstallation provides a mock function with given fields: ctx, installationID
func (_m *MockClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) {
ret := _m.Called(ctx, installationID)
if len(ret) == 0 {
panic("no return value specified for GetAppInstallation")
}
var r0 AppInstallation
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (AppInstallation, error)); ok {
return rf(ctx, installationID)
}
if rf, ok := ret.Get(0).(func(context.Context, string) AppInstallation); ok {
r0 = rf(ctx, installationID)
} else {
r0 = ret.Get(0).(AppInstallation)
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, installationID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetAppInstallation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAppInstallation'
type MockClient_GetAppInstallation_Call struct {
*mock.Call
}
// GetAppInstallation is a helper method to define mock.On call
// - ctx context.Context
// - installationID string
func (_e *MockClient_Expecter) GetAppInstallation(ctx interface{}, installationID interface{}) *MockClient_GetAppInstallation_Call {
return &MockClient_GetAppInstallation_Call{Call: _e.mock.On("GetAppInstallation", ctx, installationID)}
}
func (_c *MockClient_GetAppInstallation_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_GetAppInstallation_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockClient_GetAppInstallation_Call) Return(_a0 AppInstallation, _a1 error) *MockClient_GetAppInstallation_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Context, string) (AppInstallation, error)) *MockClient_GetAppInstallation_Call {
_c.Call.Return(run)
return _c
}
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockClient {
mock := &MockClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,469 +0,0 @@
package github_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/google/go-github/v70/github"
conngh "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
mockhub "github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGithubClient_GetApp(t *testing.T) {
tests := []struct {
name string
mockHandler *http.Client
token string
wantApp conngh.App
wantErr error
}{
{
name: "get app successfully",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetApp,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
app := &github.App{
ID: github.Ptr(int64(12345)),
Slug: github.Ptr("my-test-app"),
Owner: &github.User{
Login: github.Ptr("grafana"),
},
}
w.WriteHeader(http.StatusOK)
require.NoError(t, json.NewEncoder(w).Encode(app))
}),
),
),
token: "test-token",
wantApp: conngh.App{
ID: 12345,
Slug: "my-test-app",
Owner: "grafana",
},
wantErr: nil,
},
{
name: "service unavailable",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetApp,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusServiceUnavailable,
},
Message: "Service unavailable",
}))
}),
),
),
token: "test-token",
wantApp: conngh.App{},
wantErr: conngh.ErrServiceUnavailable,
},
{
name: "other error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetApp,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusInternalServerError,
},
Message: "Internal server error",
}))
}),
),
),
token: "test-token",
wantApp: conngh.App{},
wantErr: &github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusInternalServerError,
},
Message: "Internal server error",
},
},
{
name: "unauthorized error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetApp,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusUnauthorized,
},
Message: "Bad credentials",
}))
}),
),
),
token: "invalid-token",
wantApp: conngh.App{},
wantErr: &github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusUnauthorized,
},
Message: "Bad credentials",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock client
ghClient := github.NewClient(tt.mockHandler)
client := conngh.NewClient(ghClient)
// Call the method being tested
app, err := client.GetApp(context.Background())
// Check the error
if tt.wantErr != nil {
assert.Error(t, err)
assert.Equal(t, tt.wantApp, app)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantApp, app)
}
})
}
}
func TestGithubClient_GetAppInstallation(t *testing.T) {
tests := []struct {
name string
mockHandler *http.Client
appToken string
installationID string
wantInstallation conngh.AppInstallation
wantErr bool
errContains string
}{
{
name: "get disabled app installation successfully",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetAppInstallationsByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
installation := &github.Installation{
ID: github.Ptr(int64(67890)),
SuspendedAt: github.Ptr(github.Timestamp{Time: time.Now()}),
}
w.WriteHeader(http.StatusOK)
require.NoError(t, json.NewEncoder(w).Encode(installation))
}),
),
),
appToken: "test-app-token",
installationID: "67890",
wantInstallation: conngh.AppInstallation{
ID: 67890,
Enabled: false,
},
wantErr: false,
},
{
name: "get enabled app installation successfully",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetAppInstallationsByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
installation := &github.Installation{
ID: github.Ptr(int64(67890)),
SuspendedAt: nil,
}
w.WriteHeader(http.StatusOK)
require.NoError(t, json.NewEncoder(w).Encode(installation))
}),
),
),
appToken: "test-app-token",
installationID: "67890",
wantInstallation: conngh.AppInstallation{
ID: 67890,
Enabled: true,
},
wantErr: false,
},
{
name: "invalid installation ID",
mockHandler: mockhub.NewMockedHTTPClient(),
appToken: "test-app-token",
installationID: "not-a-number",
wantInstallation: conngh.AppInstallation{},
wantErr: true,
errContains: "invalid installation ID",
},
{
name: "service unavailable",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetAppInstallationsByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusServiceUnavailable,
},
Message: "Service unavailable",
}))
}),
),
),
appToken: "test-app-token",
installationID: "67890",
wantInstallation: conngh.AppInstallation{},
wantErr: true,
},
{
name: "installation not found",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetAppInstallationsByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusNotFound,
},
Message: "Not Found",
}))
}),
),
),
appToken: "test-app-token",
installationID: "99999",
wantInstallation: conngh.AppInstallation{},
wantErr: true,
},
{
name: "other error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.GetAppInstallationsByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusInternalServerError,
},
Message: "Internal server error",
}))
}),
),
),
appToken: "test-app-token",
installationID: "67890",
wantInstallation: conngh.AppInstallation{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock client
ghClient := github.NewClient(tt.mockHandler)
client := conngh.NewClient(ghClient)
// Call the method being tested
installation, err := client.GetAppInstallation(context.Background(), tt.installationID)
// Check the error
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
} else {
assert.NoError(t, err)
}
// Check the result
assert.Equal(t, tt.wantInstallation, installation)
})
}
}
func TestGithubClient_CreateInstallationAccessToken(t *testing.T) {
tests := []struct {
name string
mockHandler *http.Client
installationID string
repo string
wantToken conngh.InstallationToken
wantErr bool
errContains string
}{
{
name: "create installation token successfully",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
expiresAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
token := &github.InstallationToken{
Token: github.Ptr("ghs_test_token_123456789"),
ExpiresAt: &github.Timestamp{Time: expiresAt},
}
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(token))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{
Token: "ghs_test_token_123456789",
ExpiresAt: "2024-01-01 00:00:00 +0000 UTC",
},
wantErr: false,
},
{
name: "invalid installation ID",
mockHandler: mockhub.NewMockedHTTPClient(),
installationID: "not-a-number",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
errContains: "invalid installation ID",
},
{
name: "service unavailable",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusServiceUnavailable,
},
Message: "Service unavailable",
}))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
errContains: conngh.ErrServiceUnavailable.Error(),
},
{
name: "installation not found",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusNotFound,
},
Message: "Not Found",
}))
}),
),
),
installationID: "99999",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
{
name: "unauthorized error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusUnauthorized,
},
Message: "Bad credentials",
}))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
{
name: "forbidden - no permissions for repository",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusForbidden,
},
Message: "Resource not accessible by integration",
}))
}),
),
),
installationID: "12345",
repo: "private-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
{
name: "internal server error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusInternalServerError,
},
Message: "Internal server error",
}))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ghClient := github.NewClient(tt.mockHandler)
client := conngh.NewClient(ghClient)
token, err := client.CreateInstallationAccessToken(context.Background(), tt.installationID, tt.repo)
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantToken, token)
})
}
}
@@ -1,229 +0,0 @@
package github
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"
)
//go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter
type GithubFactory interface {
New(ctx context.Context, ghToken common.RawSecureValue) Client
}
type ConnectionSecrets struct {
PrivateKey common.RawSecureValue
Token common.RawSecureValue
}
type Connection struct {
obj *provisioning.Connection
ghFactory GithubFactory
secrets ConnectionSecrets
}
func NewConnection(
obj *provisioning.Connection,
factory GithubFactory,
secrets ConnectionSecrets,
) Connection {
return Connection{
obj: obj,
ghFactory: factory,
secrets: secrets,
}
}
const (
//TODO(ferruvich): these probably need to be setup in API configuration.
githubInstallationURL = "https://github.com/settings/installations"
jwtExpirationMinutes = 10 // GitHub Apps JWT tokens expire in 10 minutes maximum
)
// Mutate performs in place mutation of the underneath resource.
func (c *Connection) Mutate(_ context.Context) error {
// Do nothing in case spec.Github is nil.
// If this field is required, we should fail at validation time.
if c.obj.Spec.GitHub == nil {
return nil
}
c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID)
// Generate JWT token if a new private key is being provided.
// Same as for the spec.Github, if such a field is required, Validation will take care of that.
if !c.obj.Secure.PrivateKey.Create.IsZero() {
token, err := generateToken(c.obj.Spec.GitHub.AppID, c.secrets.PrivateKey)
if err != nil {
return fmt.Errorf("failed to generate JWT token: %w", err)
}
// Store the generated token
c.obj.Secure.Token = common.InlineSecureValue{Create: token}
}
return nil
}
// Token generates and returns the Connection token.
func generateToken(appID string, privateKey common.RawSecureValue) (common.RawSecureValue, error) {
// Decode base64-encoded private key
privateKeyPEM, err := base64.StdEncoding.DecodeString(string(privateKey))
if err != nil {
return "", fmt.Errorf("failed to decode base64 private key: %w", err)
}
// Parse the private key
key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("failed to parse private key: %w", err)
}
// Create the JWT token
now := time.Now()
claims := jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtExpirationMinutes) * time.Minute)),
Issuer: appID,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(key)
if err != nil {
return "", fmt.Errorf("failed to sign JWT token: %w", err)
}
return common.RawSecureValue(signedToken), nil
}
// Validate ensures the resource _looks_ correct.
func (c *Connection) Validate(ctx context.Context) error {
list := field.ErrorList{}
if c.obj.Spec.Type != provisioning.GithubConnectionType {
list = append(list, field.Invalid(field.NewPath("spec", "type"), c.obj.Spec.Type, "invalid connection type"))
// Doesn't make much sense to continue validating a connection which is not a Github one.
return toError(c.obj.GetName(), list)
}
if c.obj.Spec.GitHub == nil {
list = append(
list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"),
)
// Doesn't make much sense to continue validating a connection with no information.
return toError(c.obj.GetName(), list)
}
if c.secrets.PrivateKey.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
}
if c.secrets.Token.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection"))
}
if !c.obj.Secure.ClientSecret.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection"))
}
// Validate GitHub configuration fields
if c.obj.Spec.GitHub.AppID == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "appID"), "appID must be specified for GitHub connection"))
}
if c.obj.Spec.GitHub.InstallationID == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "installationID"), "installationID must be specified for GitHub connection"))
}
// In case we have any error above, we don't go forward with the validation, and return the errors.
if len(list) > 0 {
return toError(c.obj.GetName(), list)
}
// Validating app content via GH API
if err := c.validateAppAndInstallation(ctx); err != nil {
list = append(list, err)
}
return toError(c.obj.GetName(), list)
}
// validateAppAndInstallation validates the appID and installationID against the given github token.
func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error {
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
app, err := ghClient.GetApp(ctx)
if err != nil {
if errors.Is(err, ErrServiceUnavailable) {
return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable)
}
return field.Invalid(field.NewPath("spec", "token"), "[REDACTED]", "invalid token")
}
if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID {
return field.Invalid(field.NewPath("spec", "appID"), c.obj.Spec.GitHub.AppID, "appID mismatch")
}
_, err = ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID)
if err != nil {
if errors.Is(err, ErrServiceUnavailable) {
return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable)
}
return field.Invalid(field.NewPath("spec", "installationID"), c.obj.Spec.GitHub.InstallationID, "invalid installation ID")
}
return nil
}
// toError converts a field.ErrorList to an error, returning nil if the list is empty
func toError(name string, list field.ErrorList) error {
if len(list) == 0 {
return nil
}
return apierrors.NewInvalid(
provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(),
name,
list,
)
}
// GenerateRepositoryToken generates a repository-scoped access token.
func (c *Connection) GenerateRepositoryToken(ctx context.Context, repo *provisioning.Repository) (common.RawSecureValue, error) {
if repo == nil {
return "", errors.New("a repository is required to generate a token")
}
if c.obj.Spec.GitHub == nil {
return "", errors.New("connection is not a GitHub connection")
}
if repo.Spec.GitHub == nil {
return "", errors.New("repository is not a GitHub repo")
}
_, repoName, err := github.ParseOwnerRepoGithub(repo.Spec.GitHub.URL)
if err != nil {
return "", fmt.Errorf("failed to parse repo URL: %w", err)
}
// Create the GitHub client with the JWT token
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
// Create an installation access token scoped to this repository
installationToken, err := ghClient.CreateInstallationAccessToken(ctx, c.obj.Spec.GitHub.InstallationID, repoName)
if err != nil {
return "", fmt.Errorf("failed to create installation access token: %w", err)
}
return common.RawSecureValue(installationToken.Token), nil
}
var (
_ connection.Connection = (*Connection)(nil)
)
@@ -1,693 +0,0 @@
package github_test
import (
"context"
"encoding/base64"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
//nolint:gosec // Test RSA private key (generated for testing purposes only)
const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAoInVbLY9io2Q/wHvUIXlEHg2Qyvd8eRzBAVEJ92DS6fx9H10
06V0VRm78S0MXyo6i+n8ZAbZ0/R+GWpP2Ephxm0Gs2zo+iO2mpB19xQFI4o6ZTOw
b2WyjSaa2Vr4oyDkqti6AvfjW4VUAu932e08GkgwmmQSHXj7FX2CMWjgUwTTcuaX
65SHNKLNYLUP0HTumLzoZeqDTdoMMpKNdgH9Avr4/8vkVJ0mD6rqvxnw3JHsseNO
WdQTxf2aApBNHIIKxWZ2i/ZmjLNey7kltgjEquGiBdJvip3fHhH5XHdkrXcjRtnw
OJDnDmi5lQwv5yUBOSkbvbXRv/L/m0YLoD/fbwIDAQABAoIBAFfl//hM8/cnuesV
+R1Con/ZAgTXQOdPqPXbmEyniVrkMqMmCdBUOBTcST4s5yg36+RtkeaGpb/ajyyF
PAB2AYDucwvMpudGpJWOYTiOOp4R8hU1LvZfXVrRd1lo6NgQi4NLtNUpOtACeVQ+
H4Yv0YemXQ47mnuOoRNMK/u3q5NoIdSahWptXBgUno8KklNpUrH3IYWaUxfBzDN3
2xsVRTn2SfTSyoDmTDdTgptJONmoK1/sV7UsgWksdFc6XyYhsFAZgOGEJrBABRvF
546dyQ0cWxuPyVXpM7CN3tqC5ssvLjElg3LicK1V6gnjpdRnnvX88d1Eh3Uc/9IM
OZInT2ECgYEA6W8sQXTWinyEwl8SDKKMbB2ApIghAcFgdRxprZE4WFxjsYNCNL70
dnSB7MRuzmxf5W77cV0N7JhH66N8HvY6Xq9olrpQ5dNttR4w8Pyv3wavDe8x7seL
5L2Xtbu7ihDr8Dk27MjiBSin3IxhBP5CJS910+pR6LrAWtEuU+FzFfECgYEAsA6y
qxHhCMXlTnauXhsnmPd1g61q7chW8kLQFYtHMLlQlgjHTW7irDZ9cPbPYDNjwRLO
7KLorcpv2NKe7rqq2ZyCm6hf1b9WnlQjo3dLpNWMu6fhy/smK8MgbRqcWpX+oTKF
79mK6hbY7o6eBzsQHBl7Z+LBNuwYmp9qOodPa18CgYEArv6ipKdcNhFGzRfMRiCN
OHederp6VACNuP2F05IsNUF9kxOdTEFirnKE++P+VU01TqA2azOhPp6iO+ohIGzi
MR06QNSH1OL9OWvasK4dggpWrRGF00VQgDgJRTnpS4WH+lxJ6pRlrAxgWpv6F24s
VAgSQr1Ejj2B+hMasdMvHWECgYBJ4uE4yhgXBnZlp4kmFV9Y4wF+cZkekaVrpn6N
jBYkbKFVVfnOlWqru3KJpgsB5I9IyAvvY68iwIKQDFSG+/AXw4dMrC0MF3DSoZ0T
TU2Br92QI7SvVod+djV1lGVp3ukt3XY4YqPZ+hywgUnw3uiz4j3YK2HLGup4ec6r
IX5DIQKBgHRLzvT3zqtlR1Oh0vv098clLwt+pGzXOxzJpxioOa5UqK13xIpFXbcg
iWUVh5YXCcuqaICUv4RLIEac5xQitk9Is/9IhP0NJ/81rHniosvdSpCeFXzxTImS
B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb
-----END RSA PRIVATE KEY-----`
func TestConnection_Mutate(t *testing.T) {
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
tests := []struct {
name string
connection *provisioning.Connection
secrets github.ConnectionSecrets
wantErr bool
validateError func(t *testing.T, err error)
validateResult func(t *testing.T, connection *provisioning.Connection)
}{
{
name: "should add URL to Github connection",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(privateKeyBase64),
},
},
},
secrets: github.ConnectionSecrets{
PrivateKey: common.NewSecretValue(privateKeyBase64),
},
wantErr: false,
validateResult: func(t *testing.T, connection *provisioning.Connection) {
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
},
},
{
name: "should generate JWT token when private key is provided",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(privateKeyBase64),
},
},
},
secrets: github.ConnectionSecrets{
PrivateKey: common.NewSecretValue(privateKeyBase64),
},
wantErr: false,
validateResult: func(t *testing.T, connection *provisioning.Connection) {
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
assert.False(t, connection.Secure.Token.Create.IsZero(), "JWT token should be generated")
},
},
{
name: "should not generate JWT token when no new private key is provided",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection", Generation: 1},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
// The private key is already in the stoere
Name: "somePrivateKey",
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("someToken"),
},
},
},
secrets: github.ConnectionSecrets{
PrivateKey: common.NewSecretValue(privateKeyBase64),
Token: common.NewSecretValue("someToken"),
},
wantErr: false,
validateResult: func(t *testing.T, connection *provisioning.Connection) {
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
assert.False(t, connection.Secure.Token.Create.IsZero(), "JWT token should be generated")
assert.Equal(t, "someToken", connection.Secure.Token.Create.DangerouslyExposeAndConsumeValue())
},
},
{
name: "should do nothing when GitHub config is nil",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "clientID",
},
},
},
secrets: github.ConnectionSecrets{},
wantErr: false,
},
{
name: "should fail when private key is not base64",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("invalid-key"),
},
},
},
secrets: github.ConnectionSecrets{
PrivateKey: "invalid-key",
},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "failed to generate JWT token")
assert.Contains(t, err.Error(), "failed to decode base64 private key")
},
},
{
name: "should fail when private key is invalid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))),
},
},
},
secrets: github.ConnectionSecrets{},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "failed to generate JWT token")
assert.Contains(t, err.Error(), "failed to parse private key")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
conn := github.NewConnection(tt.connection, mockFactory, tt.secrets)
err := conn.Mutate(context.Background())
if tt.wantErr {
require.Error(t, err)
if tt.validateError != nil {
tt.validateError(t, err)
}
} else {
require.NoError(t, err)
if tt.validateResult != nil {
tt.validateResult(t, tt.connection)
}
}
})
}
}
func TestConnection_GenerateRepositoryToken(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
repo *provisioning.Repository
setupMock func(*github.MockGithubFactory)
expectedToken common.RawSecureValue
expectedError string
}{
{
name: "success",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test-owner/test-repo",
},
},
},
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("jwt-token")).Return(mockClient)
mockClient.EXPECT().CreateInstallationAccessToken(mock.Anything, "456", "test-repo").
Return(github.InstallationToken{Token: "ghs_repository_token_123", ExpiresAt: "2024-01-01T00:00:00Z"}, nil)
},
expectedToken: common.RawSecureValue("ghs_repository_token_123"),
},
{
name: "nil repository returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
},
repo: nil,
expectedError: "a repository is required to generate a token",
},
{
name: "connection without GitHub config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "clientID",
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test-owner/test-repo",
},
},
},
expectedError: "connection is not a GitHub connection",
},
{
name: "repository without GitHub config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: nil,
},
},
expectedError: "repository is not a GitHub repo",
},
{
name: "invalid repository URL returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "invalid-url",
},
},
},
expectedError: "failed to parse repo URL",
},
{
name: "GitHub API error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test-owner/test-repo",
},
},
},
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("jwt-token")).Return(mockClient)
mockClient.EXPECT().CreateInstallationAccessToken(mock.Anything, "456", "test-repo").
Return(github.InstallationToken{}, errors.New("API rate limit exceeded"))
},
expectedError: "failed to create installation access token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
if tt.setupMock != nil {
tt.setupMock(mockFactory)
}
conn := github.NewConnection(tt.connection, mockFactory, github.ConnectionSecrets{
Token: tt.connection.Secure.Token.Create,
PrivateKey: tt.connection.Secure.PrivateKey.Create,
})
token, err := conn.GenerateRepositoryToken(context.Background(), tt.repo)
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedToken, token)
}
})
}
}
func TestConnection_Validate(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
setupMock func(*github.MockGithubFactory)
wantErr bool
errMsgContains []string
}{
{
name: "invalid type returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: "invalid",
},
},
wantErr: true,
errMsgContains: []string{"spec.type"},
},
{
name: "github type without github config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
},
},
wantErr: true,
errMsgContains: []string{"spec.github"},
},
{
name: "github type without private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
},
wantErr: true,
errMsgContains: []string{"secure.privateKey"},
},
{
name: "github type without token returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
},
},
},
wantErr: true,
errMsgContains: []string{"secure.token"},
},
{
name: "github type with client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Create: common.NewSecretValue("test-client-secret"),
},
},
},
wantErr: true,
errMsgContains: []string{"secure.clientSecret"},
},
{
name: "github type without appID returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
},
wantErr: true,
errMsgContains: []string{"spec.github.appID"},
},
{
name: "github type without installationID returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
Token: common.InlineSecureValue{
Name: "test-token",
},
},
},
wantErr: true,
errMsgContains: []string{"spec.github.installationID"},
},
{
name: "github type with valid config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
},
wantErr: false,
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 123, Slug: "test-app"}, nil)
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(github.AppInstallation{ID: 456}, nil)
},
},
{
name: "problem getting app returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
},
wantErr: true,
errMsgContains: []string{"spec.token", "[REDACTED]"},
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{}, assert.AnError)
},
},
{
name: "mismatched app ID returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
},
wantErr: true,
errMsgContains: []string{"spec.appID"},
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 444, Slug: "test-app"}, nil)
},
},
{
name: "problem when getting installation returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
},
wantErr: true,
errMsgContains: []string{"spec.installationID", "456"},
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 123, Slug: "test-app"}, nil)
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(github.AppInstallation{}, assert.AnError)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
if tt.setupMock != nil {
tt.setupMock(mockFactory)
}
conn := github.NewConnection(tt.connection, mockFactory, github.ConnectionSecrets{
PrivateKey: tt.connection.Secure.PrivateKey.Create,
Token: tt.connection.Secure.Token.Create,
})
err := conn.Validate(context.Background())
if tt.wantErr {
assert.Error(t, err)
for _, msg := range tt.errMsgContains {
assert.Contains(t, err.Error(), msg)
}
} else {
assert.NoError(t, err)
}
})
}
}
@@ -1,60 +0,0 @@
package github
import (
"context"
"fmt"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
)
type extra struct {
factory GithubFactory
decrypter connection.Decrypter
}
func (e *extra) Type() provisioning.ConnectionType {
return provisioning.GithubConnectionType
}
func (e *extra) Build(ctx context.Context, conn *provisioning.Connection) (connection.Connection, error) {
logger := logging.FromContext(ctx)
if conn == nil || conn.Spec.GitHub == nil {
logger.Error("connection is nil or github info is nil")
return nil, fmt.Errorf("invalid github connection")
}
// Decrypt secure values
secure := e.decrypter(conn)
// Decrypt private key
pKey, err := secure.PrivateKey(ctx)
if err != nil {
logger.Error("Failed to decrypt private key", "error", err)
return nil, err
}
// Decrypt token
t, err := secure.Token(ctx)
if err != nil {
logger.Error("Failed to decrypt token", "error", err)
return nil, err
}
c := NewConnection(conn, e.factory, ConnectionSecrets{
PrivateKey: pKey,
Token: t,
})
return &c, nil
}
func Extra(decrypter connection.Decrypter, factory GithubFactory) connection.Extra {
return &extra{
decrypter: decrypter,
factory: factory,
}
}
@@ -1,231 +0,0 @@
package github_test
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
"github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type mockSecureValues struct {
privateKey common.RawSecureValue
privateKeyErr error
clientSecret common.RawSecureValue
clientSecErr error
token common.RawSecureValue
tokenErr error
}
func (m *mockSecureValues) PrivateKey(_ context.Context) (common.RawSecureValue, error) {
return m.privateKey, m.privateKeyErr
}
func (m *mockSecureValues) ClientSecret(_ context.Context) (common.RawSecureValue, error) {
return m.clientSecret, m.clientSecErr
}
func (m *mockSecureValues) Token(_ context.Context) (common.RawSecureValue, error) {
return m.token, m.tokenErr
}
func TestExtra_Type(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
decrypter := func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{}
}
e := github.Extra(decrypter, mockFactory)
result := e.Type()
assert.Equal(t, provisioning.GithubConnectionType, result)
}
func TestExtra_Build(t *testing.T) {
tests := []struct {
name string
conn *provisioning.Connection
setupDecrypter func() connection.Decrypter
expectedError string
}{
{
name: "success with valid connection",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue("test-private-key"),
token: common.RawSecureValue("test-token"),
}
}
},
},
{
name: "nil connection",
conn: nil,
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{}
}
},
expectedError: "invalid github connection",
},
{
name: "connection without github config",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: nil,
},
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{}
}
},
expectedError: "invalid github connection",
},
{
name: "error decrypting private key",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKeyErr: errors.New("failed to decrypt private key"),
}
}
},
expectedError: "failed to decrypt private key",
},
{
name: "error decrypting token",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue("test-private-key"),
tokenErr: errors.New("failed to decrypt token"),
}
}
},
expectedError: "failed to decrypt token",
},
{
name: "success with empty secure values",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue(""),
token: common.RawSecureValue(""),
}
}
},
},
{
name: "success with different app and installation IDs",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "another-connection",
Namespace: "prod",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "999888",
InstallationID: "777666",
},
},
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue("another-private-key"),
token: common.RawSecureValue("another-token"),
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
mockFactory := github.NewMockGithubFactory(t)
decrypter := tt.setupDecrypter()
e := github.Extra(decrypter, mockFactory)
result, err := e.Build(ctx, tt.conn)
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
require.NoError(t, err)
assert.NotNil(t, result)
}
})
}
}
@@ -1,39 +0,0 @@
package github
import (
"context"
"net/http"
"github.com/google/go-github/v70/github"
"golang.org/x/oauth2"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// Factory creates new GitHub clients.
// It exists only for the ability to test the code easily.
type Factory struct {
// Client allows overriding the client to use in the GH client returned. It exists primarily for testing.
// FIXME: we should replace in this way. We should add some options pattern for the factory.
Client *http.Client
}
func ProvideFactory() GithubFactory {
return &Factory{}
}
func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client {
if r.Client != nil {
return NewClient(github.NewClient(r.Client))
}
if !ghToken.IsZero() {
tokenSrc := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: string(ghToken)},
)
tokenClient := oauth2.NewClient(ctx, tokenSrc)
return NewClient(github.NewClient(tokenClient))
}
return NewClient(github.NewClient(&http.Client{}))
}
@@ -1,86 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
import (
context "context"
v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockGithubFactory is an autogenerated mock type for the GithubFactory type
type MockGithubFactory struct {
mock.Mock
}
type MockGithubFactory_Expecter struct {
mock *mock.Mock
}
func (_m *MockGithubFactory) EXPECT() *MockGithubFactory_Expecter {
return &MockGithubFactory_Expecter{mock: &_m.Mock}
}
// New provides a mock function with given fields: ctx, ghToken
func (_m *MockGithubFactory) New(ctx context.Context, ghToken v0alpha1.RawSecureValue) Client {
ret := _m.Called(ctx, ghToken)
if len(ret) == 0 {
panic("no return value specified for New")
}
var r0 Client
if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.RawSecureValue) Client); ok {
r0 = rf(ctx, ghToken)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Client)
}
}
return r0
}
// MockGithubFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New'
type MockGithubFactory_New_Call struct {
*mock.Call
}
// New is a helper method to define mock.On call
// - ctx context.Context
// - ghToken v0alpha1.RawSecureValue
func (_e *MockGithubFactory_Expecter) New(ctx interface{}, ghToken interface{}) *MockGithubFactory_New_Call {
return &MockGithubFactory_New_Call{Call: _e.mock.On("New", ctx, ghToken)}
}
func (_c *MockGithubFactory_New_Call) Run(run func(ctx context.Context, ghToken v0alpha1.RawSecureValue)) *MockGithubFactory_New_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(v0alpha1.RawSecureValue))
})
return _c
}
func (_c *MockGithubFactory_New_Call) Return(_a0 Client) *MockGithubFactory_New_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGithubFactory_New_Call) RunAndReturn(run func(context.Context, v0alpha1.RawSecureValue) Client) *MockGithubFactory_New_Call {
_c.Call.Return(run)
return _c
}
// NewMockGithubFactory creates a new instance of MockGithubFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockGithubFactory(t interface {
mock.TestingT
Cleanup(func())
}) *MockGithubFactory {
mock := &MockGithubFactory{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,28 @@
package connection
import (
"fmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
const (
githubInstallationURL = "https://github.com/settings/installations"
)
func MutateConnection(connection *provisioning.Connection) error {
switch connection.Spec.Type {
case provisioning.GithubConnectionType:
// Do nothing in case spec.Github is nil.
// If this field is required, we should fail at validation time.
if connection.Spec.GitHub == nil {
return nil
}
connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID)
return nil
default:
// TODO: we need to setup the URL for bitbucket and gitlab.
return nil
}
}
@@ -0,0 +1,35 @@
package connection_test
import (
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestMutateConnection(t *testing.T) {
t.Run("should add URL to Github connection", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
},
}
require.NoError(t, connection.MutateConnection(c))
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
})
}
@@ -1,64 +0,0 @@
package connection
import (
"context"
"fmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/secret/pkg/decrypt"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
type Decrypter = func(c *provisioning.Connection) SecureValues
type SecureValues interface {
PrivateKey(ctx context.Context) (common.RawSecureValue, error)
ClientSecret(ctx context.Context) (common.RawSecureValue, error)
Token(ctx context.Context) (common.RawSecureValue, error)
}
type secureValues struct {
svc decrypt.DecryptService
names provisioning.ConnectionSecure
namespace string
}
func (s *secureValues) get(ctx context.Context, sv common.InlineSecureValue) (common.RawSecureValue, error) {
if !sv.Create.IsZero() {
return sv.Create, nil // If this was called before the value is actually saved
}
if sv.Name == "" {
return "", nil
}
results, err := s.svc.Decrypt(ctx, provisioning.GROUP, s.namespace, sv.Name)
if err != nil {
return "", fmt.Errorf("failed to call decrypt service: %w", err)
}
v, found := results[sv.Name]
if !found {
return "", fmt.Errorf("not found")
}
if v.Error() != nil {
return "", v.Error()
}
return common.RawSecureValue(*v.Value()), nil
}
func (s *secureValues) PrivateKey(ctx context.Context) (common.RawSecureValue, error) {
return s.get(ctx, s.names.PrivateKey)
}
func (s *secureValues) ClientSecret(ctx context.Context) (common.RawSecureValue, error) {
return s.get(ctx, s.names.ClientSecret)
}
func (s *secureValues) Token(ctx context.Context) (common.RawSecureValue, error) {
return s.get(ctx, s.names.Token)
}
func ProvideDecrypter(svc decrypt.DecryptService) Decrypter {
return func(c *provisioning.Connection) SecureValues {
return &secureValues{svc: svc, names: c.Secure, namespace: c.Namespace}
}
}
@@ -1,510 +0,0 @@
package connection_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/apps/secret/pkg/decrypt"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// mockDecryptService implements decrypt.DecryptService for testing
type mockDecryptService struct {
results map[string]decrypt.DecryptResult
err error
}
func (m *mockDecryptService) Decrypt(ctx context.Context, group, namespace string, names ...string) (map[string]decrypt.DecryptResult, error) {
if m.err != nil {
return nil, m.err
}
results := make(map[string]decrypt.DecryptResult)
for _, name := range names {
if result, ok := m.results[name]; ok {
results[name] = result
}
}
return results, nil
}
func newDecryptResult(value string) decrypt.DecryptResult {
v := secretv1beta1.ExposedSecureValue(value)
return decrypt.NewDecryptResultValue(&v)
}
func newDecryptResultWithError(err error) decrypt.DecryptResult {
return decrypt.NewDecryptResultErr(err)
}
func TestProvideDecrypter(t *testing.T) {
t.Run("should return a decrypter function", func(t *testing.T) {
mockSvc := &mockDecryptService{}
decrypter := connection.ProvideDecrypter(mockSvc)
require.NotNil(t, decrypter)
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
}
result := decrypter(conn)
require.NotNil(t, result)
})
}
func TestSecureValues_PrivateKey(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
mockResults map[string]decrypt.DecryptResult
mockErr error
expectedValue common.RawSecureValue
expectedError string
}{
{
name: "returns Create value when present",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("create-private-key"),
},
},
},
expectedValue: common.RawSecureValue("create-private-key"),
},
{
name: "returns empty when Name is empty",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{},
},
},
expectedValue: "",
},
{
name: "decrypts from service when Name is provided",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "private-key-ref",
},
},
},
mockResults: map[string]decrypt.DecryptResult{
"private-key-ref": newDecryptResult("decrypted-private-key"),
},
expectedValue: common.RawSecureValue("decrypted-private-key"),
},
{
name: "returns error when decrypt service fails",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "private-key-ref",
},
},
},
mockErr: errors.New("decrypt service error"),
expectedError: "failed to call decrypt service",
},
{
name: "returns error when value not found",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "missing-key",
},
},
},
mockResults: map[string]decrypt.DecryptResult{},
expectedError: "not found",
},
{
name: "returns error when decrypt result has error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "private-key-ref",
},
},
},
mockResults: map[string]decrypt.DecryptResult{
"private-key-ref": newDecryptResultWithError(errors.New("decryption failed")),
},
expectedError: "decryption failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSvc := &mockDecryptService{
results: tt.mockResults,
err: tt.mockErr,
}
decrypter := connection.ProvideDecrypter(mockSvc)
secureVals := decrypter(tt.connection)
value, err := secureVals.PrivateKey(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedValue, value)
}
})
}
}
func TestSecureValues_ClientSecret(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
mockResults map[string]decrypt.DecryptResult
mockErr error
expectedValue common.RawSecureValue
expectedError string
}{
{
name: "returns Create value when present",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Create: common.NewSecretValue("create-client-secret"),
},
},
},
expectedValue: common.RawSecureValue("create-client-secret"),
},
{
name: "returns empty when Name is empty",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{},
},
},
expectedValue: "",
},
{
name: "decrypts from service when Name is provided",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Name: "client-secret-ref",
},
},
},
mockResults: map[string]decrypt.DecryptResult{
"client-secret-ref": newDecryptResult("decrypted-client-secret"),
},
expectedValue: common.RawSecureValue("decrypted-client-secret"),
},
{
name: "returns error when decrypt service fails",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Name: "client-secret-ref",
},
},
},
mockErr: errors.New("decrypt service error"),
expectedError: "failed to call decrypt service",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSvc := &mockDecryptService{
results: tt.mockResults,
err: tt.mockErr,
}
decrypter := connection.ProvideDecrypter(mockSvc)
secureVals := decrypter(tt.connection)
value, err := secureVals.ClientSecret(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedValue, value)
}
})
}
}
func TestSecureValues_Token(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
mockResults map[string]decrypt.DecryptResult
mockErr error
expectedValue common.RawSecureValue
expectedError string
}{
{
name: "returns Create value when present",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.NewSecretValue("create-token"),
},
},
},
expectedValue: common.RawSecureValue("create-token"),
},
{
name: "returns empty when Name is empty",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{},
},
},
expectedValue: "",
},
{
name: "decrypts from service when Name is provided",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Name: "token-ref",
},
},
},
mockResults: map[string]decrypt.DecryptResult{
"token-ref": newDecryptResult("decrypted-token"),
},
expectedValue: common.RawSecureValue("decrypted-token"),
},
{
name: "returns error when decrypt service fails",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Name: "token-ref",
},
},
},
mockErr: errors.New("decrypt service error"),
expectedError: "failed to call decrypt service",
},
{
name: "returns error when value not found",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Name: "missing-token",
},
},
},
mockResults: map[string]decrypt.DecryptResult{},
expectedError: "not found",
},
{
name: "returns error when decrypt result has error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Name: "token-ref",
},
},
},
mockResults: map[string]decrypt.DecryptResult{
"token-ref": newDecryptResultWithError(errors.New("decryption failed")),
},
expectedError: "decryption failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSvc := &mockDecryptService{
results: tt.mockResults,
err: tt.mockErr,
}
decrypter := connection.ProvideDecrypter(mockSvc)
secureVals := decrypter(tt.connection)
value, err := secureVals.Token(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedValue, value)
}
})
}
}
func TestSecureValues_MultipleFields(t *testing.T) {
t.Run("should decrypt all fields independently", func(t *testing.T) {
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "private-key-ref",
},
ClientSecret: common.InlineSecureValue{
Name: "client-secret-ref",
},
Token: common.InlineSecureValue{
Name: "token-ref",
},
},
}
mockSvc := &mockDecryptService{
results: map[string]decrypt.DecryptResult{
"private-key-ref": newDecryptResult("decrypted-private-key"),
"client-secret-ref": newDecryptResult("decrypted-client-secret"),
"token-ref": newDecryptResult("decrypted-token"),
},
}
decrypter := connection.ProvideDecrypter(mockSvc)
secureVals := decrypter(conn)
privateKey, err := secureVals.PrivateKey(context.Background())
require.NoError(t, err)
assert.Equal(t, common.RawSecureValue("decrypted-private-key"), privateKey)
clientSecret, err := secureVals.ClientSecret(context.Background())
require.NoError(t, err)
assert.Equal(t, common.RawSecureValue("decrypted-client-secret"), clientSecret)
token, err := secureVals.Token(context.Background())
require.NoError(t, err)
assert.Equal(t, common.RawSecureValue("decrypted-token"), token)
})
t.Run("should handle mix of Create and Name references", func(t *testing.T) {
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("inline-private-key"),
},
ClientSecret: common.InlineSecureValue{
Name: "client-secret-ref",
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("inline-token"),
},
},
}
mockSvc := &mockDecryptService{
results: map[string]decrypt.DecryptResult{
"client-secret-ref": newDecryptResult("decrypted-client-secret"),
},
}
decrypter := connection.ProvideDecrypter(mockSvc)
secureVals := decrypter(conn)
// PrivateKey should return Create value without calling decrypt
privateKey, err := secureVals.PrivateKey(context.Background())
require.NoError(t, err)
assert.Equal(t, common.RawSecureValue("inline-private-key"), privateKey)
// ClientSecret should decrypt
clientSecret, err := secureVals.ClientSecret(context.Background())
require.NoError(t, err)
assert.Equal(t, common.RawSecureValue("decrypted-client-secret"), clientSecret)
// Token should return Create value without calling decrypt
token, err := secureVals.Token(context.Background())
require.NoError(t, err)
assert.Equal(t, common.RawSecureValue("inline-token"), token)
})
}
@@ -0,0 +1,104 @@
package connection
import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"
)
func ValidateConnection(connection *provisioning.Connection) error {
list := field.ErrorList{}
if connection.Spec.Type == "" {
list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified"))
}
switch connection.Spec.Type {
case provisioning.GithubConnectionType:
list = append(list, validateGithubConnection(connection)...)
case provisioning.BitbucketConnectionType:
list = append(list, validateBitbucketConnection(connection)...)
case provisioning.GitlabConnectionType:
list = append(list, validateGitlabConnection(connection)...)
default:
list = append(
list, field.NotSupported(
field.NewPath("spec", "type"),
connection.Spec.Type,
[]provisioning.ConnectionType{
provisioning.GithubConnectionType,
provisioning.BitbucketConnectionType,
provisioning.GitlabConnectionType,
}),
)
}
return toError(connection.GetName(), list)
}
func validateGithubConnection(connection *provisioning.Connection) field.ErrorList {
list := field.ErrorList{}
if connection.Spec.GitHub == nil {
list = append(
list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"),
)
}
if connection.Secure.PrivateKey.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
}
if !connection.Secure.ClientSecret.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection"))
}
return list
}
func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList {
list := field.ErrorList{}
if connection.Spec.Bitbucket == nil {
list = append(
list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"),
)
}
if connection.Secure.ClientSecret.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection"))
}
if !connection.Secure.PrivateKey.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection"))
}
return list
}
func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList {
list := field.ErrorList{}
if connection.Spec.Gitlab == nil {
list = append(
list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"),
)
}
if connection.Secure.ClientSecret.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection"))
}
if !connection.Secure.PrivateKey.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection"))
}
return list
}
// toError converts a field.ErrorList to an error, returning nil if the list is empty
func toError(name string, list field.ErrorList) error {
if len(list) == 0 {
return nil
}
return apierrors.NewInvalid(
provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(),
name,
list,
)
}
@@ -0,0 +1,253 @@
package connection_test
import (
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestValidateConnection(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
wantErr bool
errMsg string
}{
{
name: "empty type returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{},
},
wantErr: true,
errMsg: "spec.type",
},
{
name: "invalid type returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: "invalid",
},
},
wantErr: true,
errMsg: "spec.type",
},
{
name: "github type without github config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
},
},
wantErr: true,
errMsg: "spec.github",
},
{
name: "github type without private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
},
wantErr: true,
errMsg: "secure.privateKey",
},
{
name: "github type with client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: true,
errMsg: "secure.clientSecret",
},
{
name: "github type with github config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
},
},
wantErr: false,
},
{
name: "bitbucket type without bitbucket config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
},
},
wantErr: true,
errMsg: "spec.bitbucket",
},
{
name: "bitbucket type without client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
Bitbucket: &provisioning.BitbucketConnectionConfig{
ClientID: "client-123",
},
},
},
wantErr: true,
errMsg: "secure.clientSecret",
},
{
name: "bitbucket type with private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
Bitbucket: &provisioning.BitbucketConnectionConfig{
ClientID: "client-123",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: true,
errMsg: "secure.privateKey",
},
{
name: "bitbucket type with bitbucket config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
Bitbucket: &provisioning.BitbucketConnectionConfig{
ClientID: "client-123",
},
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: false,
},
{
name: "gitlab type without gitlab config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
},
},
wantErr: true,
errMsg: "spec.gitlab",
},
{
name: "gitlab type without client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "client-456",
},
},
},
wantErr: true,
errMsg: "secure.clientSecret",
},
{
name: "gitlab type with private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "client-456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: true,
errMsg: "secure.privateKey",
},
{
name: "gitlab type with gitlab config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "client-456",
},
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := connection.ValidateConnection(tt.connection)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
@@ -13,7 +13,7 @@ import (
type ConnectionSecureApplyConfiguration struct {
PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"`
ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"`
Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"`
Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"`
}
// ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with
@@ -101,8 +101,7 @@ func (r *gitRepository) Validate() (list field.ErrorList) {
}
// Readonly repositories may not need a token (if public)
// Also, in case a connection is provided, the token will be created by the controller.
if len(r.config.Spec.Workflows) > 0 && r.config.Spec.Connection == nil {
if len(r.config.Spec.Workflows) > 0 {
if cfg.Token == "" && r.config.Secure.Token.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "token"), "a git access token is required"))
}
@@ -169,22 +169,6 @@ func TestGitRepository_Validate(t *testing.T) {
},
want: nil,
},
{
name: "missing token for R/W repository with connection",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: "test_type",
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
Connection: &provisioning.ConnectionInfo{Name: "my-connection"},
},
},
gitConfig: RepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
Token: "", // Empty token - should be OK because connection is provided
},
want: nil,
},
{
name: "unsafe path",
config: &provisioning.Repository{
@@ -5,15 +5,13 @@ import (
"fmt"
"github.com/grafana/grafana-app-sdk/logging"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"github.com/grafana/grafana/apps/provisioning/pkg/util"
"k8s.io/apimachinery/pkg/runtime"
)
//go:generate mockery --name WebhookURLBuilder --structname MockWebhookURLBuilder --inpackage --filename webhook_builder_mock.go --with-expecter
type WebhookURLBuilder interface {
WebhookURL(ctx context.Context, r *provisioning.Repository) string
}
@@ -37,22 +35,24 @@ func (e *extra) Type() provisioning.RepositoryType {
}
func (e *extra) Build(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
if r == nil || r.Spec.GitHub == nil {
return nil, fmt.Errorf("github configuration is required")
}
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
logger.Info("Instantiating Github repository")
secure := e.decrypter(r)
cfg := r.Spec.GitHub
if cfg == nil {
return nil, fmt.Errorf("github configuration is required")
}
token, err := secure.Token(ctx)
if err != nil {
return nil, fmt.Errorf("unable to decrypt token: %w", err)
}
gitRepo, err := git.NewRepository(ctx, r, git.RepositoryConfig{
URL: r.Spec.GitHub.URL,
Branch: r.Spec.GitHub.Branch,
Path: r.Spec.GitHub.Path,
URL: cfg.URL,
Branch: cfg.Branch,
Path: cfg.Path,
Token: token,
})
if err != nil {
@@ -1,410 +0,0 @@
package github_test
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
type mockSecureValues struct {
token common.RawSecureValue
tokenErr error
webhookSecret common.RawSecureValue
webhookErr error
}
func (m *mockSecureValues) Token(_ context.Context) (common.RawSecureValue, error) {
return m.token, m.tokenErr
}
func (m *mockSecureValues) WebhookSecret(_ context.Context) (common.RawSecureValue, error) {
return m.webhookSecret, m.webhookErr
}
func TestExtra_Type(t *testing.T) {
e := github.Extra(nil, nil, nil)
assert.Equal(t, provisioning.GitHubRepositoryType, e.Type())
}
func TestExtra_Build(t *testing.T) {
tests := []struct {
name string
repo *provisioning.Repository
setupDecrypter func() repository.Decrypter
setupWebhook func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder
expectedError string
validateResult func(t *testing.T, repo repository.Repository)
}{
{
name: "missing github config",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: nil,
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder { return nil },
expectedError: "github configuration is required",
},
{
name: "nil repository",
repo: nil,
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder { return nil },
expectedError: "github configuration is required",
},
{
name: "error decrypting token",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{
tokenErr: errors.New("decryption failed"),
}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder { return nil },
expectedError: "unable to decrypt token",
},
{
name: "success without webhooks",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{
token: common.RawSecureValue("test-token"),
}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
return nil
},
validateResult: func(t *testing.T, repo repository.Repository) {
assert.NotNil(t, repo)
},
},
{
name: "success with webhooks",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{
token: common.RawSecureValue("test-token"),
webhookSecret: common.RawSecureValue("webhook-secret"),
}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
mockWebhook := github.NewMockWebhookURLBuilder(t)
mockWebhook.EXPECT().WebhookURL(mock.Anything, repo).Return("https://example.com/webhook")
return mockWebhook
},
validateResult: func(t *testing.T, repo repository.Repository) {
assert.NotNil(t, repo)
},
},
{
name: "skip webhook setup when URL is empty",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{
token: common.RawSecureValue("test-token"),
}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
mockWebhook := github.NewMockWebhookURLBuilder(t)
mockWebhook.EXPECT().WebhookURL(mock.Anything, repo).Return("")
return mockWebhook
},
validateResult: func(t *testing.T, repo repository.Repository) {
assert.NotNil(t, repo)
},
},
{
name: "error decrypting webhook secret",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{
token: common.RawSecureValue("test-token"),
webhookErr: errors.New("webhook decryption failed"),
}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
mockWebhook := github.NewMockWebhookURLBuilder(t)
mockWebhook.EXPECT().WebhookURL(mock.Anything, repo).Return("https://example.com/webhook")
return mockWebhook
},
expectedError: "decrypt webhookSecret",
},
{
name: "success with custom path",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
Path: "custom/path",
},
},
},
setupDecrypter: func() repository.Decrypter {
return func(r *provisioning.Repository) repository.SecureValues {
return &mockSecureValues{
token: common.RawSecureValue("test-token"),
}
}
},
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
return nil
},
validateResult: func(t *testing.T, repo repository.Repository) {
assert.NotNil(t, repo)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
decrypter := tt.setupDecrypter()
webhookBuilder := tt.setupWebhook(t, tt.repo)
factory := github.ProvideFactory()
e := github.Extra(decrypter, factory, webhookBuilder)
result, err := e.Build(ctx, tt.repo)
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
require.NoError(t, err)
if tt.validateResult != nil {
tt.validateResult(t, result)
}
}
})
}
}
func TestExtra_Mutate(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
expectedError bool
validateObj func(t *testing.T, obj runtime.Object)
}{
{
name: "mutates repository with github config",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo.git/",
},
},
},
expectedError: false,
validateObj: func(t *testing.T, obj runtime.Object) {
repo := obj.(*provisioning.Repository)
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
},
},
{
name: "handles repository without github config",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: nil,
},
},
expectedError: false,
},
{
name: "handles non-repository object",
obj: &runtime.Unknown{},
expectedError: false,
},
{
name: "trims only trailing slash",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo/",
},
},
},
expectedError: false,
validateObj: func(t *testing.T, obj runtime.Object) {
repo := obj.(*provisioning.Repository)
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
},
},
{
name: "trims only .git suffix",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo.git",
},
},
},
expectedError: false,
validateObj: func(t *testing.T, obj runtime.Object) {
repo := obj.(*provisioning.Repository)
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
},
},
{
name: "no changes when URL is clean",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
},
},
},
expectedError: false,
validateObj: func(t *testing.T, obj runtime.Object) {
repo := obj.(*provisioning.Repository)
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
e := github.Extra(nil, nil, nil)
err := e.Mutate(ctx, tt.obj)
if tt.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tt.validateObj != nil {
tt.validateObj(t, tt.obj)
}
}
})
}
}
@@ -1,84 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
import (
context "context"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockWebhookURLBuilder is an autogenerated mock type for the WebhookURLBuilder type
type MockWebhookURLBuilder struct {
mock.Mock
}
type MockWebhookURLBuilder_Expecter struct {
mock *mock.Mock
}
func (_m *MockWebhookURLBuilder) EXPECT() *MockWebhookURLBuilder_Expecter {
return &MockWebhookURLBuilder_Expecter{mock: &_m.Mock}
}
// WebhookURL provides a mock function with given fields: ctx, r
func (_m *MockWebhookURLBuilder) WebhookURL(ctx context.Context, r *v0alpha1.Repository) string {
ret := _m.Called(ctx, r)
if len(ret) == 0 {
panic("no return value specified for WebhookURL")
}
var r0 string
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) string); ok {
r0 = rf(ctx, r)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockWebhookURLBuilder_WebhookURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WebhookURL'
type MockWebhookURLBuilder_WebhookURL_Call struct {
*mock.Call
}
// WebhookURL is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
func (_e *MockWebhookURLBuilder_Expecter) WebhookURL(ctx interface{}, r interface{}) *MockWebhookURLBuilder_WebhookURL_Call {
return &MockWebhookURLBuilder_WebhookURL_Call{Call: _e.mock.On("WebhookURL", ctx, r)}
}
func (_c *MockWebhookURLBuilder_WebhookURL_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository)) *MockWebhookURLBuilder_WebhookURL_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository))
})
return _c
}
func (_c *MockWebhookURLBuilder_WebhookURL_Call) Return(_a0 string) *MockWebhookURLBuilder_WebhookURL_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockWebhookURLBuilder_WebhookURL_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository) string) *MockWebhookURLBuilder_WebhookURL_Call {
_c.Call.Return(run)
return _c
}
// NewMockWebhookURLBuilder creates a new instance of MockWebhookURLBuilder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockWebhookURLBuilder(t interface {
mock.TestingT
Cleanup(func())
}) *MockWebhookURLBuilder {
mock := &MockWebhookURLBuilder{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+1 -7
View File
@@ -336,7 +336,7 @@ rudderstack_data_plane_url =
rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
rudderstack_v3_sdk_url =
rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
rudderstack_config_url =
@@ -2079,14 +2079,8 @@ enable =
# To enable features by default, set `Expression: "true"` in:
# https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go
# The feature_toggles section supports feature flags of a number of types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
#
# feature1 = true
# feature2 = false
# feature3 = "foobar"
# feature4 = 1.5
# feature5 = { "foo": "bar" }
[feature_toggles.openfeature]
# This is EXPERIMENTAL. Please, do not use this section
+3 -8
View File
@@ -323,7 +323,7 @@
;rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
;rudderstack_v3_sdk_url =
;rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
;rudderstack_config_url =
@@ -1913,7 +1913,7 @@ default_datasource_uid =
# client_queue_max_size is the maximum size in bytes of the client queue
# for Live connections. Defaults to 4MB.
;client_queue_max_size =
;client_queue_max_size =
#################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer]
@@ -1996,14 +1996,9 @@ default_datasource_uid =
;enable = feature1,feature2
# The feature_toggles section supports feature flags of a number of types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
;feature1 = true
;feature2 = false
;feature3 = "foobar"
;feature4 = 1.5
;feature5 = { "foo": "bar" }
[date_formats]
# For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/
+3 -46
View File
@@ -99,27 +99,12 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
mssql-troubleshoot:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
postgres:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/
mysql:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mysql/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mysql/
---
# Microsoft SQL Server (MSSQL) data source
Grafana ships with built-in support for Microsoft SQL Server (MSSQL).
You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database.
You can query and visualize data from any Microsoft SQL Server 2005 or newer, including the Microsoft Azure SQL Database.
Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time.
@@ -128,33 +113,10 @@ The following documentation helps you get started working with the Microsoft SQL
- [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source)
- [Microsoft SQL Server query editor](ref:mssql-query-editor)
- [Microsoft SQL Server template variables](ref:mssql-template-variables)
- [Troubleshoot Microsoft SQL Server data source issues](ref:mssql-troubleshoot)
## Supported versions
## Get the most out of the data source
This data source supports the following Microsoft SQL Server versions:
- Microsoft SQL Server 2005 and newer
- Microsoft Azure SQL Database
- Azure SQL Managed Instance
Grafana recommends using the latest available service pack for your SQL Server version for optimal compatibility.
## Key capabilities
The Microsoft SQL Server data source supports:
- **Time series queries:** Visualize metrics over time using the built-in time grouping macros.
- **Table queries:** Display query results in table format for any valid SQL query.
- **Template variables:** Create dynamic dashboards with variable-driven queries.
- **Annotations:** Overlay events from SQL Server on your dashboard graphs.
- **Alerting:** Create alerts based on SQL Server query results.
- **Stored procedures:** Execute stored procedures and visualize results.
- **Macros:** Simplify queries with built-in macros for time filtering and grouping.
## Additional resources
After configuring the Microsoft SQL Server data source, you can:
After installing and configuring the Microsoft SQL Server data source, you can:
- Create a wide variety of [visualizations](ref:visualizations)
- Configure and use [templates and variables](ref:variables)
@@ -162,8 +124,3 @@ After configuring the Microsoft SQL Server data source, you can:
- Add [annotations](ref:annotate-visualizations)
- Set up [alerting](ref:alerting)
- Optimize performance with [query caching](ref:query-caching)
## Related data sources
- [PostgreSQL](ref:postgres) - For PostgreSQL databases.
- [MySQL](ref:mysql) - For MySQL and MariaDB databases.
@@ -89,26 +89,6 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
mssql-query-editor:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
mssql-template-variables:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/template-variables/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/template-variables/
alerting:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
mssql-troubleshoot:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
---
# Configure the Microsoft SQL Server data source
@@ -117,28 +97,13 @@ This document provides instructions for configuring the Microsoft SQL Server dat
## Before you begin
Before configuring the Microsoft SQL Server data source, ensure you have the following:
- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin.
- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
- You must have the `Organization administrator` role to configure the MSSQL data source. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
- **A running SQL Server instance:** Microsoft SQL Server 2005 or newer, Azure SQL Database, or Azure SQL Managed Instance.
- Familiarize yourself with your MSSQL security configuration and gather any necessary security certificates and client keys.
- **Network access:** Grafana must be able to reach your SQL Server. The default port is `1433`.
- **Authentication credentials:** Depending on your authentication method, you need one of:
- SQL Server login credentials (username and password).
- Windows/Kerberos credentials and configuration (not supported in Grafana Cloud).
- Azure Entra ID app registration or managed identity.
- **Security certificates:** If using encrypted connections, gather any necessary TLS/SSL certificates.
{{< admonition type="note" >}}
Grafana ships with a built-in Microsoft SQL Server data source plugin. No additional installation is required.
{{< /admonition >}}
{{< admonition type="tip" >}}
**Grafana Cloud users:** If your SQL Server is in a private network, you can configure [Private data source connect](ref:private-data-source-connect) to establish connectivity.
{{< /admonition >}}
- Verify that data from MSSQL is being written to your Grafana instance.
## Add the MSSQL data source
@@ -417,48 +382,3 @@ datasources:
secureJsonData:
password: 'Password!'
```
### Configure with Terraform
You can configure the Microsoft SQL Server data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs).
For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation.
#### Terraform example
The following example creates a basic Microsoft SQL Server data source:
```hcl
resource "grafana_data_source" "mssql" {
name = "MSSQL"
type = "mssql"
url = "localhost:1433"
user = "grafana"
json_data_encoded = jsonencode({
database = "grafana"
maxOpenConns = 100
maxIdleConns = 100
maxIdleConnsAuto = true
connMaxLifetime = 14400
connectionTimeout = 0
encrypt = "false"
})
secure_json_data_encoded = jsonencode({
password = "Password!"
})
}
```
For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source).
## Next steps
After configuring your Microsoft SQL Server data source, you can:
- [Write queries](ref:mssql-query-editor) using the query editor to explore and visualize your data
- [Create template variables](ref:mssql-template-variables) to build dynamic, reusable dashboards
- [Add annotations](ref:annotate-visualizations) to overlay SQL Server events on your graphs
- [Set up alerting](ref:alerting) to create alert rules based on your SQL Server data
- [Troubleshoot issues](ref:mssql-troubleshoot) if you encounter problems with your data source
@@ -1,333 +0,0 @@
---
description: Troubleshoot common problems with the Microsoft SQL Server data source in Grafana
keywords:
- grafana
- MSSQL
- Microsoft
- SQL
- troubleshooting
- errors
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Troubleshooting
title: Troubleshoot Microsoft SQL Server data source issues
weight: 400
refs:
configure-mssql-data-source:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/configure/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/configure/
mssql-query-editor:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
private-data-source-connect:
- pattern: /docs/grafana/
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
---
# Troubleshoot Microsoft SQL Server data source issues
This document provides solutions to common issues you may encounter when configuring or using the Microsoft SQL Server (MSSQL) data source in Grafana.
## Connection errors
These errors occur when Grafana cannot establish or maintain a connection to the Microsoft SQL Server.
### Unable to connect to the server
**Error message:** "Unable to open tcp connection" or "dial tcp: connection refused"
**Cause:** Grafana cannot establish a network connection to the SQL Server.
**Solution:**
1. Verify that the SQL Server is running and accessible.
1. Check that the host and port are correct in the data source configuration. The default SQL Server port is `1433`.
1. Ensure there are no firewall rules blocking the connection between Grafana and SQL Server.
1. Verify that SQL Server is configured to allow remote connections.
1. For Grafana Cloud, ensure you have configured [Private data source connect](ref:private-data-source-connect) if your SQL Server instance is not publicly accessible.
### Connection timeout
**Error message:** "Connection timed out" or "I/O timeout"
**Cause:** The connection to SQL Server timed out before receiving a response.
**Solution:**
1. Check the network latency between Grafana and SQL Server.
1. Verify that SQL Server is not overloaded or experiencing performance issues.
1. Increase the **Connection timeout** setting in the data source configuration under **Additional settings**.
1. Check if any network devices (load balancers, proxies) are timing out the connection.
### Encryption-related connection failures
**Error message:** "TLS handshake failed" or "certificate verify failed"
**Cause:** There is a mismatch between the encryption settings in Grafana and what the SQL Server supports or requires.
**Solution:**
1. For older versions of SQL Server (2008, 2008R2), set the **Encrypt** option to **Disable** or **False** in the data source configuration.
1. Verify that the SQL Server has a valid SSL certificate if encryption is enabled.
1. Check that the certificate is trusted by the Grafana server.
1. Ensure you're using the latest available service pack for your SQL Server version for optimal compatibility.
### Named instance connection issues
**Error message:** "Cannot connect to named instance" or connection fails when using instance name
**Cause:** Grafana cannot resolve the SQL Server named instance.
**Solution:**
1. Use the format `hostname\instancename` or `hostname\instancename,port` in the **Host** field.
1. Verify that the SQL Server Browser service is running on the SQL Server machine.
1. If the Browser service is unavailable, specify the port number directly: `hostname,port`.
1. Check that UDP port 1434 is open if using the SQL Server Browser service.
## Authentication errors
These errors occur when there are issues with authentication credentials or permissions.
### Login failed for user
**Error message:** "Login failed for user 'username'" or "Authentication failed"
**Cause:** The authentication credentials are invalid or the user doesn't have permission to access the database.
**Solution:**
1. Verify that the username and password are correct.
1. Check that the user exists in SQL Server and is enabled.
1. Ensure the user has access to the specified database.
1. For Windows Authentication, verify that the credentials are in the correct format (`DOMAIN\User`).
1. Check that the SQL Server authentication mode allows the type of login you're using (SQL Server Authentication, Windows Authentication, or Mixed Mode).
### Access denied to database
**Error message:** "Cannot open database 'dbname' requested by the login"
**Cause:** The authenticated user doesn't have permission to access the specified database.
**Solution:**
1. Verify that the database name is correct in the data source configuration.
1. Ensure the user is mapped to the database with appropriate permissions.
1. Grant at least `SELECT` permission on the required tables:
```sql
USE [your_database]
GRANT SELECT ON dbo.YourTable TO [your_user]
```
1. Check that the user doesn't have any conflicting permissions from the public role.
### Windows Authentication (Kerberos) issues
**Error message:** "Kerberos authentication failed" or "Cannot initialize Kerberos"
**Cause:** Kerberos configuration is incorrect or incomplete.
**Solution:**
1. Verify that the Kerberos configuration file (`krb5.conf`) path is correct in the data source settings.
1. For keytab authentication, ensure the keytab file exists and is readable by Grafana.
1. Check that the realm and KDC settings are correct in the Kerberos configuration.
1. Verify DNS is correctly resolving the KDC servers.
1. Ensure the service principal name (SPN) is registered for the SQL Server instance.
{{< admonition type="note" >}}
Kerberos authentication is not supported in Grafana Cloud.
{{< /admonition >}}
### Azure Entra ID authentication errors
**Error message:** "AADSTS error codes" or "Azure AD authentication failed"
**Cause:** Azure Entra ID (formerly Azure AD) authentication is misconfigured.
**Solution:**
1. For **App Registration** authentication:
- Verify the tenant ID, client ID, and client secret are correct.
- Ensure the app registration has been added as a user in the Azure SQL database.
- Check that the client secret hasn't expired.
1. For **Managed Identity** authentication:
- Verify `managed_identity_enabled = true` is set in the Grafana server configuration.
- Ensure the managed identity has been added to the Azure SQL database.
- Confirm the Azure resource hosting Grafana has managed identity enabled.
1. For **Current User** authentication:
- Ensure `user_identity_enabled = true` is set in the Grafana server configuration.
- Verify the app registration is configured to issue both Access Tokens and ID Tokens.
- Check that the required API permissions are configured (`user_impersonation` for Azure SQL).
For detailed Azure authentication configuration, refer to [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source).
## Query errors
These errors occur when there are issues with query syntax or configuration.
### Time column not found or invalid
**Error message:** "Could not find time column" or time series visualization shows no data
**Cause:** The query doesn't return a properly formatted `time` column for time series visualization.
**Solution:**
1. Ensure your query includes a column named `time` when using the **Time series** format.
1. Use the `$__time()` macro to rename your date column: `$__time(your_date_column)`.
1. Verify the time column is of a valid SQL date/time type (`datetime`, `datetime2`, `date`) or contains Unix epoch values.
1. Ensure the result set is sorted by the time column using `ORDER BY`.
### Macro expansion errors
**Error message:** "Error parsing query" or macros appear unexpanded in the query
**Cause:** Grafana macros are being used incorrectly.
**Solution:**
1. Verify macro syntax: use `$__timeFilter(column)` not `$_timeFilter(column)`.
1. Macros don't work inside stored procedures—use explicit date parameters instead.
1. Check that the column name passed to macros exists in your table.
1. View the expanded query by clicking **Generated SQL** after running the query to debug macro expansion.
### Timezone and time shift issues
**Cause:** Time series data appears shifted or doesn't align with expected times.
**Solution:**
1. Store timestamps in UTC in your database to avoid timezone issues.
1. Time macros (`$__time`, `$__timeFilter`, etc.) always expand to UTC values.
1. If your timestamps are stored in local time, convert them to UTC in your query:
```sql
SELECT
your_datetime_column AT TIME ZONE 'Your Local Timezone' AT TIME ZONE 'UTC' AS time,
value
FROM your_table
```
1. Don't pass timezone parameters to time macros—they're not supported.
### Query returns too many rows
**Error message:** "Result set too large" or browser becomes unresponsive
**Cause:** The query returns more data than can be efficiently processed.
**Solution:**
1. Add time filters using `$__timeFilter(column)` to limit data to the dashboard time range.
1. Use aggregations (`AVG`, `SUM`, `COUNT`) with `GROUP BY` instead of returning raw rows.
1. Add a `TOP` clause to limit results: `SELECT TOP 1000 ...`.
1. Use the `$__timeGroup()` macro to aggregate data into time intervals.
### Stored procedure returns no data
**Cause:** Stored procedure output isn't being captured correctly.
**Solution:**
1. Ensure the stored procedure uses `SELECT` statements, not just variable assignments.
1. Remove `SET NOCOUNT ON` if present, or ensure it's followed by a `SELECT` statement.
1. Verify the stored procedure parameters are being passed correctly.
1. Test the stored procedure directly in SQL Server Management Studio with the same parameters.
For more information on using stored procedures, refer to the [query editor documentation](ref:mssql-query-editor).
## Performance issues
These issues relate to slow queries or high resource usage.
### Slow query execution
**Cause:** Queries take a long time to execute.
**Solution:**
1. Reduce the dashboard time range to limit data volume.
1. Add indexes to columns used in `WHERE` clauses and time filters.
1. Use aggregations instead of returning individual rows.
1. Increase the **Min time interval** setting to reduce the number of data points.
1. Review the query execution plan in SQL Server Management Studio to identify bottlenecks.
### Connection pool exhaustion
**Error message:** "Too many connections" or "Connection pool exhausted"
**Cause:** Too many concurrent connections to the database.
**Solution:**
1. Increase the **Max open** connection limit in the data source configuration.
1. Enable **Auto max idle** to automatically manage idle connections.
1. Reduce the number of panels querying the same data source simultaneously.
1. Check for long-running queries that might be holding connections.
## Other common issues
The following issues don't produce specific error messages but are commonly encountered.
### System databases appear in queries
**Cause:** Queries accidentally access system databases.
**Solution:**
1. The query editor automatically excludes `tempdb`, `model`, `msdb`, and `master` from the database dropdown.
1. Always specify the database in your data source configuration to restrict access.
1. Ensure the database user only has permissions on the intended database.
### Template variable queries fail
**Cause:** Variable queries return unexpected results or errors.
**Solution:**
1. Verify the variable query syntax is valid SQL that returns a single column.
1. Check that the data source connection is working.
1. Ensure the user has permission to access the tables referenced in the variable query.
1. Test the query in the query editor before using it as a variable query.
### Data appears incorrect or misaligned
**Cause:** Data formatting or type conversion issues.
**Solution:**
1. Use explicit column aliases to ensure consistent naming: `SELECT value AS metric`.
1. Verify numeric columns are actually numeric types, not strings.
1. Check for `NULL` values that might affect aggregations.
1. Use the `FILL` option in `$__timeGroup()` macro to handle missing data points.
## Get additional help
If you continue to experience issues after following this troubleshooting guide:
1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues.
1. Review the [Grafana GitHub issues](https://github.com/grafana/grafana/issues) for known bugs.
1. Enable debug logging in Grafana to capture detailed error information.
1. Check SQL Server logs for additional error details.
1. Contact Grafana Support if you're an Enterprise or Cloud customer.
When reporting issues, include:
- Grafana version
- SQL Server version
- Error messages (redact sensitive information)
- Steps to reproduce
- Relevant query examples (redact sensitive data)
@@ -2836,11 +2836,9 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../
Keys of features to enable, separated by space.
#### `FEATURE_NAME = <value>`
#### `FEATURE_TOGGLE_NAME = false`
Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details.
For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`.
<hr>
+10
View File
@@ -1156,6 +1156,11 @@
"count": 2
}
},
"public/app/core/config.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"public/app/core/navigation/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -4015,6 +4020,11 @@
"count": 1
}
},
"public/app/plugins/datasource/parca/webpack.config.ts": {
"no-barrel-files/no-barrel-files": {
"count": 1
}
},
"public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": {
"no-restricted-syntax": {
"count": 1
-2
View File
@@ -585,8 +585,6 @@ module.exports = [
// FIXME: Remove once all enterprise issues are fixed -
// we don't have a suppressions file/approach for enterprise code yet
...enterpriseIgnores,
// Ignore decoupled plugin webpack configs
'public/app/**/webpack.config.ts',
],
rules: {
'no-barrel-files/no-barrel-files': 'error',
@@ -246,8 +246,6 @@ const injectedRtkApi = api
facetLimit: queryArg.facetLimit,
tags: queryArg.tags,
libraryPanel: queryArg.libraryPanel,
panelType: queryArg.panelType,
dataSourceType: queryArg.dataSourceType,
permission: queryArg.permission,
sort: queryArg.sort,
limit: queryArg.limit,
@@ -676,10 +674,6 @@ export type SearchDashboardsAndFoldersApiArg = {
tags?: string[];
/** find dashboards that reference a given libraryPanel */
libraryPanel?: string;
/** find dashboards using panels of a given plugin type */
panelType?: string;
/** find dashboards using datasources of a given plugin type */
dataSourceType?: string;
/** permission needed for the resource (view, edit, admin) */
permission?: 'view' | 'edit' | 'admin';
/** sortable field */
@@ -1452,7 +1452,7 @@ export type ConnectionSecure = {
/** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */
privateKey?: InlineSecureValue;
/** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */
token?: InlineSecureValue;
webhook?: InlineSecureValue;
};
export type BitbucketConnectionConfig = {
/** App client ID */
@@ -9,7 +9,6 @@ import { FieldColorModeId } from '../types/fieldColor';
import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides';
import { InterpolateFunction } from '../types/panel';
import { ThresholdsMode } from '../types/thresholds';
import { MappingType } from '../types/valueMapping';
import { Registry } from '../utils/Registry';
import { locationUtil } from '../utils/location';
import { mockStandardProperties } from '../utils/tests/mockStandardProperties';
@@ -1000,45 +999,6 @@ describe('setDynamicConfigValue', () => {
expect(config.custom.property3).toEqual({});
expect(config.displayName).toBeUndefined();
});
it('works correctly with multiple value mappings in the same override', () => {
const config: FieldConfig = {
mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }],
};
setDynamicConfigValue(
config,
{
id: 'mappings',
value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }],
},
{
fieldConfigRegistry: customFieldRegistry,
data: [],
field: { type: FieldType.number } as Field,
dataFrameIndex: 0,
}
);
setDynamicConfigValue(
config,
{
id: 'mappings',
value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }],
},
{
fieldConfigRegistry: customFieldRegistry,
data: [],
field: { type: FieldType.number } as Field,
dataFrameIndex: 0,
}
);
expect(config.mappings).toHaveLength(3);
expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } });
expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } });
expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } });
});
});
describe('getLinksSupplier', () => {
@@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
return;
}
let val = item.process(value.value, context, item.settings);
const val = item.process(value.value, context, item.settings);
const remove = val === undefined || val === null;
@@ -352,15 +352,6 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
unset(config, item.path);
}
} else {
// Merge arrays (e.g. mappings) when multiple overrides target the same field
if (Array.isArray(val)) {
const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path);
if (Array.isArray(existingValue)) {
val = [...existingValue, ...val];
}
}
if (item.isCustom) {
if (!config.custom) {
config.custom = {};
+16
View File
@@ -527,6 +527,10 @@ export interface FeatureToggles {
*/
dashboardTemplates?: boolean;
/**
* Sets the logs table as default visualisation in logs explore
*/
logsExploreTableDefaultVisualization?: boolean;
/**
* Enables the new alert list view design
*/
alertingListViewV2?: boolean;
@@ -622,6 +626,10 @@ export interface FeatureToggles {
*/
exploreLogsAggregatedMetrics?: boolean;
/**
* Used in Logs Drilldown to limit the time range
*/
exploreLogsLimitedTimeRange?: boolean;
/**
* Enables the gRPC client to authenticate with the App Platform by using ID & access tokens
*/
appPlatformGrpcClientAuth?: boolean;
@@ -649,6 +657,10 @@ export interface FeatureToggles {
*/
rolePickerDrawer?: boolean;
/**
* Enable unified storage search
*/
unifiedStorageSearch?: boolean;
/**
* Enable sprinkles on unified storage search
*/
unifiedStorageSearchSprinkles?: boolean;
@@ -691,6 +703,10 @@ export interface FeatureToggles {
*/
passwordlessMagicLinkAuthentication?: boolean;
/**
* Display Related Logs in Grafana Metrics Drilldown
*/
exploreMetricsRelatedLogs?: boolean;
/**
* Adds support for quotes and special characters in label values for Prometheus queries
*/
prometheusSpecialCharsInLabelValues?: boolean;
-1
View File
@@ -52,7 +52,6 @@ export const availableIconsIndex = {
bookmark: true,
'book-open': true,
'brackets-curly': true,
brain: true,
'browser-alt': true,
bug: true,
building: true,
+1
View File
@@ -29,6 +29,7 @@
"@grafana-app/source": "./src/internal/index.ts"
},
"./eslint-plugin": {
"@grafana-app/source": "./src/eslint/index.cjs",
"types": "./src/eslint/index.d.ts",
"default": "./src/eslint/index.cjs"
}
@@ -14,8 +14,6 @@ export type Props = React.ComponentProps<typeof TextArea> & {
isConfigured: boolean;
/** Called when the user clicks on the "Reset" button in order to clear the secret */
onReset: () => void;
/** If true, the text area will grow to fill available width. */
grow?: boolean;
};
export const CONFIGURED_TEXT = 'configured';
@@ -37,11 +35,11 @@ const getStyles = (theme: GrafanaTheme2) => {
*
* https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs
*/
export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => {
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => {
const styles = useStyles2(getStyles);
return (
<Stack>
<Box grow={grow ? 1 : undefined}>
<Box>
{!isConfigured && <TextArea {...props} />}
{isConfigured && (
<TextArea
+19 -57
View File
@@ -142,24 +142,6 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "panelType",
In: "query",
Description: "find dashboards using panels of a given plugin type",
Required: false,
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "dataSourceType",
In: "query",
Description: "find dashboards using datasources of a given plugin type",
Required: false,
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "permission",
@@ -448,11 +430,14 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
}
}
// Apply facet terms
// The facet term fields
if facets, ok := queryParams["facet"]; ok {
if queryParams.Has("facetLimit") {
if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 {
facetLimit = min(parsed, 1000)
facetLimit = parsed
if facetLimit > 1000 {
facetLimit = 1000
}
}
}
searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet)
@@ -464,35 +449,21 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
}
}
if v, ok := queryParams["tag"]; ok {
// The tags filter
if tags, ok := queryParams["tag"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: "tags",
Operator: "=",
Values: v,
Values: tags,
})
}
if v, ok := queryParams["panelType"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_PANEL_TYPES,
Operator: "=",
Values: v,
})
}
if v, ok := queryParams["dataSourceType"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_DS_TYPES,
Operator: "=",
Values: v,
})
}
if v, ok := queryParams["libraryPanel"]; ok {
// The libraryPanel filter
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: builders.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: v,
Values: libraryPanel,
})
}
@@ -552,7 +523,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
// gets dashboards that the user was granted read access to
permissions := user.GetPermissions()
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
folderPermissions := permissions[dashboards.ActionFoldersRead]
dashboardUids := make([]string, 0)
sharedDashboards := make([]string, 0)
@@ -563,13 +533,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
}
for _, folderPermission := range folderPermissions {
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
dashboardUids = append(dashboardUids, folderUid)
}
}
}
if len(dashboardUids) == 0 {
return sharedDashboards, nil
@@ -580,15 +543,9 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
return sharedDashboards, err
}
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
Federated: []*resourcepb.ResourceKey{folderKey},
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Options: &resourcepb.ListOptions{
Key: key,
Fields: []*resourcepb.Requirement{{
@@ -624,6 +581,12 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
// only folders the user has access to will be returned here
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
folderSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(allFolders)),
@@ -636,7 +599,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}},
},
}
// only folders the user has access to will be returned here
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
if err != nil {
return sharedDashboards, err
+3 -27
View File
@@ -507,15 +507,6 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("publicfolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -559,15 +550,6 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -589,7 +571,6 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
@@ -600,19 +581,14 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
// first call gets all dashboards user has permission for
firstCall := mockClient.MockCalls[0]
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
// verify federated field is set to include folders
assert.NotNil(t, firstCall.Federated)
assert.Equal(t, 1, len(firstCall.Federated))
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
// second call gets folders associated with the previous dashboards
secondCall := mockClient.MockCalls[1]
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
// permission to read
thirdCall := mockClient.MockCalls[2]
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
resp := rr.Result()
defer func() {
@@ -71,6 +71,7 @@ type cachingDatasourceProvider struct {
}
func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSONData) PluginDatasourceProvider {
group, _ := plugins.GetDatasourceGroupNameFromPluginID(pluginJson.ID)
return &scopedDatasourceProvider{
plugin: pluginJson,
dsService: q.dsService,
@@ -80,7 +81,7 @@ func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSO
mapper: q.converter.mapper,
plugin: pluginJson.ID,
alias: pluginJson.AliasIDs,
group: pluginJson.ID,
group: group,
},
}
}
+19 -24
View File
@@ -37,11 +37,6 @@ var (
_ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil)
)
type DataSourceAPIBuilderConfig struct {
LoadQueryTypes bool
UseDualWriter bool
}
// DataSourceAPIBuilder is used just so wire has something unique to return
type DataSourceAPIBuilder struct {
datasourceResourceInfo utils.ResourceInfo
@@ -51,7 +46,7 @@ type DataSourceAPIBuilder struct {
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
cfg DataSourceAPIBuilderConfig
configCrudUseNewApis bool
dataSourceCRUDMetric *prometheus.HistogramVec
}
@@ -94,24 +89,20 @@ func RegisterAPIService(
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
}
groupName := pluginJSON.ID + ".datasource.grafana.app"
builder, err = NewDataSourceAPIBuilder(
groupName,
pluginJSON,
client,
datasources.GetDatasourceProvider(pluginJSON),
contextProvider,
accessControl,
DataSourceAPIBuilderConfig{
//nolint:staticcheck // not yet migrated to OpenFeature
LoadQueryTypes: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
UseDualWriter: false,
},
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections),
)
if err != nil {
return nil, err
}
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
apiRegistrar.RegisterAPI(builder)
@@ -129,27 +120,31 @@ type PluginClient interface {
}
func NewDataSourceAPIBuilder(
groupName string,
plugin plugins.JSONData,
client PluginClient,
datasources PluginDatasourceProvider,
contextProvider PluginContextWrapper,
accessControl accesscontrol.AccessControl,
cfg DataSourceAPIBuilderConfig,
loadQueryTypes bool,
configCrudUseNewApis bool,
) (*DataSourceAPIBuilder, error) {
group, err := plugins.GetDatasourceGroupNameFromPluginID(plugin.ID)
if err != nil {
return nil, err
}
builder := &DataSourceAPIBuilder{
datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(groupName, plugin.ID),
datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(group, plugin.ID),
pluginJSON: plugin,
client: client,
datasources: datasources,
contextProvider: contextProvider,
accessControl: accessControl,
cfg: cfg,
configCrudUseNewApis: configCrudUseNewApis,
}
var err error
if cfg.LoadQueryTypes {
if loadQueryTypes {
// In the future, this will somehow come from the plugin
builder.queryTypes, err = getHardcodedQueryTypes(groupName)
builder.queryTypes, err = getHardcodedQueryTypes(group)
}
return builder, err
}
@@ -159,9 +154,9 @@ func getHardcodedQueryTypes(group string) (*queryV0.QueryTypeDefinitionList, err
var err error
var raw json.RawMessage
switch group {
case "testdata.datasource.grafana.app", "grafana-testdata-datasource":
case "testdata.datasource.grafana.app":
raw, err = kinds.QueryTypeDefinitionListJSON()
case "prometheus.datasource.grafana.app", "prometheus":
case "prometheus.datasource.grafana.app":
raw, err = models.QueryTypeDefinitionListJSON()
}
if err != nil {
@@ -238,7 +233,7 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
storage["connections"] = &noopREST{} // hidden from openapi
storage["connections/query"] = storage[ds.StoragePath("query")] // deprecated in openapi
if b.cfg.UseDualWriter {
if b.configCrudUseNewApis {
legacyStore := &legacyStorage{
datasources: b.datasources,
resourceInfo: &ds,
@@ -2,8 +2,6 @@ package extras
import (
apisprovisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
@@ -44,17 +42,6 @@ func ProvideProvisioningOSSRepositoryExtras(
}
}
func ProvideProvisioningOSSConnectionExtras(
_ *setting.Cfg,
decryptSvc decrypt.DecryptService,
ghFactory ghconnection.GithubFactory,
) []connection.Extra {
decrypter := connection.ProvideDecrypter(decryptSvc)
return []connection.Extra{
ghconnection.Extra(decrypter, ghFactory),
}
}
func ProvideExtraWorkers(pullRequestWorker *pullrequest.PullRequestWorker) []jobs.Worker {
return []jobs.Worker{pullRequestWorker}
}
@@ -67,12 +54,3 @@ func ProvideFactoryFromConfig(cfg *setting.Cfg, extras []repository.Extra) (repo
return repository.ProvideFactory(enabledTypes, extras)
}
func ProvideConnectionFactoryFromConfig(cfg *setting.Cfg, extras []connection.Extra) (connection.Factory, error) {
enabledTypes := make(map[apisprovisioning.ConnectionType]struct{}, len(cfg.ProvisioningRepositoryTypes))
for _, e := range cfg.ProvisioningRepositoryTypes {
enabledTypes[apisprovisioning.ConnectionType(e)] = struct{}{}
}
return connection.ProvideFactory(enabledTypes, extras)
}
+28 -83
View File
@@ -30,7 +30,7 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
@@ -97,8 +97,7 @@ type APIBuilder struct {
usageStats usagestats.Service
tracer tracing.Tracer
repoStore grafanarest.Storage
connectionStore grafanarest.Storage
store grafanarest.Storage
parsers resources.ParserFactory
repositoryResources resources.RepositoryResourcesFactory
clients resources.ClientFactory
@@ -106,21 +105,20 @@ type APIBuilder struct {
jobs.Queue
jobs.Store
}
jobHistoryConfig *JobHistoryConfig
jobHistoryLoki *jobs.LokiJobHistory
resourceLister resources.ResourceLister
dashboardAccess legacy.MigrationDashboardAccessor
unified resource.ResourceClient
repoFactory repository.Factory
connectionFactory connection.Factory
client client.ProvisioningV0alpha1Interface
access auth.AccessChecker
accessWithAdmin auth.AccessChecker
accessWithEditor auth.AccessChecker
accessWithViewer auth.AccessChecker
statusPatcher *appcontroller.RepositoryStatusPatcher
healthChecker *controller.HealthChecker
repoValidator repository.RepositoryValidator
jobHistoryConfig *JobHistoryConfig
jobHistoryLoki *jobs.LokiJobHistory
resourceLister resources.ResourceLister
dashboardAccess legacy.MigrationDashboardAccessor
unified resource.ResourceClient
repoFactory repository.Factory
client client.ProvisioningV0alpha1Interface
access auth.AccessChecker
accessWithAdmin auth.AccessChecker
accessWithEditor auth.AccessChecker
accessWithViewer auth.AccessChecker
statusPatcher *appcontroller.RepositoryStatusPatcher
healthChecker *controller.HealthChecker
validator repository.RepositoryValidator
// Extras provides additional functionality to the API.
extras []Extra
extraWorkers []jobs.Worker
@@ -135,7 +133,6 @@ type APIBuilder struct {
func NewAPIBuilder(
onlyApiServer bool,
repoFactory repository.Factory,
connectionFactory connection.Factory,
features featuremgmt.FeatureToggles,
unified resource.ResourceClient,
configProvider apiserver.RestConfigProvider,
@@ -179,7 +176,6 @@ func NewAPIBuilder(
usageStats: usageStats,
features: features,
repoFactory: repoFactory,
connectionFactory: connectionFactory,
clients: clients,
parsers: parsers,
repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister),
@@ -196,7 +192,7 @@ func NewAPIBuilder(
allowedTargets: allowedTargets,
allowImageRendering: allowImageRendering,
registry: registry,
repoValidator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering),
validator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering),
useExclusivelyAccessCheckerForAuthz: useExclusivelyAccessCheckerForAuthz,
}
@@ -257,7 +253,6 @@ func RegisterAPIService(
extraBuilders []ExtraBuilder,
extraWorkers []jobs.Worker,
repoFactory repository.Factory,
connectionFactory connection.Factory,
) (*APIBuilder, error) {
//nolint:staticcheck // not yet migrated to OpenFeature
if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
@@ -276,7 +271,6 @@ func RegisterAPIService(
builder := NewAPIBuilder(
cfg.DisableControllers,
repoFactory,
connectionFactory,
features,
client,
configProvider,
@@ -605,7 +599,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
}
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
b.repoStore = repositoryStorage
b.store = repositoryStorage
jobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.JobResourceInfo, opts.OptsGetter)
if err != nil {
@@ -637,7 +631,6 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
return fmt.Errorf("failed to create connection storage: %w", err)
}
connectionStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, connectionsStore)
b.connectionStore = connectionsStore
storage[provisioning.JobResourceInfo.StoragePath()] = jobStore
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
@@ -648,7 +641,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage[provisioning.ConnectionResourceInfo.StoragePath("repositories")] = NewConnectionRepositoriesConnector()
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.repoValidator), b.VerifyAgainstExistingRepositories))
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories))
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin)
storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b)
storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
@@ -689,15 +682,10 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
if ok {
return nil
}
// TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700
c, ok := obj.(*provisioning.Connection)
if ok {
conn, err := b.asConnection(ctx, c, nil)
if err != nil {
return err
}
return conn.Mutate(ctx)
return connectionvalidation.MutateConnection(c)
}
r, ok := obj.(*provisioning.Repository)
@@ -748,15 +736,9 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
return nil
}
// Validate connections
c, ok := obj.(*provisioning.Connection)
connection, ok := obj.(*provisioning.Connection)
if ok {
conn, err := b.asConnection(ctx, c, a.GetOldObject())
if err != nil {
return err
}
return conn.Validate(ctx)
return connectionvalidation.ValidateConnection(connection)
}
// Validate Jobs
@@ -776,7 +758,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
// the only time to add configuration checks here is if you need to compare
// the incoming change to the current configuration
isCreate := a.GetOperation() == admission.Create
list := b.repoValidator.ValidateRepository(repo, isCreate)
list := b.validator.ValidateRepository(repo, isCreate)
cfg := repo.Config()
if a.GetOperation() == admission.Update {
@@ -819,7 +801,7 @@ func invalidRepositoryError(name string, list field.ErrorList) error {
}
func (b *APIBuilder) VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error {
return VerifyAgainstExistingRepositories(ctx, b.repoStore, cfg)
return VerifyAgainstExistingRepositories(ctx, b.store, cfg)
}
func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) {
@@ -849,7 +831,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
}
b.statusPatcher = appcontroller.NewRepositoryStatusPatcher(b.GetClient())
b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.repoValidator))
b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.validator))
// if running solely CRUD, skip the rest of the setup
if b.onlyApiServer {
@@ -867,7 +849,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
// Create the repository resources factory
repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) {
return GetRepositoriesInNamespace(ctx, b.repoStore)
return GetRepositoriesInNamespace(ctx, b.store)
}
usageMetricCollector := usage.MetricCollector(b.tracer, repositoryListerWrapper, b.unified)
b.usageStats.RegisterMetricsFunc(usageMetricCollector)
@@ -1413,21 +1395,13 @@ spec:
// TODO: where should the helpers live?
func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository.Repository, error) {
obj, err := b.repoStore.Get(ctx, name, &metav1.GetOptions{})
obj, err := b.store.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, err
}
return b.asRepository(ctx, obj, nil)
}
func (b *APIBuilder) GetConnection(ctx context.Context, name string) (connection.Connection, error) {
obj, err := b.connectionStore.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, err
}
return b.asConnection(ctx, obj, nil)
}
func (b *APIBuilder) GetRepoFactory() repository.Factory {
return b.repoFactory
}
@@ -1475,35 +1449,6 @@ func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object, old r
return b.repoFactory.Build(ctx, r)
}
func (b *APIBuilder) asConnection(ctx context.Context, obj runtime.Object, old runtime.Object) (connection.Connection, error) {
if obj == nil {
return nil, fmt.Errorf("missing connection object")
}
c, ok := obj.(*provisioning.Connection)
if !ok {
return nil, fmt.Errorf("expected connection object")
}
// Copy previous values if they exist
if old != nil {
o, ok := old.(*provisioning.Connection)
if ok && !o.Secure.IsZero() {
if c.Secure.PrivateKey.IsZero() {
c.Secure.PrivateKey = o.Secure.PrivateKey
}
if c.Secure.Token.IsZero() {
c.Secure.Token = o.Secure.Token
}
if c.Secure.ClientSecret.IsZero() {
c.Secure.ClientSecret = o.Secure.ClientSecret
}
}
}
return b.connectionFactory.Build(ctx, c)
}
func getJSONResponse(ref string) *spec3.Responses {
return &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
@@ -28,7 +28,7 @@ func TestAPIBuilderValidate(t *testing.T) {
repoFactory: factory,
allowedTargets: []v0alpha1.SyncTargetType{v0alpha1.SyncTargetTypeFolder},
allowImageRendering: false,
repoValidator: validator,
validator: validator,
}
t.Run("min sync interval is less than 10 seconds", func(t *testing.T) {
+1 -1
View File
@@ -149,7 +149,7 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
}
// TODO: check if lister could list too many repositories or resources
all, err := GetRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()), b.repoStore)
all, err := GetRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()), b.store)
if err != nil {
errhttp.Write(r.Context(), err, w)
return
+13 -38
View File
@@ -30,29 +30,23 @@ type HealthCheckerProvider interface {
type ConnectorDependencies interface {
RepoGetter
ConnectionGetter
HealthCheckerProvider
GetRepoFactory() repository.Factory
}
type testConnector struct {
repoGetter RepoGetter
repoFactory repository.Factory
connectionGetter ConnectionGetter
healthProvider HealthCheckerProvider
tester repository.RepositoryTesterWithExistingChecker
getter RepoGetter
factory repository.Factory
healthProvider HealthCheckerProvider
tester repository.RepositoryTesterWithExistingChecker
}
func NewTestConnector(
deps ConnectorDependencies,
tester repository.RepositoryTesterWithExistingChecker,
) *testConnector {
func NewTestConnector(deps ConnectorDependencies, tester repository.RepositoryTesterWithExistingChecker) *testConnector {
return &testConnector{
repoFactory: deps.GetRepoFactory(),
repoGetter: deps,
connectionGetter: deps,
healthProvider: deps,
tester: tester,
factory: deps.GetRepoFactory(),
getter: deps,
healthProvider: deps,
tester: tester,
}
}
@@ -78,7 +72,7 @@ func (*testConnector) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
ns, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing namespace")
@@ -110,7 +104,7 @@ func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Obje
name = "hack-on-hack-for-new"
} else {
// Copy previous secure values if they exist
old, _ := s.repoGetter.GetRepository(ctx, name)
old, _ := s.getter.GetRepository(ctx, name)
if old != nil && !old.Config().Secure.IsZero() {
secure := old.Config().Secure
if cfg.Secure.Token.IsZero() {
@@ -127,27 +121,8 @@ func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Obje
cfg.SetNamespace(ns)
}
// The new repository should be connected to a Connection resource,
// i.e. we should be generating the token based on it.
if cfg.Secure.Token.IsZero() && cfg.Spec.Connection != nil && cfg.Spec.Connection.Name != "" {
// A connection must be there
c, err := s.connectionGetter.GetConnection(ctx, cfg.Spec.Connection.Name)
if err != nil {
responder.Error(err)
return
}
token, err := c.GenerateRepositoryToken(ctx, &cfg)
if err != nil {
responder.Error(err)
return
}
cfg.Secure.Token.Create = token
}
// Create a temporary repository
tmp, err := s.repoFactory.Build(ctx, &cfg)
tmp, err := s.factory.Build(ctx, &cfg)
if err != nil {
responder.Error(err)
return
@@ -173,7 +148,7 @@ func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Obje
}
// Testing existing repository - get it and update health
repo, err = s.repoGetter.GetRepository(ctx, name)
repo, err = s.getter.GetRepository(ctx, name)
if err != nil {
responder.Error(err)
return
-5
View File
@@ -3,7 +3,6 @@ package provisioning
import (
"context"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
)
@@ -16,10 +15,6 @@ type RepoGetter interface {
GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error)
}
type ConnectionGetter interface {
GetConnection(ctx context.Context, name string) (connection.Connection, error)
}
type ClientGetter interface {
GetClient() client.ProvisioningV0alpha1Interface
}
-1
View File
@@ -44,7 +44,6 @@ var provisioningExtras = wire.NewSet(
pullrequest.ProvidePullRequestWorker,
webhooks.ProvideWebhooksWithImages,
extras.ProvideFactoryFromConfig,
extras.ProvideConnectionFactoryFromConfig,
extras.ProvideProvisioningExtraAPIs,
extras.ProvideExtraWorkers,
)
+29 -33
View File
@@ -3,7 +3,6 @@ package server
import (
"github.com/stretchr/testify/mock"
githubconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
"github.com/grafana/grafana/apps/secret/pkg/decrypt"
"github.com/grafana/grafana/pkg/infra/db"
@@ -35,26 +34,24 @@ func ProvideTestEnv(
featureMgmt featuremgmt.FeatureToggles,
resourceClient resource.ResourceClient,
idService auth.IDService,
githubRepoFactory *github.Factory,
githubConnectionFactory githubconnection.GithubFactory,
githubFactory *github.Factory,
decryptService decrypt.DecryptService,
) (*TestEnv, error) {
return &TestEnv{
TestingT: testingT,
Server: server,
SQLStore: db,
Cfg: cfg,
NotificationService: ns,
GRPCServer: grpcServer,
PluginRegistry: pluginRegistry,
HTTPClientProvider: httpClientProvider,
OAuthTokenService: oAuthTokenService,
FeatureToggles: featureMgmt,
ResourceClient: resourceClient,
IDService: idService,
GithubRepoFactory: githubRepoFactory,
GithubConnectionFactory: githubConnectionFactory,
DecryptService: decryptService,
TestingT: testingT,
Server: server,
SQLStore: db,
Cfg: cfg,
NotificationService: ns,
GRPCServer: grpcServer,
PluginRegistry: pluginRegistry,
HTTPClientProvider: httpClientProvider,
OAuthTokenService: oAuthTokenService,
FeatureToggles: featureMgmt,
ResourceClient: resourceClient,
IDService: idService,
GitHubFactory: githubFactory,
DecryptService: decryptService,
}, nil
}
@@ -63,19 +60,18 @@ type TestEnv struct {
mock.TestingT
Cleanup(func())
}
Server *Server
SQLStore db.DB
Cfg *setting.Cfg
NotificationService *notifications.NotificationServiceMock
GRPCServer grpcserver.Provider
PluginRegistry registry.Service
HTTPClientProvider httpclient.Provider
OAuthTokenService *oauthtokentest.Service
RequestMiddleware web.Middleware
FeatureToggles featuremgmt.FeatureToggles
ResourceClient resource.ResourceClient
IDService auth.IDService
GithubRepoFactory *github.Factory
GithubConnectionFactory githubconnection.GithubFactory
DecryptService decrypt.DecryptService
Server *Server
SQLStore db.DB
Cfg *setting.Cfg
NotificationService *notifications.NotificationServiceMock
GRPCServer grpcserver.Provider
PluginRegistry registry.Service
HTTPClientProvider httpclient.Provider
OAuthTokenService *oauthtokentest.Service
RequestMiddleware web.Middleware
FeatureToggles featuremgmt.FeatureToggles
ResourceClient resource.ResourceClient
IDService auth.IDService
GitHubFactory *github.Factory
DecryptService decrypt.DecryptService
}
-2
View File
@@ -15,7 +15,6 @@ import (
"go.opentelemetry.io/otel/trace"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/avatar"
@@ -298,7 +297,6 @@ var wireBasicSet = wire.NewSet(
notifications.ProvideService,
notifications.ProvideSmtpService,
github.ProvideFactory,
ghconnection.ProvideFactory,
tracing.ProvideService,
tracing.ProvideTracingConfig,
wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)),
+4 -17
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -72,7 +72,6 @@ import (
var provisioningExtras = wire.NewSet(
extras.ProvideProvisioningOSSRepositoryExtras,
extras.ProvideProvisioningOSSConnectionExtras,
)
var configProviderExtras = wire.NewSet(
+1 -5
View File
@@ -133,11 +133,7 @@ type FeatureFlag struct {
Stage FeatureFlagStage `json:"stage,omitempty"`
Owner codeowner `json:"-"` // Owner person or team that owns this feature flag
// Expression defined by the feature_toggles configuration.
// Supports multiple types including boolean, string, integer, float,
// and structured values following the OpenFeature specification.
// Using the value "true" means the feature flag is enabled by default,
// Using the value "1.0" means the default value of the feature flag is 1.0
// CEL-GO expression. Using the value "true" will mean this is on by default
Expression string `json:"expression,omitempty"`
// Special behavior properties
+3 -4
View File
@@ -8,7 +8,6 @@ import (
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/open-feature/go-sdk/openfeature"
@@ -27,7 +26,7 @@ type OpenFeatureConfig struct {
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
HTTPClient *http.Client
// StaticFlags are the feature flags to use with static provider
StaticFlags map[string]memprovider.InMemoryFlag
StaticFlags map[string]bool
// TargetingKey is used for evaluation context
TargetingKey string
// ContextAttrs are additional attributes for evaluation context
@@ -101,7 +100,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
func createProvider(
providerType string,
u *url.URL,
staticFlags map[string]memprovider.InMemoryFlag,
staticFlags map[string]bool,
httpClient *http.Client,
) (openfeature.FeatureProvider, error) {
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
@@ -118,7 +117,7 @@ func createProvider(
}
}
return newStaticProvider(staticFlags, standardFeatureFlags)
return newStaticProvider(staticFlags)
}
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
+29
View File
@@ -872,6 +872,13 @@ var (
Owner: grafanaSharingSquad,
FrontendOnly: false,
},
{
Name: "logsExploreTableDefaultVisualization",
Description: "Sets the logs table as default visualisation in logs explore",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityLogsSquad,
FrontendOnly: true,
},
{
Name: "alertingListViewV2",
Description: "Enables the new alert list view design",
@@ -1031,6 +1038,13 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "exploreLogsLimitedTimeRange",
Description: "Used in Logs Drilldown to limit the time range",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "appPlatformGrpcClientAuth",
Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens",
@@ -1073,6 +1087,13 @@ var (
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
},
{
Name: "unifiedStorageSearch",
Description: "Enable unified storage search",
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
HideFromDocs: true,
},
{
Name: "unifiedStorageSearchSprinkles",
Description: "Enable sprinkles on unified storage search",
@@ -1141,6 +1162,14 @@ var (
Owner: identityAccessTeam,
HideFromDocs: true,
},
{
Name: "exploreMetricsRelatedLogs",
Description: "Display Related Logs in Grafana Metrics Drilldown",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityMetricsSquad,
FrontendOnly: true,
HideFromDocs: false,
},
{
Name: "prometheusSpecialCharsInLabelValues",
Description: "Adds support for quotes and special characters in label values for Prometheus queries",
+1 -2
View File
@@ -47,8 +47,7 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
}
mgmt.warnings[key] = "unknown flag in config"
}
mgmt.startup[key] = val.Variants[val.DefaultVariant] == true
mgmt.startup[key] = val
}
// update the values
+1 -1
View File
@@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
}
staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags)
staticProvider, err := newStaticProvider(staticFlags)
if err != nil {
return nil, fmt.Errorf("failed to create static provider: %w", err)
}
+29 -18
View File
@@ -1,13 +1,8 @@
package featuremgmt
import (
"fmt"
"maps"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/setting"
)
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
@@ -33,21 +28,37 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
return keys, nil
}
func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
// Parse and add standard flags
for _, flag := range standardFlags {
inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression)
if err != nil {
return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err)
}
flags[flag.Name] = inMemFlag
}
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
// Add flags from config.ini file
maps.Copy(flags, confFlags)
for name, value := range confFlags {
flags[name] = createInMemoryFlag(name, value)
}
// Add standard flags
for _, flag := range standardFeatureFlags {
if _, exists := flags[flag.Name]; !exists {
enabled := flag.Expression == "true"
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
}
}
return newInMemoryBulkProvider(flags), nil
}
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
variant := "disabled"
if enabled {
variant = "enabled"
}
return memprovider.InMemoryFlag{
Key: name,
DefaultVariant: variant,
Variants: map[string]interface{}{
"enabled": true,
"disabled": false,
},
}
}
@@ -5,7 +5,6 @@ import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/open-feature/go-sdk/openfeature"
"github.com/stretchr/testify/assert"
@@ -94,144 +93,3 @@ ABCD = true
enabledFeatureManager := mgr.GetEnabled(ctx)
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
}
func Test_StaticProvider_TypedFlags(t *testing.T) {
tests := []struct {
flags FeatureFlag
defaultValue any
expectedValue any
}{
{
flags: FeatureFlag{
Name: "Flag",
Expression: "true",
},
defaultValue: false,
expectedValue: true,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1.0",
},
defaultValue: 0.0,
expectedValue: 1.0,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "blue",
},
defaultValue: "red",
expectedValue: "blue",
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1",
},
defaultValue: int64(0),
expectedValue: int64(1),
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: `{ "foo": "bar" }`,
},
expectedValue: map[string]any{"foo": "bar"},
},
}
for _, tt := range tests {
provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags})
assert.NoError(t, err)
var result any
switch tt.expectedValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.expectedValue, result)
}
}
func Test_StaticProvider_ConfigOverride(t *testing.T) {
tests := []struct {
name string
originalValue string
configValue any
}{
{
name: "bool",
originalValue: "false",
configValue: true,
},
{
name: "int",
originalValue: "0",
configValue: int64(1),
},
{
name: "float",
originalValue: "0.0",
configValue: 1.0,
},
{
name: "string",
originalValue: "foo",
configValue: "bar",
},
{
name: "structure",
originalValue: "{}",
configValue: make(map[string]any),
},
}
for _, tt := range tests {
configFlags, standardFlags := makeFlags(tt)
provider, err := newStaticProvider(configFlags, standardFlags)
assert.NoError(t, err)
var result any
switch tt.configValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.configValue, result)
}
}
func makeFlags(tt struct {
name string
originalValue string
configValue any
}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) {
orig := FeatureFlag{
Name: tt.name,
Expression: tt.originalValue,
}
config := map[string]memprovider.InMemoryFlag{
tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue),
}
return config, []FeatureFlag{orig}
}
+4
View File
@@ -120,6 +120,7 @@ queryLibrary,preview,@grafana/sharing-squad,false,false,false
dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false
suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false
dashboardTemplates,preview,@grafana/sharing-squad,false,false,false
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
@@ -142,12 +143,14 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false
useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false
managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
@@ -158,6 +161,7 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
enableSCIM,preview,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
120 dashboardLibrary experimental @grafana/sharing-squad false false false
121 suggestedDashboards experimental @grafana/sharing-squad false false false
122 dashboardTemplates preview @grafana/sharing-squad false false false
123 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
124 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
125 alertingSavedSearches experimental @grafana/alerting-squad false false true
126 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
143 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
144 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
145 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true
146 exploreLogsLimitedTimeRange experimental @grafana/observability-logs false false true
147 appPlatformGrpcClientAuth experimental @grafana/identity-access-team false false false
148 groupAttributeSync privatePreview @grafana/identity-access-team false false false
149 alertingQueryAndExpressionsStepMode GA @grafana/alerting-squad false false true
150 improvedExternalSessionHandling GA @grafana/identity-access-team false false false
151 useSessionStorageForRedirection GA @grafana/identity-access-team false false false
152 rolePickerDrawer experimental @grafana/identity-access-team false false false
153 unifiedStorageSearch experimental @grafana/search-and-storage false false false
154 unifiedStorageSearchSprinkles experimental @grafana/search-and-storage false false false
155 managedDualWriter experimental @grafana/search-and-storage false false false
156 pluginsSriChecks GA @grafana/plugins-platform-backend false false false
161 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
162 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false
163 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
164 exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
165 prometheusSpecialCharsInLabelValues experimental @grafana/oss-big-tent false false true
166 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
167 enableSCIM preview @grafana/identity-access-team false false false
+4
View File
@@ -455,6 +455,10 @@ const (
// Enables the new role picker drawer design
FlagRolePickerDrawer = "rolePickerDrawer"
// FlagUnifiedStorageSearch
// Enable unified storage search
FlagUnifiedStorageSearch = "unifiedStorageSearch"
// FlagUnifiedStorageSearchSprinkles
// Enable sprinkles on unified storage search
FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles"
+4 -8
View File
@@ -1382,8 +1382,7 @@
"metadata": {
"name": "exploreLogsLimitedTimeRange",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-08-29T13:55:59Z",
"deletionTimestamp": "2026-01-12T22:18:14Z"
"creationTimestamp": "2024-08-29T13:55:59Z"
},
"spec": {
"description": "Used in Logs Drilldown to limit the time range",
@@ -1409,8 +1408,7 @@
"metadata": {
"name": "exploreMetricsRelatedLogs",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-11-05T16:28:43Z",
"deletionTimestamp": "2026-01-09T22:14:53Z"
"creationTimestamp": "2024-11-05T16:28:43Z"
},
"spec": {
"description": "Display Related Logs in Grafana Metrics Drilldown",
@@ -2248,8 +2246,7 @@
"metadata": {
"name": "logsExploreTableDefaultVisualization",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-05-02T15:28:15Z",
"deletionTimestamp": "2026-01-12T14:11:46Z"
"creationTimestamp": "2024-05-02T15:28:15Z"
},
"spec": {
"description": "Sets the logs table as default visualisation in logs explore",
@@ -3700,8 +3697,7 @@
"metadata": {
"name": "unifiedStorageSearch",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-09-30T19:46:14Z",
"deletionTimestamp": "2026-01-12T10:02:12Z"
"creationTimestamp": "2024-09-30T19:46:14Z"
},
"spec": {
"description": "Enable unified storage search",
@@ -190,6 +190,9 @@ func verifyFlagsConfiguration(t *testing.T) {
if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" {
t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name)
}
if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" {
t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name)
}
// Check camel case names
if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] {
invalidNames = append(invalidNames, flag.Name)
+1 -10
View File
@@ -100,9 +100,6 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef {
if ref == nil {
return d.defaultDS
}
if ref.UID == "default" && ref.Type == "" {
return d.defaultDS
}
key := ""
if ref.UID != "" {
@@ -120,13 +117,7 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef {
return ds
}
ds, ok = d.byName[key]
if ok {
return ds
}
// With nothing was found (or configured), use the original reference
return ref
return d.byName[key]
}
func (d *DsLookup) ByType(dsType string) []DataSourceRef {
@@ -4,8 +4,8 @@
"tags": null,
"datasource": [
{
"uid": "000000001",
"type": "graphite"
"uid": "default.uid",
"type": "default.type"
}
],
"panels": [
@@ -16,8 +16,8 @@
"libraryPanel": "dfkljg98345dkf",
"datasource": [
{
"uid": "000000001",
"type": "graphite"
"uid": "default.uid",
"type": "default.type"
}
]
}
@@ -1,7 +1,5 @@
package dashboard
import "iter"
type PanelSummaryInfo struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -32,20 +30,3 @@ type DashboardSummaryInfo struct {
Refresh string `json:"refresh,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"` // editable = false
}
func (d *DashboardSummaryInfo) PanelIterator() iter.Seq[PanelSummaryInfo] {
return func(yield func(PanelSummaryInfo) bool) {
for _, p := range d.Panels {
if len(p.Collapsed) > 0 {
for _, c := range p.Collapsed {
if !yield(c) { // NOTE, rows can only be one level deep!
return
}
}
}
if !yield(p) {
return
}
}
}
}
+2 -5
View File
@@ -10,7 +10,6 @@ import (
"testing"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
@@ -379,10 +378,8 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) {
err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{
ProviderType: setting.StaticProviderType,
StaticFlags: map[string]memprovider.InMemoryFlag{
featuremgmt.FlagPluginsAutoUpdate: {
Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue},
},
StaticFlags: map[string]bool{
featuremgmt.FlagPluginsAutoUpdate: flagValue,
},
})
require.NoError(t, err)
+5 -75
View File
@@ -1,20 +1,13 @@
package setting
import (
"encoding/json"
"math"
"strconv"
"gopkg.in/ini.v1"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/util"
)
// DefaultVariantName a placeholder name for config-based Feature Flags
const DefaultVariantName = "default"
// Deprecated: should use `featuremgmt.FeatureToggles`
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
section := iniFile.Section("feature_toggles")
@@ -22,27 +15,18 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
if err != nil {
return err
}
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
// nolint:staticcheck
cfg.IsFeatureToggleEnabled = func(key string) bool {
toggle, ok := toggles[key]
if !ok {
return false
}
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
return value && ok
}
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
return nil
}
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
featureToggles := make(map[string]bool, 10)
// parse the comma separated list in `enable`.
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
for _, feature := range util.SplitString(featuresTogglesStr) {
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
featureToggles[feature] = true
}
// read all other settings under [feature_toggles]. If a toggle is
@@ -52,7 +36,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
continue
}
b, err := ParseFlag(v.Name(), v.Value())
b, err := strconv.ParseBool(v.Value())
if err != nil {
return featureToggles, err
}
@@ -61,57 +45,3 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
}
return featureToggles, nil
}
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
var structure map[string]any
if integer, err := strconv.Atoi(value); err == nil {
return NewInMemoryFlag(name, integer), nil
}
if float, err := strconv.ParseFloat(value, 64); err == nil {
return NewInMemoryFlag(name, float), nil
}
if err := json.Unmarshal([]byte(value), &structure); err == nil {
return NewInMemoryFlag(name, structure), nil
}
if boolean, err := strconv.ParseBool(value); err == nil {
return NewInMemoryFlag(name, boolean), nil
}
return NewInMemoryFlag(name, value), nil
}
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
}
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
var res = map[string]string{}
for k, v := range m {
res[k] = serializeFlagValue(v)
}
return res
}
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
value := flag.Variants[flag.DefaultVariant]
switch castedValue := value.(type) {
case bool:
return strconv.FormatBool(castedValue)
case int64:
return strconv.FormatInt(castedValue, 10)
case float64:
// handle cases with a single or no zeros after the decimal point
if math.Trunc(castedValue) == castedValue {
return strconv.FormatFloat(castedValue, 'f', 1, 64)
}
return strconv.FormatFloat(castedValue, 'g', -1, 64)
case string:
return castedValue
default:
val, _ := json.Marshal(value)
return string(val)
}
}
+23 -54
View File
@@ -1,11 +1,9 @@
package setting
import (
"strconv"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
@@ -14,16 +12,17 @@ func TestFeatureToggles(t *testing.T) {
testCases := []struct {
name string
conf map[string]string
expectedToggles map[string]memprovider.InMemoryFlag
err error
expectedToggles map[string]bool
}{
{
name: "can parse feature toggles passed in the `enable` array",
conf: map[string]string{
"enable": "feature1,feature2",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
},
},
{
@@ -32,10 +31,10 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature3": "true",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
"feature3": NewInMemoryFlag("feature3", true),
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
"feature3": true,
},
},
{
@@ -44,26 +43,19 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature2": "false",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", false),
expectedToggles: map[string]bool{
"feature1": true,
"feature2": false,
},
},
{
name: "feature flags of different types are handled correctly",
name: "invalid boolean value should return syntax error",
conf: map[string]string{
"feature1": "1", "feature2": "1.0",
"feature3": `{"foo":"bar"}`, "feature4": "bar",
"feature5": "t", "feature6": "T",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", 1),
"feature2": NewInMemoryFlag("feature2", 1.0),
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
"feature4": NewInMemoryFlag("feature4", "bar"),
"feature5": NewInMemoryFlag("feature5", true),
"feature6": NewInMemoryFlag("feature6", true),
"enable": "feature1,feature2",
"feature2": "invalid",
},
expectedToggles: map[string]bool{},
err: strconv.ErrSyntax,
},
}
@@ -77,35 +69,12 @@ func TestFeatureToggles(t *testing.T) {
}
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
require.NoError(t, err)
require.ErrorIs(t, err, tc.err)
for k, v := range featureToggles {
toggle := tc.expectedToggles[k]
require.Equal(t, toggle, v, tc.name)
}
}
}
func TestFlagValueSerialization(t *testing.T) {
testCases := []memprovider.InMemoryFlag{
NewInMemoryFlag("int", 1),
NewInMemoryFlag("1.0f", 1.0),
NewInMemoryFlag("1.01f", 1.01),
NewInMemoryFlag("1.10f", 1.10),
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
NewInMemoryFlag("string", "bar"),
NewInMemoryFlag("true", true),
NewInMemoryFlag("false", false),
}
for _, tt := range testCases {
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
assert.NoError(t, err)
if diff := cmp.Diff(tt, deserialized); diff != "" {
t.Errorf("(-want, +got) = %v", diff)
if err == nil {
for k, v := range featureToggles {
require.Equal(t, tc.expectedToggles[k], v, tc.name)
}
}
}
}
+1
View File
@@ -236,6 +236,7 @@ kubernetesDashboards = true
kubernetesFolders = true
unifiedStorage = true
unifiedStorageHistoryPruner = true
unifiedStorageSearch = true
unifiedStorageSearchPermissionFiltering = false
unifiedStorageSearchSprinkles = false
+4 -4
View File
@@ -78,13 +78,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
cache := gocache.New(cacheTTL, cacheCleanupInterval)
events := make(chan Event, opts.BufferSize)
lastRV, err := n.lastEventResourceVersion(ctx)
initialRV, err := n.lastEventResourceVersion(ctx)
if errors.Is(err, ErrNotFound) {
lastRV = 0 // No events yet, start from the beginning
initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning
} else if err != nil {
n.log.Error("Failed to get last event resource version", "error", err)
}
lastRV = lastRV + 1 // We want to start watching from the next event
lastRV := initialRV + 1 // We want to start watching from the next event
go func() {
defer close(events)
@@ -110,7 +110,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
}
// Skip old events lower than the requested resource version
if evt.ResourceVersion < lastRV {
if evt.ResourceVersion <= initialRV {
continue
}
+15 -7
View File
@@ -25,6 +25,7 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
return notifier, eventStore
}
// nolint:unused
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
@@ -59,7 +60,8 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin
func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
}
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -110,7 +112,8 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not
func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
}
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -164,7 +167,8 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier,
func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
}
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -205,7 +209,8 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti
func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
}
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -279,7 +284,8 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti
func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
}
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -345,7 +351,8 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti
func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
}
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -391,7 +398,8 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not
func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
}
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
+1 -1
View File
@@ -863,7 +863,7 @@ func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time
func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, key NamespacedResource, reason string) (ResourceIndex, error) {
if s == nil || s.search == nil {
return nil, fmt.Errorf("search is not configured properly (missing enable_search config?)")
return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)")
}
ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex")
@@ -346,8 +346,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
return 0, fmt.Errorf("failed to write data: %w", err)
}
rv = rvmanager.SnowflakeFromRv(rv)
dataKey.ResourceVersion = rv
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(rv)
} else {
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
if err != nil {
+2 -4
View File
@@ -1253,23 +1253,21 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R
queryExact.SetField(resource.SEARCH_FIELD_TITLE)
queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token
queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit.
searchQuery := bleve.NewDisjunctionQuery(queryExact)
// Query 2: Phrase query with standard analyzer
queryPhrase := bleve.NewMatchPhraseQuery(req.Query)
queryPhrase.SetBoost(5.0)
queryPhrase.SetField(resource.SEARCH_FIELD_TITLE)
queryPhrase.Analyzer = standard.Name
searchQuery.AddQuery(queryPhrase)
// Query 3: Match query with standard analyzer
queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query))
queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE)
queryAnalyzed.SetBoost(2.0)
queryAnalyzed.Analyzer = standard.Name
queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched
searchQuery.AddQuery(queryAnalyzed)
// At least one of the queries must match
searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase)
queries = append(queries, searchQuery)
}
+1
View File
@@ -23,6 +23,7 @@ import (
"go.uber.org/goleak"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"fmt"
"slices"
"sort"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -19,7 +18,6 @@ import (
const DASHBOARD_SCHEMA_VERSION = "schema_version"
const DASHBOARD_LINK_COUNT = "link_count"
const DASHBOARD_PANEL_TYPES = "panel_types"
const DASHBOARD_PANEL_TITLE = "panel_title"
const DASHBOARD_DS_TYPES = "ds_types"
const DASHBOARD_TRANSFORMATIONS = "transformation"
const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel"
@@ -55,21 +53,11 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource.
Type: resourcepb.ResourceTableColumnDefinition_INT32,
Description: "How many links appear on the page",
},
{
Name: DASHBOARD_PANEL_TITLE,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
IsArray: true,
Description: "The panel title text",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
Filterable: false, // full text
FreeText: true,
},
},
{
Name: DASHBOARD_PANEL_TYPES,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
IsArray: true,
Description: "The panel types used in this dashboard",
Description: "How many links appear on the page",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
Filterable: true,
},
@@ -281,22 +269,14 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
doc.Description = summary.Description
doc.Tags = summary.Tags
panelTitles := []string{}
panelTypes := []string{}
transformations := []string{}
dsTypes := []string{}
for p := range summary.PanelIterator() {
switch p.Type {
case "": // ignore
case "row": // row should map to a layout type when we support v2 constructs
default:
for _, p := range summary.Panels {
if p.Type != "" {
panelTypes = append(panelTypes, p.Type)
}
if len(p.Title) > 0 {
panelTitles = append(panelTitles, p.Title)
}
if len(p.Transformer) > 0 {
transformations = append(transformations, p.Transformer...)
}
@@ -329,20 +309,17 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
resource.SEARCH_FIELD_LEGACY_ID: summary.ID,
}
if len(panelTitles) > 0 {
doc.Fields[DASHBOARD_PANEL_TITLE] = panelTitles
}
if len(panelTypes) > 0 {
sort.Strings(panelTypes)
doc.Fields[DASHBOARD_PANEL_TYPES] = slices.Compact(panelTypes) // distinct values
doc.Fields[DASHBOARD_PANEL_TYPES] = panelTypes
}
if len(dsTypes) > 0 {
sort.Strings(dsTypes)
doc.Fields[DASHBOARD_DS_TYPES] = slices.Compact(dsTypes) // distinct values
doc.Fields[DASHBOARD_DS_TYPES] = dsTypes
}
if len(transformations) > 0 {
sort.Strings(transformations)
doc.Fields[DASHBOARD_TRANSFORMATIONS] = slices.Compact(transformations) // distinct values
doc.Fields[DASHBOARD_TRANSFORMATIONS] = transformations
}
for k, v := range s.Stats[summary.UID] {
@@ -32,16 +32,10 @@
"errors_last_7_days": 1,
"grafana.app/deprecatedInternalID": 141,
"link_count": 0,
"panel_title": [
"green pie",
"red pie",
"blue pie",
"collapsed row"
],
"panel_types": [
"barchart",
"graph",
"pie"
"row"
],
"schema_version": 38
},
@@ -52,12 +46,6 @@
"kind": "DataSource",
"name": "DSUID"
},
{
"relation": "depends-on",
"group": "dashboards.grafana.app",
"kind": "LibraryPanel",
"name": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
},
{
"relation": "depends-on",
"group": "dashboards.grafana.app",
@@ -67,7 +67,7 @@
"name": "red pie",
"uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee"
},
"title": "red pie"
"title": "green pie"
},
{
"id": 7,
@@ -78,14 +78,6 @@
"id": 8,
"type": "graph"
},
{
"id": 20,
"type": "graph"
},
{
"id": 30,
"type": "graph"
},
{
"collapsed": true,
"gridPos": {
@@ -109,10 +101,6 @@
"uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
},
"title": "blue pie"
},
{
"id": 40,
"type": "pie"
}
],
"title": "collapsed row",

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