Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 314db3c24c | |||
| e7ae54f1a6 | |||
| fa4dbce7da | |||
| 93264ac67d | |||
| 0b9b47aaea | |||
| 5e74848ee0 | |||
| aa90ac7ccc |
@@ -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 }}"
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
-206
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
-220
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 23,
|
||||
"id": 1,
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
@@ -77,7 +77,7 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 24,
|
||||
"id": 23,
|
||||
"panels": [],
|
||||
"targets": [
|
||||
{
|
||||
|
||||
+48
-48
@@ -31,6 +31,53 @@
|
||||
"cursorSync": "Off",
|
||||
"editable": false,
|
||||
"elements": {
|
||||
"panel-1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 1,
|
||||
"title": "Application Monitoring",
|
||||
"description": "",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "prometheus",
|
||||
"spec": {}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "text",
|
||||
"spec": {
|
||||
"pluginVersion": "",
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-10": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
@@ -977,53 +1024,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-23": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 23,
|
||||
"title": "Application Monitoring",
|
||||
"description": "",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "prometheus",
|
||||
"spec": {}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "text",
|
||||
"spec": {
|
||||
"pluginVersion": "",
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-6": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
@@ -1259,7 +1259,7 @@
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-23"
|
||||
"name": "panel-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
-50
@@ -32,6 +32,55 @@
|
||||
"cursorSync": "Off",
|
||||
"editable": false,
|
||||
"elements": {
|
||||
"panel-1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 1,
|
||||
"title": "Application Monitoring",
|
||||
"description": "",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "prometheus",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "default-ds-uid"
|
||||
},
|
||||
"spec": {}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "text",
|
||||
"version": "",
|
||||
"spec": {
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-10": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
@@ -1018,55 +1067,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-23": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 23,
|
||||
"title": "Application Monitoring",
|
||||
"description": "",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "prometheus",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "default-ds-uid"
|
||||
},
|
||||
"spec": {}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "text",
|
||||
"version": "",
|
||||
"spec": {
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-6": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
@@ -1310,7 +1310,7 @@
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-23"
|
||||
"name": "panel-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-140
@@ -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,
|
||||
|
||||
Vendored
-140
@@ -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,
|
||||
|
||||
Vendored
-212
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
-140
@@ -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,
|
||||
|
||||
-140
@@ -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,
|
||||
|
||||
Vendored
-214
@@ -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
|
||||
|
||||
@@ -432,21 +432,6 @@ func getPanels(dashboard map[string]interface{}) []map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// Also get panels from rows
|
||||
if rows, ok := dashboard["rows"].([]interface{}); ok {
|
||||
for _, rowInterface := range rows {
|
||||
if row, ok := rowInterface.(map[string]interface{}); ok {
|
||||
if rowPanels, ok := row["panels"].([]interface{}); ok {
|
||||
for _, panelInterface := range rowPanels {
|
||||
if panel, ok := panelInterface.(map[string]interface{}); ok {
|
||||
panels = append(panels, panel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panels
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ func upgradeToGridLayout(dashboard map[string]interface{}) {
|
||||
widthFactor := gridColumnCount / 12.0
|
||||
|
||||
// Find max panel ID (lines 1014-1021 in TS)
|
||||
// Also check top-level panels which may have been assigned IDs by ensurePanelsHaveUniqueIds
|
||||
maxPanelID := getMaxPanelID(dashboard, rows)
|
||||
maxPanelID := getMaxPanelID(rows)
|
||||
nextRowID := maxPanelID + 1
|
||||
|
||||
// Match frontend: dashboard.panels already exists with top-level panels
|
||||
@@ -270,25 +269,10 @@ func (r *rowArea) getPanelPosition(panelHeight int, panelWidth int) map[string]i
|
||||
return r.getPanelPosition(panelHeight, panelWidth)
|
||||
}
|
||||
|
||||
func getMaxPanelID(dashboard map[string]interface{}, rows []interface{}) int {
|
||||
func getMaxPanelID(rows []interface{}) int {
|
||||
maxID := 0
|
||||
hasValidID := false
|
||||
|
||||
// Check top-level panels first (these may have been assigned IDs by ensurePanelsHaveUniqueIds)
|
||||
if panels, ok := dashboard["panels"].([]interface{}); ok {
|
||||
for _, panelInterface := range panels {
|
||||
if panel, ok := panelInterface.(map[string]interface{}); ok {
|
||||
if id := GetIntValue(panel, "id", 0); id > 0 {
|
||||
hasValidID = true
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check panels inside rows
|
||||
for _, rowInterface := range rows {
|
||||
if row, ok := rowInterface.(map[string]interface{}); ok {
|
||||
if panels, ok := row["panels"].([]interface{}); ok {
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 23,
|
||||
"id": 1,
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
@@ -71,7 +71,7 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 24,
|
||||
"id": 23,
|
||||
"panels": [],
|
||||
"targets": [
|
||||
{
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 23,
|
||||
"id": 1,
|
||||
"options": {
|
||||
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
|
||||
"mode": "markdown"
|
||||
@@ -51,7 +51,7 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 24,
|
||||
"id": 23,
|
||||
"panels": [],
|
||||
"title": "Application Service",
|
||||
"type": "row"
|
||||
|
||||
@@ -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{}{},
|
||||
|
||||
+1
@@ -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,16 +0,0 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
//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
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package connection
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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,309 +0,0 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestProvideFactory(t *testing.T) {
|
||||
t.Run("should create factory with valid extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
t.Run("should create factory with empty extras", func(t *testing.T) {
|
||||
enabled := map[provisioning.ConnectionType]struct{}{}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
t.Run("should create factory with nil enabled map", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
factory, err := ProvideFactory(nil, []Extra{extra1})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
t.Run("should return error when duplicate repository types", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, factory)
|
||||
assert.Contains(t, err.Error(), "connection type \"github\" is already registered")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFactory_Types(t *testing.T) {
|
||||
t.Run("should return only enabled types that have extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 2)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.Contains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
t.Run("should return sorted list of types", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 2)
|
||||
// github should come before gitlab alphabetically
|
||||
assert.Equal(t, provisioning.GithubConnectionType, types[0])
|
||||
assert.Equal(t, provisioning.GitlabConnectionType, types[1])
|
||||
})
|
||||
|
||||
t.Run("should return empty list when no types are enabled", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
|
||||
t.Run("should not return types that are enabled but have no extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 1)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.NotContains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
t.Run("should not return types that have extras but are not enabled", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 1)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.NotContains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
t.Run("should return empty list when no extras are provided", func(t *testing.T) {
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFactory_Build(t *testing.T) {
|
||||
t.Run("should successfully build connection when type is enabled and has extra", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
mockConnection := NewMockConnection(t)
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mockConnection, result)
|
||||
})
|
||||
|
||||
t.Run("should return error when type is not enabled", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled")
|
||||
})
|
||||
|
||||
t.Run("should return error when type is not supported", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should pass through errors from extra.Build()", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
expectedErr := errors.New("build error")
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, conn).Return(nil, expectedErr)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("should build with multiple extras registered", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
mockConnection := NewMockConnection(t)
|
||||
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mockConnection, result)
|
||||
})
|
||||
}
|
||||
@@ -1,93 +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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TODO(ferruvich): do we need any other info?
|
||||
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
|
||||
}
|
||||
|
||||
// TODO(ferruvich): do we need any other info?
|
||||
return AppInstallation{
|
||||
ID: installation.GetID(),
|
||||
Enabled: installation.GetSuspendedAt().IsZero(),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,149 +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}
|
||||
}
|
||||
|
||||
// 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,297 +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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,192 +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"
|
||||
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 Connection struct {
|
||||
obj *provisioning.Connection
|
||||
ghFactory GithubFactory
|
||||
}
|
||||
|
||||
func NewConnection(
|
||||
obj *provisioning.Connection,
|
||||
factory GithubFactory,
|
||||
) Connection {
|
||||
return Connection{
|
||||
obj: obj,
|
||||
ghFactory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
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 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.obj.Secure.PrivateKey.Create)
|
||||
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.obj.Secure.PrivateKey.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
|
||||
}
|
||||
if c.obj.Secure.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.obj.Secure.Token.Create)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
_ connection.Connection = (*Connection)(nil)
|
||||
)
|
||||
@@ -1,434 +0,0 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
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) {
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
|
||||
})
|
||||
|
||||
t.Run("should generate JWT token when private key is provided", func(t *testing.T) {
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
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{
|
||||
Create: common.NewSecretValue(privateKeyBase64),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
|
||||
assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated")
|
||||
})
|
||||
|
||||
t.Run("should do nothing when GitHub config is nil", func(t *testing.T) {
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "clientID",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should fail when private key is not base64", 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{
|
||||
Create: common.NewSecretValue("invalid-key"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
err := conn.Mutate(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to generate JWT token")
|
||||
assert.Contains(t, err.Error(), "failed to decode base64 private key")
|
||||
})
|
||||
|
||||
t.Run("should fail when private key is invalid", 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{
|
||||
Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
err := conn.Mutate(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to generate JWT token")
|
||||
assert.Contains(t, err.Error(), "failed to parse private key")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnection_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
setupMock func(*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 *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(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 *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(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 *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(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 *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
if tt.setupMock != nil {
|
||||
tt.setupMock(mockFactory)
|
||||
}
|
||||
|
||||
conn := NewConnection(tt.connection, mockFactory)
|
||||
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,36 +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
|
||||
}
|
||||
|
||||
func (e *extra) Type() provisioning.ConnectionType {
|
||||
return provisioning.GithubConnectionType
|
||||
}
|
||||
|
||||
func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) {
|
||||
logger := logging.FromContext(ctx)
|
||||
if connection == nil || connection.Spec.GitHub == nil {
|
||||
logger.Error("connection is nil or github info is nil")
|
||||
|
||||
return nil, fmt.Errorf("invalid github connection")
|
||||
}
|
||||
|
||||
c := NewConnection(connection, e.factory)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func Extra(factory GithubFactory) connection.Extra {
|
||||
return &extra{
|
||||
factory: factory,
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package github_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestExtra_Type(t *testing.T) {
|
||||
t.Run("should return GithubConnectionType", func(t *testing.T) {
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
e := github.Extra(mockFactory)
|
||||
result := e.Type()
|
||||
assert.Equal(t, provisioning.GithubConnectionType, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtra_Build(t *testing.T) {
|
||||
t.Run("should successfully build connection", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &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"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
|
||||
e := github.Extra(mockFactory)
|
||||
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should handle different connection configurations", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "another-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "789",
|
||||
InstallationID: "101112",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "existing-private-key",
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "existing-token",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
|
||||
e := github.Extra(mockFactory)
|
||||
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should build connection with background context", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
e := github.Extra(mockFactory)
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should always pass empty token to factory.New", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &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.NewSecretValue("some-token"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
e := github.Extra(mockFactory)
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -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 = {};
|
||||
|
||||
@@ -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;
|
||||
@@ -695,6 +699,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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -552,7 +552,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 +562,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 +572,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 +610,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 +628,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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +42,6 @@ func ProvideProvisioningOSSRepositoryExtras(
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideProvisioningOSSConnectionExtras(
|
||||
_ *setting.Cfg,
|
||||
ghFactory ghconnection.GithubFactory,
|
||||
) []connection.Extra {
|
||||
return []connection.Extra{
|
||||
ghconnection.Extra(ghFactory),
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideExtraWorkers(pullRequestWorker *pullrequest.PullRequestWorker) []jobs.Worker {
|
||||
return []jobs.Worker{pullRequestWorker}
|
||||
}
|
||||
@@ -65,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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -105,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
|
||||
@@ -134,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,
|
||||
@@ -178,7 +176,6 @@ func NewAPIBuilder(
|
||||
usageStats: usageStats,
|
||||
features: features,
|
||||
repoFactory: repoFactory,
|
||||
connectionFactory: connectionFactory,
|
||||
clients: clients,
|
||||
parsers: parsers,
|
||||
repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister),
|
||||
@@ -195,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,
|
||||
}
|
||||
|
||||
@@ -256,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) {
|
||||
@@ -275,7 +271,6 @@ func RegisterAPIService(
|
||||
builder := NewAPIBuilder(
|
||||
cfg.DisableControllers,
|
||||
repoFactory,
|
||||
connectionFactory,
|
||||
features,
|
||||
client,
|
||||
configProvider,
|
||||
@@ -646,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{
|
||||
@@ -687,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)
|
||||
@@ -746,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
|
||||
@@ -774,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 {
|
||||
@@ -847,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 {
|
||||
@@ -1465,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) {
|
||||
|
||||
@@ -44,7 +44,6 @@ var provisioningExtras = wire.NewSet(
|
||||
pullrequest.ProvidePullRequestWorker,
|
||||
webhooks.ProvideWebhooksWithImages,
|
||||
extras.ProvideFactoryFromConfig,
|
||||
extras.ProvideConnectionFactoryFromConfig,
|
||||
extras.ProvideProvisioningExtraAPIs,
|
||||
extras.ProvideExtraWorkers,
|
||||
)
|
||||
|
||||
+29
-33
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Generated
+4
-17
File diff suppressed because one or more lines are too long
@@ -72,7 +72,6 @@ import (
|
||||
|
||||
var provisioningExtras = wire.NewSet(
|
||||
extras.ProvideProvisioningOSSRepositoryExtras,
|
||||
extras.ProvideProvisioningOSSConnectionExtras,
|
||||
)
|
||||
|
||||
var configProviderExtras = wire.NewSet(
|
||||
|
||||
@@ -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",
|
||||
@@ -1148,6 +1155,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",
|
||||
|
||||
Generated
+2
@@ -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
|
||||
@@ -159,6 +160,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
|
||||
|
||||
|
+2
-4
@@ -1408,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",
|
||||
@@ -2247,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",
|
||||
|
||||
@@ -603,6 +603,7 @@ type Cfg struct {
|
||||
MaxFileIndexAge time.Duration // Max age of file-based indexes. Index older than this will be rebuilt asynchronously.
|
||||
MinFileIndexBuildVersion string // Minimum version of Grafana that built the file-based index. If index was built with older Grafana, it will be rebuilt asynchronously.
|
||||
EnableSharding bool
|
||||
SubIndexesPerNamespace int // Number of sub-indexes per (namespace, group, resource) for sharding. 0 = disabled.
|
||||
QOSEnabled bool
|
||||
QOSNumberWorker int
|
||||
QOSMaxSizePerTenant int
|
||||
|
||||
@@ -102,19 +102,33 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
}
|
||||
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
|
||||
cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0)
|
||||
// Index storage path. For Kubernetes Deployments without PVCs, use emptyDir:
|
||||
// index_path = /var/lib/grafana/unified-search/bleve
|
||||
// Indexes are derived data and will be rebuilt from SQL on pod restart.
|
||||
cfg.IndexPath = section.Key("index_path").String()
|
||||
cfg.IndexWorkers = section.Key("index_workers").MustInt(10)
|
||||
cfg.IndexRebuildWorkers = section.Key("index_rebuild_workers").MustInt(5)
|
||||
// Sharding configuration for large-scale deployments (200k+ dashboards)
|
||||
// When enable_sharding=true, indexes are distributed across pods using a ring.
|
||||
// Each pod owns a subset of sub-indexes and rebuilds them from SQL on startup.
|
||||
// This enables horizontal scaling without requiring PVCs (use emptyDir volumes).
|
||||
cfg.EnableSharding = section.Key("enable_sharding").MustBool(false)
|
||||
cfg.SubIndexesPerNamespace = section.Key("sub_indexes_per_namespace").MustInt(0) // 0 = disabled, recommended: 64 for 1M scale
|
||||
cfg.QOSEnabled = section.Key("qos_enabled").MustBool(false)
|
||||
cfg.QOSNumberWorker = section.Key("qos_num_worker").MustInt(16)
|
||||
cfg.QOSMaxSizePerTenant = section.Key("qos_max_size_per_tenant").MustInt(1000)
|
||||
// Memberlist ring configuration for distributed search
|
||||
// For Kubernetes Deployments, use DNS-based discovery with headless services:
|
||||
// memberlist_join_member = dnssrv+grafana-memberlist.namespace.svc:7946
|
||||
// The dnssrv+ prefix triggers SRV record lookup for pod IPs.
|
||||
cfg.MemberlistBindAddr = section.Key("memberlist_bind_addr").String()
|
||||
cfg.MemberlistAdvertiseAddr = section.Key("memberlist_advertise_addr").String()
|
||||
cfg.MemberlistAdvertisePort = section.Key("memberlist_advertise_port").MustInt(7946)
|
||||
cfg.MemberlistJoinMember = section.Key("memberlist_join_member").String()
|
||||
cfg.MemberlistClusterLabel = section.Key("memberlist_cluster_label").String()
|
||||
cfg.MemberlistClusterLabelVerificationDisabled = section.Key("memberlist_cluster_label_verification_disabled").MustBool(false)
|
||||
// SearchRingReplicationFactor configures replication factor of indexes across multiple instances.
|
||||
// Recommended: 2 for production deployments using emptyDir volumes to provides availability during pod restarts/rebuilds.
|
||||
cfg.SearchRingReplicationFactor = section.Key("search_ring_replication_factor").MustInt(1)
|
||||
cfg.InstanceID = section.Key("instance_id").String()
|
||||
cfg.IndexFileThreshold = section.Key("index_file_threshold").MustInt(10)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -45,6 +45,23 @@ func (s *NamespacedResource) String() string {
|
||||
return fmt.Sprintf("%s/%s/%s", s.Namespace, s.Group, s.Resource)
|
||||
}
|
||||
|
||||
// SubIndexKey extends NamespacedResource with a sub-index identifier for sharding.
|
||||
// When sub-index sharding is enabled, each (namespace, group, resource) is split
|
||||
// into multiple sub-indexes identified by SubIndexID.
|
||||
type SubIndexKey struct {
|
||||
NamespacedResource
|
||||
SubIndexID int
|
||||
}
|
||||
|
||||
func (s *SubIndexKey) String() string {
|
||||
return fmt.Sprintf("%s/%s/%s/shard-%d", s.Namespace, s.Group, s.Resource, s.SubIndexID)
|
||||
}
|
||||
|
||||
// ToNamespacedResource returns the NamespacedResource without the sub-index ID.
|
||||
func (s *SubIndexKey) ToNamespacedResource() NamespacedResource {
|
||||
return s.NamespacedResource
|
||||
}
|
||||
|
||||
type IndexAction int
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"github.com/grafana/dskit/services"
|
||||
userutils "github.com/grafana/dskit/user"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
@@ -35,10 +39,11 @@ func ProvideSearchDistributorServer(cfg *setting.Cfg, features featuremgmt.Featu
|
||||
}
|
||||
|
||||
distributorServer := &distributorServer{
|
||||
log: log.New("index-server-distributor"),
|
||||
ring: ring,
|
||||
clientPool: ringClientPool,
|
||||
tracing: tracer,
|
||||
log: log.New("index-server-distributor"),
|
||||
ring: ring,
|
||||
clientPool: ringClientPool,
|
||||
tracing: tracer,
|
||||
subIndexesPerNamespace: cfg.SubIndexesPerNamespace,
|
||||
}
|
||||
|
||||
healthService, err := ProvideHealthService(distributorServer)
|
||||
@@ -83,10 +88,11 @@ const RingHeartbeatTimeout = time.Minute
|
||||
const RingNumTokens = 128
|
||||
|
||||
type distributorServer struct {
|
||||
clientPool *ringclient.Pool
|
||||
ring *ring.Ring
|
||||
log log.Logger
|
||||
tracing trace.Tracer
|
||||
clientPool *ringclient.Pool
|
||||
ring *ring.Ring
|
||||
log log.Logger
|
||||
tracing trace.Tracer
|
||||
subIndexesPerNamespace int // Number of sub-indexes per namespace (0 = disabled)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -99,12 +105,33 @@ var (
|
||||
func (ds *distributorServer) Search(ctx context.Context, r *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
|
||||
ctx, span := ds.tracing.Start(ctx, "distributor.Search")
|
||||
defer span.End()
|
||||
ctx, client, err := ds.getClientToDistributeRequest(ctx, r.Options.Key.Namespace, "Search")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
// If sub-index sharding is not enabled, use the existing single-node routing
|
||||
if ds.subIndexesPerNamespace <= 0 {
|
||||
ctx, client, err := ds.getClientToDistributeRequest(ctx, r.Options.Key.Namespace, "Search")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Search(ctx, r)
|
||||
}
|
||||
|
||||
return client.Search(ctx, r)
|
||||
// Scatter-gather search across all sub-indexes
|
||||
nsr := NamespacedResource{
|
||||
Namespace: r.Options.Key.Namespace,
|
||||
Group: r.Options.Key.Group,
|
||||
Resource: r.Options.Key.Resource,
|
||||
}
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("namespace", nsr.Namespace),
|
||||
attribute.String("group", nsr.Group),
|
||||
attribute.String("resource", nsr.Resource),
|
||||
attribute.Int("sub_indexes", ds.subIndexesPerNamespace),
|
||||
)
|
||||
|
||||
subIndexes := ds.getSubIndexesForNamespace(nsr)
|
||||
results := ds.parallelSearchWithFailover(ctx, subIndexes, r)
|
||||
return ds.mergeResults(ctx, results, r)
|
||||
}
|
||||
|
||||
func (ds *distributorServer) GetStats(ctx context.Context, r *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) {
|
||||
@@ -242,7 +269,7 @@ func (ds *distributorServer) getClientToDistributeRequest(ctx context.Context, n
|
||||
return ctx, nil, err
|
||||
}
|
||||
|
||||
rs, err := ds.ring.GetWithOptions(ringHasher.Sum32(), searchRingRead, ring.WithReplicationFactor(ds.ring.ReplicationFactor()))
|
||||
rs, err := ds.ring.GetWithOptions(ringHasher.Sum32(), searchRingRead)
|
||||
if err != nil {
|
||||
ds.log.Debug("error getting replication set from ring", "err", err, "namespace", namespace)
|
||||
return ctx, nil, err
|
||||
@@ -276,3 +303,384 @@ func (ds *distributorServer) IsHealthy(ctx context.Context, r *resourcepb.Health
|
||||
|
||||
return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_NOT_SERVING}, nil
|
||||
}
|
||||
|
||||
// --- Scatter-Gather Query Implementation with Replica Failover ---
|
||||
|
||||
// subIndexSearchResult holds the result from searching a single sub-index.
|
||||
type subIndexSearchResult struct {
|
||||
subIndexID int
|
||||
response *resourcepb.ResourceSearchResponse
|
||||
err error
|
||||
partialFailure bool // true if all replicas for this sub-index failed
|
||||
}
|
||||
|
||||
// getSubIndexesForNamespace returns all sub-index keys for a NamespacedResource.
|
||||
// This is used for scatter-gather queries that need to query all sub-indexes.
|
||||
func (ds *distributorServer) getSubIndexesForNamespace(nsr NamespacedResource) []SubIndexKey {
|
||||
count := ds.subIndexesPerNamespace
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
keys := make([]SubIndexKey, count)
|
||||
for i := 0; i < count; i++ {
|
||||
keys[i] = SubIndexKey{
|
||||
NamespacedResource: nsr,
|
||||
SubIndexID: i,
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// getReplicasForSubIndex returns the ordered list of replicas (instances) that own
|
||||
// the given sub-index. Replicas are ordered by preference from the ring.
|
||||
// Uses consistent hashing including the sub-index ID to determine ownership.
|
||||
func (ds *distributorServer) getReplicasForSubIndex(subIndex SubIndexKey) ([]ring.InstanceDesc, error) {
|
||||
ringHasher := fnv.New32a()
|
||||
// Include sub-index ID in hash to distribute sub-indexes across nodes
|
||||
_, err := ringHasher.Write([]byte(fmt.Sprintf("%s/%d", subIndex.Namespace, subIndex.SubIndexID)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error hashing sub-index key: %w", err)
|
||||
}
|
||||
rs, err := ds.ring.GetWithOptions(ringHasher.Sum32(), searchRingRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting replication set from ring for sub-index %s: %w", subIndex.String(), err)
|
||||
}
|
||||
|
||||
return rs.Instances, nil
|
||||
}
|
||||
|
||||
// parallelSearchWithFailover executes search queries across all sub-indexes in parallel.
|
||||
// For each sub-index, it tries replicas in order until one succeeds.
|
||||
// Returns results from all sub-indexes (some may be marked as partial failures).
|
||||
func (ds *distributorServer) parallelSearchWithFailover(ctx context.Context, subIndexes []SubIndexKey, r *resourcepb.ResourceSearchRequest) []*subIndexSearchResult {
|
||||
ctx, span := ds.tracing.Start(ctx, "distributor.parallelSearchWithFailover")
|
||||
defer span.End()
|
||||
|
||||
results := make([]*subIndexSearchResult, len(subIndexes))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, subIdx := range subIndexes {
|
||||
wg.Add(1)
|
||||
go func(idx int, key SubIndexKey) {
|
||||
defer wg.Done()
|
||||
results[idx] = ds.searchSubIndexWithFailover(ctx, key, r)
|
||||
}(i, subIdx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Count partial failures for logging
|
||||
partialFailures := 0
|
||||
for _, result := range results {
|
||||
if result.partialFailure {
|
||||
partialFailures++
|
||||
}
|
||||
}
|
||||
span.SetAttributes(attribute.Int("partial_failures", partialFailures))
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// searchSubIndexWithFailover searches a single sub-index, trying each replica in order
|
||||
// until one succeeds. If all replicas fail, marks the result as a partial failure.
|
||||
func (ds *distributorServer) searchSubIndexWithFailover(ctx context.Context, subIndex SubIndexKey, r *resourcepb.ResourceSearchRequest) *subIndexSearchResult {
|
||||
result := &subIndexSearchResult{
|
||||
subIndexID: subIndex.SubIndexID,
|
||||
}
|
||||
|
||||
// Get ordered list of replicas for this sub-index
|
||||
replicas, err := ds.getReplicasForSubIndex(subIndex)
|
||||
if err != nil {
|
||||
ds.log.Warn("failed to get replicas for sub-index", "subIndex", subIndex.String(), "error", err)
|
||||
result.err = err
|
||||
result.partialFailure = true
|
||||
return result
|
||||
}
|
||||
|
||||
if len(replicas) == 0 {
|
||||
ds.log.Warn("no replicas available for sub-index", "subIndex", subIndex.String())
|
||||
result.err = fmt.Errorf("no replicas available for sub-index %s", subIndex.String())
|
||||
result.partialFailure = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Prepare context with metadata
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
md = make(metadata.MD)
|
||||
}
|
||||
rCtx := userutils.InjectOrgID(metadata.NewOutgoingContext(ctx, md), subIndex.Namespace)
|
||||
|
||||
// Try each replica in order until success
|
||||
// Per-replica timeout ensures SLO compliance (≤500ms for distributed search)
|
||||
// With replication factor 2: worst case is 2 attempts × 200ms = 400ms, leaving room for merge
|
||||
const replicaTimeout = 200 * time.Millisecond
|
||||
var lastErr error
|
||||
for replicaIdx, replica := range replicas {
|
||||
client, err := ds.clientPool.GetClientForInstance(replica)
|
||||
if err != nil {
|
||||
ds.log.Debug("failed to get client for replica",
|
||||
"subIndex", subIndex.String(),
|
||||
"replica", replica.Id,
|
||||
"replicaIdx", replicaIdx,
|
||||
"error", err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply per-replica timeout to ensure failover happens quickly
|
||||
replicaCtx, cancel := context.WithTimeout(rCtx, replicaTimeout)
|
||||
resp, err := client.(*RingClient).Client.Search(replicaCtx, r)
|
||||
cancel() // Always cancel to release resources
|
||||
|
||||
if err == nil && resp.Error == nil {
|
||||
// Success
|
||||
result.response = resp
|
||||
return result
|
||||
}
|
||||
|
||||
// Log failover event
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
ds.log.Warn("search failed on replica, failing over to next",
|
||||
"subIndex", subIndex.String(),
|
||||
"replica", replica.Id,
|
||||
"replicaIdx", replicaIdx,
|
||||
"remainingReplicas", len(replicas)-replicaIdx-1,
|
||||
"error", err)
|
||||
} else if resp.Error != nil {
|
||||
lastErr = fmt.Errorf("search error: %s", resp.Error.Message)
|
||||
ds.log.Warn("search returned error on replica, failing over to next",
|
||||
"subIndex", subIndex.String(),
|
||||
"replica", replica.Id,
|
||||
"replicaIdx", replicaIdx,
|
||||
"remainingReplicas", len(replicas)-replicaIdx-1,
|
||||
"errorMessage", resp.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// All replicas failed - mark as partial failure
|
||||
ds.log.Error("all replicas failed for sub-index",
|
||||
"subIndex", subIndex.String(),
|
||||
"totalReplicas", len(replicas),
|
||||
"lastError", lastErr)
|
||||
result.err = lastErr
|
||||
result.partialFailure = true
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeResults combines results from all sub-indexes into a single response.
|
||||
// It handles:
|
||||
// - Result deduplication by resource key
|
||||
// - Sort merging (merge-sort for sorted results)
|
||||
// - Pagination across shards
|
||||
// - Tracking and reporting partial failures
|
||||
func (ds *distributorServer) mergeResults(ctx context.Context, results []*subIndexSearchResult, req *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
|
||||
ctx, span := ds.tracing.Start(ctx, "distributor.mergeResults")
|
||||
defer span.End()
|
||||
|
||||
// Collect all rows and track partial failures
|
||||
var allRows []*resourcepb.ResourceTableRow
|
||||
var columns []*resourcepb.ResourceTableColumnDefinition
|
||||
var totalHits int64
|
||||
var totalQueryCost float64
|
||||
var maxScore float64
|
||||
partialFailures := 0
|
||||
failedSubIndexes := []int{}
|
||||
facets := make(map[string]*resourcepb.ResourceSearchResponse_Facet)
|
||||
|
||||
for _, result := range results {
|
||||
if result.partialFailure {
|
||||
partialFailures++
|
||||
failedSubIndexes = append(failedSubIndexes, result.subIndexID)
|
||||
continue
|
||||
}
|
||||
|
||||
if result.response == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resp := result.response
|
||||
totalHits += resp.TotalHits
|
||||
totalQueryCost += resp.QueryCost
|
||||
if resp.MaxScore > maxScore {
|
||||
maxScore = resp.MaxScore
|
||||
}
|
||||
|
||||
// Use columns from first valid response
|
||||
if columns == nil && resp.Results != nil {
|
||||
columns = resp.Results.Columns
|
||||
}
|
||||
|
||||
// Collect rows
|
||||
if resp.Results != nil && len(resp.Results.Rows) > 0 {
|
||||
allRows = append(allRows, resp.Results.Rows...)
|
||||
}
|
||||
|
||||
// Merge facets
|
||||
for k, v := range resp.Facet {
|
||||
if existing, ok := facets[k]; ok {
|
||||
mergeFacets(existing, v)
|
||||
} else {
|
||||
facets[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int("total_rows_before_dedup", len(allRows)),
|
||||
attribute.Int("partial_failures", partialFailures),
|
||||
)
|
||||
|
||||
// Deduplicate rows by resource key
|
||||
allRows = deduplicateRows(allRows)
|
||||
|
||||
span.SetAttributes(attribute.Int("total_rows_after_dedup", len(allRows)))
|
||||
|
||||
// Sort rows if sort criteria provided
|
||||
if len(req.SortBy) > 0 && columns != nil {
|
||||
sortRows(allRows, columns, req.SortBy)
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
offset := int(req.Offset)
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 100 // default limit
|
||||
}
|
||||
|
||||
var paginatedRows []*resourcepb.ResourceTableRow
|
||||
if offset < len(allRows) {
|
||||
end := offset + limit
|
||||
if end > len(allRows) {
|
||||
end = len(allRows)
|
||||
}
|
||||
paginatedRows = allRows[offset:end]
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := &resourcepb.ResourceSearchResponse{
|
||||
TotalHits: totalHits,
|
||||
QueryCost: totalQueryCost,
|
||||
MaxScore: maxScore,
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: columns,
|
||||
Rows: paginatedRows,
|
||||
},
|
||||
Facet: facets,
|
||||
}
|
||||
|
||||
// Report partial failures if any
|
||||
if partialFailures > 0 {
|
||||
response.Error = &resourcepb.ErrorResult{
|
||||
Code: 206, // Partial Content
|
||||
Message: fmt.Sprintf("partial results: %d of %d sub-indexes failed (sub-indexes: %v)", partialFailures, len(results), failedSubIndexes),
|
||||
}
|
||||
ds.log.Warn("search returned partial results",
|
||||
"namespace", req.Options.Key.Namespace,
|
||||
"failedSubIndexes", partialFailures,
|
||||
"totalSubIndexes", len(results))
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// deduplicateRows removes duplicate rows based on their resource key.
|
||||
// If duplicates exist, keeps the first occurrence.
|
||||
func deduplicateRows(rows []*resourcepb.ResourceTableRow) []*resourcepb.ResourceTableRow {
|
||||
if len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(rows))
|
||||
result := make([]*resourcepb.ResourceTableRow, 0, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Key == nil {
|
||||
continue
|
||||
}
|
||||
key := SearchID(row.Key)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, row)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// sortRows sorts rows based on the sort criteria.
|
||||
// Uses stable sort to maintain relative ordering of equal elements.
|
||||
func sortRows(rows []*resourcepb.ResourceTableRow, columns []*resourcepb.ResourceTableColumnDefinition, sortBy []*resourcepb.ResourceSearchRequest_Sort) {
|
||||
if len(rows) == 0 || len(sortBy) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build column index map
|
||||
columnIndex := make(map[string]int)
|
||||
for i, col := range columns {
|
||||
columnIndex[col.Name] = i
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
for _, s := range sortBy {
|
||||
colIdx, ok := columnIndex[s.Field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get cell values
|
||||
var valI, valJ []byte
|
||||
if colIdx < len(rows[i].Cells) {
|
||||
valI = rows[i].Cells[colIdx]
|
||||
}
|
||||
if colIdx < len(rows[j].Cells) {
|
||||
valJ = rows[j].Cells[colIdx]
|
||||
}
|
||||
|
||||
// Compare byte slices
|
||||
cmpResult := slices.Compare(valI, valJ)
|
||||
if cmpResult == 0 {
|
||||
continue // Values are equal, check next sort field
|
||||
}
|
||||
|
||||
// Apply descending order if needed
|
||||
if s.Desc {
|
||||
return cmpResult > 0
|
||||
}
|
||||
return cmpResult < 0
|
||||
}
|
||||
return false // All sort fields are equal
|
||||
})
|
||||
}
|
||||
|
||||
// mergeFacets merges facet data from source into target.
|
||||
func mergeFacets(target, source *resourcepb.ResourceSearchResponse_Facet) {
|
||||
if source == nil {
|
||||
return
|
||||
}
|
||||
|
||||
target.Total += source.Total
|
||||
target.Missing += source.Missing
|
||||
|
||||
// Merge term facets
|
||||
termMap := make(map[string]int64)
|
||||
for _, t := range target.Terms {
|
||||
termMap[t.Term] = t.Count
|
||||
}
|
||||
for _, t := range source.Terms {
|
||||
termMap[t.Term] += t.Count
|
||||
}
|
||||
|
||||
// Rebuild term slice sorted by count (descending)
|
||||
target.Terms = make([]*resourcepb.ResourceSearchResponse_TermFacet, 0, len(termMap))
|
||||
for term, count := range termMap {
|
||||
target.Terms = append(target.Terms, &resourcepb.ResourceSearchResponse_TermFacet{
|
||||
Term: term,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(target.Terms, func(a, b *resourcepb.ResourceSearchResponse_TermFacet) int {
|
||||
return cmp.Compare(b.Count, a.Count) // Descending order
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func TestGetSubIndexesForNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subIndexCount int
|
||||
expectedCount int
|
||||
expectedIDs []int
|
||||
}{
|
||||
{
|
||||
name: "zero sub-indexes defaults to 1",
|
||||
subIndexCount: 0,
|
||||
expectedCount: 1,
|
||||
expectedIDs: []int{0},
|
||||
},
|
||||
{
|
||||
name: "negative sub-indexes defaults to 1",
|
||||
subIndexCount: -1,
|
||||
expectedCount: 1,
|
||||
expectedIDs: []int{0},
|
||||
},
|
||||
{
|
||||
name: "4 sub-indexes",
|
||||
subIndexCount: 4,
|
||||
expectedCount: 4,
|
||||
expectedIDs: []int{0, 1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "64 sub-indexes",
|
||||
subIndexCount: 64,
|
||||
expectedCount: 64,
|
||||
expectedIDs: nil, // Don't check all 64
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ds := &distributorServer{
|
||||
subIndexesPerNamespace: tt.subIndexCount,
|
||||
}
|
||||
|
||||
nsr := NamespacedResource{
|
||||
Namespace: "org-1",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
|
||||
result := ds.getSubIndexesForNamespace(nsr)
|
||||
assert.Len(t, result, tt.expectedCount)
|
||||
|
||||
// Check that all keys have the correct NSR
|
||||
for i, key := range result {
|
||||
assert.Equal(t, nsr, key.NamespacedResource)
|
||||
assert.Equal(t, i, key.SubIndexID)
|
||||
}
|
||||
|
||||
// Check specific IDs if provided
|
||||
if tt.expectedIDs != nil {
|
||||
for i, expectedID := range tt.expectedIDs {
|
||||
assert.Equal(t, expectedID, result[i].SubIndexID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduplicateRows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []*resourcepb.ResourceTableRow
|
||||
expected int // expected number of rows after dedup
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: []*resourcepb.ResourceTableRow{},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "no duplicates",
|
||||
input: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "a"}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "b"}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "c"}},
|
||||
},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "with duplicates",
|
||||
input: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "a"}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "b"}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "a"}}, // duplicate
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "c"}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "b"}}, // duplicate
|
||||
},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "rows with nil keys are skipped",
|
||||
input: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "a"}},
|
||||
{Key: nil},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "b"}},
|
||||
},
|
||||
expected: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := deduplicateRows(tt.input)
|
||||
assert.Len(t, result, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortRows(t *testing.T) {
|
||||
columns := []*resourcepb.ResourceTableColumnDefinition{
|
||||
{Name: "title"},
|
||||
{Name: "created"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []*resourcepb.ResourceTableRow
|
||||
sortBy []*resourcepb.ResourceSearchRequest_Sort
|
||||
expectedOrder []string // expected order of first cell values
|
||||
}{
|
||||
{
|
||||
name: "sort ascending by title",
|
||||
rows: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Name: "c"}, Cells: [][]byte{[]byte("charlie"), nil}},
|
||||
{Key: &resourcepb.ResourceKey{Name: "a"}, Cells: [][]byte{[]byte("alpha"), nil}},
|
||||
{Key: &resourcepb.ResourceKey{Name: "b"}, Cells: [][]byte{[]byte("bravo"), nil}},
|
||||
},
|
||||
sortBy: []*resourcepb.ResourceSearchRequest_Sort{
|
||||
{Field: "title", Desc: false},
|
||||
},
|
||||
expectedOrder: []string{"alpha", "bravo", "charlie"},
|
||||
},
|
||||
{
|
||||
name: "sort descending by title",
|
||||
rows: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Name: "a"}, Cells: [][]byte{[]byte("alpha"), nil}},
|
||||
{Key: &resourcepb.ResourceKey{Name: "b"}, Cells: [][]byte{[]byte("bravo"), nil}},
|
||||
{Key: &resourcepb.ResourceKey{Name: "c"}, Cells: [][]byte{[]byte("charlie"), nil}},
|
||||
},
|
||||
sortBy: []*resourcepb.ResourceSearchRequest_Sort{
|
||||
{Field: "title", Desc: true},
|
||||
},
|
||||
expectedOrder: []string{"charlie", "bravo", "alpha"},
|
||||
},
|
||||
{
|
||||
name: "empty rows",
|
||||
rows: []*resourcepb.ResourceTableRow{},
|
||||
sortBy: []*resourcepb.ResourceSearchRequest_Sort{{Field: "title"}},
|
||||
expectedOrder: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sortRows(tt.rows, columns, tt.sortBy)
|
||||
|
||||
for i, expected := range tt.expectedOrder {
|
||||
if i < len(tt.rows) {
|
||||
assert.Equal(t, expected, string(tt.rows[i].Cells[0]))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFacets(t *testing.T) {
|
||||
t.Run("merge term facets", func(t *testing.T) {
|
||||
target := &resourcepb.ResourceSearchResponse_Facet{
|
||||
Field: "tags",
|
||||
Total: 100,
|
||||
Missing: 5,
|
||||
Terms: []*resourcepb.ResourceSearchResponse_TermFacet{
|
||||
{Term: "production", Count: 50},
|
||||
{Term: "staging", Count: 30},
|
||||
},
|
||||
}
|
||||
|
||||
source := &resourcepb.ResourceSearchResponse_Facet{
|
||||
Field: "tags",
|
||||
Total: 80,
|
||||
Missing: 3,
|
||||
Terms: []*resourcepb.ResourceSearchResponse_TermFacet{
|
||||
{Term: "production", Count: 40},
|
||||
{Term: "development", Count: 25},
|
||||
},
|
||||
}
|
||||
|
||||
mergeFacets(target, source)
|
||||
|
||||
assert.Equal(t, int64(180), target.Total)
|
||||
assert.Equal(t, int64(8), target.Missing)
|
||||
assert.Len(t, target.Terms, 3)
|
||||
|
||||
// Terms should be sorted by count descending
|
||||
termCounts := make(map[string]int64)
|
||||
for _, term := range target.Terms {
|
||||
termCounts[term.Term] = term.Count
|
||||
}
|
||||
assert.Equal(t, int64(90), termCounts["production"])
|
||||
assert.Equal(t, int64(30), termCounts["staging"])
|
||||
assert.Equal(t, int64(25), termCounts["development"])
|
||||
})
|
||||
|
||||
t.Run("merge nil source", func(t *testing.T) {
|
||||
target := &resourcepb.ResourceSearchResponse_Facet{
|
||||
Total: 100,
|
||||
}
|
||||
mergeFacets(target, nil)
|
||||
assert.Equal(t, int64(100), target.Total)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubIndexSearchResult(t *testing.T) {
|
||||
t.Run("successful result", func(t *testing.T) {
|
||||
result := &subIndexSearchResult{
|
||||
subIndexID: 5,
|
||||
response: &resourcepb.ResourceSearchResponse{
|
||||
TotalHits: 100,
|
||||
},
|
||||
partialFailure: false,
|
||||
}
|
||||
assert.False(t, result.partialFailure)
|
||||
assert.NotNil(t, result.response)
|
||||
})
|
||||
|
||||
t.Run("partial failure result", func(t *testing.T) {
|
||||
result := &subIndexSearchResult{
|
||||
subIndexID: 3,
|
||||
err: assert.AnError,
|
||||
partialFailure: true,
|
||||
}
|
||||
assert.True(t, result.partialFailure)
|
||||
assert.Nil(t, result.response)
|
||||
assert.Error(t, result.err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubIndexKey(t *testing.T) {
|
||||
key := SubIndexKey{
|
||||
NamespacedResource: NamespacedResource{
|
||||
Namespace: "org-1",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
SubIndexID: 42,
|
||||
}
|
||||
|
||||
t.Run("String representation", func(t *testing.T) {
|
||||
expected := "org-1/dashboard.grafana.app/dashboards/shard-42"
|
||||
assert.Equal(t, expected, key.String())
|
||||
})
|
||||
|
||||
t.Run("ToNamespacedResource", func(t *testing.T) {
|
||||
nsr := key.ToNamespacedResource()
|
||||
assert.Equal(t, "org-1", nsr.Namespace)
|
||||
assert.Equal(t, "dashboard.grafana.app", nsr.Group)
|
||||
assert.Equal(t, "dashboards", nsr.Resource)
|
||||
})
|
||||
}
|
||||
|
||||
func newTestDistributorServer() *distributorServer {
|
||||
return &distributorServer{
|
||||
tracing: noop.NewTracerProvider().Tracer("test"),
|
||||
log: log.New("test-distributor"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeResultsPagination(t *testing.T) {
|
||||
// Create mock results from 3 sub-indexes
|
||||
results := []*subIndexSearchResult{
|
||||
{
|
||||
subIndexID: 0,
|
||||
response: &resourcepb.ResourceSearchResponse{
|
||||
TotalHits: 10,
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{{Name: "title"}},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "a1"}, Cells: [][]byte{[]byte("a1")}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "a2"}, Cells: [][]byte{[]byte("a2")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
subIndexID: 1,
|
||||
response: &resourcepb.ResourceSearchResponse{
|
||||
TotalHits: 10,
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{{Name: "title"}},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "b1"}, Cells: [][]byte{[]byte("b1")}},
|
||||
{Key: &resourcepb.ResourceKey{Namespace: "ns", Group: "g", Resource: "r", Name: "b2"}, Cells: [][]byte{[]byte("b2")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("pagination with limit", func(t *testing.T) {
|
||||
ds := newTestDistributorServer()
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 2,
|
||||
Offset: 0,
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{Namespace: "ns"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ds.mergeResults(context.Background(), results, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(20), resp.TotalHits) // 10 + 10
|
||||
assert.Len(t, resp.Results.Rows, 2)
|
||||
})
|
||||
|
||||
t.Run("pagination with offset", func(t *testing.T) {
|
||||
ds := newTestDistributorServer()
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 2,
|
||||
Offset: 2,
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{Namespace: "ns"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ds.mergeResults(context.Background(), results, req)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp.Results.Rows, 2) // rows 3 and 4
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeResultsPartialFailure(t *testing.T) {
|
||||
results := []*subIndexSearchResult{
|
||||
{
|
||||
subIndexID: 0,
|
||||
response: &resourcepb.ResourceSearchResponse{
|
||||
TotalHits: 10,
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{{Name: "title"}},
|
||||
Rows: []*resourcepb.ResourceTableRow{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
subIndexID: 1,
|
||||
partialFailure: true,
|
||||
err: assert.AnError,
|
||||
},
|
||||
{
|
||||
subIndexID: 2,
|
||||
partialFailure: true,
|
||||
err: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
ds := newTestDistributorServer()
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 100,
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{Namespace: "ns"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ds.mergeResults(context.Background(), results, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have partial failure error
|
||||
require.NotNil(t, resp.Error)
|
||||
assert.Equal(t, int32(206), resp.Error.Code) // HTTP 206 Partial Content
|
||||
assert.Contains(t, resp.Error.Message, "2 of 3 sub-indexes failed")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -81,6 +82,17 @@ type BleveOptions struct {
|
||||
// Indexes that are not owned by current instance are eligible for cleanup.
|
||||
// If nil, all indexes are owned by the current instance.
|
||||
OwnsIndex func(key resource.NamespacedResource) (bool, error)
|
||||
|
||||
// OwnsSubIndex is called to check whether a specific sub-index is owned by the current instance.
|
||||
// This function considers the sub-index ID when determining ownership via ring hash.
|
||||
// If nil, falls back to OwnsIndex behavior with subIndexID=0.
|
||||
OwnsSubIndex func(key resource.NamespacedResource, subIndexID int) (bool, error)
|
||||
|
||||
// SubIndexCount is the number of sub-indexes per (namespace, group, resource).
|
||||
// When > 0, documents are distributed across sub-indexes using consistent hashing.
|
||||
// This enables horizontal scaling for large namespaces (1M+ documents).
|
||||
// Recommended: 64 for large scale deployments.
|
||||
SubIndexCount int
|
||||
}
|
||||
|
||||
type bleveBackend struct {
|
||||
@@ -90,9 +102,17 @@ type bleveBackend struct {
|
||||
// set from opts.OwnsIndex, always non-nil
|
||||
ownsIndexFn func(key resource.NamespacedResource) (bool, error)
|
||||
|
||||
// set from opts.OwnsSubIndex, always non-nil
|
||||
// Used for checking ownership of sub-indexes when sharding is enabled
|
||||
ownsSubIndexFn func(key resource.NamespacedResource, subIndexID int) (bool, error)
|
||||
|
||||
cacheMx sync.RWMutex
|
||||
cache map[resource.NamespacedResource]*bleveIndex
|
||||
|
||||
// subIndexCache stores sub-indexes when sub-index sharding is enabled.
|
||||
// Key is SubIndexKey which includes the sub-index ID.
|
||||
subIndexCache map[resource.SubIndexKey]*bleveIndex
|
||||
|
||||
indexMetrics *resource.BleveIndexMetrics
|
||||
|
||||
bgTasksCancel func()
|
||||
@@ -136,12 +156,23 @@ func NewBleveBackend(opts BleveOptions, indexMetrics *resource.BleveIndexMetrics
|
||||
ownFn = func(key resource.NamespacedResource) (bool, error) { return true, nil }
|
||||
}
|
||||
|
||||
ownSubFn := opts.OwnsSubIndex
|
||||
if ownSubFn == nil {
|
||||
// By default, fall back to OwnsIndex behavior (ignore subIndexID).
|
||||
// This maintains backward compatibility when sub-index sharding is not enabled.
|
||||
ownSubFn = func(key resource.NamespacedResource, subIndexID int) (bool, error) {
|
||||
return ownFn(key)
|
||||
}
|
||||
}
|
||||
|
||||
be := &bleveBackend{
|
||||
log: l,
|
||||
cache: map[resource.NamespacedResource]*bleveIndex{},
|
||||
opts: opts,
|
||||
ownsIndexFn: ownFn,
|
||||
indexMetrics: indexMetrics,
|
||||
log: l,
|
||||
cache: map[resource.NamespacedResource]*bleveIndex{},
|
||||
subIndexCache: map[resource.SubIndexKey]*bleveIndex{},
|
||||
opts: opts,
|
||||
ownsIndexFn: ownFn,
|
||||
ownsSubIndexFn: ownSubFn,
|
||||
indexMetrics: indexMetrics,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -158,6 +189,53 @@ func NewBleveBackend(opts BleveOptions, indexMetrics *resource.BleveIndexMetrics
|
||||
return be, nil
|
||||
}
|
||||
|
||||
// IsSubIndexShardingEnabled returns true if sub-index sharding is configured.
|
||||
func (b *bleveBackend) IsSubIndexShardingEnabled() bool {
|
||||
return b.opts.SubIndexCount > 0
|
||||
}
|
||||
|
||||
// GetSubIndexCount returns the number of sub-indexes per (namespace, group, resource).
|
||||
func (b *bleveBackend) GetSubIndexCount() int {
|
||||
return b.opts.SubIndexCount
|
||||
}
|
||||
|
||||
// GetSubIndexForDocument computes the sub-index ID for a document based on its key.
|
||||
// Uses FNV32a hash of the full document key for consistent distribution.
|
||||
func (b *bleveBackend) GetSubIndexForDocument(key *resourcepb.ResourceKey) int {
|
||||
if b.opts.SubIndexCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New32a()
|
||||
// Hash the full document key: namespace/group/resource/name
|
||||
_, _ = h.Write([]byte(fmt.Sprintf("%s/%s/%s/%s", key.Namespace, key.Group, key.Resource, key.Name)))
|
||||
return int(h.Sum32() % uint32(b.opts.SubIndexCount))
|
||||
}
|
||||
|
||||
// GetSubIndexKey returns the SubIndexKey for a document.
|
||||
func (b *bleveBackend) GetSubIndexKey(nsr resource.NamespacedResource, docKey *resourcepb.ResourceKey) resource.SubIndexKey {
|
||||
return resource.SubIndexKey{
|
||||
NamespacedResource: nsr,
|
||||
SubIndexID: b.GetSubIndexForDocument(docKey),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllSubIndexKeys returns all SubIndexKey values for a NamespacedResource.
|
||||
// This is used for scatter-gather queries that need to query all sub-indexes.
|
||||
func (b *bleveBackend) GetAllSubIndexKeys(nsr resource.NamespacedResource) []resource.SubIndexKey {
|
||||
count := b.opts.SubIndexCount
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
keys := make([]resource.SubIndexKey, count)
|
||||
for i := 0; i < count; i++ {
|
||||
keys[i] = resource.SubIndexKey{
|
||||
NamespacedResource: nsr,
|
||||
SubIndexID: i,
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// GetIndex will return nil if the key does not exist
|
||||
func (b *bleveBackend) GetIndex(key resource.NamespacedResource) resource.ResourceIndex {
|
||||
idx := b.getCachedIndex(key, time.Now())
|
||||
@@ -228,7 +306,13 @@ func (b *bleveBackend) runEvictExpiredOrUnownedIndexes(now time.Time) {
|
||||
unowned := map[resource.NamespacedResource]*bleveIndex{}
|
||||
ownCheckErrors := map[resource.NamespacedResource]error{}
|
||||
|
||||
// For sub-indexes
|
||||
expiredSubIndexes := map[resource.SubIndexKey]*bleveIndex{}
|
||||
unownedSubIndexes := map[resource.SubIndexKey]*bleveIndex{}
|
||||
ownSubCheckErrors := map[resource.SubIndexKey]error{}
|
||||
|
||||
b.cacheMx.Lock()
|
||||
// Process main cache (non-sharded indexes)
|
||||
for key, idx := range b.cache {
|
||||
// Check if index has expired.
|
||||
if !idx.expiration.IsZero() && now.After(idx.expiration) {
|
||||
@@ -248,21 +332,75 @@ func (b *bleveBackend) runEvictExpiredOrUnownedIndexes(now time.Time) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process sub-index cache (sharded indexes)
|
||||
for subKey, idx := range b.subIndexCache {
|
||||
// Check if sub-index has expired.
|
||||
if !idx.expiration.IsZero() && now.After(idx.expiration) {
|
||||
delete(b.subIndexCache, subKey)
|
||||
expiredSubIndexes[subKey] = idx
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if sub-index is owned by this instance using OwnsSubIndex.
|
||||
// This considers the subIndexID when determining ownership via ring hash.
|
||||
if cacheTTLMillis > 0 {
|
||||
owned, err := b.ownsSubIndexFn(subKey.NamespacedResource, subKey.SubIndexID)
|
||||
if err != nil {
|
||||
ownSubCheckErrors[subKey] = err
|
||||
} else if !owned && now.UnixMilli()-idx.lastFetchedFromCache.Load() > cacheTTLMillis {
|
||||
delete(b.subIndexCache, subKey)
|
||||
unownedSubIndexes[subKey] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
b.cacheMx.Unlock()
|
||||
|
||||
// Log errors for main cache ownership checks
|
||||
for key, err := range ownCheckErrors {
|
||||
b.log.Warn("failed to check if index belongs to this instance", "key", key, "err", err)
|
||||
}
|
||||
|
||||
// Log errors for sub-index ownership checks
|
||||
for subKey, err := range ownSubCheckErrors {
|
||||
b.log.Warn("failed to check if sub-index belongs to this instance", "subKey", subKey, "err", err)
|
||||
}
|
||||
|
||||
// Evict unowned main indexes
|
||||
for key, idx := range unowned {
|
||||
b.log.Info("index evicted from cache", "reason", "unowned", "key", key, "storage", idx.indexStorage)
|
||||
b.closeIndex(idx, key)
|
||||
}
|
||||
|
||||
// Evict unowned sub-indexes
|
||||
for subKey, idx := range unownedSubIndexes {
|
||||
b.log.Info("sub-index evicted from cache", "reason", "unowned", "subKey", subKey, "storage", idx.indexStorage)
|
||||
b.closeSubIndex(idx, subKey)
|
||||
}
|
||||
|
||||
// Evict expired main indexes
|
||||
for key, idx := range expired {
|
||||
b.log.Info("index evicted from cache", "reason", "expired", "key", key, "storage", idx.indexStorage)
|
||||
b.closeIndex(idx, key)
|
||||
}
|
||||
|
||||
// Evict expired sub-indexes
|
||||
for subKey, idx := range expiredSubIndexes {
|
||||
b.log.Info("sub-index evicted from cache", "reason", "expired", "subKey", subKey, "storage", idx.indexStorage)
|
||||
b.closeSubIndex(idx, subKey)
|
||||
}
|
||||
}
|
||||
|
||||
// closeSubIndex closes a sub-index and updates metrics.
|
||||
func (b *bleveBackend) closeSubIndex(idx *bleveIndex, key resource.SubIndexKey) {
|
||||
err := idx.stopUpdaterAndCloseIndex()
|
||||
if err != nil {
|
||||
b.log.Error("failed to close sub-index", "key", key, "err", err)
|
||||
}
|
||||
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.OpenIndexes.WithLabelValues(idx.indexStorage).Dec()
|
||||
}
|
||||
}
|
||||
|
||||
// updateIndexSizeMetric sets the total size of all file-based indices metric.
|
||||
@@ -368,6 +506,12 @@ func (b *bleveBackend) BuildIndex(
|
||||
attribute.String("reason", indexBuildReason),
|
||||
)
|
||||
|
||||
// If sub-index sharding is enabled, delegate to the parallel sub-index builder
|
||||
if b.opts.SubIndexCount > 0 {
|
||||
span.SetAttributes(attribute.Int("sub_index_count", b.opts.SubIndexCount))
|
||||
return b.buildShardedIndex(ctx, key, size, fields, indexBuildReason, builder, updater, rebuild)
|
||||
}
|
||||
|
||||
mapper, err := GetBleveMappings(fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -603,6 +747,294 @@ func isPathWithinRoot(path, absoluteRoot string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// buildShardedIndex builds all sub-indexes in parallel and returns a compositeIndex.
|
||||
// This is used when sub-index sharding is enabled (SubIndexCount > 0).
|
||||
func (b *bleveBackend) buildShardedIndex(
|
||||
ctx context.Context,
|
||||
key resource.NamespacedResource,
|
||||
size int64,
|
||||
fields resource.SearchableDocumentFields,
|
||||
indexBuildReason string,
|
||||
builder resource.BuildFn,
|
||||
updater resource.UpdateFn,
|
||||
rebuild bool,
|
||||
) (resource.ResourceIndex, error) {
|
||||
_, span := tracer.Start(ctx, "search.bleveBackend.buildShardedIndex")
|
||||
defer span.End()
|
||||
|
||||
logWithDetails := b.log.FromContext(ctx).New(
|
||||
"namespace", key.Namespace,
|
||||
"group", key.Group,
|
||||
"resource", key.Resource,
|
||||
"size", size,
|
||||
"reason", indexBuildReason,
|
||||
"sub_index_count", b.opts.SubIndexCount,
|
||||
)
|
||||
|
||||
mapper, err := GetBleveMappings(fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
standardSearchFields := resource.StandardSearchFields()
|
||||
allFields, err := getAllFields(standardSearchFields, fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate size per sub-index for threshold decisions
|
||||
sizePerSubIndex := size / int64(b.opts.SubIndexCount)
|
||||
if sizePerSubIndex < 1 {
|
||||
sizePerSubIndex = 1
|
||||
}
|
||||
|
||||
// Create all sub-indexes with limited concurrency to avoid overwhelming the file system
|
||||
// Use a worker pool pattern - limit to 8 concurrent index creations
|
||||
type subIndexResult struct {
|
||||
subIndex *bleveIndex
|
||||
subIndexKey resource.SubIndexKey
|
||||
err error
|
||||
}
|
||||
|
||||
results := make([]subIndexResult, b.opts.SubIndexCount)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Limit concurrent index creations to avoid file system contention
|
||||
maxConcurrentIndexCreations := 8
|
||||
if b.opts.SubIndexCount < maxConcurrentIndexCreations {
|
||||
maxConcurrentIndexCreations = b.opts.SubIndexCount
|
||||
}
|
||||
semaphore := make(chan struct{}, maxConcurrentIndexCreations)
|
||||
|
||||
logWithDetails.Info("Building sharded index", "sub_index_count", b.opts.SubIndexCount, "size_per_sub_index", sizePerSubIndex, "max_concurrent", maxConcurrentIndexCreations)
|
||||
|
||||
for i := 0; i < b.opts.SubIndexCount; i++ {
|
||||
wg.Add(1)
|
||||
go func(subIndexID int) {
|
||||
defer wg.Done()
|
||||
// Acquire semaphore
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
subKey := resource.SubIndexKey{
|
||||
NamespacedResource: key,
|
||||
SubIndexID: subIndexID,
|
||||
}
|
||||
results[subIndexID].subIndexKey = subKey
|
||||
|
||||
// Build individual sub-index
|
||||
idx, buildErr := b.buildSingleSubIndex(
|
||||
ctx, subKey, sizePerSubIndex, mapper, fields, allFields, standardSearchFields, updater, indexBuildReason, rebuild,
|
||||
)
|
||||
if buildErr != nil {
|
||||
results[subIndexID].err = buildErr
|
||||
return
|
||||
}
|
||||
results[subIndexID].subIndex = idx
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Check for errors and collect successful sub-indexes
|
||||
subIndexes := make([]*bleveIndex, 0, b.opts.SubIndexCount)
|
||||
var firstErr error
|
||||
for i, result := range results {
|
||||
if result.err != nil {
|
||||
logWithDetails.Error("Failed to build sub-index", "sub_index_id", i, "err", result.err)
|
||||
if firstErr == nil {
|
||||
firstErr = result.err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if result.subIndex != nil {
|
||||
subIndexes = append(subIndexes, result.subIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// If any sub-index failed, clean up and return error
|
||||
if firstErr != nil {
|
||||
for _, result := range results {
|
||||
if result.subIndex != nil {
|
||||
_ = result.subIndex.stopUpdaterAndCloseIndex()
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to build sharded index: %w", firstErr)
|
||||
}
|
||||
|
||||
// Create the composite index
|
||||
composite := b.newCompositeIndex(key, subIndexes, fields, allFields, standardSearchFields, b.log.New("namespace", key.Namespace, "group", key.Group, "resource", key.Resource))
|
||||
|
||||
// Build the index by calling the builder with the composite index
|
||||
// The builder will call BulkIndex on the composite, which routes documents to correct sub-indexes
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.IndexBuilds.WithLabelValues(indexBuildReason).Inc()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
listRV, err := builder(composite)
|
||||
if err != nil {
|
||||
logWithDetails.Error("Failed to build sharded index", "err", err)
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.IndexBuildFailures.Inc()
|
||||
}
|
||||
// Clean up all sub-indexes on failure
|
||||
for _, idx := range subIndexes {
|
||||
_ = idx.stopUpdaterAndCloseIndex()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to build sharded index: %w", err)
|
||||
}
|
||||
|
||||
// Update resource version on all sub-indexes
|
||||
for _, idx := range subIndexes {
|
||||
if err := idx.updateResourceVersion(listRV); err != nil {
|
||||
logWithDetails.Error("Failed to persist RV to sub-index", "err", err, "rv", listRV)
|
||||
// Continue - this is not fatal
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logWithDetails.Info("Finished building sharded index", "elapsed", elapsed, "listRV", listRV, "sub_indexes_built", len(subIndexes))
|
||||
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.IndexCreationTime.WithLabelValues().Observe(elapsed.Seconds())
|
||||
}
|
||||
|
||||
// Store sub-indexes in the cache
|
||||
b.cacheMx.Lock()
|
||||
for i, idx := range subIndexes {
|
||||
subKey := resource.SubIndexKey{
|
||||
NamespacedResource: key,
|
||||
SubIndexID: i,
|
||||
}
|
||||
prev := b.subIndexCache[subKey]
|
||||
b.subIndexCache[subKey] = idx
|
||||
|
||||
// Close previous sub-index if it existed
|
||||
if prev != nil {
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.OpenIndexes.WithLabelValues(prev.indexStorage).Dec()
|
||||
}
|
||||
if err := prev.stopUpdaterAndCloseIndex(); err != nil {
|
||||
logWithDetails.Error("failed to close previous sub-index", "sub_key", subKey, "err", err)
|
||||
}
|
||||
}
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.OpenIndexes.WithLabelValues(idx.indexStorage).Inc()
|
||||
}
|
||||
}
|
||||
b.cacheMx.Unlock()
|
||||
|
||||
return composite, nil
|
||||
}
|
||||
|
||||
// buildSingleSubIndex builds a single sub-index for sharded indexing.
|
||||
func (b *bleveBackend) buildSingleSubIndex(
|
||||
ctx context.Context,
|
||||
subKey resource.SubIndexKey,
|
||||
size int64,
|
||||
mapper mapping.IndexMapping,
|
||||
fields resource.SearchableDocumentFields,
|
||||
allFields []*resourcepb.ResourceTableColumnDefinition,
|
||||
standardSearchFields resource.SearchableDocumentFields,
|
||||
updater resource.UpdateFn,
|
||||
indexBuildReason string,
|
||||
rebuild bool,
|
||||
) (*bleveIndex, error) {
|
||||
key := subKey.ToNamespacedResource()
|
||||
logWithDetails := b.log.FromContext(ctx).New(
|
||||
"namespace", key.Namespace,
|
||||
"group", key.Group,
|
||||
"resource", key.Resource,
|
||||
"sub_index_id", subKey.SubIndexID,
|
||||
"size", size,
|
||||
)
|
||||
|
||||
// Get directory for this sub-index
|
||||
subIndexDir := b.getSubIndexDir(subKey)
|
||||
|
||||
var index bleve.Index
|
||||
var indexRV int64
|
||||
var err error
|
||||
fileIndexName := ""
|
||||
newIndexType := indexStorageMemory
|
||||
|
||||
if size >= b.opts.FileThreshold {
|
||||
newIndexType = indexStorageFile
|
||||
|
||||
// Check for existing file-based index if not rebuilding
|
||||
if !rebuild {
|
||||
index, fileIndexName, indexRV, err = b.findPreviousFileBasedIndex(subIndexDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if index != nil {
|
||||
logWithDetails.Debug("Existing sub-index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(subIndexDir, fileIndexName))
|
||||
} else {
|
||||
// Create new file-based index
|
||||
indexDir := ""
|
||||
now := time.Now()
|
||||
for index == nil {
|
||||
fileIndexName = formatIndexName(now)
|
||||
indexDir = filepath.Join(subIndexDir, fileIndexName)
|
||||
if !isPathWithinRoot(indexDir, b.opts.Root) {
|
||||
return nil, fmt.Errorf("invalid path %s", indexDir)
|
||||
}
|
||||
|
||||
// Ensure sub-index directory exists
|
||||
if err := os.MkdirAll(subIndexDir, 0750); err != nil {
|
||||
return nil, fmt.Errorf("failed to create sub-index directory: %w", err)
|
||||
}
|
||||
|
||||
index, err = newBleveIndex(indexDir, mapper, time.Now(), b.opts.BuildVersion)
|
||||
if errors.Is(err, bleve.ErrorIndexPathExists) {
|
||||
now = now.Add(time.Second)
|
||||
index = nil
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new bleve sub-index: %s %w", indexDir, err)
|
||||
}
|
||||
}
|
||||
logWithDetails.Debug("Created new file-based sub-index", "directory", indexDir)
|
||||
}
|
||||
} else {
|
||||
index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new in-memory bleve sub-index: %w", err)
|
||||
}
|
||||
logWithDetails.Debug("Created new in-memory sub-index")
|
||||
}
|
||||
|
||||
// Create the bleveIndex wrapper
|
||||
idx := b.newBleveIndex(key, index, newIndexType, fields, allFields, standardSearchFields, updater, logWithDetails)
|
||||
|
||||
// Set expiration for in-memory indexes
|
||||
if fileIndexName == "" && b.opts.IndexCacheTTL > 0 {
|
||||
idx.expiration = time.Now().Add(b.opts.IndexCacheTTL)
|
||||
}
|
||||
|
||||
// If we reused an existing index, set its resource version
|
||||
if indexRV > 0 {
|
||||
idx.resourceVersion = indexRV
|
||||
}
|
||||
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// getSubIndexDir returns the directory path for a sub-index.
|
||||
func (b *bleveBackend) getSubIndexDir(subKey resource.SubIndexKey) string {
|
||||
key := subKey.ToNamespacedResource()
|
||||
return filepath.Join(
|
||||
b.opts.Root,
|
||||
cleanFileSegment(key.Namespace),
|
||||
cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group)),
|
||||
fmt.Sprintf("shard-%d", subKey.SubIndexID),
|
||||
)
|
||||
}
|
||||
|
||||
// TotalDocs returns the total number of documents across all indices
|
||||
func (b *bleveBackend) TotalDocs() int64 {
|
||||
var totalDocs int64
|
||||
@@ -678,6 +1110,7 @@ func (b *bleveBackend) closeAllIndexes() {
|
||||
b.cacheMx.Lock()
|
||||
defer b.cacheMx.Unlock()
|
||||
|
||||
// Close main indexes
|
||||
for key, idx := range b.cache {
|
||||
if err := idx.stopUpdaterAndCloseIndex(); err != nil {
|
||||
b.log.Error("Failed to close index", "err", err)
|
||||
@@ -688,6 +1121,18 @@ func (b *bleveBackend) closeAllIndexes() {
|
||||
b.indexMetrics.OpenIndexes.WithLabelValues(idx.indexStorage).Dec()
|
||||
}
|
||||
}
|
||||
|
||||
// Close sub-indexes
|
||||
for subKey, idx := range b.subIndexCache {
|
||||
if err := idx.stopUpdaterAndCloseIndex(); err != nil {
|
||||
b.log.Error("Failed to close sub-index", "subKey", subKey, "err", err)
|
||||
}
|
||||
delete(b.subIndexCache, subKey)
|
||||
|
||||
if b.indexMetrics != nil {
|
||||
b.indexMetrics.OpenIndexes.WithLabelValues(idx.indexStorage).Dec()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
@@ -741,6 +1186,184 @@ type bleveIndex struct {
|
||||
lastFetchedFromCache atomic.Int64
|
||||
}
|
||||
|
||||
// compositeIndex wraps multiple sub-indexes for sharded search.
|
||||
// It implements resource.ResourceIndex and distributes operations across sub-indexes.
|
||||
type compositeIndex struct {
|
||||
key resource.NamespacedResource
|
||||
subIndexes []*bleveIndex
|
||||
alias bleve.Index // Bleve IndexAlias for unified search across all sub-indexes
|
||||
subIndexCount int
|
||||
backend *bleveBackend
|
||||
logger log.Logger
|
||||
|
||||
// standard and custom fields, shared across all sub-indexes
|
||||
standard resource.SearchableDocumentFields
|
||||
fields resource.SearchableDocumentFields
|
||||
allFields []*resourcepb.ResourceTableColumnDefinition
|
||||
}
|
||||
|
||||
var _ resource.ResourceIndex = &compositeIndex{}
|
||||
|
||||
// newCompositeIndex creates a new composite index wrapping multiple sub-indexes.
|
||||
func (b *bleveBackend) newCompositeIndex(
|
||||
key resource.NamespacedResource,
|
||||
subIndexes []*bleveIndex,
|
||||
fields resource.SearchableDocumentFields,
|
||||
allFields []*resourcepb.ResourceTableColumnDefinition,
|
||||
standardSearchFields resource.SearchableDocumentFields,
|
||||
logger log.Logger,
|
||||
) *compositeIndex {
|
||||
// Create Bleve IndexAlias for unified search
|
||||
indexes := make([]bleve.Index, len(subIndexes))
|
||||
for i, idx := range subIndexes {
|
||||
indexes[i] = idx.index
|
||||
}
|
||||
|
||||
return &compositeIndex{
|
||||
key: key,
|
||||
subIndexes: subIndexes,
|
||||
alias: bleve.NewIndexAlias(indexes...),
|
||||
subIndexCount: len(subIndexes),
|
||||
backend: b,
|
||||
logger: logger,
|
||||
standard: standardSearchFields,
|
||||
fields: fields,
|
||||
allFields: allFields,
|
||||
}
|
||||
}
|
||||
|
||||
// BulkIndex routes documents to the appropriate sub-index based on document key hash.
|
||||
// Documents are grouped by sub-index and then indexed in parallel to maximize throughput.
|
||||
func (c *compositeIndex) BulkIndex(req *resource.BulkIndexRequest) error {
|
||||
if len(req.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group items by sub-index
|
||||
itemsBySubIndex := make(map[int][]*resource.BulkIndexItem)
|
||||
for _, item := range req.Items {
|
||||
var docKey *resourcepb.ResourceKey
|
||||
if item.Action == resource.ActionIndex && item.Doc != nil {
|
||||
docKey = item.Doc.Key
|
||||
} else if item.Action == resource.ActionDelete {
|
||||
docKey = item.Key
|
||||
}
|
||||
if docKey == nil {
|
||||
return fmt.Errorf("missing document key for bulk index item")
|
||||
}
|
||||
|
||||
subIndexID := c.backend.GetSubIndexForDocument(docKey)
|
||||
itemsBySubIndex[subIndexID] = append(itemsBySubIndex[subIndexID], item)
|
||||
}
|
||||
|
||||
// Process sub-index batches in parallel for better throughput
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(itemsBySubIndex))
|
||||
|
||||
for subIndexID, items := range itemsBySubIndex {
|
||||
if subIndexID >= len(c.subIndexes) {
|
||||
return fmt.Errorf("sub-index ID %d out of range (max %d)", subIndexID, len(c.subIndexes)-1)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(idx int, indexItems []*resource.BulkIndexItem) {
|
||||
defer wg.Done()
|
||||
subReq := &resource.BulkIndexRequest{
|
||||
Items: indexItems,
|
||||
ResourceVersion: req.ResourceVersion,
|
||||
}
|
||||
if err := c.subIndexes[idx].BulkIndex(subReq); err != nil {
|
||||
errCh <- fmt.Errorf("error indexing to sub-index %d: %w", idx, err)
|
||||
}
|
||||
}(subIndexID, items)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
// Return first error if any
|
||||
for err := range errCh {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search performs a search across all sub-indexes using the IndexAlias.
|
||||
func (c *compositeIndex) Search(
|
||||
ctx context.Context,
|
||||
access authlib.AccessClient,
|
||||
req *resourcepb.ResourceSearchRequest,
|
||||
federate []resource.ResourceIndex,
|
||||
stats *resource.SearchStats,
|
||||
) (*resourcepb.ResourceSearchResponse, error) {
|
||||
// Delegate to the first sub-index's search logic, but use our alias
|
||||
// This works because we set up the IndexAlias to search across all sub-indexes
|
||||
if len(c.subIndexes) == 0 {
|
||||
return &resourcepb.ResourceSearchResponse{
|
||||
Error: resource.NewBadRequestError("no sub-indexes available"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Use the first sub-index for search implementation, but with our composite alias
|
||||
// The sub-index will use its search logic with our federated alias
|
||||
return c.subIndexes[0].Search(ctx, access, req, federate, stats)
|
||||
}
|
||||
|
||||
// ListManagedObjects aggregates results from all sub-indexes.
|
||||
func (c *compositeIndex) ListManagedObjects(ctx context.Context, req *resourcepb.ListManagedObjectsRequest, stats *resource.SearchStats) (*resourcepb.ListManagedObjectsResponse, error) {
|
||||
if len(c.subIndexes) == 0 {
|
||||
return &resourcepb.ListManagedObjectsResponse{}, nil
|
||||
}
|
||||
// Use the first sub-index - the alias handles cross-index queries
|
||||
return c.subIndexes[0].ListManagedObjects(ctx, req, stats)
|
||||
}
|
||||
|
||||
// CountManagedObjects aggregates counts from all sub-indexes.
|
||||
func (c *compositeIndex) CountManagedObjects(ctx context.Context, stats *resource.SearchStats) ([]*resourcepb.CountManagedObjectsResponse_ResourceCount, error) {
|
||||
if len(c.subIndexes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Use the first sub-index - the alias handles cross-index queries
|
||||
return c.subIndexes[0].CountManagedObjects(ctx, stats)
|
||||
}
|
||||
|
||||
// DocCount returns the total document count across all sub-indexes.
|
||||
func (c *compositeIndex) DocCount(ctx context.Context, folder string, stats *resource.SearchStats) (int64, error) {
|
||||
var total int64
|
||||
for _, idx := range c.subIndexes {
|
||||
count, err := idx.DocCount(ctx, folder, stats)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += count
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// UpdateIndex updates all sub-indexes to the latest data.
|
||||
func (c *compositeIndex) UpdateIndex(ctx context.Context) (int64, error) {
|
||||
var maxRV int64
|
||||
for _, idx := range c.subIndexes {
|
||||
rv, err := idx.UpdateIndex(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if rv > maxRV {
|
||||
maxRV = rv
|
||||
}
|
||||
}
|
||||
return maxRV, nil
|
||||
}
|
||||
|
||||
// BuildInfo returns build information from the first sub-index.
|
||||
func (c *compositeIndex) BuildInfo() (resource.IndexBuildInfo, error) {
|
||||
if len(c.subIndexes) == 0 {
|
||||
return resource.IndexBuildInfo{}, fmt.Errorf("no sub-indexes available")
|
||||
}
|
||||
return c.subIndexes[0].BuildInfo()
|
||||
}
|
||||
|
||||
func (b *bleveBackend) newBleveIndex(
|
||||
key resource.NamespacedResource,
|
||||
index bleve.Index,
|
||||
|
||||
@@ -17,6 +17,7 @@ func NewSearchOptions(
|
||||
docs resource.DocumentBuilderSupplier,
|
||||
indexMetrics *resource.BleveIndexMetrics,
|
||||
ownsIndexFn func(key resource.NamespacedResource) (bool, error),
|
||||
ownsSubIndexFn ...func(key resource.NamespacedResource, subIndexID int) (bool, error),
|
||||
) (resource.SearchOptions, error) {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
@@ -39,13 +40,21 @@ func NewSearchOptions(
|
||||
}
|
||||
}
|
||||
|
||||
// Get OwnsSubIndex function if provided
|
||||
var ownsSubIdx func(key resource.NamespacedResource, subIndexID int) (bool, error)
|
||||
if len(ownsSubIndexFn) > 0 && ownsSubIndexFn[0] != nil {
|
||||
ownsSubIdx = ownsSubIndexFn[0]
|
||||
}
|
||||
|
||||
bleve, err := NewBleveBackend(BleveOptions{
|
||||
Root: root,
|
||||
FileThreshold: int64(cfg.IndexFileThreshold), // fewer than X items will use a memory index
|
||||
IndexCacheTTL: cfg.IndexCacheTTL, // How long to keep the index cache in memory
|
||||
BuildVersion: cfg.BuildVersion,
|
||||
OwnsIndex: ownsIndexFn,
|
||||
OwnsSubIndex: ownsSubIdx,
|
||||
IndexMinUpdateInterval: cfg.IndexMinUpdateInterval,
|
||||
SubIndexCount: cfg.SubIndexesPerNamespace,
|
||||
}, indexMetrics)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -226,6 +226,18 @@ var (
|
||||
)
|
||||
|
||||
func (s *service) OwnsIndex(key resource.NamespacedResource) (bool, error) {
|
||||
// When sub-index sharding is enabled, use OwnsSubIndex with subIndexID=0
|
||||
// to maintain backward compatibility. OwnsIndex is used for the main index.
|
||||
return s.OwnsSubIndex(key, 0)
|
||||
}
|
||||
|
||||
// OwnsSubIndex checks if the current instance owns a specific sub-index.
|
||||
// The sub-index ID is included in the ring hash to distribute sub-indexes
|
||||
// across nodes in the ring. This enables horizontal scaling for large namespaces.
|
||||
//
|
||||
// When subIndexID is 0 and SubIndexesPerNamespace is 0 (disabled), this behaves
|
||||
// exactly like the original OwnsIndex - maintaining backward compatibility.
|
||||
func (s *service) OwnsSubIndex(key resource.NamespacedResource, subIndexID int) (bool, error) {
|
||||
if s.searchRing == nil {
|
||||
return true, nil
|
||||
}
|
||||
@@ -235,12 +247,25 @@ func (s *service) OwnsIndex(key resource.NamespacedResource) (bool, error) {
|
||||
}
|
||||
|
||||
ringHasher := fnv.New32a()
|
||||
_, err := ringHasher.Write([]byte(key.Namespace))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error hashing namespace: %w", err)
|
||||
|
||||
// When sub-index sharding is enabled (SubIndexesPerNamespace > 0),
|
||||
// include the subIndexID in the hash to distribute sub-indexes across nodes.
|
||||
// This allows different sub-indexes of the same namespace to be owned by
|
||||
// different nodes, enabling horizontal scaling.
|
||||
if s.cfg.SubIndexesPerNamespace > 0 {
|
||||
_, err := ringHasher.Write([]byte(fmt.Sprintf("%s/%d", key.Namespace, subIndexID)))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error hashing namespace with sub-index: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Original behavior: hash only the namespace
|
||||
_, err := ringHasher.Write([]byte(key.Namespace))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error hashing namespace: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rs, err := s.searchRing.GetWithOptions(ringHasher.Sum32(), searchOwnerRead, ring.WithReplicationFactor(s.searchRing.ReplicationFactor()))
|
||||
rs, err := s.searchRing.GetWithOptions(ringHasher.Sum32(), searchOwnerRead)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error getting replicaset from ring: %w", err)
|
||||
}
|
||||
@@ -261,7 +286,7 @@ func (s *service) starting(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
searchOptions, err := search.NewSearchOptions(s.features, s.cfg, s.docBuilders, s.indexMetrics, s.OwnsIndex)
|
||||
searchOptions, err := search.NewSearchOptions(s.features, s.cfg, s.docBuilders, s.indexMetrics, s.OwnsIndex, s.OwnsSubIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
@@ -186,30 +187,13 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
|
||||
|
||||
// verifyKeyPath is a helper function to verify key_path generation
|
||||
func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) {
|
||||
// For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format
|
||||
// but key_path stores snowflake RV, so convert to snowflake
|
||||
// For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format
|
||||
isSqlBackend := strings.Contains(key.Namespace, "-sql")
|
||||
|
||||
var keyPathRV int64
|
||||
if isSqlBackend {
|
||||
// Convert microsecond RV to snowflake for key_path construction
|
||||
keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion)
|
||||
} else {
|
||||
// KV backend already provides snowflake RV
|
||||
keyPathRV = resourceVersion
|
||||
}
|
||||
|
||||
// Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder
|
||||
expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder)
|
||||
|
||||
var query string
|
||||
if db.DriverName() == "postgres" {
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1"
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3"
|
||||
} else {
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?"
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?"
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, query, expectedKeyPath)
|
||||
rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table")
|
||||
@@ -236,6 +220,10 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource
|
||||
// Verify action suffix
|
||||
require.Contains(t, keyPath, fmt.Sprintf("~%s~", action))
|
||||
|
||||
// Verify snowflake calculation
|
||||
expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000)
|
||||
require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), "actual RV: %d", actualRV)
|
||||
|
||||
// Verify folder if specified
|
||||
if expectedFolder != "" {
|
||||
require.Equal(t, expectedFolder, actualFolder)
|
||||
@@ -504,10 +492,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
|
||||
}
|
||||
|
||||
// Validate previous_resource_version
|
||||
// For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend)
|
||||
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
|
||||
// For KV backend operations, resource versions are stored as snowflake format
|
||||
// but expectedPrevRV is in microsecond format, so we need to use IsRvEqual for comparison
|
||||
if strings.Contains(record.Namespace, "-kv") {
|
||||
require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion),
|
||||
require.True(t, rvmanager.IsRvEqual(record.PreviousResourceVersion, expectedPrevRV),
|
||||
"Previous resource version should match (KV backend snowflake format)")
|
||||
} else {
|
||||
require.Equal(t, expectedPrevRV, record.PreviousResourceVersion)
|
||||
@@ -517,10 +505,9 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
|
||||
require.Equal(t, expectedGeneration, record.Generation)
|
||||
|
||||
// Validate resource_version
|
||||
// For KV backend operations, expectedRV is now in snowflake format (returned by KV backend)
|
||||
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
|
||||
// For KV backend operations, resource versions are stored as snowflake format
|
||||
if strings.Contains(record.Namespace, "-kv") {
|
||||
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
|
||||
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
|
||||
"Resource version should match (KV backend snowflake format)")
|
||||
} else {
|
||||
require.Equal(t, expectedRV, record.ResourceVersion)
|
||||
@@ -587,7 +574,7 @@ func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources
|
||||
// Resource version should match the expected version for test-resource-3 (updated version)
|
||||
expectedRV := resourceVersions[2][1] // test-resource-3's update version
|
||||
if strings.Contains(namespace, "-kv") {
|
||||
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
|
||||
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
|
||||
"Resource version should match (KV backend snowflake format)")
|
||||
} else {
|
||||
require.Equal(t, expectedRV, record.ResourceVersion)
|
||||
@@ -638,16 +625,9 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res
|
||||
|
||||
// The resource_version table should contain the latest RV for the group+resource
|
||||
// It might be slightly higher due to RV manager operations, so check it's at least our max
|
||||
// For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format
|
||||
// Use IsRvEqual for proper comparison between different RV formats
|
||||
isKvBackend := strings.Contains(namespace, "-kv")
|
||||
recordResourceVersion := record.ResourceVersion
|
||||
if isKvBackend {
|
||||
recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion)
|
||||
}
|
||||
|
||||
require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable")
|
||||
require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
|
||||
require.GreaterOrEqual(t, record.ResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
|
||||
// But it shouldn't be too much higher (within a reasonable range)
|
||||
require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected")
|
||||
}
|
||||
|
||||
// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight)
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestBadgerKVStorageBackend(t *testing.T) {
|
||||
|
||||
func TestSQLKVStorageBackend(t *testing.T) {
|
||||
skipTests := map[string]bool{
|
||||
TestHappyPath: true,
|
||||
TestWatchWriteEvents: true,
|
||||
TestList: true,
|
||||
TestBlobSupport: true,
|
||||
@@ -50,24 +51,21 @@ func TestSQLKVStorageBackend(t *testing.T) {
|
||||
TestGetResourceLastImportTime: true,
|
||||
TestOptimisticLocking: true,
|
||||
}
|
||||
|
||||
t.Run("Without RvManager", func(t *testing.T) {
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, false)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-test",
|
||||
SkipTests: skipTests,
|
||||
})
|
||||
// without RvManager
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, false)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-test",
|
||||
SkipTests: skipTests,
|
||||
})
|
||||
|
||||
t.Run("With RvManager", func(t *testing.T) {
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, true)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-withrvmanager-test",
|
||||
SkipTests: skipTests,
|
||||
})
|
||||
// with RvManager
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, true)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-withrvmanager-test",
|
||||
SkipTests: skipTests,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestIntegrationTestDatasource(t *testing.T) {
|
||||
|
||||
t.Run("Admin configs", func(t *testing.T) {
|
||||
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
|
||||
Group: "grafana-testdata-datasource.datasource.grafana.app",
|
||||
Group: "testdata.datasource.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "datasources",
|
||||
}).Namespace("default")
|
||||
@@ -92,7 +92,7 @@ func TestIntegrationTestDatasource(t *testing.T) {
|
||||
|
||||
t.Run("Call subresources", func(t *testing.T) {
|
||||
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
|
||||
Group: "grafana-testdata-datasource.datasource.grafana.app",
|
||||
Group: "testdata.datasource.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "datasources",
|
||||
}).Namespace("default")
|
||||
@@ -128,7 +128,7 @@ func TestIntegrationTestDatasource(t *testing.T) {
|
||||
raw := apis.DoRequest[any](helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: "GET",
|
||||
Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource",
|
||||
Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource",
|
||||
}, nil)
|
||||
// endpoint is disabled currently because it has not been
|
||||
// sufficiently tested.
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -208,10 +207,6 @@ func (c *K8sTestHelper) GetEnv() server.TestEnv {
|
||||
return c.env
|
||||
}
|
||||
|
||||
func (c *K8sTestHelper) SetGithubConnectionFactory(f githubConnection.GithubFactory) {
|
||||
c.env.GithubConnectionFactory = f
|
||||
}
|
||||
|
||||
func (c *K8sTestHelper) GetListenerAddress() string {
|
||||
return c.listenerAddress
|
||||
}
|
||||
|
||||
@@ -4559,7 +4559,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"token": {
|
||||
"webhook": {
|
||||
"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": {},
|
||||
"allOf": [
|
||||
|
||||
+21
-21
@@ -2,10 +2,10 @@
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"description": "Generates test data in different forms",
|
||||
"title": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1"
|
||||
"title": "testdata.datasource.grafana.app/v0alpha1"
|
||||
},
|
||||
"paths": {
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"API Discovery"
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Connections (deprecated)"
|
||||
@@ -68,7 +68,7 @@
|
||||
"deprecated": true,
|
||||
"x-kubernetes-action": "connect",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "QueryDataResponse"
|
||||
}
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"DataSource"
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"x-kubernetes-action": "list",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "DataSource"
|
||||
}
|
||||
@@ -254,7 +254,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"DataSource"
|
||||
@@ -285,7 +285,7 @@
|
||||
},
|
||||
"x-kubernetes-action": "get",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "DataSource"
|
||||
}
|
||||
@@ -322,7 +322,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"DataSource"
|
||||
@@ -343,7 +343,7 @@
|
||||
},
|
||||
"x-kubernetes-action": "connect",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "HealthCheckResult"
|
||||
}
|
||||
@@ -371,7 +371,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"DataSource"
|
||||
@@ -401,7 +401,7 @@
|
||||
},
|
||||
"x-kubernetes-action": "connect",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "QueryDataResponse"
|
||||
}
|
||||
@@ -429,7 +429,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"DataSource"
|
||||
@@ -450,7 +450,7 @@
|
||||
},
|
||||
"x-kubernetes-action": "connect",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "Status"
|
||||
}
|
||||
@@ -478,7 +478,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"QueryDataRequest"
|
||||
@@ -499,7 +499,7 @@
|
||||
},
|
||||
"x-kubernetes-action": "connect",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "QueryDataRequest"
|
||||
}
|
||||
@@ -620,7 +620,7 @@
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grafana-testdata-datasource.datasource.grafana.app/v0alpha1"
|
||||
"testdata.datasource.grafana.app/v0alpha1"
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
@@ -660,7 +660,7 @@
|
||||
},
|
||||
"x-kubernetes-group-version-kind": [
|
||||
{
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"kind": "DataSource",
|
||||
"version": "v0alpha1"
|
||||
}
|
||||
@@ -703,7 +703,7 @@
|
||||
},
|
||||
"x-kubernetes-group-version-kind": [
|
||||
{
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"kind": "DataSourceList",
|
||||
"version": "v0alpha1"
|
||||
}
|
||||
@@ -744,7 +744,7 @@
|
||||
},
|
||||
"x-kubernetes-group-version-kind": [
|
||||
{
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"kind": "HealthCheckResult",
|
||||
"version": "v0alpha1"
|
||||
}
|
||||
@@ -833,7 +833,7 @@
|
||||
},
|
||||
"x-kubernetes-group-version-kind": [
|
||||
{
|
||||
"group": "grafana-testdata-datasource.datasource.grafana.app",
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"kind": "QueryDataResponse",
|
||||
"version": "v0alpha1"
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestIntegrationOpenAPIs(t *testing.T) {
|
||||
Group: "shorturl.grafana.app",
|
||||
Version: "v1beta1",
|
||||
}, {
|
||||
Group: "grafana-testdata-datasource.datasource.grafana.app",
|
||||
Group: "testdata.datasource.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
}, {
|
||||
Group: "logsdrilldown.grafana.app",
|
||||
|
||||
@@ -2,13 +2,13 @@ package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
@@ -20,7 +20,7 @@ func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) {
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -39,12 +39,13 @@ func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Run("endpoint returns not implemented", func(t *testing.T) {
|
||||
var statusCode int
|
||||
@@ -128,14 +129,14 @@ func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection-repositories-test",
|
||||
"name": "connection-repositories-type-test",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
@@ -147,12 +148,13 @@ func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Run("verify ExternalRepositoryList type exists in API", func(t *testing.T) {
|
||||
// Verify the type is registered and can be instantiated
|
||||
|
||||
@@ -2,12 +2,12 @@ package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
@@ -18,7 +18,7 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) {
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -37,12 +37,13 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Run("admin can GET connection status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
|
||||
@@ -2,20 +2,11 @@ package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/go-github/v70/github"
|
||||
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/grafana/grafana/pkg/extensions"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -26,55 +17,12 @@ import (
|
||||
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
|
||||
)
|
||||
|
||||
//nolint:gosec // Test RSA private key (generated for testing purposes only)
|
||||
const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEoQIBAAKCAQBn1MuM5hIfH6d3TNStI1ofWv/gcjQ4joi9cFijEwVLuPYkF1nD
|
||||
KkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6m5T/sHfVSiEWAEUblh3cA34HVCmD
|
||||
cqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyjpKOXuojJ9wN9a17D2cYU5WkXjoDC
|
||||
4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7DA9T8yiZwIWPp5YesgsAPyQLCFPgM
|
||||
s77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQohUID0BIxE7G5quL069RuuCZWZkoF
|
||||
oPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmXAgMBAAECggEADSs4Bc7ITZo+Kytb
|
||||
bfol3AQ2n8jcRrANN7mgBE7NRSVYUouDnvUlbnCC2t3QXPwLdxQa11GkygLSQ2bg
|
||||
GeVDgq1o4GUJTcvxFlFCcpU/hEANI/DQsxNAQ/4wUGoLOlHaO3HPvwBblHA70gGe
|
||||
Ux/xpG+lMAFAiB0EHEwZ4M0mClBEOQv3NzaFTWuBHtIMS8eid7M1q5qz9+rCgZSL
|
||||
KBBHo0OvUbajG4CWl8SM6LUYapASGg+U17E+4xA3npwpIdsk+CbtX+vvX324n4kn
|
||||
0EkrJqCjv8M1KiCKAP+UxwP00ywxOg4PN+x+dHI/I7xBvEKe/x6BltVSdGA+PlUK
|
||||
02wagQKBgQDF7gdQLFIagPH7X7dBP6qEGxj/Ck9Qdz3S1gotPkVeq+1/UtQijYZ1
|
||||
j44up/0yB2B9P4kW091n+iWcyfoU5UwBua9dHvCZP3QH05LR1ZscUHxLGjDPBASt
|
||||
l2xSq0hqqNWBspb1M0eCY0Yxi65iDkj3xsI2iN35BEb1FlWdR5KGvwKBgQCGS0ce
|
||||
wASWbZIPU2UoKGOQkIJU6QmLy0KZbfYkpyfE8IxGttYVEQ8puNvDDNZWHNf+LP85
|
||||
c8iV6SfnWiLmu1XkG2YmJFBCCAWgJ8Mq2XQD8E+a/xcaW3NqlcC5+I2czX367j3r
|
||||
69wZSxRbzR+DCfOiIkrekJImwN183ZYy2cBbKQKBgFj86IrSMmO6H5Ft+j06u5ZD
|
||||
fJyF7Rz3T3NwSgkHWzbyQ4ggHEIgsRg/36P4YSzSBj6phyAdRwkNfUWdxXMJmH+a
|
||||
FU7frzqnPaqbJAJ1cBRt10QI1XLtkpDdaJVObvONTtjOC3LYiEkGCzQRYeiyFXpZ
|
||||
AU51gJ8JnkFotjtNR4KPAoGAehVREDlLcl0lnN0ZZspgyPk2Im6/iOA9KTH3xBZZ
|
||||
ZwWu4FIyiHA7spgk4Ep5R0ttZ9oMI3SIcw/EgONGOy8uw/HMiPwWIhEc3B2JpRiO
|
||||
CU6bb7JalFFyuQBudiHoyxVcY5PVovWF31CLr3DoJr4TR9+Y5H/U/XnzYCIo+w1N
|
||||
exECgYBFAGKYTIeGAvhIvD5TphLpbCyeVLBIq5hRyrdRY+6Iwqdr5PGvLPKwin5+
|
||||
+4CDhWPW4spq8MYPCRiMrvRSctKt/7FhVGL2vE/0VY3TcLk14qLC+2+0lnPVgnYn
|
||||
u5/wOyuHp1cIBnjeN41/pluOWFBHI9xLW3ExLtmYMiecJ8VdRA==
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
//nolint:gosec // Test RSA public key (generated for testing purposes only)
|
||||
const testPublicKeyPem = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQBn1MuM5hIfH6d3TNStI1of
|
||||
Wv/gcjQ4joi9cFijEwVLuPYkF1nDKkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6
|
||||
m5T/sHfVSiEWAEUblh3cA34HVCmDcqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyj
|
||||
pKOXuojJ9wN9a17D2cYU5WkXjoDC4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7D
|
||||
A9T8yiZwIWPp5YesgsAPyQLCFPgMs77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQo
|
||||
hUID0BIxE7G5quL069RuuCZWZkoFoPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmX
|
||||
AgMBAAE=
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
decryptService := helper.GetEnv().DecryptService
|
||||
require.NotNil(t, decryptService, "decrypt service not wired properly")
|
||||
|
||||
t.Run("should perform CRUDL requests on connection", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -93,12 +41,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
// CREATE
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create resource")
|
||||
|
||||
// READ
|
||||
@@ -116,22 +64,6 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
require.Contains(t, output.Object, "secure", "object should contain secure")
|
||||
assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey")
|
||||
|
||||
// Verifying token
|
||||
assert.Contains(t, output.Object["secure"], "token", "token should be created")
|
||||
secretName, found, err := unstructured.NestedString(output.Object, "secure", "token", "name")
|
||||
require.NoError(t, err, "error getting secret name")
|
||||
require.True(t, found, "secret name should exist: %v", output.Object)
|
||||
decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", output.GetNamespace(), secretName)
|
||||
require.NoError(t, err, "decryption error")
|
||||
require.Len(t, decrypted, 1)
|
||||
|
||||
val := decrypted[secretName].Value()
|
||||
require.NotNil(t, val)
|
||||
k := val.DangerouslyExposeAndConsumeValue()
|
||||
valid, err := verifyToken(t, "123456", testPublicKeyPem, k)
|
||||
require.NoError(t, err, "error verifying token: %s", k)
|
||||
require.True(t, valid, "token should be valid: %s", k)
|
||||
|
||||
// LIST
|
||||
list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err, "failed to list resource")
|
||||
@@ -149,22 +81,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454546",
|
||||
"appID": "456789",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
res, err := helper.UpdateGithubConnection(t, ctx, updatedConnection)
|
||||
res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{})
|
||||
require.NoError(t, err, "failed to update resource")
|
||||
spec = res.Object["spec"].(map[string]any)
|
||||
require.Contains(t, spec, "github")
|
||||
githubInfo = spec["github"].(map[string]any)
|
||||
assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated")
|
||||
assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated")
|
||||
|
||||
// DELETE
|
||||
require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource")
|
||||
@@ -190,7 +122,7 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -223,12 +155,9 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
t.Run("should fail when type is empty", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -243,13 +172,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "connection type \"\" is not supported")
|
||||
assert.Contains(t, err.Error(), "type must be specified")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is invalid", func(t *testing.T) {
|
||||
@@ -265,57 +194,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "connection type \"some-invalid-type\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is 'git'", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "git",
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "connection type \"git\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is 'local'", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "local",
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "connection type \"local\" is not supported")
|
||||
assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) {
|
||||
@@ -331,13 +216,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "invalid github connection")
|
||||
assert.Contains(t, err.Error(), "github info must be specified for GitHub connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github but private key is not there", func(t *testing.T) {
|
||||
@@ -361,7 +246,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github but a client Secret is also specified", func(t *testing.T) {
|
||||
t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
@@ -378,7 +263,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "someSecret",
|
||||
},
|
||||
"clientSecret": map[string]any{
|
||||
"create": "someSecret",
|
||||
@@ -390,100 +275,6 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github and github API is unavailable", func(t *testing.T) {
|
||||
connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
|
||||
connectionFactory.Client = ghmock.NewMockedHTTPClient(
|
||||
ghmock.WithRequestMatchHandler(
|
||||
ghmock.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",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
)
|
||||
helper.SetGithubConnectionFactory(connectionFactory)
|
||||
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "spec.token: Internal error: github is unavailable")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github and returned app ID doesn't match given one", func(t *testing.T) {
|
||||
var appID int64 = 123455
|
||||
appSlug := "appSlug"
|
||||
connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
|
||||
connectionFactory.Client = ghmock.NewMockedHTTPClient(
|
||||
ghmock.WithRequestMatch(
|
||||
ghmock.GetApp, github.App{
|
||||
ID: &appID,
|
||||
Slug: &appSlug,
|
||||
},
|
||||
),
|
||||
)
|
||||
helper.SetGithubConnectionFactory(connectionFactory)
|
||||
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "spec.appID: Invalid value: \"123456\": appID mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
if !extensions.IsEnterprise {
|
||||
t.Skip("Skipping integration test when not enterprise")
|
||||
}
|
||||
|
||||
helper := runGrafana(t)
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
@@ -503,7 +294,7 @@ func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) {
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "invalid bitbucket connection")
|
||||
assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) {
|
||||
@@ -573,7 +364,7 @@ func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) {
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "invalid gitlab connection")
|
||||
assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) {
|
||||
@@ -637,7 +428,6 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
provisioningClient, err := clientset.NewForConfig(restConfig)
|
||||
require.NoError(t, err)
|
||||
connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace)
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
t.Run("health check gets updated after initial creation", func(t *testing.T) {
|
||||
// Create a connection using unstructured (like other connection tests)
|
||||
@@ -657,12 +447,12 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "test-private-key",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured)
|
||||
createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdUnstructured)
|
||||
|
||||
@@ -711,12 +501,12 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "test-private-key-2",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured)
|
||||
createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdUnstructured)
|
||||
|
||||
@@ -748,7 +538,7 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
updatedUnstructured := latestUnstructured.DeepCopy()
|
||||
githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any)
|
||||
githubSpec["appID"] = "99999"
|
||||
_, err = helper.UpdateGithubConnection(t, ctx, updatedUnstructured)
|
||||
_, err = helper.Connections.Resource.Update(ctx, updatedUnstructured, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for reconciliation after spec change
|
||||
@@ -776,7 +566,6 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
// Create a connection first
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -795,12 +584,12 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
"create": "test-private-key",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Cleanup(func() {
|
||||
@@ -942,27 +731,3 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.
|
||||
assert.Contains(t, names, "repo-with-different-connection")
|
||||
})
|
||||
}
|
||||
|
||||
func verifyToken(t *testing.T, appID, publicKey, token string) (bool, error) {
|
||||
t.Helper()
|
||||
|
||||
// Parse the private key
|
||||
key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
|
||||
return key, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||
if !ok || !parsedToken.Valid {
|
||||
return false, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
return claims.VerifyIssuer(appID, true), nil
|
||||
}
|
||||
|
||||
@@ -10,14 +10,11 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v70/github"
|
||||
"github.com/grafana/grafana/pkg/extensions"
|
||||
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -33,7 +30,6 @@ import (
|
||||
dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
|
||||
folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -703,18 +699,13 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
|
||||
// (instance is needed for export jobs, folder for most operations)
|
||||
ProvisioningAllowedTargets: []string{"folder", "instance"},
|
||||
}
|
||||
|
||||
if extensions.IsEnterprise {
|
||||
opts.ProvisioningRepositoryTypes = []string{"local", "github", "gitlab", "bitbucket"}
|
||||
}
|
||||
|
||||
for _, o := range options {
|
||||
o(&opts)
|
||||
}
|
||||
helper := apis.NewK8sTestHelper(t, opts)
|
||||
|
||||
// FIXME: keeping these lines here to keep the dependency around until we have tests which use this again.
|
||||
helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient()
|
||||
// FIXME: keeping this line here to keep the dependency around until we have tests which use this again.
|
||||
helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient()
|
||||
|
||||
repositories := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
@@ -982,79 +973,6 @@ func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) {
|
||||
}, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests")
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) CreateGithubConnection(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
connection *unstructured.Unstructured,
|
||||
) (*unstructured.Unstructured, error) {
|
||||
t.Helper()
|
||||
|
||||
err := h.setGithubClient(t, connection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{FieldValidation: "Strict"})
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) UpdateGithubConnection(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
connection *unstructured.Unstructured,
|
||||
) (*unstructured.Unstructured, error) {
|
||||
t.Helper()
|
||||
|
||||
err := h.setGithubClient(t, connection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h.Connections.Resource.Update(ctx, connection, metav1.UpdateOptions{FieldValidation: "Strict"})
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) setGithubClient(t *testing.T, connection *unstructured.Unstructured) error {
|
||||
t.Helper()
|
||||
|
||||
objectSpec := connection.Object["spec"].(map[string]interface{})
|
||||
githubObj := objectSpec["github"].(map[string]interface{})
|
||||
appID := githubObj["appID"].(string)
|
||||
id, err := strconv.ParseInt(appID, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSlug := "someSlug"
|
||||
connectionFactory := h.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
|
||||
connectionFactory.Client = ghmock.NewMockedHTTPClient(
|
||||
ghmock.WithRequestMatchHandler(
|
||||
ghmock.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
app := github.App{
|
||||
ID: &id,
|
||||
Slug: &appSlug,
|
||||
}
|
||||
_, _ = w.Write(ghmock.MustMarshal(app))
|
||||
}),
|
||||
),
|
||||
ghmock.WithRequestMatchHandler(
|
||||
ghmock.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("installation_id")
|
||||
idInt, _ := strconv.ParseInt(id, 10, 64)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
installation := github.Installation{
|
||||
ID: &idInt,
|
||||
}
|
||||
_, _ = w.Write(ghmock.MustMarshal(installation))
|
||||
}),
|
||||
),
|
||||
)
|
||||
h.SetGithubConnectionFactory(connectionFactory)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) {
|
||||
return requestHelper(t, helper, http.MethodPost, path, body, user)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/extensions"
|
||||
provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -150,19 +149,10 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if extensions.IsEnterprise {
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
provisioning.BitbucketRepositoryType,
|
||||
provisioning.GitLabRepositoryType,
|
||||
}, settings.AvailableRepositoryTypes)
|
||||
} else {
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
}, settings.AvailableRepositoryTypes)
|
||||
}
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
}, settings.AvailableRepositoryTypes)
|
||||
}, time.Second*10, time.Millisecond*100, "Expected settings to match")
|
||||
})
|
||||
|
||||
|
||||
@@ -622,12 +622,6 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
_, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if len(opts.ProvisioningRepositoryTypes) > 0 {
|
||||
provisioningSect, err := getOrCreateSection("provisioning")
|
||||
require.NoError(t, err)
|
||||
_, err = provisioningSect.NewKey("repository_types", strings.Join(opts.ProvisioningRepositoryTypes, "|"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if opts.EnableSCIM {
|
||||
scimSection, err := getOrCreateSection("auth.scim")
|
||||
require.NoError(t, err)
|
||||
@@ -737,7 +731,6 @@ type GrafanaOpts struct {
|
||||
UnifiedStorageMaxPageSizeBytes int
|
||||
PermittedProvisioningPaths string
|
||||
ProvisioningAllowedTargets []string
|
||||
ProvisioningRepositoryTypes []string
|
||||
GrafanaComSSOAPIToken string
|
||||
LicensePath string
|
||||
EnableRecordingRules bool
|
||||
|
||||
@@ -28,17 +28,18 @@ describe('DatasourceAPIVersions', () => {
|
||||
it('get', async () => {
|
||||
const getMock = jest.fn().mockResolvedValue({
|
||||
groups: [
|
||||
{ name: 'grafana-testdata-datasource.datasource.grafana.app', preferredVersion: { version: 'v1' } },
|
||||
{ name: 'testdata.datasource.grafana.app', preferredVersion: { version: 'v1' } },
|
||||
{ name: 'prometheus.datasource.grafana.app', preferredVersion: { version: 'v2' } },
|
||||
{ name: 'myorg-myplugin.datasource.grafana.app', preferredVersion: { version: 'v3' } },
|
||||
],
|
||||
});
|
||||
getBackendSrv().get = getMock;
|
||||
const apiVersions = new DatasourceAPIVersions();
|
||||
expect(await apiVersions.get('testdata')).toBe('v1');
|
||||
expect(await apiVersions.get('grafana-testdata-datasource')).toBe('v1');
|
||||
expect(await apiVersions.get('prometheus')).toBe('v2');
|
||||
expect(await apiVersions.get('graphite')).toBeUndefined();
|
||||
expect(await apiVersions.get('myorg-myplugin')).toBe('v3');
|
||||
expect(await apiVersions.get('myorg-myplugin-datasource')).toBe('v3');
|
||||
expect(getMock).toHaveBeenCalledTimes(1);
|
||||
expect(getMock).toHaveBeenCalledWith('/apis');
|
||||
});
|
||||
|
||||
@@ -162,6 +162,17 @@ export class DatasourceAPIVersions {
|
||||
if (group.name.includes('datasource.grafana.app')) {
|
||||
const id = group.name.split('.')[0];
|
||||
apiVersions[id] = group.preferredVersion.version;
|
||||
// workaround for plugins that don't append '-datasource' for the group name
|
||||
// e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app
|
||||
if (!id.endsWith('-datasource')) {
|
||||
if (!id.includes('-')) {
|
||||
// workaroud for Grafana plugins that don't include the org either
|
||||
// e.g. testdata uses testdata.datasource.grafana.app
|
||||
apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version;
|
||||
} else {
|
||||
apiVersions[`${id}-datasource`] = group.preferredVersion.version;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.apiVersions = apiVersions;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAsyncRetry } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, store } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
|
||||
import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
@@ -36,18 +35,10 @@ export function RecentlyViewedDashboards() {
|
||||
const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0);
|
||||
|
||||
const handleClearHistory = () => {
|
||||
reportInteraction('grafana_recently_viewed_dashboards_clear_history');
|
||||
store.set(recentDashboardsKey, JSON.stringify([]));
|
||||
retry();
|
||||
};
|
||||
|
||||
const handleSectionToggle = () => {
|
||||
reportInteraction('grafana_recently_viewed_dashboards_toggle_section', {
|
||||
expanded: !isOpen,
|
||||
});
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +48,7 @@ export function RecentlyViewedDashboards() {
|
||||
headerDataTestId="browseDashboardsRecentlyViewedTitle"
|
||||
label={
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" width="100%">
|
||||
<Text variant="h5" element="h3" onClick={handleSectionToggle}>
|
||||
<Text variant="h5" element="h3" onClick={() => setIsOpen(!isOpen)}>
|
||||
<Trans i18nKey="browse-dashboards.recently-viewed.title">Recently viewed</Trans>
|
||||
</Text>
|
||||
<Button icon="times" size="xs" variant="secondary" fill="text" onClick={handleClearHistory}>
|
||||
@@ -89,10 +80,9 @@ export function RecentlyViewedDashboards() {
|
||||
{!loading && recentDashboards.length > 0 && (
|
||||
<ul className={styles.list}>
|
||||
<Grid columns={{ xs: 1, sm: 2, md: 3, lg: 5 }} gap={2}>
|
||||
{recentDashboards.map((dash, idx) => (
|
||||
{recentDashboards.map((dash) => (
|
||||
<li key={dash.uid} className={styles.listItem}>
|
||||
<DashListItem
|
||||
order={idx + 1}
|
||||
key={dash.uid}
|
||||
dashboard={dash}
|
||||
url={dash.url}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { VariableHide } from '@grafana/data';
|
||||
import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { VariableControls } from './VariableControls';
|
||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const runtime = jest.requireActual('@grafana/runtime');
|
||||
return {
|
||||
...runtime,
|
||||
config: {
|
||||
...runtime.config,
|
||||
featureToggles: {
|
||||
dashboardNewLayouts: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('VariableControls', () => {
|
||||
it('should not render scopes variable', () => {
|
||||
const variables = [new ScopesVariable({})];
|
||||
const dashboard = buildScene(variables);
|
||||
dashboard.activate();
|
||||
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(screen.queryByText('__scopes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render regular hidden variables', () => {
|
||||
const hiddenVariable = new TextBoxVariable({
|
||||
name: 'HiddenVar',
|
||||
hide: VariableHide.hideVariable,
|
||||
});
|
||||
const variables = [hiddenVariable];
|
||||
const dashboard = buildScene(variables);
|
||||
dashboard.activate();
|
||||
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render regular hidden variables in edit mode', async () => {
|
||||
const hiddenVariable = new TextBoxVariable({
|
||||
name: 'HiddenVar',
|
||||
hide: VariableHide.hideVariable,
|
||||
});
|
||||
const variables = [hiddenVariable];
|
||||
const dashboard = buildScene(variables);
|
||||
dashboard.activate();
|
||||
|
||||
dashboard.setState({ isEditing: true });
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(await screen.findByText('HiddenVar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render variables hidden in controls menu in edit mode', async () => {
|
||||
const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]);
|
||||
dashboard.activate();
|
||||
|
||||
dashboard.setState({ isEditing: true });
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function buildScene(variables: SceneVariable[] = []) {
|
||||
const dashboard = new DashboardScene({
|
||||
$variables: new SceneVariableSet({ variables }),
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
children: [],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return dashboard;
|
||||
}
|
||||
@@ -39,9 +39,8 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
|
||||
: variables.filter(
|
||||
(v) =>
|
||||
// used for scopes variables, should always be hidden
|
||||
// if we're editing in dynamic dashboards, still shows hidden variable but greyed out
|
||||
(!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
|
||||
(isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
|
||||
v.state.hide !== VariableHide.inControlsMenu
|
||||
);
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
defaultDataQueryKind,
|
||||
defaultPanelSpec,
|
||||
PanelKind,
|
||||
PanelQueryKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
|
||||
import { ensureUniqueRefIds, getPanelDataSource, getRuntimePanelDataSource } from './utils';
|
||||
import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils';
|
||||
|
||||
describe('getRuntimePanelDataSource', () => {
|
||||
it('should return uid and type when explicit datasource UID is provided', () => {
|
||||
@@ -148,159 +141,6 @@ describe('getRuntimePanelDataSource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPanelDataSource', () => {
|
||||
const createPanelWithQueries = (queries: PanelQueryKind[]): PanelKind => ({
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
...defaultPanelSpec(),
|
||||
id: 1,
|
||||
title: 'Test Panel',
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
queryOptions: {},
|
||||
transformations: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createQuery = (datasourceName: string, group: string, refId = 'A'): PanelQueryKind => ({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId,
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group,
|
||||
datasource: {
|
||||
name: datasourceName,
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createQueryWithoutDatasourceName = (group: string, refId = 'A'): PanelQueryKind => ({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId,
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group,
|
||||
spec: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('should return undefined when panel has no queries', () => {
|
||||
const panel = createPanelWithQueries([]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a single query with specific datasource (not mixed)', () => {
|
||||
const panel = createPanelWithQueries([createQuery('prometheus-uid', 'prometheus')]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for multiple queries with the same datasource', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery('prometheus-uid', 'prometheus', 'A'),
|
||||
createQuery('prometheus-uid', 'prometheus', 'B'),
|
||||
createQuery('prometheus-uid', 'prometheus', 'C'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return mixed datasource when queries use different datasource UIDs', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery('prometheus-uid', 'prometheus', 'A'),
|
||||
createQuery('loki-uid', 'loki', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return mixed datasource when queries use different datasource types', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery('ds-uid', 'prometheus', 'A'),
|
||||
createQuery('ds-uid', 'loki', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return mixed datasource when multiple queries use Dashboard datasource', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'),
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'B'),
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'C'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return Dashboard datasource when single query uses Dashboard datasource', () => {
|
||||
const panel = createPanelWithQueries([createQuery(SHARED_DASHBOARD_QUERY, 'datasource')]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'datasource', uid: SHARED_DASHBOARD_QUERY });
|
||||
});
|
||||
|
||||
it('should return mixed when Dashboard datasource is mixed with other datasources', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'),
|
||||
createQuery('prometheus-uid', 'prometheus', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return undefined when queries have no explicit datasource name but same type', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQueryWithoutDatasourceName('prometheus', 'A'),
|
||||
createQueryWithoutDatasourceName('prometheus', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return mixed when queries have no explicit datasource name but different types', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQueryWithoutDatasourceName('prometheus', 'A'),
|
||||
createQueryWithoutDatasourceName('loki', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureUniqueRefIds', () => {
|
||||
const createQuery = (refId: string): PanelQueryKind => ({
|
||||
kind: 'PanelQuery',
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
DataQueryKind,
|
||||
defaultPanelQueryKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
||||
@@ -229,45 +228,29 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider
|
||||
* This ensures v2→Scene→v1 conversion produces the same output as the Go backend,
|
||||
* which does NOT add panel-level datasource for non-mixed panels.
|
||||
*/
|
||||
export function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
|
||||
const queries = panel.spec.data?.spec.queries;
|
||||
if (!queries?.length) {
|
||||
function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
|
||||
if (!panel.spec.data?.spec.queries?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if multiple queries use Dashboard datasource - this needs mixed mode
|
||||
const dashboardDsQueryCount = queries.filter((q) => q.spec.query.datasource?.name === SHARED_DASHBOARD_QUERY).length;
|
||||
if (dashboardDsQueryCount > 1) {
|
||||
return { type: 'mixed', uid: MIXED_DATASOURCE_NAME };
|
||||
}
|
||||
let firstDatasource: DataSourceRef | undefined = undefined;
|
||||
let isMixedDatasource = false;
|
||||
|
||||
// Get all datasources from queries
|
||||
const datasources = queries.map((query) =>
|
||||
query.spec.query.datasource?.name
|
||||
panel.spec.data.spec.queries.forEach((query) => {
|
||||
const queryDs = query.spec.query.datasource?.name
|
||||
? { uid: query.spec.query.datasource.name, type: query.spec.query.group }
|
||||
: getRuntimePanelDataSource(query.spec.query)
|
||||
);
|
||||
: getRuntimePanelDataSource(query.spec.query);
|
||||
|
||||
const firstDatasource = datasources[0];
|
||||
|
||||
// Check if queries use different datasources
|
||||
const isMixedDatasource = datasources.some(
|
||||
(ds) => ds?.uid !== firstDatasource?.uid || ds?.type !== firstDatasource?.type
|
||||
);
|
||||
|
||||
if (isMixedDatasource) {
|
||||
return { type: 'mixed', uid: MIXED_DATASOURCE_NAME };
|
||||
}
|
||||
|
||||
// Handle case where all queries use Dashboard datasource - needs to set datasource for proper data fetching
|
||||
// See DashboardDatasourceBehaviour.tsx for more details
|
||||
if (firstDatasource?.uid === SHARED_DASHBOARD_QUERY) {
|
||||
return { type: 'datasource', uid: SHARED_DASHBOARD_QUERY };
|
||||
}
|
||||
if (!firstDatasource) {
|
||||
firstDatasource = queryDs;
|
||||
} else if (firstDatasource.uid !== queryDs?.uid || firstDatasource.type !== queryDs?.type) {
|
||||
isMixedDatasource = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Only return mixed datasource - for non-mixed panels, each query already has its own datasource
|
||||
// This matches the Go backend behavior which doesn't add panel.datasource for non-mixed panels
|
||||
return undefined;
|
||||
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -816,17 +816,14 @@ export class DashboardMigrator {
|
||||
let yPos = 0;
|
||||
const widthFactor = GRID_COLUMN_COUNT / 12;
|
||||
|
||||
// Find max panel ID from both rows and existing top-level panels
|
||||
// Top-level panels may have been assigned IDs by ensurePanelsHaveUniqueIds
|
||||
const rowPanelIds = flattenDeep(
|
||||
map(old.rows, (row) => {
|
||||
return map(row.panels, 'id');
|
||||
})
|
||||
).filter((id) => id != null);
|
||||
|
||||
const topLevelPanelIds = map(this.dashboard.panels, 'id').filter((id) => id != null);
|
||||
|
||||
const maxPanelId = max([...rowPanelIds, ...topLevelPanelIds]) || 0;
|
||||
const maxPanelId =
|
||||
max(
|
||||
flattenDeep(
|
||||
map(old.rows, (row) => {
|
||||
return map(row.panels, 'id');
|
||||
})
|
||||
).filter((id) => id != null)
|
||||
) || 0;
|
||||
let nextRowId = maxPanelId + 1;
|
||||
|
||||
if (!old.rows) {
|
||||
|
||||
@@ -525,14 +525,6 @@ export class DashboardModel implements TimeModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Also check panels in legacy rows (pre-v16 dashboard format)
|
||||
// This ensures unique IDs are assigned before the row upgrade migration runs
|
||||
for (const panel of this.rawPanelIterator()) {
|
||||
if (panel.id > max) {
|
||||
max = panel.id;
|
||||
}
|
||||
}
|
||||
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
@@ -547,26 +539,6 @@ export class DashboardModel implements TimeModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over panels from the original raw dashboard data, including legacy rows.
|
||||
* This is needed to find panel IDs before row upgrade migration runs.
|
||||
*/
|
||||
private *rawPanelIterator() {
|
||||
// @ts-expect-error - rows is a legacy property not included in the modern Dashboard schema
|
||||
const rows = this.originalDashboard?.rows;
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
for (const row of rows) {
|
||||
const rowPanels = row?.panels;
|
||||
if (Array.isArray(rowPanels)) {
|
||||
for (const panel of rowPanels) {
|
||||
yield panel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forEachPanel(callback: (panel: PanelModel, index: number) => void) {
|
||||
for (let i = 0; i < this.panels.length; i++) {
|
||||
callback(this.panels[i], i);
|
||||
|
||||
@@ -149,6 +149,9 @@ const getDefaultVisualisationType = (): LogsVisualisationType => {
|
||||
if (visualisationType === 'logs') {
|
||||
return 'logs';
|
||||
}
|
||||
if (config.featureToggles.logsExploreTableDefaultVisualization) {
|
||||
return 'table';
|
||||
}
|
||||
return 'logs';
|
||||
};
|
||||
|
||||
@@ -444,6 +447,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
reportInteraction('grafana_explore_logs_visualisation_changed', {
|
||||
newVisualizationType: visualisation,
|
||||
datasourceType: props.datasourceType ?? 'unknown',
|
||||
defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs',
|
||||
});
|
||||
},
|
||||
[panelState?.logs, props.datasourceType, updatePanelState]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Box, Card, Icon, Link, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { LocationInfo } from 'app/features/search/service/types';
|
||||
import { StarToolbarButton } from 'app/features/stars/StarToolbarButton';
|
||||
@@ -12,26 +11,11 @@ interface Props {
|
||||
showFolderNames: boolean;
|
||||
locationInfo?: LocationInfo;
|
||||
layoutMode: 'list' | 'card';
|
||||
order?: number; // for rudderstack analytics to track position in card list
|
||||
onStarChange?: (id: string, isStarred: boolean) => void;
|
||||
}
|
||||
export function DashListItem({
|
||||
dashboard,
|
||||
url,
|
||||
showFolderNames,
|
||||
locationInfo,
|
||||
layoutMode,
|
||||
order,
|
||||
onStarChange,
|
||||
}: Props) {
|
||||
export function DashListItem({ dashboard, url, showFolderNames, locationInfo, layoutMode, onStarChange }: Props) {
|
||||
const css = useStyles2(getStyles);
|
||||
|
||||
const onCardLinkClick = () => {
|
||||
reportInteraction('grafana_recently_viewed_dashboards_click_card', {
|
||||
cardOrder: order,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{layoutMode === 'list' ? (
|
||||
@@ -55,9 +39,7 @@ export function DashListItem({
|
||||
) : (
|
||||
<Card className={css.dashlistCard} noMargin>
|
||||
<Stack justifyContent="space-between" alignItems="center">
|
||||
<Link href={url} onClick={onCardLinkClick}>
|
||||
{dashboard.name}
|
||||
</Link>
|
||||
<Link href={url}>{dashboard.name}</Link>
|
||||
<StarToolbarButton
|
||||
title={dashboard.name}
|
||||
group="dashboard.grafana.app"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user