SuggestedDashboards- Extend dashvalidator POC: Prometheus validator

This commit is contained in:
alexandra vargas
2025-12-31 17:06:49 +01:00
parent 9d980a9244
commit 8a0f2fa9f3
10 changed files with 923 additions and 11 deletions
+3
View File
@@ -6,6 +6,7 @@ require (
github.com/grafana/grafana v0.0.0-00010101000000-000000000000
github.com/grafana/grafana-app-sdk v0.48.7
github.com/grafana/grafana-app-sdk/logging v0.48.7
github.com/prometheus/prometheus v0.303.1
k8s.io/apimachinery v0.34.3
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e
)
@@ -38,6 +39,7 @@ require (
github.com/cloudflare/circl v1.6.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect
github.com/dolthub/go-icu-regex v0.0.0-20250916051405-78a38d478790 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -91,6 +93,7 @@ require (
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grafana/sqlds/v4 v4.2.7 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
+27
View File
@@ -1,6 +1,13 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0=
cuelang.org/go v0.11.1/go.mod h1:PBY6XvPUswPPJ2inpvUozP9mebDVTXaeehQikhZPBz0=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -84,6 +91,8 @@ github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM=
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -141,6 +150,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE=
github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc=
@@ -334,11 +345,17 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@@ -370,6 +387,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA=
github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
@@ -554,6 +573,8 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -627,6 +648,10 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/prometheus v0.303.1 h1:He/2jRE6sB23Ew38AIoR1WRR3fCMgPlJA2E0obD2WSY=
github.com/prometheus/prometheus v0.303.1/go.mod h1:WEq2ogBPZoLjj9x5K67VEk7ECR0nRD9XCjaOt1lsYck=
github.com/prometheus/sigv4 v0.1.2 h1:R7570f8AoM5YnTUPFm3mjZH5q2k4D+I/phCWvZ4PXG8=
github.com/prometheus/sigv4 v0.1.2/go.mod h1:GF9fwrvLgkQwDdQ5BXeV9XUSCH/IPNqzvAoaohfjqMU=
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
@@ -872,6 +897,8 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.3.2/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+184 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/grafana/grafana-app-sdk/app"
@@ -13,13 +14,61 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
validatorv1alpha1 "github.com/grafana/grafana/apps/dashvalidator/pkg/apis/dashvalidator/v1alpha1"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
_ "github.com/grafana/grafana/apps/dashvalidator/pkg/validator/prometheus" // Register prometheus validator via init()
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
)
type DashValidatorConfig struct {
DatasourceSvc datasources.DataSourceService
PluginCtx *plugincontext.Provider
DatasourceSvc datasources.DataSourceService
PluginCtx *plugincontext.Provider
HTTPClientProvider httpclient.Provider
}
// checkRequest matches the CUE schema for POST /check request
type checkRequest struct {
DashboardJSON map[string]interface{} `json:"dashboardJson"`
DatasourceMappings []datasourceMapping `json:"datasourceMappings"`
}
// datasourceMapping represents a datasource to validate against
type datasourceMapping struct {
UID string `json:"uid"`
Type string `json:"type"`
Name *string `json:"name,omitempty"`
}
// checkResponse matches the CUE schema for POST /check response
type checkResponse struct {
CompatibilityScore float64 `json:"compatibilityScore"`
DatasourceResults []datasourceResult `json:"datasourceResults"`
}
// datasourceResult contains validation results for a single datasource
type datasourceResult struct {
UID string `json:"uid"`
Type string `json:"type"`
Name *string `json:"name,omitempty"`
TotalQueries int `json:"totalQueries"`
CheckedQueries int `json:"checkedQueries"`
TotalMetrics int `json:"totalMetrics"`
FoundMetrics int `json:"foundMetrics"`
MissingMetrics []string `json:"missingMetrics"`
QueryBreakdown []queryResult `json:"queryBreakdown"`
CompatibilityScore float64 `json:"compatibilityScore"`
}
// queryResult contains validation results for a single query
type queryResult struct {
PanelTitle string `json:"panelTitle"`
PanelID int `json:"panelID"`
QueryRefID string `json:"queryRefId"`
TotalMetrics int `json:"totalMetrics"`
FoundMetrics int `json:"foundMetrics"`
MissingMetrics []string `json:"missingMetrics"`
CompatibilityScore float64 `json:"compatibilityScore"`
}
func New(cfg app.Config) (app.App, error) {
@@ -42,7 +91,7 @@ func New(cfg app.Config) (app.App, error) {
Namespaced: true,
Path: "check",
Method: "POST",
}: handleCheckRoute(log, specificConfig.DatasourceSvc, specificConfig.PluginCtx),
}: handleCheckRoute(log, specificConfig.DatasourceSvc, specificConfig.PluginCtx, specificConfig.HTTPClientProvider),
},
},
}
@@ -60,21 +109,149 @@ func handleCheckRoute(
log logging.Logger,
datasourceSvc datasources.DataSourceService,
pluginCtx *plugincontext.Provider,
httpClientProvider httpclient.Provider,
) func(context.Context, app.CustomRouteResponseWriter, *app.CustomRouteRequest) error {
return func(ctx context.Context, w app.CustomRouteResponseWriter, r *app.CustomRouteRequest) error {
logger := log.WithContext(ctx)
logger.Info("Received compatibility check request")
// TODO validation logic here
// Step 1: Parse request body
body, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("Failed to read request body", "error", err)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": "failed to read request body",
})
}
// for now a simple response
var req checkRequest
if err := json.Unmarshal(body, &req); err != nil {
logger.Error("Failed to parse request JSON", "error", err)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": "invalid JSON in request body",
})
}
// Step 2: Build validator request
validatorReq := validator.DashboardCompatibilityRequest{
DashboardJSON: req.DashboardJSON,
DatasourceMappings: make([]validator.DatasourceMapping, 0, len(req.DatasourceMappings)),
}
logger.Info("Processing request", "dashboardTitle", req.DashboardJSON["title"], "numMappings", len(req.DatasourceMappings))
// Get namespace from request (needed for datasource lookup)
namespace := r.ResourceIdentifier.Namespace
for _, dsMapping := range req.DatasourceMappings {
// Convert optional name pointer to string
name := ""
if dsMapping.Name != nil {
name = *dsMapping.Name
}
// Fetch datasource from Grafana using app-platform method
// Parameters: namespace, name (UID), group (datasource type)
ds, err := datasourceSvc.GetDataSourceInNamespace(ctx, namespace, dsMapping.UID, dsMapping.Type)
if err != nil {
logger.Error("Failed to get datasource", "namespace", namespace, "uid", dsMapping.UID, "type", dsMapping.Type, "error", err)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("datasource not found: %s", dsMapping.UID),
})
}
logger.Info("Retrieved datasource", "uid", ds.UID, "url", ds.URL, "type", ds.Type)
// Get authenticated HTTP transport for this datasource
transport, err := datasourceSvc.GetHTTPTransport(ctx, ds, httpClientProvider)
if err != nil {
logger.Error("Failed to get HTTP transport", "uid", ds.UID, "error", err)
w.WriteHeader(http.StatusInternalServerError)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("failed to configure datasource transport: %s", dsMapping.UID),
})
}
// Create HTTP client with authenticated transport
httpClient := &http.Client{
Transport: transport,
}
validatorReq.DatasourceMappings = append(validatorReq.DatasourceMappings, validator.DatasourceMapping{
UID: dsMapping.UID,
Type: dsMapping.Type,
Name: name,
URL: ds.URL,
HTTPClient: httpClient, // Pass authenticated client
})
}
// Step 3: Validate dashboard compatibility
result, err := validator.ValidateDashboardCompatibility(ctx, validatorReq)
if err != nil {
logger.Error("Validation failed", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("validation failed: %v", err),
})
}
// Step 4: Convert result to response format
response := convertToCheckResponse(result)
// Step 5: Return response
w.WriteHeader(http.StatusOK)
return json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Handler working!",
return json.NewEncoder(w).Encode(response)
}
}
// convertToCheckResponse converts validator result to API response format
func convertToCheckResponse(result *validator.DashboardCompatibilityResult) checkResponse {
response := checkResponse{
CompatibilityScore: result.CompatibilityScore,
DatasourceResults: make([]datasourceResult, 0, len(result.DatasourceResults)),
}
for _, dsResult := range result.DatasourceResults {
// Convert name string to pointer
var name *string
if dsResult.Name != "" {
name = &dsResult.Name
}
// Convert query results
queryBreakdown := make([]queryResult, 0, len(dsResult.QueryBreakdown))
for _, qr := range dsResult.QueryBreakdown {
queryBreakdown = append(queryBreakdown, queryResult{
PanelTitle: qr.PanelTitle,
PanelID: qr.PanelID,
QueryRefID: qr.QueryRefID,
TotalMetrics: qr.TotalMetrics,
FoundMetrics: qr.FoundMetrics,
MissingMetrics: qr.MissingMetrics,
CompatibilityScore: qr.CompatibilityScore,
})
}
response.DatasourceResults = append(response.DatasourceResults, datasourceResult{
UID: dsResult.UID,
Type: dsResult.Type,
Name: name,
TotalQueries: dsResult.TotalQueries,
CheckedQueries: dsResult.CheckedQueries,
TotalMetrics: dsResult.TotalMetrics,
FoundMetrics: dsResult.FoundMetrics,
MissingMetrics: dsResult.MissingMetrics,
QueryBreakdown: queryBreakdown,
CompatibilityScore: dsResult.CompatibilityScore,
})
}
return response
}
func GetKinds() map[schema.GroupVersion][]resource.Kind {
@@ -0,0 +1,354 @@
package validator
import (
"context"
"fmt"
"net/http"
)
// DashboardCompatibilityRequest contains the dashboard and datasource mappings to validate
type DashboardCompatibilityRequest struct {
DashboardJSON map[string]interface{} // Dashboard JSON structure
DatasourceMappings []DatasourceMapping // List of datasources to validate against
}
// DatasourceMapping maps a datasource UID to its type and optionally name/URL
type DatasourceMapping struct {
UID string // Datasource UID
Type string // Datasource type (prometheus, mysql, etc.)
Name string // Optional: Datasource name
URL string // Datasource URL
HTTPClient *http.Client // Authenticated HTTP client
}
// DashboardCompatibilityResult contains the validation results for a dashboard
type DashboardCompatibilityResult struct {
CompatibilityScore float64 // Overall compatibility (0.0 - 1.0)
DatasourceResults []DatasourceValidationResult // Per-datasource results
}
// DatasourceValidationResult contains validation results for one datasource
type DatasourceValidationResult struct {
UID string
Type string
Name string
TotalQueries int
CheckedQueries int
TotalMetrics int
FoundMetrics int
MissingMetrics []string
QueryBreakdown []QueryResult
CompatibilityScore float64
}
// ValidateDashboardCompatibility is the main entry point for validating dashboard compatibility
// It extracts queries from the dashboard, validates them against each datasource, and returns aggregated results
func ValidateDashboardCompatibility(ctx context.Context, req DashboardCompatibilityRequest) (*DashboardCompatibilityResult, error) {
result := &DashboardCompatibilityResult{
DatasourceResults: make([]DatasourceValidationResult, 0, len(req.DatasourceMappings)),
}
// Step 1: Extract queries from dashboard JSON
queries, err := extractQueriesFromDashboard(req.DashboardJSON)
if err != nil {
return nil, fmt.Errorf("failed to extract queries from dashboard: %w", err)
}
fmt.Printf("[DEBUG] Extracted %d queries from dashboard\n", len(queries))
for i, q := range queries {
fmt.Printf("[DEBUG] Query %d: DS=%s, RefID=%s, Query=%s\n", i, q.DatasourceUID, q.RefID, q.QueryText)
}
// Step 2: Group queries by datasource UID
queriesByDatasource := groupQueriesByDatasource(queries)
fmt.Printf("[DEBUG] Grouped queries by %d datasources\n", len(queriesByDatasource))
for dsUID, dsQueries := range queriesByDatasource {
fmt.Printf("[DEBUG] Datasource %s has %d queries\n", dsUID, len(dsQueries))
}
// Step 3: Validate each datasource
var totalCompatibility float64
validatedCount := 0
for _, dsMapping := range req.DatasourceMappings {
fmt.Printf("[DEBUG] Processing datasource mapping: UID=%s, Type=%s, URL=%s\n", dsMapping.UID, dsMapping.Type, dsMapping.URL)
// Get queries for this datasource
dsQueries, ok := queriesByDatasource[dsMapping.UID]
if !ok || len(dsQueries) == 0 {
// No queries for this datasource, skip
fmt.Printf("[DEBUG] No queries found for datasource %s, skipping\n", dsMapping.UID)
continue
}
fmt.Printf("[DEBUG] Found %d queries for datasource %s\n", len(dsQueries), dsMapping.UID)
// Get validator for this datasource type
v, err := GetValidator(dsMapping.Type)
if err != nil {
// Unsupported datasource type, skip but log
fmt.Printf("[DEBUG] Failed to get validator for type %s: %v\n", dsMapping.Type, err)
continue
}
fmt.Printf("[DEBUG] Got validator for type %s, starting validation\n", dsMapping.Type)
// Build Datasource struct
ds := Datasource{
UID: dsMapping.UID,
Type: dsMapping.Type,
Name: dsMapping.Name,
URL: dsMapping.URL,
HTTPClient: dsMapping.HTTPClient,
}
// Validate queries
validationResult, err := v.ValidateQueries(ctx, dsQueries, ds)
if err != nil {
// Validation failed for this datasource, skip but could log
continue
}
// Convert to DatasourceValidationResult
dsResult := DatasourceValidationResult{
UID: dsMapping.UID,
Type: dsMapping.Type,
Name: dsMapping.Name,
TotalQueries: validationResult.TotalQueries,
CheckedQueries: validationResult.CheckedQueries,
TotalMetrics: validationResult.TotalMetrics,
FoundMetrics: validationResult.FoundMetrics,
MissingMetrics: validationResult.MissingMetrics,
QueryBreakdown: validationResult.QueryBreakdown,
CompatibilityScore: validationResult.CompatibilityScore,
}
result.DatasourceResults = append(result.DatasourceResults, dsResult)
totalCompatibility += validationResult.CompatibilityScore
validatedCount++
}
// Step 4: Calculate overall compatibility score
if validatedCount > 0 {
result.CompatibilityScore = totalCompatibility / float64(validatedCount)
} else {
result.CompatibilityScore = 1.0 // No datasources = perfect compatibility
}
return result, nil
}
// extractQueriesFromDashboard parses the dashboard JSON and extracts all queries
// Supports both v1 (legacy) and v2 (new) dashboard formats
func extractQueriesFromDashboard(dashboardJSON map[string]interface{}) ([]DashboardQuery, error) {
var queries []DashboardQuery
// Debug: Print what keys we have
fmt.Printf("[DEBUG] Dashboard JSON keys: ")
for key := range dashboardJSON {
fmt.Printf("%s, ", key)
}
fmt.Printf("\n")
// Detect dashboard version (v1 uses "panels", v2 uses different structure)
// For MVP, we only support v1 (legacy format with panels array)
if !isV1Dashboard(dashboardJSON) {
fmt.Printf("[DEBUG] isV1Dashboard returned false, 'panels' key exists: %v\n", dashboardJSON["panels"] != nil)
return nil, fmt.Errorf("unsupported dashboard format: only v1 dashboards are supported in MVP")
}
// Extract panels array
panels, ok := dashboardJSON["panels"].([]interface{})
if !ok {
// No panels in dashboard, return empty array
return queries, nil
}
// Iterate through all panels
for _, panelInterface := range panels {
panel, ok := panelInterface.(map[string]interface{})
if !ok {
continue
}
// Extract queries from this panel
panelQueries := extractQueriesFromPanel(panel)
queries = append(queries, panelQueries...)
// Handle nested panels in collapsed rows
nestedPanels, hasNested := panel["panels"].([]interface{})
if hasNested {
for _, nestedPanelInterface := range nestedPanels {
nestedPanel, ok := nestedPanelInterface.(map[string]interface{})
if !ok {
continue
}
nestedQueries := extractQueriesFromPanel(nestedPanel)
queries = append(queries, nestedQueries...)
}
}
}
return queries, nil
}
// isV1Dashboard checks if a dashboard is in v1 (legacy) format
// v1 dashboards have a "panels" array at the top level
func isV1Dashboard(dashboard map[string]interface{}) bool {
_, hasPanels := dashboard["panels"]
return hasPanels
}
// extractQueriesFromPanel extracts all queries/targets from a single panel
func extractQueriesFromPanel(panel map[string]interface{}) []DashboardQuery {
var queries []DashboardQuery
// Get panel info for context
panelTitle := getStringValue(panel, "title", "Untitled Panel")
panelID := getIntValue(panel, "id", 0)
// Extract targets array (queries)
targets, hasTargets := panel["targets"].([]interface{})
if !hasTargets {
return queries
}
// Iterate through each target/query
for _, targetInterface := range targets {
target, ok := targetInterface.(map[string]interface{})
if !ok {
continue
}
// Extract datasource UID
datasourceUID := extractDatasourceUID(target, panel)
if datasourceUID == "" {
// Skip queries without datasource
continue
}
// Extract query text (different fields for different datasources)
queryText := extractQueryText(target)
if queryText == "" {
// Skip empty queries
continue
}
// Extract refId (A, B, C, etc.)
refID := getStringValue(target, "refId", "")
// Build DashboardQuery
query := DashboardQuery{
DatasourceUID: datasourceUID,
RefID: refID,
QueryText: queryText,
PanelTitle: panelTitle,
PanelID: panelID,
}
queries = append(queries, query)
}
return queries
}
// extractDatasourceUID gets the datasource UID from a target, falling back to panel datasource
func extractDatasourceUID(target map[string]interface{}, panel map[string]interface{}) string {
// Try target-level datasource first
if ds, ok := target["datasource"]; ok {
if uid := getDatasourceUIDFromValue(ds); uid != "" {
return uid
}
}
// Fall back to panel-level datasource
if ds, ok := panel["datasource"]; ok {
if uid := getDatasourceUIDFromValue(ds); uid != "" {
return uid
}
}
return ""
}
// getDatasourceUIDFromValue extracts UID from datasource value (can be string or object)
func getDatasourceUIDFromValue(ds interface{}) string {
switch v := ds.(type) {
case string:
// Direct UID string
return v
case map[string]interface{}:
// Structured datasource reference { uid: "...", type: "..." }
return getStringValue(v, "uid", "")
default:
return ""
}
}
// extractQueryText extracts the query text from a target
// Different datasources use different field names (expr, query, rawSql, etc.)
func extractQueryText(target map[string]interface{}) string {
// Try common query field names
queryFields := []string{"expr", "query", "rawSql", "rawQuery", "target", "measurement"}
for _, field := range queryFields {
if queryText := getStringValue(target, field, ""); queryText != "" {
return queryText
}
}
return ""
}
// getStringValue safely extracts a string value from a map
func getStringValue(m map[string]interface{}, key string, defaultValue string) string {
if value, ok := m[key]; ok {
if s, ok := value.(string); ok {
return s
}
}
return defaultValue
}
// getIntValue safely extracts an int value from a map
func getIntValue(m map[string]interface{}, key string, defaultValue int) int {
if value, ok := m[key]; ok {
switch v := value.(type) {
case int:
return v
case float64:
return int(v)
case int64:
return int(v)
}
}
return defaultValue
}
// DashboardQuery represents a query extracted from a dashboard panel
type DashboardQuery struct {
DatasourceUID string // Which datasource this query belongs to
RefID string // Query reference ID
QueryText string // The actual query
PanelTitle string // Panel title
PanelID int // Panel ID
}
// groupQueriesByDatasource groups dashboard queries by their datasource UID
func groupQueriesByDatasource(queries []DashboardQuery) map[string][]Query {
grouped := make(map[string][]Query)
for _, dq := range queries {
q := Query{
RefID: dq.RefID,
QueryText: dq.QueryText,
PanelTitle: dq.PanelTitle,
PanelID: dq.PanelID,
}
grouped[dq.DatasourceUID] = append(grouped[dq.DatasourceUID], q)
}
return grouped
}
@@ -0,0 +1,76 @@
package prometheus
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
// Fetcher fetches available metrics from a Prometheus datasource
type Fetcher struct{}
// NewFetcher creates a new Prometheus metrics fetcher
func NewFetcher() *Fetcher {
return &Fetcher{}
}
// prometheusResponse represents the Prometheus API response structure
type prometheusResponse struct {
Status string `json:"status"`
Data []string `json:"data"`
Error string `json:"error,omitempty"`
}
// FetchMetrics queries Prometheus to get all available metric names
// It uses the /api/v1/label/__name__/values endpoint
// The provided HTTP client should have proper authentication configured
func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceURL string, client *http.Client) ([]string, error) {
// Build the API URL
baseURL, err := url.Parse(datasourceURL)
if err != nil {
return nil, fmt.Errorf("invalid datasource URL: %w", err)
}
// Prometheus metrics endpoint
apiPath, err := url.Parse("/api/v1/label/__name__/values")
if err != nil {
return nil, fmt.Errorf("failed to parse API path: %w", err)
}
fullURL := baseURL.ResolveReference(apiPath)
// Create the request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Execute the request using the provided authenticated client
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch metrics from Prometheus: %w", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Prometheus API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse the response
var promResp prometheusResponse
if err := json.NewDecoder(resp.Body).Decode(&promResp); err != nil {
return nil, fmt.Errorf("failed to decode Prometheus response: %w", err)
}
// Check Prometheus API status
if promResp.Status != "success" {
return nil, fmt.Errorf("Prometheus API returned error: %s", promResp.Error)
}
return promResp.Data, nil
}
@@ -0,0 +1,49 @@
package prometheus
import (
"fmt"
"github.com/prometheus/prometheus/promql/parser"
)
// Parser extracts metric names from PromQL queries
type Parser struct{}
// NewParser creates a new PromQL parser
func NewParser() *Parser {
return &Parser{}
}
// ExtractMetrics parses a PromQL query and extracts all metric names
// For example: "rate(http_requests_total[5m])" returns ["http_requests_total"]
func (p *Parser) ExtractMetrics(query string) ([]string, error) {
// Parse the PromQL expression
expr, err := parser.ParseExpr(query)
if err != nil {
return nil, fmt.Errorf("failed to parse PromQL query: %w", err)
}
// Extract metric names by walking the AST
metrics := make(map[string]bool) // Use map to deduplicate
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
// VectorSelector represents a metric selector like "up" or "up{job="foo"}"
if vs, ok := node.(*parser.VectorSelector); ok {
metrics[vs.Name] = true
}
// MatrixSelector represents range queries like "up[5m]"
if ms, ok := node.(*parser.MatrixSelector); ok {
if vs, ok := ms.VectorSelector.(*parser.VectorSelector); ok {
metrics[vs.Name] = true
}
}
return nil
})
// Convert map to slice
result := make([]string, 0, len(metrics))
for metric := range metrics {
result = append(result, metric)
}
return result, nil
}
@@ -0,0 +1,149 @@
package prometheus
import (
"context"
"fmt"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
)
// Register Prometheus validator on package import
func init() {
validator.RegisterValidator("prometheus", func() validator.DatasourceValidator {
return NewValidator()
})
}
// Validator implements validator.DatasourceValidator for Prometheus datasources
type Validator struct {
parser *Parser
fetcher *Fetcher
}
// NewValidator creates a new Prometheus validator
func NewValidator() validator.DatasourceValidator {
return &Validator{
parser: NewParser(),
fetcher: NewFetcher(),
}
}
// ValidateQueries validates Prometheus queries against the datasource
func (v *Validator) ValidateQueries(ctx context.Context, queries []validator.Query, datasource validator.Datasource) (*validator.ValidationResult, error) {
fmt.Printf("[DEBUG PROM] Starting validation for %d queries against datasource %s\n", len(queries), datasource.URL)
result := &validator.ValidationResult{
TotalQueries: len(queries),
QueryBreakdown: make([]validator.QueryResult, 0, len(queries)),
}
// Step 1: Parse all queries to extract metrics
allMetrics := make(map[string]bool) // Use map to deduplicate
queryMetrics := make(map[int][]string)
for i, query := range queries {
fmt.Printf("[DEBUG PROM] Parsing query %d: %s\n", i, query.QueryText)
metrics, err := v.parser.ExtractMetrics(query.QueryText)
if err != nil {
// If we can't parse the query, we still continue with others
// but we don't count this query as "checked"
fmt.Printf("[DEBUG PROM] Failed to parse query %d: %v\n", i, err)
continue
}
fmt.Printf("[DEBUG PROM] Extracted %d metrics from query %d: %v\n", len(metrics), i, metrics)
result.CheckedQueries++
queryMetrics[i] = metrics
// Add to global metrics set
for _, metric := range metrics {
allMetrics[metric] = true
}
}
// Convert map to slice for fetcher
metricsToCheck := make([]string, 0, len(allMetrics))
for metric := range allMetrics {
metricsToCheck = append(metricsToCheck, metric)
}
result.TotalMetrics = len(metricsToCheck)
fmt.Printf("[DEBUG PROM] Total metrics to check: %d - %v\n", len(metricsToCheck), metricsToCheck)
// Step 2: Fetch available metrics from Prometheus
fmt.Printf("[DEBUG PROM] Fetching available metrics from %s\n", datasource.URL)
availableMetrics, err := v.fetcher.FetchMetrics(ctx, datasource.URL, datasource.HTTPClient)
if err != nil {
fmt.Printf("[DEBUG PROM] Failed to fetch metrics: %v\n", err)
return nil, fmt.Errorf("failed to fetch metrics from Prometheus: %w", err)
}
fmt.Printf("[DEBUG PROM] Fetched %d available metrics from Prometheus\n", len(availableMetrics))
// Build a set for O(1) lookup
availableSet := make(map[string]bool)
for _, metric := range availableMetrics {
availableSet[metric] = true
}
// Step 3: Calculate compatibility
missingMetricsMap := make(map[string]bool)
for _, metric := range metricsToCheck {
if !availableSet[metric] {
missingMetricsMap[metric] = true
}
}
result.FoundMetrics = result.TotalMetrics - len(missingMetricsMap)
// Convert missing metrics map to slice
result.MissingMetrics = make([]string, 0, len(missingMetricsMap))
for metric := range missingMetricsMap {
result.MissingMetrics = append(result.MissingMetrics, metric)
}
// Step 4: Build per-query breakdown
for i, query := range queries {
metrics, ok := queryMetrics[i]
if !ok {
// Query wasn't parsed successfully, skip
continue
}
queryResult := validator.QueryResult{
PanelTitle: query.PanelTitle,
PanelID: query.PanelID,
QueryRefID: query.RefID,
TotalMetrics: len(metrics),
}
// Check which metrics from this query are missing
queryMissing := make([]string, 0)
for _, metric := range metrics {
if missingMetricsMap[metric] {
queryMissing = append(queryMissing, metric)
}
}
queryResult.MissingMetrics = queryMissing
queryResult.FoundMetrics = queryResult.TotalMetrics - len(queryMissing)
// Calculate query-level compatibility score
if queryResult.TotalMetrics > 0 {
queryResult.CompatibilityScore = float64(queryResult.FoundMetrics) / float64(queryResult.TotalMetrics)
} else {
queryResult.CompatibilityScore = 1.0 // No metrics = perfect compatibility
}
result.QueryBreakdown = append(result.QueryBreakdown, queryResult)
}
// Step 5: Calculate overall compatibility score
if result.TotalMetrics > 0 {
result.CompatibilityScore = float64(result.FoundMetrics) / float64(result.TotalMetrics)
} else {
result.CompatibilityScore = 1.0 // No metrics = perfect compatibility
}
fmt.Printf("[DEBUG PROM] Validation complete! Score: %.2f, Found: %d/%d metrics\n",
result.CompatibilityScore, result.FoundMetrics, result.TotalMetrics)
return result, nil
}
+74
View File
@@ -0,0 +1,74 @@
package validator
import (
"context"
"fmt"
"net/http"
)
// DatasourceValidator validates dashboard queries against a datasource
// Implementations exist per datasource type (Prometheus, MySQL, etc.)
type DatasourceValidator interface {
// ValidateQueries checks if queries are compatible with the datasource
ValidateQueries(ctx context.Context, queries []Query, datasource Datasource) (*ValidationResult, error)
}
// Query represents a dashboard query to validate
type Query struct {
RefID string // Query reference ID (A, B, C, etc.)
QueryText string // The actual query text (PromQL, SQL, etc.)
PanelTitle string // Panel title for user-friendly reporting
PanelID int // Panel ID for reference
}
// Datasource contains connection information for a datasource
type Datasource struct {
UID string // Datasource UID from dashboard
Type string // Datasource type (prometheus, mysql, etc.)
Name string // Datasource name for reporting
URL string // Datasource URL for API calls
HTTPClient *http.Client // Authenticated HTTP client for making requests
}
// ValidationResult contains validation results for a datasource
type ValidationResult struct {
TotalQueries int // Total number of queries found
CheckedQueries int // Number of queries successfully checked
TotalMetrics int // Total metrics/entities referenced
FoundMetrics int // Metrics found in datasource
MissingMetrics []string // List of missing metrics
QueryBreakdown []QueryResult // Per-query results
CompatibilityScore float64 // Overall compatibility (0.0 - 1.0)
}
// QueryResult contains validation results for a single query
type QueryResult struct {
PanelTitle string // Panel title
PanelID int // Panel ID
QueryRefID string // Query reference ID
TotalMetrics int // Metrics in this query
FoundMetrics int // Metrics found
MissingMetrics []string // Missing metrics for this query
CompatibilityScore float64 // Query compatibility (0.0 - 1.0)
}
// validatorRegistry holds registered validator constructors
// Validators register themselves using RegisterValidator in their init() functions
var validatorRegistry = make(map[string]func() DatasourceValidator)
// RegisterValidator registers a validator constructor for a datasource type
// This is called by validator implementations in their init() functions
// Example: validator.RegisterValidator("prometheus", func() validator.DatasourceValidator { return NewValidator() })
func RegisterValidator(dsType string, constructor func() DatasourceValidator) {
validatorRegistry[dsType] = constructor
}
// GetValidator returns a validator for the given datasource type
// Returns an error if the datasource type is not supported
func GetValidator(dsType string) (DatasourceValidator, error) {
constructor, ok := validatorRegistry[dsType]
if !ok {
return nil, fmt.Errorf("unsupported datasource type: %s", dsType)
}
return constructor(), nil
}
+5 -2
View File
@@ -9,6 +9,7 @@ import (
validatorapis "github.com/grafana/grafana/apps/dashvalidator/pkg/apis/manifestdata"
validatorapp "github.com/grafana/grafana/apps/dashvalidator/pkg/app"
"github.com/grafana/grafana/pkg/infra/httpclient"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
@@ -24,11 +25,13 @@ type DashValidatorAppInstaller struct {
func RegisterAppInstaller(
datasourceSvc datasources.DataSourceService,
pluginCtx *plugincontext.Provider,
httpClientProvider httpclient.Provider,
) (*DashValidatorAppInstaller, error) {
// Create specific config for the app
specificConfig := &validatorapp.DashValidatorConfig{
DatasourceSvc: datasourceSvc,
PluginCtx: pluginCtx,
DatasourceSvc: datasourceSvc,
PluginCtx: pluginCtx,
HTTPClientProvider: httpClientProvider,
}
// Create the app provider
+2 -2
View File
@@ -832,7 +832,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, plugincontextProvider)
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, plugincontextProvider, httpclientProvider)
if err != nil {
return nil, err
}
@@ -1498,7 +1498,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, plugincontextProvider)
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, plugincontextProvider, httpclientProvider)
if err != nil {
return nil, err
}