Merge branch 'main' of https://github.com/grafana/grafana into kristina/rtk-corr
This commit is contained in:
@@ -4,7 +4,7 @@ go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/emicklei/go-restful/v3 v3.13.0
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.283.0
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.284.0
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250514132646-acbc7b54ed9e
|
||||
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
k8s.io/component-base v0.34.2
|
||||
k8s.io/klog/v2 v2.130.1
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -101,7 +101,7 @@ require (
|
||||
go.etcd.io/etcd/api/v3 v3.6.4 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.6.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
@@ -130,9 +130,9 @@ require (
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
|
||||
+12
-12
@@ -101,8 +101,8 @@ 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250514132646-acbc7b54ed9e h1:vheR6iPO1np+G/ARjcWx9yiWd7BnTDgyTgsnMhOvx70=
|
||||
@@ -269,8 +269,8 @@ go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU=
|
||||
go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg=
|
||||
go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ=
|
||||
go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8=
|
||||
@@ -382,13 +382,13 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -426,7 +426,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -200,6 +202,11 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
|
||||
|
||||
if err != nil {
|
||||
@@ -231,6 +238,11 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
|
||||
if uid == "" {
|
||||
@@ -361,6 +373,11 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
|
||||
// 409: conflictError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
cmd := datasources.AddDataSourceCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
@@ -478,6 +495,10 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
|
||||
// 409: conflictError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
cmd := datasources.UpdateDataSourceCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
@@ -81,6 +83,19 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
|
||||
}, mockSQLStore)
|
||||
}
|
||||
|
||||
// setupDsConfigMetrics creates and registers the prometheus metrics needed for HTTPServer tests
|
||||
// that call methods using dsConfigHandlerRequestsDuration.
|
||||
func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.HistogramVec) {
|
||||
promRegister := prometheus.NewRegistry()
|
||||
dsConfigHandlerRequestsDuration := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"})
|
||||
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
||||
return promRegister, dsConfigHandlerRequestsDuration
|
||||
}
|
||||
|
||||
// Adding data sources with invalid URLs should lead to an error.
|
||||
func TestAddDataSource_InvalidURL(t *testing.T) {
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
@@ -88,6 +103,7 @@ func TestAddDataSource_InvalidURL(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{
|
||||
@@ -118,6 +134,7 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) {
|
||||
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
|
||||
@@ -143,6 +160,7 @@ func TestAddDataSource_InvalidJSONData(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
|
||||
@@ -175,6 +193,7 @@ func TestUpdateDataSource_InvalidURL(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
@@ -199,6 +218,7 @@ func TestUpdateDataSource_InvalidJSONData(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
hs.Cfg.AuthProxy.Enabled = true
|
||||
@@ -236,6 +256,7 @@ func TestAddDataSourceTeamHTTPHeaders(t *testing.T) {
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
sc := setupScenarioContext(t, fmt.Sprintf("/api/datasources/%s", tenantID))
|
||||
hs.Cfg.AuthProxy.Enabled = true
|
||||
|
||||
@@ -289,6 +310,7 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) {
|
||||
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
@@ -429,6 +451,7 @@ func TestAPI_datasources_AccessControl(t *testing.T) {
|
||||
hs.DataSourcesService = &dataSourcesServiceMock{expectedDatasource: &datasources.DataSource{}}
|
||||
hs.accesscontrolService = actest.FakeService{}
|
||||
hs.Live = newTestLive(t, hs.SQLStore)
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
})
|
||||
|
||||
for _, url := range tt.urls {
|
||||
|
||||
+28
-21
@@ -203,27 +203,28 @@ type HTTPServer struct {
|
||||
pluginsCDNService *pluginscdn.Service
|
||||
managedPluginsService managedplugins.Manager
|
||||
|
||||
userService user.Service
|
||||
tempUserService tempUser.Service
|
||||
loginAttemptService loginAttempt.Service
|
||||
orgService org.Service
|
||||
orgDeletionService org.DeletionService
|
||||
TeamService team.Service
|
||||
accesscontrolService accesscontrol.Service
|
||||
annotationsRepo annotations.Repository
|
||||
tagService tag.Service
|
||||
oauthTokenService oauthtoken.OAuthTokenService
|
||||
statsService stats.Service
|
||||
authnService authn.Service
|
||||
starApi *starApi.API
|
||||
promRegister prometheus.Registerer
|
||||
promGatherer prometheus.Gatherer
|
||||
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
|
||||
namespacer request.NamespaceMapper
|
||||
anonService anonymous.Service
|
||||
userVerifier user.Verifier
|
||||
tlsCerts TLSCerts
|
||||
htmlHandlerRequestsDuration *prometheus.HistogramVec
|
||||
userService user.Service
|
||||
tempUserService tempUser.Service
|
||||
loginAttemptService loginAttempt.Service
|
||||
orgService org.Service
|
||||
orgDeletionService org.DeletionService
|
||||
TeamService team.Service
|
||||
accesscontrolService accesscontrol.Service
|
||||
annotationsRepo annotations.Repository
|
||||
tagService tag.Service
|
||||
oauthTokenService oauthtoken.OAuthTokenService
|
||||
statsService stats.Service
|
||||
authnService authn.Service
|
||||
starApi *starApi.API
|
||||
promRegister prometheus.Registerer
|
||||
promGatherer prometheus.Gatherer
|
||||
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
|
||||
namespacer request.NamespaceMapper
|
||||
anonService anonymous.Service
|
||||
userVerifier user.Verifier
|
||||
tlsCerts TLSCerts
|
||||
htmlHandlerRequestsDuration *prometheus.HistogramVec
|
||||
dsConfigHandlerRequestsDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
type TLSCerts struct {
|
||||
@@ -382,9 +383,15 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
Name: "html_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by the index.go HTML handler",
|
||||
}, []string{"handler"}),
|
||||
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"}),
|
||||
}
|
||||
|
||||
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
||||
promRegister.MustRegister(hs.dsConfigHandlerRequestsDuration)
|
||||
|
||||
if hs.Listener != nil {
|
||||
hs.log.Debug("Using provided listener")
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.2
|
||||
github.com/go-jose/go-jose/v4 v4.1.3
|
||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -34,27 +34,25 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
|
||||
)
|
||||
|
||||
+10
-12
@@ -6,8 +6,8 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -77,8 +77,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
@@ -96,8 +96,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -130,10 +128,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -157,7 +155,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.25.3
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.1
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.3
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
k8s.io/component-base v0.34.2
|
||||
k8s.io/klog/v2 v2.130.1
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,7 +31,7 @@ require (
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
@@ -71,7 +71,7 @@ require (
|
||||
go.etcd.io/etcd/api/v3 v3.6.4 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.6.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
@@ -92,9 +92,9 @@ require (
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
|
||||
+14
-14
@@ -25,8 +25,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -71,8 +71,8 @@ github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGr
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
|
||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
|
||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
|
||||
@@ -170,8 +170,8 @@ go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU=
|
||||
go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg=
|
||||
go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ=
|
||||
go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
@@ -260,13 +260,13 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -303,7 +303,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ require (
|
||||
golang.org/x/net v0.47.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources
|
||||
golang.org/x/sync v0.18.0 // @grafana/alerting-backend
|
||||
golang.org/x/text v0.31.0 // indirect; @grafana/grafana-backend-group
|
||||
google.golang.org/grpc v1.76.0 // indirect; @grafana/plugins-platform-backend
|
||||
google.golang.org/grpc v1.77.0 // indirect; @grafana/plugins-platform-backend
|
||||
google.golang.org/protobuf v1.36.10 // indirect; @grafana/plugins-platform-backend
|
||||
)
|
||||
|
||||
@@ -30,8 +30,8 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -51,7 +51,7 @@ require (
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
|
||||
|
||||
+8
-8
@@ -59,8 +59,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8=
|
||||
@@ -105,12 +105,12 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -187,7 +187,7 @@ func fieldValFromRowVal(fieldType data.FieldType, val interface{}) (interface{},
|
||||
case data.FieldTypeBool, data.FieldTypeNullableBool:
|
||||
return parseBoolFromInt8(val, nullable)
|
||||
|
||||
case data.FieldTypeJSON, data.FieldTypeNullableJSON:
|
||||
case data.FieldTypeJSON, data.FieldTypeNullableJSON: //nolint:staticcheck
|
||||
switch v := val.(type) {
|
||||
case types.JSONDocument:
|
||||
raw := json.RawMessage(v.String())
|
||||
|
||||
@@ -182,7 +182,7 @@ func convertDataType(fieldType data.FieldType) mysql.Type {
|
||||
return types.Boolean
|
||||
case data.FieldTypeTime, data.FieldTypeNullableTime:
|
||||
return types.Timestamp
|
||||
case data.FieldTypeJSON, data.FieldTypeNullableJSON:
|
||||
case data.FieldTypeJSON, data.FieldTypeNullableJSON: //nolint:staticcheck
|
||||
return types.JSON
|
||||
default:
|
||||
fmt.Printf("------- Unsupported field type: %v", fieldType)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -21,6 +22,13 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
)
|
||||
|
||||
var openfeatureClient = openfeature.NewDefaultClient()
|
||||
|
||||
const (
|
||||
pluginPageFeatureFlagPrefix = "plugin-page-visible."
|
||||
)
|
||||
|
||||
type AuthOptions struct {
|
||||
@@ -146,6 +154,12 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log
|
||||
return
|
||||
}
|
||||
|
||||
if !PageIsFeatureToggleEnabled(c.Req.Context(), c.Req.URL.Path) {
|
||||
logger.Debug("Forbidden experimental plugin page", "plugin", pluginID, "path", c.Req.URL.Path)
|
||||
accessForbidden(c)
|
||||
return
|
||||
}
|
||||
|
||||
permitted := true
|
||||
path := normalizeIncludePath(c.Req.URL.Path)
|
||||
hasAccess := ac.HasAccess(accessControl, c)
|
||||
@@ -294,3 +308,18 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool {
|
||||
|
||||
return forceLogin
|
||||
}
|
||||
|
||||
// PageIsFeatureToggleEnabled checks if a page is enabled via OpenFeature feature flags.
|
||||
// It returns false if the feature flag is set and set to false.
|
||||
// The feature flag key format is: "plugin-page-visible.<path>"
|
||||
func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool {
|
||||
flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path)
|
||||
enabled := openfeatureClient.Boolean(
|
||||
ctx,
|
||||
flagKey,
|
||||
true,
|
||||
openfeature.TransactionContext(ctx),
|
||||
)
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
oftesting "github.com/open-feature/go-sdk/openfeature/testing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -28,6 +33,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var openfeatureTestMutex sync.Mutex
|
||||
|
||||
func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler {
|
||||
return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{
|
||||
ExpectedErr: authErr,
|
||||
@@ -422,6 +429,60 @@ func TestCanAdminPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIsFeatureToggleEnabled(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
path string
|
||||
flags map[string]bool
|
||||
expectedResult bool
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "returns true when feature flag is enabled",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": true,
|
||||
},
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
desc: "returns false when feature flag is disabled",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false,
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
desc: "returns false when feature flag is disabled with trailing slash",
|
||||
path: "/a/my-plugin/settings/",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false,
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
desc: "returns true when feature flag does not exist",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{},
|
||||
expectedResult: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
setupTestProvider(t, tt.flags)
|
||||
|
||||
result := PageIsFeatureToggleEnabled(ctx, tt.path)
|
||||
|
||||
assert.Equal(t, tt.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler {
|
||||
return func(c *web.Context) {
|
||||
reqCtx := &contextmodel.ReqContext{
|
||||
@@ -437,3 +498,38 @@ func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler
|
||||
c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), reqCtx))
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestProvider creates a test OpenFeature provider with the given flags.
|
||||
// Uses a global lock to prevent concurrent provider changes across tests.
|
||||
func setupTestProvider(t *testing.T, flags map[string]bool) oftesting.TestProvider {
|
||||
t.Helper()
|
||||
|
||||
// Lock to prevent concurrent provider changes
|
||||
openfeatureTestMutex.Lock()
|
||||
|
||||
testProvider := oftesting.NewTestProvider()
|
||||
flagsMap := map[string]memprovider.InMemoryFlag{}
|
||||
|
||||
for key, value := range flags {
|
||||
flagsMap[key] = memprovider.InMemoryFlag{
|
||||
DefaultVariant: "defaultVariant",
|
||||
Variants: map[string]any{
|
||||
"defaultVariant": value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
testProvider.UsingFlags(t, flagsMap)
|
||||
|
||||
err := openfeature.SetProviderAndWait(testProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testProvider.Cleanup()
|
||||
_ = openfeature.SetProviderAndWait(openfeature.NoopProvider{})
|
||||
// Unlock after cleanup to allow other tests to run
|
||||
openfeatureTestMutex.Unlock()
|
||||
})
|
||||
|
||||
return testProvider
|
||||
}
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.283.0
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.284.0
|
||||
github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -86,7 +86,7 @@ require (
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect
|
||||
@@ -111,9 +111,9 @@ require (
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.242.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/client-go v0.34.2 // indirect
|
||||
@@ -122,5 +122,5 @@ require (
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
|
||||
)
|
||||
|
||||
+14
-14
@@ -3,8 +3,8 @@ 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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 h1:wL5IEG5zb7BVv1Kv0Xm92orq+5hB5Nipn3B5tn4Rqfk=
|
||||
@@ -130,8 +130,8 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
|
||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E=
|
||||
github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134 h1:xly75v5lFNR37q+wXnwA5yU/fPW9IOSYbhFpt4tQyt8=
|
||||
github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134/go.mod h1:zijsUNa1zi476JJIR2Lcm/Paz1nRCno9XCp6hbS6G9o=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a h1:L7xgV9mP6MRF3L2/vDOjNR7heaBPbXPMGTDN9/jXSFQ=
|
||||
@@ -278,8 +278,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
|
||||
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8=
|
||||
@@ -378,12 +378,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
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/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -410,7 +410,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) {
|
||||
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
|
||||
return []schema.GroupVersion{
|
||||
dashv2beta1.DashboardResourceInfo.GroupVersion(),
|
||||
|
||||
@@ -136,9 +136,9 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "permission",
|
||||
In: "query",
|
||||
Description: "permission needed for the resource (View, Edit, Admin)",
|
||||
Description: "permission needed for the resource (view, edit, admin)",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty().WithEnum("View", "Edit", "Admin"),
|
||||
Schema: spec.StringProperty().WithEnum("view", "edit", "admin"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,9 @@ package datasource
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,8 +29,9 @@ var (
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
datasources PluginDatasourceProvider
|
||||
resourceInfo *utils.ResourceInfo
|
||||
datasources PluginDatasourceProvider
|
||||
resourceInfo *utils.ResourceInfo
|
||||
dsConfigHandlerRequestsDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
@@ -57,11 +61,21 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
return s.datasources.GetDataSource(ctx, name)
|
||||
}
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
ds, ok := obj.(*v0alpha1.DataSource)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected a datasource object")
|
||||
@@ -71,6 +85,11 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
||||
|
||||
// Update implements rest.Updater.
|
||||
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
old, err := s.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -107,6 +126,11 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
|
||||
|
||||
// Delete implements rest.GracefulDeleter.
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
err := s.datasources.DeleteDataSource(ctx, name)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
@@ -218,6 +219,11 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
legacyStore := &legacyStorage{
|
||||
datasources: b.datasources,
|
||||
resourceInfo: &ds,
|
||||
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"}),
|
||||
}
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter)
|
||||
if err != nil {
|
||||
|
||||
@@ -219,7 +219,9 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
}
|
||||
|
||||
storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store)
|
||||
storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler
|
||||
if b.teamGroupsHandler != nil {
|
||||
storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler
|
||||
}
|
||||
|
||||
teamBindingResource := iamv0.TeamBindingResourceInfo
|
||||
teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter)
|
||||
@@ -344,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana")
|
||||
roleBindingStore.AfterCreate = b.AfterRoleBindingCreate
|
||||
roleBindingStore.AfterDelete = b.AfterRoleBindingDelete
|
||||
roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate
|
||||
}
|
||||
storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
|
||||
@@ -2,7 +2,6 @@ package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -14,8 +13,6 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyName = errors.New("name cannot be empty")
|
||||
|
||||
defaultWriteTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
)
|
||||
|
||||
const resourceType = "rolebinding"
|
||||
|
||||
// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rb, ok := obj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
operation := "create"
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
// This limits the amount of concurrent connections to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(rb *iamv0.RoleBinding) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
// Release the ticket after write is done
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
b.logger.Debug("writing role binding to zanzana",
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
|
||||
for _, roleRef := range rb.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: string(rb.Spec.Subject.Kind),
|
||||
SubjectName: rb.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(operations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: rb.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to write role binding to zanzana",
|
||||
"err", err,
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
}
|
||||
}(rb.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rb, ok := obj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
operation := "delete"
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
// This limits the amount of concurrent connections to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(rb *iamv0.RoleBinding) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
// Release the ticket after write is done
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
b.logger.Debug("deleting role binding from zanzana",
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
|
||||
for _, roleRef := range rb.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: string(rb.Spec.Subject.Kind),
|
||||
SubjectName: rb.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(operations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: rb.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to delete role binding from zanzana",
|
||||
"err", err,
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
}
|
||||
}(rb.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates.
|
||||
// It performs the zanzana write after K8s update succeeds.
|
||||
func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
||||
if b.zClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extract role bindings from both old and new objects
|
||||
oldRB, ok := oldObj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newRB, ok := obj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) {
|
||||
return nil, nil // No changes to the role binding
|
||||
}
|
||||
|
||||
if newRB.Spec.Subject.Name == "" {
|
||||
b.logger.Error("invalid role binding",
|
||||
"namespace", newRB.Namespace,
|
||||
"name", newRB.Name,
|
||||
"subject", newRB.Spec.Subject.Name,
|
||||
"roleRefs", newRB.Spec.RoleRefs,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return a finish function that performs the zanzana write only on success
|
||||
return func(ctx context.Context, success bool) {
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
b.logger.Debug("updating role binding in zanzana",
|
||||
"namespace", newRB.Namespace,
|
||||
"name", newRB.Name,
|
||||
"oldSubject", oldRB.Spec.Subject.Name,
|
||||
"newSubject", newRB.Spec.Subject.Name,
|
||||
"oldRoleRefs", oldRB.Spec.RoleRefs,
|
||||
"newRoleRefs", newRB.Spec.RoleRefs,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs))
|
||||
for _, roleRef := range oldRB.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: string(oldRB.Spec.Subject.Kind),
|
||||
SubjectName: oldRB.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, roleRef := range newRB.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: string(newRB.Spec.Subject.Kind),
|
||||
SubjectName: newRB.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Only make the request if there are deletes or writes
|
||||
if len(operations) == 0 {
|
||||
b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name)
|
||||
return
|
||||
}
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: newRB.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to update role binding in zanzana",
|
||||
"err", err,
|
||||
"namespace", newRB.Namespace,
|
||||
"name", newRB.Name,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool {
|
||||
if len(oldRoleRefs) != len(newRoleRefs) {
|
||||
return false
|
||||
}
|
||||
|
||||
oldRoleRefsMap := make(map[string]string)
|
||||
for _, roleRef := range oldRoleRefs {
|
||||
oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind)
|
||||
}
|
||||
for _, roleRef := range newRoleRefs {
|
||||
refKind, ok := oldRoleRefsMap[roleRef.Name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if refKind != string(roleRef.Kind) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
)
|
||||
|
||||
func TestAfterRoleBindingCreate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should create zanzana entry for role binding", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
expectedOperation := &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
|
||||
expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
|
||||
|
||||
require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind)
|
||||
require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName)
|
||||
require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind)
|
||||
require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding}
|
||||
b.AfterRoleBindingCreate(&roleBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-3",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-3",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should not panic or error when zClient is nil
|
||||
builder.AfterRoleBindingCreate(&roleBinding, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginRoleBindingUpdate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should update zanzana entry when role binding changed", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-foo",
|
||||
},
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 3)
|
||||
|
||||
// Should write new binding and delete old one
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-foo",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-bar",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate}
|
||||
|
||||
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
|
||||
oldBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when bindings are identical")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange}
|
||||
|
||||
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when bindings are identical
|
||||
|
||||
// Verify write was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
|
||||
oldBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-8",
|
||||
Namespace: "org-8",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-8",
|
||||
Namespace: "org-8",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "",
|
||||
Name: "", // Empty name - should cause early return
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when new binding has empty subject name")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding}
|
||||
|
||||
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
|
||||
|
||||
// Verify write was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterRoleBindingDelete(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// Should have deletes but no writes
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-1",
|
||||
},
|
||||
},
|
||||
}))
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-2",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete}
|
||||
b.AfterRoleBindingDelete(&roleBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-3",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-3",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should not panic or error when zClient is nil
|
||||
builder.AfterRoleBindingDelete(&roleBinding, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool {
|
||||
return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool {
|
||||
switch operation.Operation.(type) {
|
||||
case *v1.MutateOperation_DeleteRoleBinding:
|
||||
deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding)
|
||||
deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind &&
|
||||
deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName &&
|
||||
deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind &&
|
||||
deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName
|
||||
case *v1.MutateOperation_CreateRoleBinding:
|
||||
createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding)
|
||||
createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind &&
|
||||
createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName &&
|
||||
createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind &&
|
||||
createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -10,42 +10,8 @@ import (
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
)
|
||||
|
||||
// convertTeamBindingToTuple converts a TeamBinding to a v1 TupleKey format
|
||||
// TeamBinding represents a user's membership in a team with a specific permission level
|
||||
func convertTeamBindingToTuple(tb *iamv0.TeamBinding) (*v1.TupleKey, error) {
|
||||
if tb.Spec.Subject.Name == "" {
|
||||
return nil, errEmptyName
|
||||
}
|
||||
|
||||
if tb.Spec.TeamRef.Name == "" {
|
||||
return nil, errEmptyName
|
||||
}
|
||||
|
||||
// Map permission to relation
|
||||
var relation string
|
||||
switch tb.Spec.Permission {
|
||||
case iamv0.TeamBindingTeamPermissionAdmin:
|
||||
relation = zanzana.RelationTeamAdmin
|
||||
case iamv0.TeamBindingTeamPermissionMember:
|
||||
relation = zanzana.RelationTeamMember
|
||||
default:
|
||||
// Default to member if unknown permission
|
||||
relation = zanzana.RelationTeamMember
|
||||
}
|
||||
|
||||
// Create tuple: user:{subjectUID} has {relation} relation to team:{teamUID}
|
||||
tuple := &v1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeUser, tb.Spec.Subject.Name, ""),
|
||||
Relation: relation,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeTeam, tb.Spec.TeamRef.Name, ""),
|
||||
}
|
||||
|
||||
return tuple, nil
|
||||
}
|
||||
|
||||
// AfterTeamBindingCreate is a post-create hook that writes the team binding to Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
@@ -79,20 +45,6 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.
|
||||
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
|
||||
}()
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert team binding to tuple",
|
||||
"namespace", tb.Namespace,
|
||||
"name", tb.Name,
|
||||
"subject", tb.Spec.Subject.Name,
|
||||
"teamRef", tb.Spec.TeamRef.Name,
|
||||
"permission", tb.Spec.Permission,
|
||||
"err", err,
|
||||
)
|
||||
status = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("writing team binding to zanzana",
|
||||
"namespace", tb.Namespace,
|
||||
"name", tb.Name,
|
||||
@@ -104,12 +56,21 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: tb.Namespace,
|
||||
Writes: &v1.WriteRequestWrites{
|
||||
TupleKeys: []*v1.TupleKey{tuple},
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: tb.Spec.Subject.Name,
|
||||
TeamName: tb.Spec.TeamRef.Name,
|
||||
Permission: string(tb.Spec.Permission),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to write team binding to zanzana",
|
||||
@@ -159,34 +120,28 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Convert old team binding to tuple for deletion
|
||||
var oldTuple *v1.TupleKey
|
||||
var oldErr error
|
||||
if oldTB.Spec.Subject.Name != "" && oldTB.Spec.TeamRef.Name != "" {
|
||||
oldTuple, oldErr = convertTeamBindingToTuple(oldTB)
|
||||
if oldErr != nil {
|
||||
b.logger.Error("failed to convert old team binding to tuple",
|
||||
"namespace", oldTB.Namespace,
|
||||
"name", oldTB.Name,
|
||||
"err", oldErr,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convert new team binding to tuple for writing
|
||||
var newTuple *v1.TupleKey
|
||||
var newErr error
|
||||
if newTB.Spec.Subject.Name != "" && newTB.Spec.TeamRef.Name != "" {
|
||||
newTuple, newErr = convertTeamBindingToTuple(newTB)
|
||||
if newErr != nil {
|
||||
b.logger.Error("failed to convert new team binding to tuple",
|
||||
"namespace", newTB.Namespace,
|
||||
"name", newTB.Name,
|
||||
"err", newErr,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
operations := make([]*v1.MutateOperation, 0, 2)
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: oldTB.Spec.Subject.Name,
|
||||
TeamName: oldTB.Spec.TeamRef.Name,
|
||||
Permission: string(oldTB.Spec.Permission),
|
||||
},
|
||||
},
|
||||
})
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: newTB.Spec.Subject.Name,
|
||||
TeamName: newTB.Spec.TeamRef.Name,
|
||||
Permission: string(newTB.Spec.Permission),
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(operations) == 0 {
|
||||
b.logger.Debug("no updates to team binding in zanzana", "namespace", newTB.Namespace, "name", newTB.Name)
|
||||
return func(ctx context.Context, success bool) {}, nil
|
||||
}
|
||||
|
||||
// Return a finish function that performs the zanzana write only on success
|
||||
@@ -224,57 +179,22 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Prepare write request
|
||||
req := &v1.WriteRequest{
|
||||
Namespace: newTB.Namespace,
|
||||
}
|
||||
|
||||
// Add delete for old tuple
|
||||
if oldTuple != nil && oldErr == nil {
|
||||
deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{oldTuple})
|
||||
req.Deletes = &v1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuple,
|
||||
}
|
||||
b.logger.Debug("deleting existing team binding from zanzana",
|
||||
"namespace", newTB.Namespace,
|
||||
"subject", oldTB.Spec.Subject.Name,
|
||||
"teamRef", oldTB.Spec.TeamRef.Name,
|
||||
)
|
||||
}
|
||||
|
||||
// Add write for new tuple
|
||||
if newTuple != nil && newErr == nil {
|
||||
req.Writes = &v1.WriteRequestWrites{
|
||||
TupleKeys: []*v1.TupleKey{newTuple},
|
||||
}
|
||||
b.logger.Debug("writing new team binding to zanzana",
|
||||
"namespace", newTB.Namespace,
|
||||
"subject", newTB.Spec.Subject.Name,
|
||||
"teamRef", newTB.Spec.TeamRef.Name,
|
||||
)
|
||||
}
|
||||
|
||||
// Only make the request if there are deletes or writes
|
||||
if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) {
|
||||
err := b.zClient.Write(ctx, req)
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to update team binding in zanzana",
|
||||
"err", err,
|
||||
"namespace", newTB.Namespace,
|
||||
"name", newTB.Name,
|
||||
)
|
||||
} else {
|
||||
// Record successful tuple operations
|
||||
if oldTuple != nil && oldErr == nil {
|
||||
hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc()
|
||||
}
|
||||
if newTuple != nil && newErr == nil {
|
||||
hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc()
|
||||
}
|
||||
}
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: newTB.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to update team binding in zanzana",
|
||||
"err", err,
|
||||
"namespace", newTB.Namespace,
|
||||
"name", newTB.Name,
|
||||
)
|
||||
} else {
|
||||
b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name)
|
||||
// Record successful tuple operations
|
||||
hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc()
|
||||
hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc()
|
||||
}
|
||||
}()
|
||||
}, nil
|
||||
@@ -313,22 +233,6 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime.
|
||||
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
|
||||
}()
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert team binding to tuple for deletion",
|
||||
"namespace", tb.Namespace,
|
||||
"name", tb.Name,
|
||||
"subject", tb.Spec.Subject.Name,
|
||||
"teamRef", tb.Spec.TeamRef.Name,
|
||||
"err", err,
|
||||
)
|
||||
status = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
// Convert tuple to TupleKeyWithoutCondition for deletion
|
||||
deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{tuple})
|
||||
|
||||
b.logger.Debug("deleting team binding from zanzana",
|
||||
"namespace", tb.Namespace,
|
||||
"name", tb.Name,
|
||||
@@ -340,10 +244,18 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: tb.Namespace,
|
||||
Deletes: &v1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuple,
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: tb.Spec.Subject.Name,
|
||||
TeamName: tb.Spec.TeamRef.Name,
|
||||
Permission: string(tb.Spec.Permission),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -2,16 +2,18 @@ package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAfterTeamBindingCreate(t *testing.T) {
|
||||
@@ -40,30 +42,29 @@ func TestAfterTeamBindingCreate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testMemberBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testMemberBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
require.Nil(t, req.Deletes)
|
||||
|
||||
expectedTuple := &v1.TupleKey{
|
||||
User: "user:user-1",
|
||||
Relation: "member",
|
||||
Object: "team:team-1",
|
||||
expectedOperation := &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
actualTuple := req.Writes.TupleKeys[0]
|
||||
require.Equal(t, expectedTuple.User, actualTuple.User)
|
||||
require.Equal(t, expectedTuple.Relation, actualTuple.Relation)
|
||||
require.Equal(t, expectedTuple.Object, actualTuple.Object)
|
||||
require.Nil(t, actualTuple.Condition)
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testMemberBinding}
|
||||
b.AfterTeamBindingCreate(&teamBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
@@ -87,30 +88,29 @@ func TestAfterTeamBindingCreate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testAdminBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testAdminBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
require.Nil(t, req.Deletes)
|
||||
|
||||
expectedTuple := &v1.TupleKey{
|
||||
User: "user:user-2",
|
||||
Relation: "admin",
|
||||
Object: "team:team-2",
|
||||
expectedOperation := &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "user-2",
|
||||
TeamName: "team-2",
|
||||
Permission: "admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
actualTuple := req.Writes.TupleKeys[0]
|
||||
require.Equal(t, expectedTuple.User, actualTuple.User)
|
||||
require.Equal(t, expectedTuple.Relation, actualTuple.Relation)
|
||||
require.Equal(t, expectedTuple.Object, actualTuple.Object)
|
||||
require.Nil(t, actualTuple.Condition)
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testAdminBinding}
|
||||
b.AfterTeamBindingCreate(&teamBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
@@ -141,40 +141,6 @@ func TestAfterTeamBindingCreate(t *testing.T) {
|
||||
// Should not panic or error when zClient is nil
|
||||
builder.AfterTeamBindingCreate(&teamBinding, nil)
|
||||
})
|
||||
|
||||
t.Run("should handle conversion error gracefully", func(t *testing.T) {
|
||||
// TeamBinding with empty subject name should fail conversion
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-4",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "", // Empty name should cause error
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-4",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
writeCalled = true
|
||||
// Should not be called due to conversion error
|
||||
require.Fail(t, "Write should not be called when conversion fails")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling}
|
||||
b.AfterTeamBindingCreate(&teamBinding, nil)
|
||||
// Wait a bit to ensure the goroutine has time to process
|
||||
// The goroutine will complete but won't call the write callback
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when conversion fails")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
@@ -218,33 +184,37 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testPermissionUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testPermissionUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// Should delete old member permission
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Deletes.TupleKeys[0],
|
||||
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"},
|
||||
)
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Should write new admin permission
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Writes.TupleKeys[0],
|
||||
&v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-1"},
|
||||
)
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-1",
|
||||
Permission: "admin",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testPermissionUpdate}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -288,33 +258,36 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testUserUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testUserUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// Should delete old user binding
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Deletes.TupleKeys[0],
|
||||
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"},
|
||||
)
|
||||
|
||||
// Should write new user binding
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Writes.TupleKeys[0],
|
||||
&v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"},
|
||||
)
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "user-2",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testUserUpdate}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -358,33 +331,35 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testTeamUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testTeamUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// Should delete old team binding
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Deletes.TupleKeys[0],
|
||||
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "admin", Object: "team:team-1"},
|
||||
)
|
||||
|
||||
// Should write new team binding
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Writes.TupleKeys[0],
|
||||
&v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-2"},
|
||||
)
|
||||
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-1",
|
||||
Permission: "admin",
|
||||
},
|
||||
},
|
||||
}))
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-2",
|
||||
Permission: "admin",
|
||||
},
|
||||
},
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testTeamUpdate}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -427,13 +402,13 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testNoMutateOnFailure := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
// Should not be called when success=false
|
||||
require.Fail(t, "Write should not be called when update fails")
|
||||
require.Fail(t, "Mutate should not be called when update fails")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnFailure}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -441,7 +416,7 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
|
||||
// Call finish function with success=false
|
||||
finishFunc(context.Background(), false)
|
||||
// No wait needed since write should not be called
|
||||
// No wait needed since mutate should not be called
|
||||
})
|
||||
|
||||
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
|
||||
@@ -497,7 +472,7 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "", // Empty name - conversion will be skipped
|
||||
Name: "", // Empty name will cause server-side error on delete
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
@@ -522,27 +497,42 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testEmptyOldBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-6", req.Namespace)
|
||||
require.NotNil(t, req.Operations)
|
||||
|
||||
// Should not delete old binding (it was skipped due to empty name)
|
||||
require.Nil(t, req.Deletes)
|
||||
// Should have both delete and create operations
|
||||
// The delete will have empty subject and fail server-side validation
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// Should write new binding
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.Equal(
|
||||
t,
|
||||
req.Writes.TupleKeys[0],
|
||||
&v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"},
|
||||
)
|
||||
// First operation is delete with empty subject
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Second operation is create with valid data
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "user-2",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testEmptyOldBinding}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -585,22 +575,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnNoChange := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when bindings are identical")
|
||||
mutateCalled := false
|
||||
testNoMutateOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
mutateCalled = true
|
||||
require.Fail(t, "Mutate should not be called when bindings are identical")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnNoChange}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when bindings are identical
|
||||
|
||||
// Verify write was never called
|
||||
// Verify mutate was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
|
||||
require.False(t, mutateCalled, "Mutate callback should not be called when bindings are identical")
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
|
||||
@@ -636,22 +626,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when new binding has empty subject name")
|
||||
mutateCalled := false
|
||||
testNoMutateOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
mutateCalled = true
|
||||
require.Fail(t, "Mutate should not be called when new binding has empty subject name")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnInvalidBinding}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
|
||||
|
||||
// Verify write was never called
|
||||
// Verify mutate was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
|
||||
require.False(t, mutateCalled, "Mutate callback should not be called when new binding has empty subject name")
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when new binding has empty team ref name", func(t *testing.T) {
|
||||
@@ -687,22 +677,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when new binding has empty team ref name")
|
||||
mutateCalled := false
|
||||
testNoMutateOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
mutateCalled = true
|
||||
require.Fail(t, "Mutate should not be called when new binding has empty team ref name")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnInvalidBinding}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when new binding has empty team ref name
|
||||
|
||||
// Verify write was never called
|
||||
// Verify mutate was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name")
|
||||
require.False(t, mutateCalled, "Mutate callback should not be called when new binding has empty team ref name")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -732,26 +722,28 @@ func TestAfterTeamBindingDelete(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testMemberDelete := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testMemberDelete := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
// Should have deletes but no writes
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
require.Nil(t, req.Writes)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
req.Deletes.TupleKeys[0],
|
||||
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"},
|
||||
)
|
||||
expectedOperation := &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "user-1",
|
||||
TeamName: "team-1",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation))
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testMemberDelete}
|
||||
b.AfterTeamBindingDelete(&teamBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
@@ -775,26 +767,28 @@ func TestAfterTeamBindingDelete(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
testAdminDelete := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
// Should have deletes but no writes
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
require.Nil(t, req.Writes)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
req.Deletes.TupleKeys[0],
|
||||
&v1.TupleKeyWithoutCondition{User: "user:user-2", Relation: "admin", Object: "team:team-2"},
|
||||
)
|
||||
expectedOperation := &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "user-2",
|
||||
TeamName: "team-2",
|
||||
Permission: "admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation))
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testAdminDelete}
|
||||
b.AfterTeamBindingDelete(&teamBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
@@ -826,8 +820,9 @@ func TestAfterTeamBindingDelete(t *testing.T) {
|
||||
builder.AfterTeamBindingDelete(&teamBinding, nil)
|
||||
})
|
||||
|
||||
t.Run("should handle conversion error gracefully", func(t *testing.T) {
|
||||
// TeamBinding with empty team ref name should fail conversion
|
||||
t.Run("should handle empty team name gracefully", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
// TeamBinding with empty team ref name will be sent to server which will return error
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-4",
|
||||
@@ -838,129 +833,60 @@ func TestAfterTeamBindingDelete(t *testing.T) {
|
||||
Name: "user-4",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "", // Empty name should cause error
|
||||
Name: "", // Empty name will cause server-side error
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
writeCalled = true
|
||||
// Should not be called due to conversion error
|
||||
require.Fail(t, "Write should not be called when conversion fails")
|
||||
testErrorHandling := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
require.Equal(t, "org-4", req.Namespace)
|
||||
|
||||
// Operation will have empty team name, which would fail server-side validation
|
||||
require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "user-4",
|
||||
TeamName: "",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling}
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testErrorHandling}
|
||||
b.AfterTeamBindingDelete(&teamBinding, nil)
|
||||
// Wait a bit to ensure the goroutine has time to process
|
||||
// The goroutine will complete but won't call the write callback
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when conversion fails")
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertTeamBindingToTuple(t *testing.T) {
|
||||
t.Run("should convert member permission correctly", func(t *testing.T) {
|
||||
tb := &iamv0.TeamBinding{
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
func containsTeamBindingOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool {
|
||||
return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool {
|
||||
switch operation.Operation.(type) {
|
||||
case *v1.MutateOperation_DeleteTeamBinding:
|
||||
deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteTeamBinding)
|
||||
deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteTeamBinding)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return deleteO.DeleteTeamBinding.SubjectName == deleteOperation.DeleteTeamBinding.SubjectName &&
|
||||
deleteO.DeleteTeamBinding.TeamName == deleteOperation.DeleteTeamBinding.TeamName &&
|
||||
deleteO.DeleteTeamBinding.Permission == deleteOperation.DeleteTeamBinding.Permission
|
||||
case *v1.MutateOperation_CreateTeamBinding:
|
||||
createOperation := operation.Operation.(*v1.MutateOperation_CreateTeamBinding)
|
||||
createO, ok := o.Operation.(*v1.MutateOperation_CreateTeamBinding)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return createO.CreateTeamBinding.SubjectName == createOperation.CreateTeamBinding.SubjectName &&
|
||||
createO.CreateTeamBinding.TeamName == createOperation.CreateTeamBinding.TeamName &&
|
||||
createO.CreateTeamBinding.Permission == createOperation.CreateTeamBinding.Permission
|
||||
}
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tuple)
|
||||
require.Equal(t, "user:user-1", tuple.User)
|
||||
require.Equal(t, "member", tuple.Relation)
|
||||
require.Equal(t, "team:team-1", tuple.Object)
|
||||
require.Nil(t, tuple.Condition)
|
||||
})
|
||||
|
||||
t.Run("should convert admin permission correctly", func(t *testing.T) {
|
||||
tb := &iamv0.TeamBinding{
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-2",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tuple)
|
||||
require.Equal(t, "user:user-2", tuple.User)
|
||||
require.Equal(t, "admin", tuple.Relation)
|
||||
require.Equal(t, "team:team-2", tuple.Object)
|
||||
require.Nil(t, tuple.Condition)
|
||||
})
|
||||
|
||||
t.Run("should return error for empty subject name", func(t *testing.T) {
|
||||
tb := &iamv0.TeamBinding{
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, tuple)
|
||||
require.Equal(t, errEmptyName, err)
|
||||
})
|
||||
|
||||
t.Run("should return error for empty team ref name", func(t *testing.T) {
|
||||
tb := &iamv0.TeamBinding{
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, tuple)
|
||||
require.Equal(t, errEmptyName, err)
|
||||
})
|
||||
|
||||
t.Run("should default to member for unknown permission", func(t *testing.T) {
|
||||
tb := &iamv0.TeamBinding{
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: "unknown", // Invalid permission
|
||||
},
|
||||
}
|
||||
|
||||
tuple, err := convertTeamBindingToTuple(tb)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tuple)
|
||||
// Should default to member relation
|
||||
require.Equal(t, "member", tuple.Relation)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile stri
|
||||
}
|
||||
|
||||
func RegisterAPIService(apiregistration builder.APIRegistrar, cfg *setting.Cfg) (*APIBuilder, error) {
|
||||
if !cfg.OpenFeature.APIEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var staticEvaluator featuremgmt.StaticFlagEvaluator // No static evaluator needed for non-static provider
|
||||
var err error
|
||||
if cfg.OpenFeature.ProviderType == setting.StaticProviderType {
|
||||
|
||||
@@ -172,6 +172,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
for i, val := range all {
|
||||
branch := val.Branch()
|
||||
url := val.URL()
|
||||
path := val.Path()
|
||||
|
||||
settings.Items[i] = provisioning.RepositoryView{
|
||||
Name: val.Name,
|
||||
@@ -179,6 +181,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
Type: val.Spec.Type,
|
||||
Target: val.Spec.Sync.Target,
|
||||
Branch: branch,
|
||||
URL: url,
|
||||
Path: path,
|
||||
Workflows: val.Spec.Workflows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// The maximum size of a secure value in bytes when written as raw input.
|
||||
const SecureValueRawInputMaxSizeBytes = 24576 // 24 KiB
|
||||
const SecureValueRawInputMaxSizeBytes = 24 << 10 // 24 KiB
|
||||
|
||||
type DecryptSecureValue struct {
|
||||
Keeper *string
|
||||
@@ -47,7 +47,6 @@ type SecureValueService interface {
|
||||
List(ctx context.Context, namespace xkube.Namespace) (*secretv1beta1.SecureValueList, error)
|
||||
Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error)
|
||||
Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error)
|
||||
SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, keeperName string) error
|
||||
}
|
||||
|
||||
type SecureValueClient interface {
|
||||
|
||||
@@ -27,7 +27,6 @@ var _ contracts.SecureValueService = (*SecureValueService)(nil)
|
||||
type SecureValueService struct {
|
||||
tracer trace.Tracer
|
||||
accessClient claims.AccessClient
|
||||
database contracts.Database
|
||||
secureValueMetadataStorage contracts.SecureValueMetadataStorage
|
||||
secureValueValidator contracts.SecureValueValidator
|
||||
secureValueMutator contracts.SecureValueMutator
|
||||
@@ -39,7 +38,6 @@ type SecureValueService struct {
|
||||
func ProvideSecureValueService(
|
||||
tracer trace.Tracer,
|
||||
accessClient claims.AccessClient,
|
||||
database contracts.Database,
|
||||
secureValueMetadataStorage contracts.SecureValueMetadataStorage,
|
||||
secureValueValidator contracts.SecureValueValidator,
|
||||
secureValueMutator contracts.SecureValueMutator,
|
||||
@@ -50,7 +48,6 @@ func ProvideSecureValueService(
|
||||
return &SecureValueService{
|
||||
tracer: tracer,
|
||||
accessClient: accessClient,
|
||||
database: database,
|
||||
secureValueMetadataStorage: secureValueMetadataStorage,
|
||||
secureValueValidator: secureValueValidator,
|
||||
secureValueMutator: secureValueMutator,
|
||||
@@ -369,10 +366,3 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error {
|
||||
if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil {
|
||||
return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut {
|
||||
secureValueValidator := validator.ProvideSecureValueValidator()
|
||||
secureValueMutator := mutator.ProvideSecureValueMutator()
|
||||
|
||||
secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil)
|
||||
secureValueService := service.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil)
|
||||
|
||||
decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package historian
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
type Historian interface {
|
||||
Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error)
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
historian Historian
|
||||
}
|
||||
|
||||
func (h handlers) GetAlertStateHistoryHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return &apierrors.StatusError{
|
||||
ErrStatus: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "authentication required",
|
||||
}}
|
||||
}
|
||||
|
||||
query, err := api.ParseHistoryQuery(user.GetOrgID(), user, request.URL.Query())
|
||||
if err != nil {
|
||||
return &apierrors.StatusError{
|
||||
ErrStatus: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusBadRequest,
|
||||
Message: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
frame, err := h.historian.Query(ctx, query)
|
||||
if err != nil {
|
||||
return &apierrors.StatusError{
|
||||
ErrStatus: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
return json.NewEncoder(writer).Encode(frame)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package historian
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
type mockHistorian struct {
|
||||
queryFunc func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error)
|
||||
}
|
||||
|
||||
func (m *mockHistorian) Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
if m.queryFunc != nil {
|
||||
return m.queryFunc(ctx, query)
|
||||
}
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
type mockResponseWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
func newMockResponseWriter() *mockResponseWriter {
|
||||
return &mockResponseWriter{
|
||||
ResponseRecorder: httptest.NewRecorder(),
|
||||
headers: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockResponseWriter) Header() http.Header {
|
||||
return m.headers
|
||||
}
|
||||
|
||||
func TestGetAlertStateHistoryHandler(t *testing.T) {
|
||||
t.Run("returns data frame when query succeeds", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
testFrame := data.NewFrame("test",
|
||||
data.NewField("Time", nil, []time.Time{now, now.Add(time.Second)}),
|
||||
data.NewField("Line", nil, []string{"alert fired", "alert resolved"}),
|
||||
)
|
||||
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
assert.Equal(t, int64(123), query.OrgID)
|
||||
assert.NotNil(t, query.SignedInUser)
|
||||
return testFrame, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{
|
||||
OrgID: 123,
|
||||
})
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: ""},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, writer.Code)
|
||||
assert.Equal(t, "application/json", writer.headers.Get("Content-Type"))
|
||||
|
||||
var result *data.Frame
|
||||
err = json.Unmarshal(writer.Body.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", result.Name)
|
||||
assert.Equal(t, 2, result.Rows())
|
||||
})
|
||||
|
||||
t.Run("passes query parameters to historian", func(t *testing.T) {
|
||||
testFrame := data.NewFrame("test",
|
||||
data.NewField("Time", nil, []time.Time{time.Now()}),
|
||||
data.NewField("Line", nil, []string{"test"}),
|
||||
)
|
||||
|
||||
var capturedQuery models.HistoryQuery
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
capturedQuery = query
|
||||
return testFrame, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 99})
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("ruleUID", "rule-123")
|
||||
params.Set("dashboardUID", "dash-456")
|
||||
params.Set("panelID", "7")
|
||||
params.Set("from", "1000")
|
||||
params.Set("to", "2000")
|
||||
params.Set("limit", "50")
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: params.Encode()},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "rule-123", capturedQuery.RuleUID)
|
||||
assert.Equal(t, "dash-456", capturedQuery.DashboardUID)
|
||||
assert.Equal(t, int64(7), capturedQuery.PanelID)
|
||||
assert.Equal(t, time.Unix(1000, 0), capturedQuery.From)
|
||||
assert.Equal(t, time.Unix(2000, 0), capturedQuery.To)
|
||||
assert.Equal(t, 50, capturedQuery.Limit)
|
||||
})
|
||||
|
||||
t.Run("handles label matchers in query", func(t *testing.T) {
|
||||
testFrame := data.NewFrame("test",
|
||||
data.NewField("Time", nil, []time.Time{time.Now()}),
|
||||
data.NewField("Line", nil, []string{"test"}),
|
||||
)
|
||||
|
||||
var capturedQuery models.HistoryQuery
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
capturedQuery = query
|
||||
return testFrame, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("labels", `env=prod`)
|
||||
params.Add("labels", `region=us-west`)
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: params.Encode()},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
if len(capturedQuery.Labels) > 0 {
|
||||
assert.NotEmpty(t, capturedQuery.Labels)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns unauthorized when no user in context", func(t *testing.T) {
|
||||
h := handlers{historian: &mockHistorian{}}
|
||||
ctx := context.Background()
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: ""},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "authentication required")
|
||||
})
|
||||
|
||||
t.Run("returns internal error when historian query fails", func(t *testing.T) {
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
return nil, errors.New("database connection failed")
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: ""},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "database connection failed")
|
||||
})
|
||||
|
||||
t.Run("returns empty frame when no results", func(t *testing.T) {
|
||||
emptyFrame := data.NewFrame("empty")
|
||||
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
return emptyFrame, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: ""},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, writer.Code)
|
||||
|
||||
var result *data.Frame
|
||||
err = json.Unmarshal(writer.Body.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Rows())
|
||||
})
|
||||
|
||||
t.Run("encodes complex data frame with multiple fields", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
complexFrame := data.NewFrame("complex",
|
||||
data.NewField("Time", nil, []time.Time{now}),
|
||||
data.NewField("Line", nil, []string{"alert fired"}),
|
||||
data.NewField("Value", nil, []float64{42.5}),
|
||||
data.NewField("Labels", nil, []string{`{"env":"prod"}`}),
|
||||
)
|
||||
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
return complexFrame, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: ""},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, writer.Code)
|
||||
|
||||
var result *data.Frame
|
||||
err = json.Unmarshal(writer.Body.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, len(result.Fields))
|
||||
assert.Equal(t, 1, result.Rows())
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseHistoryQueryIntegration(t *testing.T) {
|
||||
t.Run("parses all supported query parameters", func(t *testing.T) {
|
||||
testFrame := data.NewFrame("test",
|
||||
data.NewField("Time", nil, []time.Time{time.Now()}),
|
||||
data.NewField("Line", nil, []string{"test"}),
|
||||
)
|
||||
|
||||
var capturedQuery models.HistoryQuery
|
||||
mock := &mockHistorian{
|
||||
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
||||
capturedQuery = query
|
||||
return testFrame, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := handlers{historian: mock}
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 5})
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("ruleUID", "test-rule")
|
||||
params.Set("dashboardUID", "test-dash")
|
||||
params.Set("panelID", "3")
|
||||
params.Set("from", "1609459200")
|
||||
params.Set("to", "1609545600")
|
||||
params.Set("limit", "100")
|
||||
params.Set("current", "alerting")
|
||||
params.Set("previous", "normal")
|
||||
|
||||
writer := newMockResponseWriter()
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: &url.URL{RawQuery: params.Encode()},
|
||||
}
|
||||
|
||||
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), capturedQuery.OrgID)
|
||||
assert.Equal(t, "test-rule", capturedQuery.RuleUID)
|
||||
assert.Equal(t, "test-dash", capturedQuery.DashboardUID)
|
||||
assert.Equal(t, int64(3), capturedQuery.PanelID)
|
||||
assert.Equal(t, time.Unix(1609459200, 0), capturedQuery.From)
|
||||
assert.Equal(t, time.Unix(1609545600, 0), capturedQuery.To)
|
||||
assert.Equal(t, 100, capturedQuery.Limit)
|
||||
assert.Equal(t, "alerting", capturedQuery.Current)
|
||||
assert.Equal(t, "normal", capturedQuery.Previous)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package historian
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis"
|
||||
historianApp "github.com/grafana/grafana/apps/alerting/historian/pkg/app"
|
||||
historianAppConfig "github.com/grafana/grafana/apps/alerting/historian/pkg/app/config"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var (
|
||||
_ appsdkapiserver.AppInstaller = (*AlertingHistorianAppInstaller)(nil)
|
||||
)
|
||||
|
||||
type AlertingHistorianAppInstaller struct {
|
||||
appsdkapiserver.AppInstaller
|
||||
}
|
||||
|
||||
func RegisterAppInstaller(
|
||||
cfg *setting.Cfg,
|
||||
ng *ngalert.AlertNG,
|
||||
) (*AlertingHistorianAppInstaller, error) {
|
||||
if ng.IsDisabled() {
|
||||
log.New("app-registry").Info("Skipping Kubernetes Alerting Historian apiserver (historian.alerting.grafana.app): Unified Alerting is disabled")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
installer := &AlertingHistorianAppInstaller{}
|
||||
|
||||
handlers := &handlers{
|
||||
historian: ng.Api.Historian,
|
||||
}
|
||||
|
||||
appSpecificConfig := historianAppConfig.RuntimeConfig{
|
||||
GetAlertStateHistoryHandler: handlers.GetAlertStateHistoryHandler,
|
||||
}
|
||||
|
||||
provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, historianApp.New)
|
||||
|
||||
appConfig := app.Config{
|
||||
KubeConfig: restclient.Config{},
|
||||
ManifestData: *apis.LocalManifest().ManifestData,
|
||||
SpecificConfig: appSpecificConfig,
|
||||
}
|
||||
|
||||
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installer.AppInstaller = i
|
||||
return installer, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/annotation"
|
||||
@@ -42,6 +43,7 @@ func ProvideAppInstallers(
|
||||
annotationAppInstaller *annotation.AnnotationAppInstaller,
|
||||
exampleAppInstaller *example.ExampleAppInstaller,
|
||||
advisorAppInstaller *advisor.AdvisorAppInstaller,
|
||||
alertingHistorianAppInstaller *historian.AlertingHistorianAppInstaller,
|
||||
) []appsdkapiserver.AppInstaller {
|
||||
installers := []appsdkapiserver.AppInstaller{
|
||||
playlistAppInstaller,
|
||||
@@ -75,6 +77,10 @@ func ProvideAppInstallers(
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) {
|
||||
installers = append(installers, advisorAppInstaller)
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAlertingHistorian) && alertingHistorianAppInstaller != nil {
|
||||
installers = append(installers, alertingHistorianAppInstaller)
|
||||
}
|
||||
|
||||
return installers
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/annotation"
|
||||
@@ -25,6 +26,8 @@ func TestProvideAppInstallers_Table(t *testing.T) {
|
||||
annotationAppInstaller := &annotation.AnnotationAppInstaller{}
|
||||
exampleAppInstaller := &example.ExampleAppInstaller{}
|
||||
advisorAppInstaller := &advisor.AdvisorAppInstaller{}
|
||||
historianAppInstaller := &historian.AlertingHistorianAppInstaller{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []any
|
||||
@@ -40,7 +43,7 @@ func TestProvideAppInstallers_Table(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
features := featuremgmt.WithFeatures(tt.flags...)
|
||||
got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller)
|
||||
got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, historianAppInstaller)
|
||||
if tt.expectRulesApp {
|
||||
require.Contains(t, got, tt.rulesInst)
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
@@ -55,6 +56,10 @@ func RegisterAppInstaller(
|
||||
return installer, nil
|
||||
}
|
||||
|
||||
func (a *ShortURLAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
return shorturlapp.GetAuthorizer()
|
||||
}
|
||||
|
||||
func (s *ShortURLAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) grafanarest.Storage {
|
||||
gvr := shorturl.ShortURLKind().GroupVersionResource()
|
||||
if requested.String() != gvr.String() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package appregistry
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/annotation"
|
||||
@@ -25,6 +26,7 @@ var WireSet = wire.NewSet(
|
||||
correlations.RegisterAppInstaller,
|
||||
rules.RegisterAppInstaller,
|
||||
notifications.RegisterAppInstaller,
|
||||
historian.RegisterAppInstaller,
|
||||
logsdrilldown.RegisterAppInstaller,
|
||||
annotation.RegisterAppInstaller,
|
||||
example.RegisterAppInstaller,
|
||||
|
||||
Generated
+17
-8
@@ -80,6 +80,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/userstorage"
|
||||
"github.com/grafana/grafana/pkg/registry/apps"
|
||||
advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
|
||||
notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/annotation"
|
||||
@@ -459,8 +460,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
}
|
||||
ossDashboardStats := builders.ProvideDashboardStats()
|
||||
documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats)
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
clockClock := clock.ProvideClock()
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(clockClock, databaseDatabase, tracer, registerer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -508,7 +509,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -668,7 +669,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -825,7 +826,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller)
|
||||
alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller)
|
||||
builderMetrics := builder.ProvideBuilderMetrics(registerer)
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
|
||||
if err != nil {
|
||||
@@ -1107,8 +1112,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
}
|
||||
ossDashboardStats := builders.ProvideDashboardStats()
|
||||
documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats)
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
clockClock := clock.ProvideClock()
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(clockClock, databaseDatabase, tracer, registerer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1156,7 +1161,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1312,7 +1317,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1475,7 +1480,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller)
|
||||
alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller)
|
||||
builderMetrics := builder.ProvideBuilderMetrics(registerer)
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
k8srequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
"k8s.io/apiserver/pkg/util/openapi"
|
||||
k8sscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
k8stracing "k8s.io/component-base/tracing"
|
||||
@@ -73,36 +72,13 @@ var PathRewriters = []filters.PathRewriter{
|
||||
},
|
||||
}
|
||||
|
||||
// GetDefaultBuildHandlerChainFuncForAggregator is a replica of GetDefaultBuildHandlerChainFunc except it skips custom routes handling
|
||||
func GetDefaultBuildHandlerChainFuncForAggregator() BuildHandlerChainFunc {
|
||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
||||
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
|
||||
handler := filters.WithRequester(delegateHandler)
|
||||
|
||||
// Call DefaultBuildHandlerChain on the main entrypoint http.Handler
|
||||
// See https://github.com/kubernetes/apiserver/blob/v0.28.0/pkg/server/config.go#L906
|
||||
// DefaultBuildHandlerChain provides many things, notably CORS, HSTS, cache-control, authz and latency tracking
|
||||
handler = genericapiserver.DefaultBuildHandlerChain(handler, c)
|
||||
|
||||
handler = filters.WithAcceptHeader(handler)
|
||||
handler = filters.WithPathRewriters(handler, PathRewriters)
|
||||
handler = k8stracing.WithTracing(handler, c.TracerProvider, "KubernetesAPI")
|
||||
handler = filters.WithExtractJaegerTrace(handler)
|
||||
// Configure filters.WithPanicRecovery to not crash on panic
|
||||
utilruntime.ReallyCrash = false
|
||||
|
||||
return handler
|
||||
}
|
||||
}
|
||||
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
||||
requestHandler, err := GetCustomRoutesHandler(
|
||||
delegateHandler,
|
||||
c.LoopbackClientConfig,
|
||||
builders,
|
||||
reg,
|
||||
c.MergedResourceConfig,
|
||||
)
|
||||
reg)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error()))
|
||||
}
|
||||
@@ -129,8 +105,6 @@ func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.
|
||||
}
|
||||
}
|
||||
|
||||
// SetupConfig sets up the server config for the API server
|
||||
// specify isAggregator=true, if the chain is being constructed for kube-aggregator
|
||||
func SetupConfig(
|
||||
scheme *runtime.Scheme,
|
||||
serverConfig *genericapiserver.RecommendedConfig,
|
||||
@@ -140,7 +114,6 @@ func SetupConfig(
|
||||
gvs []schema.GroupVersion,
|
||||
additionalOpenAPIDefGetters []common.GetOpenAPIDefinitions,
|
||||
reg prometheus.Registerer,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) error {
|
||||
serverConfig.AdmissionControl = NewAdmissionFromBuilders(builders)
|
||||
defsGetter := GetOpenAPIDefinitions(builders, additionalOpenAPIDefGetters...)
|
||||
@@ -153,7 +126,7 @@ func SetupConfig(
|
||||
openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme))
|
||||
|
||||
// Add the custom routes to service discovery
|
||||
serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders, gvs, apiResourceConfig)
|
||||
serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders, gvs)
|
||||
serverConfig.OpenAPIV3Config.GetOperationIDAndTagsFromRoute = func(r common.Route) (string, []string, error) {
|
||||
meta := r.Metadata()
|
||||
kind := ""
|
||||
@@ -314,7 +287,6 @@ func InstallAPIs(
|
||||
features featuremgmt.FeatureToggles,
|
||||
dualWriterMetrics *grafanarest.DualWriterMetrics,
|
||||
builderMetrics *BuilderMetrics,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) error {
|
||||
// dual writing is only enabled when the storage type is not legacy.
|
||||
// this is needed to support setting a default RESTOptionsGetter for new APIs that don't
|
||||
@@ -429,9 +401,34 @@ func InstallAPIs(
|
||||
for group, buildersForGroup := range buildersGroupMap {
|
||||
g := genericapiserver.NewDefaultAPIGroupInfo(group, scheme, metav1.ParameterCodec, codecs)
|
||||
for _, b := range buildersForGroup {
|
||||
if err := installAPIGroupsForBuilder(&g, group, b, apiResourceConfig, scheme, optsGetter, dualWrite, reg, optsregister, storageOpts, features); err != nil {
|
||||
if err := b.UpdateAPIGroupInfo(&g, APIGroupOptions{
|
||||
Scheme: scheme,
|
||||
OptsGetter: optsGetter,
|
||||
DualWriteBuilder: dualWrite,
|
||||
MetricsRegister: reg,
|
||||
StorageOptsRegister: optsregister,
|
||||
StorageOpts: storageOpts,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.PrioritizedVersions) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// if grafanaAPIServerWithExperimentalAPIs is not enabled, remove v0alpha1 resources unless explicitly allowed
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
if resources, ok := g.VersionedResourcesStorageMap["v0alpha1"]; ok {
|
||||
for name := range resources {
|
||||
if !allowRegisteringResourceByInfo(b.AllowedV0Alpha1Resources(), name) {
|
||||
delete(resources, name)
|
||||
}
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
delete(g.VersionedResourcesStorageMap, "v0alpha1")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skip installing the group if there are no resources left after filtering
|
||||
@@ -448,53 +445,6 @@ func InstallAPIs(
|
||||
return nil
|
||||
}
|
||||
|
||||
func installAPIGroupsForBuilder(g *genericapiserver.APIGroupInfo, group string, b APIGroupBuilder, apiResourceConfig *serverstorage.ResourceConfig, scheme *runtime.Scheme,
|
||||
optsGetter generic.RESTOptionsGetter, dualWrite grafanarest.DualWriteBuilder, reg prometheus.Registerer, optsregister apistore.StorageOptionsRegister,
|
||||
storageOpts *options.StorageOptions, features featuremgmt.FeatureToggles) error {
|
||||
if err := b.UpdateAPIGroupInfo(g, APIGroupOptions{
|
||||
Scheme: scheme,
|
||||
OptsGetter: optsGetter,
|
||||
DualWriteBuilder: dualWrite,
|
||||
MetricsRegister: reg,
|
||||
StorageOptsRegister: optsregister,
|
||||
StorageOpts: storageOpts,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.PrioritizedVersions) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
for version := range g.VersionedResourcesStorageMap {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: group,
|
||||
Version: version,
|
||||
}
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
||||
klog.InfoS("Skipping storage for disabled resource", "gvr", gvr.String())
|
||||
delete(g.VersionedResourcesStorageMap, version)
|
||||
}
|
||||
}
|
||||
|
||||
// if grafanaAPIServerWithExperimentalAPIs is not enabled, remove v0alpha1 resources unless explicitly allowed
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
if resources, ok := g.VersionedResourcesStorageMap["v0alpha1"]; ok {
|
||||
for name := range resources {
|
||||
if !allowRegisteringResourceByInfo(b.AllowedV0Alpha1Resources(), name) {
|
||||
delete(resources, name)
|
||||
}
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
delete(g.VersionedResourcesStorageMap, "v0alpha1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPostStartHooks adds post start hooks to a generic API server config
|
||||
func AddPostStartHooks(
|
||||
config *genericapiserver.RecommendedConfig,
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
"k8s.io/klog/v2"
|
||||
openapi "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
spec "k8s.io/kube-openapi/pkg/validation/spec"
|
||||
@@ -78,7 +76,6 @@ func addBuilderRoutes(
|
||||
targetGroupVersion schema.GroupVersion,
|
||||
openAPISpec *spec3.OpenAPI,
|
||||
apiGroupBuilders []APIGroupBuilder,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) (*spec3.OpenAPI, error) {
|
||||
for _, apiGroupBuilder := range apiGroupBuilders {
|
||||
// Optionally include raw http handlers for all builders
|
||||
@@ -110,24 +107,12 @@ func addBuilderRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
for path := range openAPISpec.Paths.Paths {
|
||||
if strings.HasPrefix(path, "/apis/"+targetGroupVersion.String()+"/") {
|
||||
gv := targetGroupVersion.WithResource("")
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gv) {
|
||||
klog.InfoS("removing openapi routes for disabled resource", "gv", gv.String())
|
||||
delete(openAPISpec.Paths.Paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openAPISpec, nil
|
||||
}
|
||||
|
||||
// Modify the OpenAPI spec to include the additional routes.
|
||||
// nolint:gocyclo
|
||||
func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []schema.GroupVersion, apiResourceConfig *serverstorage.ResourceConfig) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []schema.GroupVersion) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
return func(s *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
if s.Paths == nil {
|
||||
return s, nil
|
||||
@@ -242,7 +227,7 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s
|
||||
}
|
||||
}
|
||||
}
|
||||
return addBuilderRoutes(gv, ©, builders, apiResourceConfig)
|
||||
return addBuilderRoutes(gv, ©, builders)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
klog "k8s.io/klog/v2"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
)
|
||||
|
||||
@@ -16,7 +14,7 @@ type requestHandler struct {
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) {
|
||||
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer) (http.Handler, error) {
|
||||
useful := false // only true if any routes exist anywhere
|
||||
router := mux.NewRouter()
|
||||
|
||||
@@ -29,12 +27,6 @@ func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient
|
||||
}
|
||||
|
||||
for _, gv := range GetGroupVersions(builder) {
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
gvr := gv.WithResource("")
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
||||
klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String())
|
||||
continue
|
||||
}
|
||||
routes := provider.GetAPIRoutes(gv)
|
||||
if routes == nil {
|
||||
continue
|
||||
|
||||
@@ -316,11 +316,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
s.cfg.BuildBranch,
|
||||
)
|
||||
|
||||
apiResourceConfig := appinstaller.NewAPIResourceConfig(s.appInstallers)
|
||||
// add the builder group versions to the api resource config
|
||||
apiResourceConfig.EnableVersions(groupVersions...)
|
||||
|
||||
if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, apiResourceConfig, s.scheme); err != nil {
|
||||
if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, appinstaller.NewAPIResourceConfig(s.appInstallers), s.scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -363,7 +359,6 @@ func (s *service) start(ctx context.Context) error {
|
||||
groupVersions,
|
||||
defGetters,
|
||||
s.metrics,
|
||||
apiResourceConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -405,7 +400,6 @@ func (s *service) start(ctx context.Context) error {
|
||||
s.features,
|
||||
s.dualWriterMetrics,
|
||||
s.builderMetrics,
|
||||
apiResourceConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,12 @@ message MutateOperation {
|
||||
UpdateUserOrgRoleOperation update_user_org_role = 5;
|
||||
DeleteUserOrgRoleOperation delete_user_org_role = 6;
|
||||
AddUserOrgRoleOperation add_user_org_role = 7;
|
||||
CreateRoleBindingOperation create_role_binding = 8;
|
||||
DeleteRoleBindingOperation delete_role_binding = 9;
|
||||
CreateTeamBindingOperation create_team_binding = 10;
|
||||
DeleteTeamBindingOperation delete_team_binding = 11;
|
||||
CreateRoleOperation create_role = 12;
|
||||
DeleteRoleOperation delete_role = 13;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +93,69 @@ message DeleteUserOrgRoleOperation {
|
||||
string role = 2;
|
||||
}
|
||||
|
||||
message CreateRoleBindingOperation {
|
||||
// kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole)
|
||||
string subject_kind = 1;
|
||||
// uid of the identity
|
||||
string subject_name = 2;
|
||||
// kind of the role (Role/CoreRole/GlobalRole)
|
||||
string role_kind = 3;
|
||||
// uid of the role
|
||||
string role_name = 4;
|
||||
}
|
||||
|
||||
message DeleteRoleBindingOperation {
|
||||
// kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole)
|
||||
string subject_kind = 1;
|
||||
// uid of the identity
|
||||
string subject_name = 2;
|
||||
// kind of the role (Role/CoreRole/GlobalRole)
|
||||
string role_kind = 3;
|
||||
// uid of the role
|
||||
string role_name = 4;
|
||||
}
|
||||
|
||||
message CreateTeamBindingOperation {
|
||||
// uid of the identity
|
||||
string subject_name = 1;
|
||||
// uid of the team
|
||||
string team_name = 2;
|
||||
// permission of the identity in the team (admin/member)
|
||||
string permission = 3;
|
||||
}
|
||||
|
||||
message DeleteTeamBindingOperation {
|
||||
// uid of the identity
|
||||
string subject_name = 1;
|
||||
// uid of the team
|
||||
string team_name = 2;
|
||||
// permission of the identity in the team (admin/member)
|
||||
string permission = 3;
|
||||
}
|
||||
|
||||
message CreateRoleOperation {
|
||||
// kind of the role (Role/CoreRole/GlobalRole)
|
||||
string role_kind = 1;
|
||||
// uid of the role
|
||||
string role_name = 2;
|
||||
// permissions of the role
|
||||
repeated RolePermission permissions = 3;
|
||||
}
|
||||
|
||||
message DeleteRoleOperation {
|
||||
// kind of the role (Role/CoreRole/GlobalRole)
|
||||
string role_kind = 1;
|
||||
// uid of the role
|
||||
string role_name = 2;
|
||||
// permissions of the role
|
||||
repeated RolePermission permissions = 3;
|
||||
}
|
||||
|
||||
message RolePermission {
|
||||
string action = 1;
|
||||
string scope = 2;
|
||||
}
|
||||
|
||||
message Resource {
|
||||
// group of the resource (e.g: "dashboard.grafana.app")
|
||||
string group = 1;
|
||||
|
||||
@@ -447,6 +447,14 @@ func ToOpenFGATuples(tuples []*authzextv1.Tuple) []*openfgav1.Tuple {
|
||||
return result
|
||||
}
|
||||
|
||||
func ToOpenFGADeleteTupleKey(tuples *openfgav1.TupleKey) *openfgav1.TupleKeyWithoutCondition {
|
||||
return &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuples.GetUser(),
|
||||
Relation: tuples.GetRelation(),
|
||||
Object: tuples.GetObject(),
|
||||
}
|
||||
}
|
||||
|
||||
func AddRenderContext(req *openfgav1.CheckRequest) {
|
||||
if req.ContextualTuples == nil {
|
||||
req.ContextualTuples = &openfgav1.ContextualTupleKeys{}
|
||||
|
||||
@@ -15,6 +15,9 @@ const (
|
||||
OperationGroupFolder OperationGroup = "folder"
|
||||
OperationGroupPermission OperationGroup = "permission"
|
||||
OperationGroupUserOrgRole OperationGroup = "user_org_role"
|
||||
OperationGroupRoleBinding OperationGroup = "role_binding"
|
||||
OperationGroupTeamBinding OperationGroup = "team_binding"
|
||||
OperationGroupRole OperationGroup = "role"
|
||||
)
|
||||
|
||||
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||
@@ -63,6 +66,18 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
||||
if err := s.mutateOrgRoles(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate org roles: %w", err)
|
||||
}
|
||||
case OperationGroupRoleBinding:
|
||||
if err := s.mutateRoleBindings(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate role bindings: %w", err)
|
||||
}
|
||||
case OperationGroupTeamBinding:
|
||||
if err := s.mutateTeamBindings(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate team bindings: %w", err)
|
||||
}
|
||||
case OperationGroupRole:
|
||||
if err := s.mutateRoles(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate roles: %w", err)
|
||||
}
|
||||
default:
|
||||
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
|
||||
}
|
||||
@@ -79,6 +94,12 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
|
||||
return OperationGroupPermission, nil
|
||||
case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole, *authzextv1.MutateOperation_AddUserOrgRole:
|
||||
return OperationGroupUserOrgRole, nil
|
||||
case *authzextv1.MutateOperation_CreateRoleBinding, *authzextv1.MutateOperation_DeleteRoleBinding:
|
||||
return OperationGroupRoleBinding, nil
|
||||
case *authzextv1.MutateOperation_CreateTeamBinding, *authzextv1.MutateOperation_DeleteTeamBinding:
|
||||
return OperationGroupTeamBinding, nil
|
||||
case *authzextv1.MutateOperation_CreateRole, *authzextv1.MutateOperation_DeleteRole:
|
||||
return OperationGroupRole, nil
|
||||
}
|
||||
return OperationGroup(""), errors.New("unsupported mutate operation type")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) mutateRoleBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateRoleBindings")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_CreateRoleBinding:
|
||||
r := op.CreateRoleBinding
|
||||
tuple, err := s.getRoleBindingTuple(ctx, r.SubjectKind, r.SubjectName, r.RoleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuples = append(writeTuples, tuple)
|
||||
case *authzextv1.MutateOperation_DeleteRoleBinding:
|
||||
r := op.DeleteRoleBinding
|
||||
tuple, err := s.getRoleBindingTuple(ctx, r.SubjectKind, r.SubjectName, r.RoleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuple := &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.User,
|
||||
Relation: tuple.Relation,
|
||||
Object: tuple.Object,
|
||||
}
|
||||
deleteTuples = append(deleteTuples, writeTuple)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write resource role binding tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getRoleBindingTuple(ctx context.Context, subjectKind string, subjectName string, roleName string) (*openfgav1.TupleKey, error) {
|
||||
zanzanaType := ""
|
||||
subjectRelation := ""
|
||||
|
||||
switch subjectKind {
|
||||
case string(iamv0.RoleBindingSpecSubjectKindUser):
|
||||
zanzanaType = zanzana.TypeUser
|
||||
case string(iamv0.RoleBindingSpecSubjectKindTeam):
|
||||
zanzanaType = zanzana.TypeTeam
|
||||
subjectRelation = zanzana.RelationTeamMember
|
||||
case string(iamv0.RoleBindingSpecSubjectKindServiceAccount):
|
||||
zanzanaType = zanzana.TypeServiceAccount
|
||||
case string(iamv0.RoleBindingSpecSubjectKindBasicRole):
|
||||
zanzanaType = zanzana.TypeRole
|
||||
subjectRelation = zanzana.RelationAssignee
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid subject kind: %s", subjectKind)
|
||||
}
|
||||
|
||||
tuple := &openfgav1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzanaType, subjectName, subjectRelation),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, roleName, ""),
|
||||
}
|
||||
|
||||
return tuple, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateRoleBindings(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewTuple("user:1", common.RelationAssignee, "role:foo_viewer"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateRoleBindings(t *testing.T, srv *Server) {
|
||||
setupMutateRoleBindings(t, srv)
|
||||
|
||||
t.Run("should update user role and delete old role", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "User",
|
||||
SubjectName: "1",
|
||||
RoleKind: "Role",
|
||||
RoleName: "foo_editor",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "User",
|
||||
SubjectName: "1",
|
||||
RoleKind: "Role",
|
||||
RoleName: "foo_viewer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:foo_editor",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:1", res.Tuples[0].Key.User)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:foo_viewer",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
|
||||
t.Run("should assign role to basic role", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "BasicRole",
|
||||
SubjectName: "basic_viewer",
|
||||
RoleKind: "Role",
|
||||
RoleName: "foo_bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:foo_bar",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "role:basic_viewer#assignee", res.Tuples[0].Key.User)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) mutateRoles(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateRoles")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_CreateRole:
|
||||
tuples, err := convertRoleToTuples(op.CreateRole.RoleName, op.CreateRole.Permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuples = append(writeTuples, tuples...)
|
||||
case *authzextv1.MutateOperation_DeleteRole:
|
||||
tuples, err := convertRoleToTuples(op.DeleteRole.RoleName, op.DeleteRole.Permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deletes := make([]*openfgav1.TupleKeyWithoutCondition, 0, len(tuples))
|
||||
for _, tuple := range tuples {
|
||||
deletes = append(deletes, common.ToOpenFGADeleteTupleKey(tuple))
|
||||
}
|
||||
deleteTuples = append(deleteTuples, deletes...)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write resource role binding tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertRoleToTuples converts role and its permissions (action/scope) to v1 TupleKey format
|
||||
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
|
||||
func convertRoleToTuples(roleUID string, permissions []*authzextv1.RolePermission) ([]*openfgav1.TupleKey, error) {
|
||||
// Convert to zanzana.RolePermission
|
||||
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
|
||||
for _, perm := range permissions {
|
||||
// Split the scope to get kind, attribute, identifier
|
||||
kind, _, identifier := splitScope(perm.Scope)
|
||||
rolePerms = append(rolePerms, zanzana.RolePermission{
|
||||
Action: perm.Action,
|
||||
Kind: kind,
|
||||
Identifier: identifier,
|
||||
})
|
||||
}
|
||||
|
||||
// Translate to Zanzana tuples
|
||||
tuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func splitScope(scope string) (string, string, string) {
|
||||
if scope == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
fragments := strings.Split(scope, ":")
|
||||
switch l := len(fragments); l {
|
||||
case 1: // Splitting a wildcard scope "*" -> kind: "*"; attribute: "*"; identifier: "*"
|
||||
return fragments[0], fragments[0], fragments[0]
|
||||
case 2: // Splitting a wildcard scope with specified kind "dashboards:*" -> kind: "dashboards"; attribute: "*"; identifier: "*"
|
||||
return fragments[0], fragments[1], fragments[1]
|
||||
default: // Splitting a scope with all fields specified "dashboards:uid:my_dash" -> kind: "dashboards"; attribute: "uid"; identifier: "my_dash"
|
||||
return fragments[0], fragments[1], strings.Join(fragments[2:], ":")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateRoles(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewTuple("role:foo_viewer#assignee", "view", "group_resource:dashboard.grafana.app/dashboards"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateRoles(t *testing.T, srv *Server) {
|
||||
setupMutateRoles(t, srv)
|
||||
|
||||
t.Run("should update role and delete old role permissions", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateRole{
|
||||
CreateRole: &v1.CreateRoleOperation{
|
||||
RoleName: "foo_viewer",
|
||||
RoleKind: "Role",
|
||||
Permissions: []*v1.RolePermission{
|
||||
{
|
||||
Action: "dashboards:edit",
|
||||
Scope: "dashboards:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteRole{
|
||||
DeleteRole: &v1.DeleteRoleOperation{
|
||||
RoleName: "foo_viewer",
|
||||
RoleKind: "Role",
|
||||
Permissions: []*v1.RolePermission{
|
||||
{
|
||||
Action: "dashboards:view",
|
||||
Scope: "dashboards:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
User: "role:foo_viewer#assignee",
|
||||
Relation: "edit",
|
||||
Object: "group_resource:",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "role:foo_viewer#assignee", res.Tuples[0].Key.User)
|
||||
require.Equal(t, "group_resource:dashboard.grafana.app/dashboards", res.Tuples[0].Key.Object)
|
||||
require.Equal(t, "edit", res.Tuples[0].Key.Relation)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) mutateTeamBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateTeamBindings")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_CreateTeamBinding:
|
||||
tuple, err := s.getTeamBindingTuple(ctx, op.CreateTeamBinding.GetSubjectName(), op.CreateTeamBinding.GetTeamName(), op.CreateTeamBinding.GetPermission())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuples = append(writeTuples, tuple)
|
||||
case *authzextv1.MutateOperation_DeleteTeamBinding:
|
||||
tuple, err := s.getTeamBindingTuple(ctx, op.DeleteTeamBinding.GetSubjectName(), op.DeleteTeamBinding.GetTeamName(), op.DeleteTeamBinding.GetPermission())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteTuple := &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.User,
|
||||
Relation: tuple.Relation,
|
||||
Object: tuple.Object,
|
||||
}
|
||||
deleteTuples = append(deleteTuples, deleteTuple)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write resource role binding tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getTeamBindingTuple(ctx context.Context, subject string, team string, permission string) (*openfgav1.TupleKey, error) {
|
||||
if subject == "" {
|
||||
return nil, errors.New("subject name cannot be empty")
|
||||
}
|
||||
|
||||
if team == "" {
|
||||
return nil, errors.New("team name cannot be empty")
|
||||
}
|
||||
|
||||
relation := ""
|
||||
switch permission {
|
||||
case string(iamv0.TeamBindingTeamPermissionAdmin):
|
||||
relation = zanzana.RelationTeamAdmin
|
||||
case string(iamv0.TeamBindingTeamPermissionMember):
|
||||
relation = zanzana.RelationTeamMember
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown team permission '%s', expected member or admin", permission)
|
||||
}
|
||||
|
||||
tuple := &openfgav1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeUser, subject, ""),
|
||||
Relation: relation,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeTeam, team, ""),
|
||||
}
|
||||
|
||||
return tuple, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateTeamBindings(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewTuple("user:1", common.RelationTeamMember, "team:foo"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateTeamBindings(t *testing.T, srv *Server) {
|
||||
setupMutateTeamBindings(t, srv)
|
||||
|
||||
t.Run("should update user team binding and delete old team binding", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateTeamBinding{
|
||||
CreateTeamBinding: &v1.CreateTeamBindingOperation{
|
||||
SubjectName: "1",
|
||||
TeamName: "foo",
|
||||
Permission: "admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteTeamBinding{
|
||||
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
|
||||
SubjectName: "1",
|
||||
TeamName: "foo",
|
||||
Permission: "member",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationTeamAdmin,
|
||||
Object: "team:foo",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:1", res.Tuples[0].Key.User)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationTeamMember,
|
||||
Object: "team:foo",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
}
|
||||
@@ -136,6 +136,18 @@ func TestIntegrationServer(t *testing.T) {
|
||||
t.Run("test query folders", func(t *testing.T) {
|
||||
testQueryFolders(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate role bindings", func(t *testing.T) {
|
||||
testMutateRoleBindings(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate team bindings", func(t *testing.T) {
|
||||
testMutateTeamBindings(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate roles", func(t *testing.T) {
|
||||
testMutateRoles(t, srv)
|
||||
})
|
||||
}
|
||||
|
||||
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
|
||||
@@ -2016,6 +2016,26 @@ func TestSearchDashboardsThroughK8sRaw(t *testing.T) {
|
||||
_, err := service.searchDashboardsThroughK8s(ctx, query)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("search will request legacy dashboard ID", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
k8sCliMock := new(client.MockK8sHandler)
|
||||
service := &DashboardServiceImpl{k8sclient: k8sCliMock}
|
||||
query := &dashboards.FindPersistedDashboardsQuery{
|
||||
ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck
|
||||
OrgId: 1,
|
||||
}
|
||||
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool {
|
||||
return slices.Contains(req.Fields, "grafana.app/deprecatedInternalID") &&
|
||||
slices.Contains(req.Fields, "labels.grafana.app/deprecatedInternalID")
|
||||
})).Return(&resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{},
|
||||
TotalHits: 0,
|
||||
}, nil)
|
||||
_, err := service.searchDashboardsThroughK8s(ctx, query)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) {
|
||||
|
||||
@@ -40,6 +40,9 @@ var (
|
||||
resource.SEARCH_FIELD_SOURCE_PATH,
|
||||
resource.SEARCH_FIELD_SOURCE_CHECKSUM,
|
||||
resource.SEARCH_FIELD_SOURCE_TIME,
|
||||
// below is needed to determine whether a provisioned dashboard exists or not
|
||||
resource.SEARCH_FIELD_LEGACY_ID,
|
||||
resource.SEARCH_FIELD_LABELS + "." + resource.SEARCH_FIELD_LEGACY_ID,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ type FakeDataSourceService struct {
|
||||
lastID int64
|
||||
DataSources []*datasources.DataSource
|
||||
SimulatePluginFailure bool
|
||||
|
||||
// UID -> Headers
|
||||
DataSourceHeaders map[string]http.Header
|
||||
}
|
||||
|
||||
var _ datasources.DataSourceService = &FakeDataSourceService{}
|
||||
@@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas
|
||||
}
|
||||
|
||||
func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) {
|
||||
return nil, nil
|
||||
return s.DataSourceHeaders[ds.UID], nil
|
||||
}
|
||||
|
||||
@@ -579,6 +579,13 @@ var (
|
||||
FrontendOnly: false, // The restore backend feature changes behavior based on this flag
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesDashboardsV2",
|
||||
Description: "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "dashboardUndoRedo",
|
||||
Description: "Enables undo/redo in dynamic dashboards",
|
||||
@@ -600,6 +607,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelGroupBy",
|
||||
Description: "Enabled a group by action per panel",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelFilterVariable",
|
||||
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
|
||||
@@ -1315,7 +1329,7 @@ var (
|
||||
Name: "elasticsearchImprovedParsing",
|
||||
Description: "Enables less memory intensive Elasticsearch result parsing",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: awsDatasourcesSquad,
|
||||
Owner: grafanaPartnerPluginsSquad,
|
||||
},
|
||||
{
|
||||
Name: "datasourceConnectionsTab",
|
||||
@@ -1801,10 +1815,10 @@ var (
|
||||
{
|
||||
Name: "azureResourcePickerUpdates",
|
||||
Description: "Enables the updated Azure Monitor resource picker",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaPartnerPluginsSquad,
|
||||
Expression: "false",
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "prometheusTypeMigration",
|
||||
@@ -1895,10 +1909,10 @@ var (
|
||||
{
|
||||
Name: "newPanelPadding",
|
||||
Description: "Increases panel padding globally",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Stage: FeatureStagePublicPreview,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
Expression: "false",
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "onlyStoreActionSets",
|
||||
@@ -1963,6 +1977,13 @@ var (
|
||||
RequiresRestart: false,
|
||||
HideFromDocs: false,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAlertingHistorian",
|
||||
Description: "Adds support for Kubernetes alerting historian APIs",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Generated
+6
-3
@@ -80,9 +80,11 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
|
||||
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
@@ -181,7 +183,7 @@ k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
|
||||
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
|
||||
teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false
|
||||
grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false
|
||||
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
|
||||
elasticsearchImprovedParsing,experimental,@grafana/partner-datasources,false,false,false
|
||||
datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true
|
||||
fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false
|
||||
newLogsPanel,GA,@grafana/observability-logs,false,false,true
|
||||
@@ -245,7 +247,7 @@ teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,f
|
||||
interactiveLearning,preview,@grafana/pathfinder,false,false,false
|
||||
alertingTriage,experimental,@grafana/alerting-squad,false,false,false
|
||||
graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false
|
||||
azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true
|
||||
azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true
|
||||
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
||||
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
||||
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
|
||||
@@ -257,7 +259,7 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
||||
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
||||
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false
|
||||
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
||||
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
|
||||
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
||||
@@ -266,3 +268,4 @@ transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
|
||||
ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true
|
||||
rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false
|
||||
|
||||
|
Generated
+8
-4
@@ -259,6 +259,10 @@ const (
|
||||
// Enables experimental new dashboard layouts
|
||||
FlagDashboardNewLayouts = "dashboardNewLayouts"
|
||||
|
||||
// FlagKubernetesDashboardsV2
|
||||
// Use the v2 kubernetes API in the frontend for dashboards
|
||||
FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2"
|
||||
|
||||
// FlagPdfTables
|
||||
// Enables generating table data as PDF in reporting
|
||||
FlagPdfTables = "pdfTables"
|
||||
@@ -742,10 +746,6 @@ const (
|
||||
// Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded
|
||||
FlagPluginStoreServiceLoading = "pluginStoreServiceLoading"
|
||||
|
||||
// FlagNewPanelPadding
|
||||
// Increases panel padding globally
|
||||
FlagNewPanelPadding = "newPanelPadding"
|
||||
|
||||
// FlagOnlyStoreActionSets
|
||||
// When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission
|
||||
FlagOnlyStoreActionSets = "onlyStoreActionSets"
|
||||
@@ -761,4 +761,8 @@ const (
|
||||
// FlagAwsDatasourcesHttpProxy
|
||||
// Enables http proxy settings for aws datasources
|
||||
FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy"
|
||||
|
||||
// FlagKubernetesAlertingHistorian
|
||||
// Adds support for Kubernetes alerting historian APIs
|
||||
FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian"
|
||||
)
|
||||
|
||||
+76
-14
@@ -551,7 +551,6 @@
|
||||
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
@@ -565,7 +564,6 @@
|
||||
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
@@ -768,16 +766,19 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "azureResourcePickerUpdates",
|
||||
"resourceVersion": "1763734583253",
|
||||
"resourceVersion": "1764153435365",
|
||||
"creationTimestamp": "2025-07-31T22:56:50Z",
|
||||
"deletionTimestamp": "2025-08-01T11:30:17Z"
|
||||
"deletionTimestamp": "2025-08-01T11:30:17Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-26 10:37:15.365919 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the updated Azure Monitor resource picker",
|
||||
"stage": "preview",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/partner-datasources",
|
||||
"frontend": true,
|
||||
"expression": "false"
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1213,13 +1214,16 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "elasticsearchImprovedParsing",
|
||||
"resourceVersion": "1763734583253",
|
||||
"creationTimestamp": "2025-01-15T17:05:54Z"
|
||||
"resourceVersion": "1764260048941",
|
||||
"creationTimestamp": "2025-01-15T17:05:54Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-27 16:14:08.941633 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables less memory intensive Elasticsearch result parsing",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/aws-datasources"
|
||||
"codeowner": "@grafana/partner-datasources"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1789,6 +1793,19 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAlertingHistorian",
|
||||
"resourceVersion": "1764257713773",
|
||||
"creationTimestamp": "2025-11-27T15:35:13Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Adds support for Kubernetes alerting historian APIs",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAlertingRules",
|
||||
@@ -1910,6 +1927,18 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesDashboardsV2",
|
||||
"resourceVersion": "1764236054307",
|
||||
"creationTimestamp": "2025-11-27T09:34:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesExternalGroupMapping",
|
||||
@@ -2361,14 +2390,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newPanelPadding",
|
||||
"resourceVersion": "1763734583253",
|
||||
"creationTimestamp": "2025-11-12T15:40:46Z"
|
||||
"resourceVersion": "1764168915089",
|
||||
"creationTimestamp": "2025-11-12T15:40:46Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-26 14:55:15.089551 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Increases panel padding globally",
|
||||
"stage": "experimental",
|
||||
"stage": "preview",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"expression": "false"
|
||||
"frontend": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2479,6 +2512,19 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelGroupBy",
|
||||
"resourceVersion": "1764257043719",
|
||||
"creationTimestamp": "2025-11-27T15:24:03Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enabled a group by action per panel",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelTimeSettings",
|
||||
@@ -3542,6 +3588,22 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "v2DashboardAPIVersion",
|
||||
"resourceVersion": "1762457740470",
|
||||
"creationTimestamp": "2025-11-06T19:22:05Z",
|
||||
"deletionTimestamp": "2025-11-27T09:34:14Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the v2 dashboard API version",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "vizActionsAuth",
|
||||
@@ -3584,4 +3646,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
line-height: 1; /* prevent shift when css loads in that changes the body line-height */
|
||||
}
|
||||
|
||||
.fs-variant-loader, .fs-variant-error, .fs-custom-domain-error {
|
||||
@@ -239,6 +240,30 @@
|
||||
|
||||
const CHECK_INTERVAL = 1 * 1000;
|
||||
|
||||
function getCookie(name) {
|
||||
const cookies = document.cookie.split(";").map(c => c.trim());
|
||||
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.startsWith(name + "=")) {
|
||||
return cookie.substring(name.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionExpiration() {
|
||||
const value = getCookie("grafana_session_expiry") || "0";
|
||||
const realExpiresSeconds = parseInt(value, 10);
|
||||
const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration
|
||||
const expiration = new Date(expiresSeconds * 1000);
|
||||
return expiration;
|
||||
}
|
||||
|
||||
async function rotateSession() {
|
||||
await fetch('/api/user/auth-tokens/rotate', { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches boot data from the server. If it returns undefined, it should be retried later.
|
||||
* Will return a rejected promise on unrecoverable errors.
|
||||
@@ -295,6 +320,19 @@
|
||||
function loadBootData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attemptFetch = async () => {
|
||||
try {
|
||||
const sessionExpiration = getSessionExpiration();
|
||||
const now = new Date();
|
||||
|
||||
// If the session has expired, don't continue trying to fetch boot data
|
||||
if (now >= sessionExpiration) {
|
||||
await rotateSession();
|
||||
}
|
||||
} catch (error) {
|
||||
// Just ignore any errors in session rotation. The user can just log in again.
|
||||
console.warn("Failed to rotate session", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const bootData = await fetchBootData();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
@@ -128,6 +129,10 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
|
||||
}
|
||||
|
||||
if include.Type == "page" {
|
||||
if !middleware.PageIsFeatureToggleEnabled(c.Req.Context(), include.Path) {
|
||||
s.log.Debug("Skipping page", "plugin", plugin.ID, "path", include.Path)
|
||||
continue
|
||||
}
|
||||
link := &navtree.NavLink{
|
||||
Text: include.Name,
|
||||
Icon: include.Icon,
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
@@ -24,55 +27,64 @@ type HistorySrv struct {
|
||||
hist Historian
|
||||
}
|
||||
|
||||
const labelQueryPrefix = "labels_"
|
||||
|
||||
func (srv *HistorySrv) RouteQueryStateHistory(c *contextmodel.ReqContext) response.Response {
|
||||
from := c.QueryInt64("from")
|
||||
to := c.QueryInt64("to")
|
||||
limit := c.QueryInt("limit")
|
||||
ruleUID := c.Query("ruleUID")
|
||||
dashUID := c.Query("dashboardUID")
|
||||
panelID := c.QueryInt64("panelID")
|
||||
|
||||
previous := c.Query("previous")
|
||||
if previous != "" {
|
||||
_, err := eval.ParseStateString(previous)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid previous state filter: %w", err), "")
|
||||
}
|
||||
query, err := ParseHistoryQuery(c.OrgID, c.SignedInUser, c.Req.URL.Query())
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
current := c.Query("current")
|
||||
if current != "" {
|
||||
_, err := eval.ParseStateString(current)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid current state filter: %w", err), "")
|
||||
}
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
for k, v := range c.Req.URL.Query() {
|
||||
if strings.HasPrefix(k, labelQueryPrefix) {
|
||||
labels[k[len(labelQueryPrefix):]] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
query := models.HistoryQuery{
|
||||
RuleUID: ruleUID,
|
||||
OrgID: c.GetOrgID(),
|
||||
DashboardUID: dashUID,
|
||||
PanelID: panelID,
|
||||
Previous: previous,
|
||||
Current: current,
|
||||
SignedInUser: c.SignedInUser,
|
||||
From: time.Unix(from, 0),
|
||||
To: time.Unix(to, 0),
|
||||
Limit: limit,
|
||||
Labels: labels,
|
||||
}
|
||||
frame, err := srv.hist.Query(c.Req.Context(), query)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "")
|
||||
}
|
||||
return response.JSON(http.StatusOK, frame)
|
||||
}
|
||||
|
||||
const labelQueryPrefix = "labels_"
|
||||
|
||||
// ParseHistoryQuery parses a HistoryQuery from request parameters.
|
||||
func ParseHistoryQuery(orgID int64, user identity.Requester, query url.Values) (models.HistoryQuery, error) {
|
||||
from, _ := strconv.ParseInt(query.Get("from"), 10, 64)
|
||||
to, _ := strconv.ParseInt(query.Get("to"), 10, 64)
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
ruleUID := query.Get("ruleUID")
|
||||
dashUID := query.Get("dashboardUID")
|
||||
panelID, _ := strconv.ParseInt(query.Get("panelID"), 10, 64)
|
||||
|
||||
previous := query.Get("previous")
|
||||
if previous != "" {
|
||||
_, err := eval.ParseStateString(previous)
|
||||
if err != nil {
|
||||
return models.HistoryQuery{}, fmt.Errorf("invalid previous state filter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
current := query.Get("current")
|
||||
if current != "" {
|
||||
_, err := eval.ParseStateString(current)
|
||||
if err != nil {
|
||||
return models.HistoryQuery{}, fmt.Errorf("invalid current state filter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
for k, v := range query {
|
||||
if strings.HasPrefix(k, labelQueryPrefix) {
|
||||
labels[k[len(labelQueryPrefix):]] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
return models.HistoryQuery{
|
||||
RuleUID: ruleUID,
|
||||
OrgID: orgID,
|
||||
DashboardUID: dashUID,
|
||||
PanelID: panelID,
|
||||
Previous: previous,
|
||||
Current: current,
|
||||
SignedInUser: user,
|
||||
From: time.Unix(from, 0),
|
||||
To: time.Unix(to, 0),
|
||||
Limit: limit,
|
||||
Labels: labels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -165,6 +165,21 @@ func (aq *AlertQuery) setMaxDatapoints() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setRefID sets the model refId if it's missing or invalid
|
||||
func (aq *AlertQuery) setRefID() error {
|
||||
if aq.modelProps == nil {
|
||||
err := aq.setModelProps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if refID, ok := aq.modelProps["refId"].(string); !ok || refID != aq.RefID {
|
||||
aq.modelProps["refId"] = aq.RefID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (aq *AlertQuery) GetMaxDatapoints() (int64, error) {
|
||||
err := aq.setMaxDatapoints()
|
||||
if err != nil {
|
||||
@@ -256,6 +271,11 @@ func (aq *AlertQuery) GetModel() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = aq.setRefID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = aq.setIntervalMS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We need to add the writer headers (valid for any data source) and any data-source-specific headers.
|
||||
headers := make(http.Header)
|
||||
for k, v := range w.cfg.CustomHeaders {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
|
||||
dsHeaders, err := w.datasources.CustomHeaders(ctx, ds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get headers for data source: %w", err)
|
||||
}
|
||||
|
||||
for k, values := range dsHeaders {
|
||||
for _, v := range values {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
var backend backendType
|
||||
if dsUID == string(grafanaCloudPromType) {
|
||||
backend = grafanaCloudPromType
|
||||
|
||||
@@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl
|
||||
type testDataSources struct {
|
||||
dsfakes.FakeDataSourceService
|
||||
|
||||
prom1, prom2, prom3 *TestRemoteWriteTarget
|
||||
prom1, prom2, prom3, prom4 *TestRemoteWriteTarget
|
||||
}
|
||||
|
||||
func (t *testDataSources) Reset() {
|
||||
t.prom1.Reset()
|
||||
t.prom2.Reset()
|
||||
t.prom3.Reset()
|
||||
t.prom4.Reset()
|
||||
}
|
||||
|
||||
func setupDataSources(t *testing.T) *testDataSources {
|
||||
@@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
prom1: NewTestRemoteWriteTarget(t),
|
||||
prom2: NewTestRemoteWriteTarget(t),
|
||||
prom3: NewTestRemoteWriteTarget(t),
|
||||
prom4: NewTestRemoteWriteTarget(t),
|
||||
}
|
||||
res.DataSourceHeaders = make(map[string]http.Header)
|
||||
|
||||
t.Cleanup(func() {
|
||||
res.prom1.Close()
|
||||
@@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
t.Cleanup(func() {
|
||||
res.prom3.Close()
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
res.prom4.Close()
|
||||
})
|
||||
|
||||
p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "prom-1",
|
||||
@@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
Type: datasources.DS_LOKI,
|
||||
})
|
||||
|
||||
// Add a third Prometheus datasource that uses PDC
|
||||
// Add a third Prometheus datasource that uses PDC.
|
||||
p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "prom-3",
|
||||
UID: "prom-3",
|
||||
@@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
|
||||
require.True(t, p3.IsSecureSocksDSProxyEnabled())
|
||||
|
||||
// Add a fourth Prometheus datasource with headers in the JSON config.
|
||||
p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "prom-4",
|
||||
UID: "prom-4",
|
||||
Type: datasources.DS_PROMETHEUS,
|
||||
JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)),
|
||||
})
|
||||
p4.URL = res.prom4.srv.URL
|
||||
res.prom4.ExpectedPath = "/api/v1/write"
|
||||
res.DataSourceHeaders["prom-4"] = http.Header{
|
||||
"X-Scope-OrgID": []string{"test-user"},
|
||||
"X-Test-Header": []string{"test-value"},
|
||||
"X-Double-Header": []string{"one", "two", "three"},
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) {
|
||||
assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2))
|
||||
})
|
||||
|
||||
t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) {
|
||||
testDS.Reset()
|
||||
overwrittenHeader := "X-Test-Header"
|
||||
cHeaders := map[string]string{
|
||||
"X-Custom-Header": "test-value",
|
||||
"X-Another-Header": "another-value",
|
||||
overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers.
|
||||
}
|
||||
|
||||
cfg = DatasourceWriterConfig{
|
||||
Timeout: time.Second * 5,
|
||||
DefaultDatasourceUID: "prom-1",
|
||||
CustomHeaders: cHeaders,
|
||||
}
|
||||
writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met)
|
||||
|
||||
uid := "prom-4"
|
||||
err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
dsHeaders := testDS.DataSourceHeaders[uid]
|
||||
require.Len(t, dsHeaders, 3)
|
||||
|
||||
// We're confirming we have a data source header with the same name but different value.
|
||||
// This one should not be sent in the request.
|
||||
require.NotEmpty(t, dsHeaders[overwrittenHeader])
|
||||
require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader])
|
||||
|
||||
// All headers (except for the one that was overwritten) should have been used.
|
||||
for k, vv := range dsHeaders {
|
||||
if k != overwrittenHeader {
|
||||
assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k))
|
||||
}
|
||||
}
|
||||
for k, v := range cHeaders {
|
||||
assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) {
|
||||
testDS.Reset()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package writer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget {
|
||||
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != target.ExpectedPath {
|
||||
require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path)
|
||||
require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path))
|
||||
}
|
||||
|
||||
target.mtx.Lock()
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@@ -9,10 +10,12 @@ import (
|
||||
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
)
|
||||
|
||||
@@ -28,7 +31,7 @@ type DashboardProvisioner interface {
|
||||
}
|
||||
|
||||
// DashboardProvisionerFactory creates DashboardProvisioners based on input
|
||||
type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (DashboardProvisioner, error)
|
||||
type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (DashboardProvisioner, error)
|
||||
|
||||
// Provisioner is responsible for syncing dashboard from disk to Grafana's database.
|
||||
type Provisioner struct {
|
||||
@@ -38,6 +41,8 @@ type Provisioner struct {
|
||||
duplicateValidator duplicateValidator
|
||||
provisioner dashboards.DashboardProvisioningService
|
||||
dual dualwrite.Service
|
||||
serverLock *serverlock.ServerLockService
|
||||
cfg *setting.Cfg
|
||||
}
|
||||
|
||||
func (provider *Provisioner) HasDashboardSources() bool {
|
||||
@@ -45,7 +50,7 @@ func (provider *Provisioner) HasDashboardSources() bool {
|
||||
}
|
||||
|
||||
// New returns a new DashboardProvisioner
|
||||
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service) (DashboardProvisioner, error) {
|
||||
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, cfg *setting.Cfg, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service, serverLockService *serverlock.ServerLockService) (DashboardProvisioner, error) {
|
||||
logger := log.New("provisioning.dashboard")
|
||||
cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)}
|
||||
configs, err := cfgReader.readConfig(ctx)
|
||||
@@ -78,6 +83,8 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das
|
||||
duplicateValidator: newDuplicateValidator(logger, fileReaders),
|
||||
provisioner: provisioner,
|
||||
dual: dual,
|
||||
serverLock: serverLockService,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
return d, nil
|
||||
@@ -95,23 +102,53 @@ func (provider *Provisioner) Provision(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
provider.log.Info("starting to provision dashboards")
|
||||
var errProvisioning error
|
||||
|
||||
for _, reader := range provider.fileReaders {
|
||||
if err := reader.walkDisk(ctx); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// don't stop the provisioning service in case the folder is missing. The folder can appear after the startup
|
||||
provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err)
|
||||
// retry obtaining the lock for 20 attempts
|
||||
retryOpt := func(attempts int) error {
|
||||
if attempts < 20 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("retries exhausted")
|
||||
}
|
||||
|
||||
provider.duplicateValidator.validate()
|
||||
provider.log.Info("finished to provision dashboards")
|
||||
return nil
|
||||
lockTimeConfig := serverlock.LockTimeConfig{
|
||||
// if a replica crashes while holding the lock, other replicas can obtain the
|
||||
// lock after this duration (15s default value, might be configured via config file)
|
||||
MaxInterval: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds) * time.Second,
|
||||
|
||||
// wait beetween 100ms and 1s before retrying to obtain the lock (default values, might be configured via config file)
|
||||
MinWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMinWaitMs) * time.Millisecond,
|
||||
MaxWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs) * time.Millisecond,
|
||||
}
|
||||
|
||||
// this means that if we fail to obtain the lock after ~10 seconds, we return an error
|
||||
lockErr := provider.serverLock.LockExecuteAndReleaseWithRetries(ctx, "provisioning_dashboards", lockTimeConfig, func(ctx context.Context) {
|
||||
provider.log.Info("starting to provision dashboards")
|
||||
|
||||
for _, reader := range provider.fileReaders {
|
||||
if err := reader.walkDisk(ctx); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// don't stop the provisioning service in case the folder is missing. The folder can appear after the startup
|
||||
provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
errProvisioning = fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
provider.duplicateValidator.validate()
|
||||
provider.log.Info("finished to provision dashboards")
|
||||
}, retryOpt)
|
||||
|
||||
if lockErr != nil {
|
||||
provider.log.Error("Failed to obtain dashboard provisioning lock", "error", lockErr)
|
||||
return lockErr
|
||||
}
|
||||
|
||||
return errProvisioning
|
||||
}
|
||||
|
||||
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -64,6 +65,7 @@ func ProvideService(
|
||||
tracer tracing.Tracer,
|
||||
dual dualwrite.Service,
|
||||
promTypeMigrationProvider promtypemigration.PromTypeMigrationProvider,
|
||||
serverLockService *serverlock.ServerLockService,
|
||||
) (*ProvisioningServiceImpl, error) {
|
||||
s := &ProvisioningServiceImpl{
|
||||
Cfg: cfg,
|
||||
@@ -92,6 +94,7 @@ func ProvideService(
|
||||
tracer: tracer,
|
||||
migratePrometheusType: promTypeMigrationProvider.Run,
|
||||
dual: dual,
|
||||
serverLock: serverLockService,
|
||||
}
|
||||
|
||||
s.NamedService = services.NewBasicService(s.starting, s.running, nil).WithName(ServiceName)
|
||||
@@ -166,7 +169,7 @@ func (ps *ProvisioningServiceImpl) running(ctx context.Context) error {
|
||||
|
||||
func (ps *ProvisioningServiceImpl) setDashboardProvisioner() error {
|
||||
dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards")
|
||||
dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.orgService, ps.dashboardService, ps.folderService, ps.dual)
|
||||
dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.Cfg, ps.orgService, ps.dashboardService, ps.folderService, ps.dual, ps.serverLock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "Failed to create provisioner", err)
|
||||
}
|
||||
@@ -242,6 +245,7 @@ type ProvisioningServiceImpl struct {
|
||||
resourcePermissions accesscontrol.ReceiverPermissionsService
|
||||
tracer tracing.Tracer
|
||||
dual dualwrite.Service
|
||||
serverLock *serverlock.ServerLockService
|
||||
migratePrometheusType func(context.Context) error
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
dashboardstore "github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
||||
"github.com/grafana/grafana/pkg/services/searchV2"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
)
|
||||
|
||||
@@ -160,7 +162,7 @@ func setup(t *testing.T) *serviceTestStruct {
|
||||
searchStub := searchV2.NewStubSearchService()
|
||||
|
||||
service, err := newProvisioningServiceImpl(
|
||||
func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (dashboards.DashboardProvisioner, error) {
|
||||
func(context.Context, string, dashboardstore.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (dashboards.DashboardProvisioner, error) {
|
||||
serviceTest.dashboardProvisionerInstantiations++
|
||||
return serviceTest.mock, nil
|
||||
},
|
||||
|
||||
@@ -513,9 +513,9 @@ func doSearchQuery(
|
||||
fName := data.NewFieldFromFieldType(data.FieldTypeString, 0)
|
||||
fURL := data.NewFieldFromFieldType(data.FieldTypeString, 0)
|
||||
fLocation := data.NewFieldFromFieldType(data.FieldTypeString, 0)
|
||||
fTags := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)
|
||||
fTags := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) //nolint:staticcheck
|
||||
fDSUIDs := data.NewFieldFromFieldType(data.FieldTypeJSON, 0)
|
||||
fExplain := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)
|
||||
fExplain := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) //nolint:staticcheck
|
||||
|
||||
fScore.Name = "score"
|
||||
fUID.Name = "uid"
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gopkg.in/ini.v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/client-go/dynamic"
|
||||
clientrest "k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/transport"
|
||||
|
||||
authlib "github.com/grafana/authlib/authn"
|
||||
logging "github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/semconv"
|
||||
)
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/setting")
|
||||
|
||||
const LogPrefix = "setting.service"
|
||||
|
||||
const DefaultPageSize = int64(500)
|
||||
const DefaultQPS = float32(10)
|
||||
const DefaultBurst = 25
|
||||
|
||||
const (
|
||||
ApiGroup = "setting.grafana.app"
|
||||
apiVersion = "v0alpha1"
|
||||
resource = "settings"
|
||||
kind = "Setting"
|
||||
listKind = "SettingList"
|
||||
)
|
||||
|
||||
var settingGroupVersion = schema.GroupVersionResource{
|
||||
Group: ApiGroup,
|
||||
Version: apiVersion,
|
||||
Resource: resource,
|
||||
}
|
||||
|
||||
var settingGroupListKind = map[schema.GroupVersionResource]string{
|
||||
settingGroupVersion: listKind,
|
||||
}
|
||||
|
||||
type remoteSettingServiceMetrics struct {
|
||||
listDuration *prometheus.HistogramVec
|
||||
listResultSize *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
// Service retrieves configuration settings from a remote settings service.
|
||||
//
|
||||
// The service uses label selectors to filter settings. Settings are labeled with
|
||||
// "section" and "key" labels matching their spec fields.
|
||||
//
|
||||
// Example - Select all settings:
|
||||
//
|
||||
// ctx := request.WithNamespace(context.Background(), "my-namespace")
|
||||
// ini, err := service.ListAsIni(ctx, metav1.LabelSelector{})
|
||||
//
|
||||
// Example - Select settings from specific sections:
|
||||
//
|
||||
// selector := metav1.LabelSelector{
|
||||
// MatchExpressions: []metav1.LabelSelectorRequirement{
|
||||
// {
|
||||
// Key: "section",
|
||||
// Operator: metav1.LabelSelectorOpIn,
|
||||
// Values: []string{"database", "server"},
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// ini, err := service.ListAsIni(ctx, selector)
|
||||
//
|
||||
// Example - Select settings from a single section with specific labels:
|
||||
//
|
||||
// selector := metav1.LabelSelector{
|
||||
// MatchLabels: map[string]string{
|
||||
// "section": "database",
|
||||
// },
|
||||
// }
|
||||
// settings, err := service.List(ctx, selector)
|
||||
type Service interface {
|
||||
prometheus.Collector
|
||||
// ListAsIni retrieves settings filtered by a label selector from the namespace in context
|
||||
// and returns them as an ini.File.
|
||||
//
|
||||
// The namespace must be present in the context, ie: via request.WithNamespace.
|
||||
// An empty selector returns all settings in the namespace.
|
||||
ListAsIni(ctx context.Context, selector metav1.LabelSelector) (*ini.File, error)
|
||||
|
||||
// List retrieves settings filtered by a label selector from the namespace in context
|
||||
// and returns them as a slice of Setting structs.
|
||||
//
|
||||
// The namespace must be present in the context, ie: via request.WithNamespace.
|
||||
// An empty selector returns all settings in the namespace.
|
||||
List(ctx context.Context, selector metav1.LabelSelector) ([]*Setting, error)
|
||||
}
|
||||
|
||||
type remoteSettingService struct {
|
||||
dynamicClient dynamic.Interface
|
||||
log logging.Logger
|
||||
pageSize int64
|
||||
metrics remoteSettingServiceMetrics
|
||||
}
|
||||
|
||||
var _ Service = (*remoteSettingService)(nil)
|
||||
var _ prometheus.Collector = (*remoteSettingService)(nil)
|
||||
|
||||
// Config configures a Service.
|
||||
type Config struct {
|
||||
// URL is the base URL for the remote settings service (required).
|
||||
URL string
|
||||
// TokenExchangeClient authenticates requests (required if WrapTransport is not set).
|
||||
TokenExchangeClient *authlib.TokenExchangeClient
|
||||
// WrapTransport wraps the HTTP transport for authentication.
|
||||
// Takes precedence over TokenExchangeClient when both are set.
|
||||
// At least one of WrapTransport or TokenExchangeClient is required.
|
||||
WrapTransport transport.WrapperFunc
|
||||
// TLSClientConfig configures TLS for the client connection.
|
||||
TLSClientConfig clientrest.TLSClientConfig
|
||||
// QPS limits requests per second (defaults to DefaultQPS).
|
||||
QPS float32
|
||||
// Burst allows request bursts above QPS (defaults to DefaultBurst).
|
||||
Burst int
|
||||
// PageSize sets the number of items per API page (defaults to DefaultPageSize).
|
||||
PageSize int64
|
||||
}
|
||||
|
||||
// Setting represents the parsed spec of a Setting resource.
|
||||
type Setting struct {
|
||||
// Setting section
|
||||
Section string `json:"section"`
|
||||
// Setting key
|
||||
Key string `json:"key"`
|
||||
// Setting value
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// New creates a Service from the provided configuration.
|
||||
func New(config Config) (Service, error) {
|
||||
log := logging.New(LogPrefix)
|
||||
dynamicClient, err := getDynamicClient(config, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageSize := DefaultPageSize
|
||||
if config.PageSize > 0 {
|
||||
pageSize = config.PageSize
|
||||
}
|
||||
|
||||
metrics := initMetrics()
|
||||
|
||||
return &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: pageSize,
|
||||
log: log,
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) ListAsIni(ctx context.Context, labelSelector metav1.LabelSelector) (*ini.File, error) {
|
||||
namespace, ok := request.NamespaceFrom(ctx)
|
||||
ns := semconv.GrafanaNamespaceName(namespace)
|
||||
ctx, span := tracer.Start(ctx, "remoteSettingService.ListAsIni",
|
||||
trace.WithAttributes(ns))
|
||||
defer span.End()
|
||||
|
||||
if !ok || namespace == "" {
|
||||
return nil, tracing.Errorf(span, "missing namespace in context")
|
||||
}
|
||||
|
||||
settings, err := m.List(ctx, labelSelector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iniFile, err := m.toIni(settings)
|
||||
if err != nil {
|
||||
return nil, tracing.Error(span, err)
|
||||
}
|
||||
return iniFile, nil
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) List(ctx context.Context, labelSelector metav1.LabelSelector) ([]*Setting, error) {
|
||||
namespace, ok := request.NamespaceFrom(ctx)
|
||||
ns := semconv.GrafanaNamespaceName(namespace)
|
||||
ctx, span := tracer.Start(ctx, "remoteSettingService.List",
|
||||
trace.WithAttributes(ns))
|
||||
defer span.End()
|
||||
if !ok || namespace == "" {
|
||||
return nil, tracing.Errorf(span, "missing namespace in context")
|
||||
}
|
||||
log := m.log.FromContext(ctx).New(ns.Key, ns.Value, "function", "remoteSettingService.List", "traceId", span.SpanContext().TraceID())
|
||||
|
||||
startTime := time.Now()
|
||||
var status string
|
||||
defer func() {
|
||||
duration := time.Since(startTime).Seconds()
|
||||
m.metrics.listDuration.WithLabelValues(status).Observe(duration)
|
||||
}()
|
||||
|
||||
selector, err := metav1.LabelSelectorAsSelector(&labelSelector)
|
||||
if err != nil {
|
||||
status = "error"
|
||||
return nil, tracing.Error(span, err)
|
||||
}
|
||||
if selector.Empty() {
|
||||
log.Debug("empty selector. Fetching all settings")
|
||||
}
|
||||
|
||||
var allSettings []*Setting
|
||||
var continueToken string
|
||||
hasNext := true
|
||||
totalPages := 0
|
||||
// Using an upper limit to prevent infinite loops
|
||||
for hasNext && totalPages < 1000 {
|
||||
totalPages++
|
||||
opts := metav1.ListOptions{
|
||||
Limit: m.pageSize,
|
||||
Continue: continueToken,
|
||||
}
|
||||
if !selector.Empty() {
|
||||
opts.LabelSelector = selector.String()
|
||||
}
|
||||
|
||||
settingsList, lErr := m.dynamicClient.Resource(settingGroupVersion).Namespace(namespace).List(ctx, opts)
|
||||
if lErr != nil {
|
||||
status = "error"
|
||||
return nil, tracing.Error(span, lErr)
|
||||
}
|
||||
for i := range settingsList.Items {
|
||||
setting, pErr := parseSettingResource(&settingsList.Items[i])
|
||||
if pErr != nil {
|
||||
status = "error"
|
||||
return nil, tracing.Error(span, pErr)
|
||||
}
|
||||
allSettings = append(allSettings, setting)
|
||||
}
|
||||
continueToken = settingsList.GetContinue()
|
||||
if continueToken == "" {
|
||||
hasNext = false
|
||||
}
|
||||
}
|
||||
|
||||
status = "success"
|
||||
m.metrics.listResultSize.WithLabelValues(status).Observe(float64(len(allSettings)))
|
||||
|
||||
return allSettings, nil
|
||||
}
|
||||
|
||||
func parseSettingResource(setting *unstructured.Unstructured) (*Setting, error) {
|
||||
spec, found, err := unstructured.NestedMap(setting.Object, "spec")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get spec from setting: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("spec not found in setting %s", setting.GetName())
|
||||
}
|
||||
|
||||
var result Setting
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(spec, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert spec to Setting: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) toIni(settings []*Setting) (*ini.File, error) {
|
||||
conf := ini.Empty()
|
||||
for _, setting := range settings {
|
||||
if !conf.HasSection(setting.Section) {
|
||||
_, _ = conf.NewSection(setting.Section)
|
||||
}
|
||||
_, err := conf.Section(setting.Section).NewKey(setting.Key, setting.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func getDynamicClient(config Config, log logging.Logger) (dynamic.Interface, error) {
|
||||
if config.URL == "" {
|
||||
return nil, fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
if config.WrapTransport == nil && config.TokenExchangeClient == nil {
|
||||
return nil, fmt.Errorf("must set either TokenExchangeClient or WrapTransport")
|
||||
}
|
||||
|
||||
wrapTransport := config.WrapTransport
|
||||
if config.WrapTransport == nil {
|
||||
log.Debug("using default wrapTransport with TokenExchangeClient")
|
||||
wrapTransport = func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &authRoundTripper{
|
||||
tokenClient: config.TokenExchangeClient,
|
||||
transport: rt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qps := DefaultQPS
|
||||
if config.QPS > 0 {
|
||||
qps = config.QPS
|
||||
}
|
||||
|
||||
burst := DefaultBurst
|
||||
if config.Burst > 0 {
|
||||
burst = config.Burst
|
||||
}
|
||||
|
||||
return dynamic.NewForConfig(&clientrest.Config{
|
||||
Host: config.URL,
|
||||
WrapTransport: wrapTransport,
|
||||
TLSClientConfig: config.TLSClientConfig,
|
||||
QPS: qps,
|
||||
Burst: burst,
|
||||
})
|
||||
}
|
||||
|
||||
// authRoundTripper wraps an HTTP transport with token-based authentication.
|
||||
type authRoundTripper struct {
|
||||
tokenClient *authlib.TokenExchangeClient
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
var _ http.RoundTripper = (*authRoundTripper)(nil)
|
||||
|
||||
func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
token, err := a.tokenClient.Exchange(req.Context(), authlib.TokenExchangeRequest{
|
||||
Audiences: []string{ApiGroup},
|
||||
Namespace: "*",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
||||
}
|
||||
req = utilnet.CloneRequest(req)
|
||||
|
||||
req.Header.Set("X-Access-Token", fmt.Sprintf("Bearer %s", token.Token))
|
||||
return a.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func initMetrics() remoteSettingServiceMetrics {
|
||||
metrics := remoteSettingServiceMetrics{
|
||||
listDuration: prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "settings",
|
||||
Subsystem: "service",
|
||||
Name: "list_settings_duration_seconds",
|
||||
Help: "Duration of remote settings service List operations",
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
},
|
||||
[]string{"status"}, // status: "success" or "error"
|
||||
),
|
||||
listResultSize: prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "settings",
|
||||
Subsystem: "service",
|
||||
Name: "list_settings_result_size",
|
||||
Help: "Number of settings returned by remote settings service List operations",
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
},
|
||||
[]string{"status"}, // status: "success" or "error"
|
||||
),
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) Describe(descs chan<- *prometheus.Desc) {
|
||||
m.metrics.listDuration.Describe(descs)
|
||||
m.metrics.listResultSize.Describe(descs)
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) Collect(metrics chan<- prometheus.Metric) {
|
||||
m.metrics.listDuration.Collect(metrics)
|
||||
m.metrics.listResultSize.Collect(metrics)
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/client-go/dynamic/fake"
|
||||
k8testing "k8s.io/client-go/testing"
|
||||
|
||||
authlib "github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
)
|
||||
|
||||
func TestRemoteSettingService_ListAsIni(t *testing.T) {
|
||||
t.Run("should filter settings by label selector", func(t *testing.T) {
|
||||
// Create multiple settings, only some matching the selector
|
||||
setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"})
|
||||
setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
|
||||
setting3 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "host", Value: "localhost"})
|
||||
|
||||
client := newTestClient(500, setting1, setting2, setting3)
|
||||
|
||||
// Create a selector that should match only database settings
|
||||
selector := metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"section": "database",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.ListAsIni(ctx, selector)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
// Should only have database settings, not server settings
|
||||
assert.True(t, result.HasSection("database"))
|
||||
assert.Equal(t, "postgres", result.Section("database").Key("type").String())
|
||||
assert.Equal(t, "localhost", result.Section("database").Key("host").String())
|
||||
// Should NOT have server settings
|
||||
assert.False(t, result.HasSection("server"))
|
||||
})
|
||||
|
||||
t.Run("should return all settings with empty selector", func(t *testing.T) {
|
||||
// Create multiple settings across different sections
|
||||
setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
|
||||
setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "mysql"})
|
||||
|
||||
client := newTestClient(500, setting1, setting2)
|
||||
|
||||
// Empty selector should select everything
|
||||
selector := metav1.LabelSelector{}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.ListAsIni(ctx, selector)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
// Should have all settings from all sections
|
||||
assert.True(t, result.HasSection("server"))
|
||||
assert.Equal(t, "3000", result.Section("server").Key("port").String())
|
||||
assert.True(t, result.HasSection("database"))
|
||||
assert.Equal(t, "mysql", result.Section("database").Key("type").String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoteSettingService_List(t *testing.T) {
|
||||
t.Run("should handle single page response", func(t *testing.T) {
|
||||
setting := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
|
||||
|
||||
client := newTestClient(500, setting)
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
|
||||
spec := result[0]
|
||||
assert.Equal(t, "server", spec.Section)
|
||||
assert.Equal(t, "port", spec.Key)
|
||||
assert.Equal(t, "3000", spec.Value)
|
||||
})
|
||||
|
||||
t.Run("should handle multiple pages", func(t *testing.T) {
|
||||
totalPages := 3
|
||||
pageSize := 5
|
||||
|
||||
pages := make([][]*unstructured.Unstructured, totalPages)
|
||||
for pageNum := 0; pageNum < totalPages; pageNum++ {
|
||||
for idx := 0; idx < pageSize; idx++ {
|
||||
item := newUnstructuredSetting(
|
||||
"test-namespace",
|
||||
Setting{
|
||||
Section: fmt.Sprintf("section-%d", pageNum),
|
||||
Key: fmt.Sprintf("key-%d", idx),
|
||||
Value: fmt.Sprintf("val-%d-%d", pageNum, idx),
|
||||
},
|
||||
)
|
||||
pages[pageNum] = append(pages[pageNum], item)
|
||||
}
|
||||
}
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
listCallCount := 0
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
listCallCount++
|
||||
|
||||
continueToken := fmt.Sprintf("continue-%d", listCallCount)
|
||||
if listCallCount == totalPages {
|
||||
continueToken = ""
|
||||
}
|
||||
|
||||
if listCallCount <= totalPages {
|
||||
list := &unstructured.UnstructuredList{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": listKind,
|
||||
},
|
||||
}
|
||||
list.SetContinue(continueToken)
|
||||
for _, item := range pages[listCallCount-1] {
|
||||
list.Items = append(list.Items, *item)
|
||||
}
|
||||
return true, list, nil
|
||||
}
|
||||
|
||||
return false, nil, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: int64(pageSize),
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, totalPages*pageSize)
|
||||
assert.Equal(t, totalPages, listCallCount)
|
||||
})
|
||||
|
||||
t.Run("should pass label selector when provided", func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
listAction := action.(k8testing.ListActionImpl)
|
||||
assert.Equal(t, "app=grafana", listAction.ListOptions.LabelSelector)
|
||||
return true, &unstructured.UnstructuredList{}, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
_, err := client.List(ctx, metav1.LabelSelector{MatchLabels: map[string]string{"app": "grafana"}})
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should stop pagination at 1000 pages", func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
listCallCount := 0
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
listCallCount++
|
||||
// Always return a continue token to simulate infinite pagination
|
||||
list := &unstructured.UnstructuredList{}
|
||||
list.SetContinue("continue-forever")
|
||||
return true, list, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: 10,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
_, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1000, listCallCount, "Should stop at 1000 pages to prevent infinite loops")
|
||||
})
|
||||
|
||||
t.Run("should return error when parsing setting fails", func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
// Return a malformed setting without spec
|
||||
list := &unstructured.UnstructuredList{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": listKind,
|
||||
},
|
||||
}
|
||||
malformedSetting := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "malformed",
|
||||
"namespace": "test-namespace",
|
||||
},
|
||||
// Missing spec
|
||||
},
|
||||
}
|
||||
list.Items = append(list.Items, *malformedSetting)
|
||||
return true, list, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "spec not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSettingResource(t *testing.T) {
|
||||
t.Run("should parse valid setting resource", func(t *testing.T) {
|
||||
setting := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"})
|
||||
|
||||
result, err := parseSettingResource(setting)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "database", result.Section)
|
||||
assert.Equal(t, "type", result.Key)
|
||||
assert.Equal(t, "postgres", result.Value)
|
||||
})
|
||||
|
||||
t.Run("should return error when spec is missing", func(t *testing.T) {
|
||||
setting := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-setting",
|
||||
"namespace": "test-namespace",
|
||||
},
|
||||
// No spec
|
||||
},
|
||||
}
|
||||
|
||||
result, err := parseSettingResource(setting)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "spec not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoteSettingService_ToIni(t *testing.T) {
|
||||
t.Run("should convert settings to ini format", func(t *testing.T) {
|
||||
settings := []*Setting{
|
||||
{Section: "database", Key: "type", Value: "postgres"},
|
||||
{Section: "database", Key: "host", Value: "localhost"},
|
||||
{Section: "server", Key: "http_port", Value: "3000"},
|
||||
}
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.HasSection("database"))
|
||||
assert.True(t, result.HasSection("server"))
|
||||
assert.Equal(t, "postgres", result.Section("database").Key("type").String())
|
||||
assert.Equal(t, "localhost", result.Section("database").Key("host").String())
|
||||
assert.Equal(t, "3000", result.Section("server").Key("http_port").String())
|
||||
})
|
||||
|
||||
t.Run("should handle empty settings list", func(t *testing.T) {
|
||||
var settings []*Setting
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
sections := result.Sections()
|
||||
assert.Len(t, sections, 1) // Only default section
|
||||
})
|
||||
|
||||
t.Run("should create section if it does not exist", func(t *testing.T) {
|
||||
settings := []*Setting{
|
||||
{Section: "new_section", Key: "new_key", Value: "new_value"},
|
||||
}
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.HasSection("new_section"))
|
||||
assert.Equal(t, "new_value", result.Section("new_section").Key("new_key").String())
|
||||
})
|
||||
|
||||
t.Run("should handle multiple keys in same section", func(t *testing.T) {
|
||||
settings := []*Setting{
|
||||
{Section: "auth", Key: "disable_login_form", Value: "false"},
|
||||
{Section: "auth", Key: "disable_signout_menu", Value: "true"},
|
||||
}
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.HasSection("auth"))
|
||||
authSection := result.Section("auth")
|
||||
assert.Equal(t, "false", authSection.Key("disable_login_form").String())
|
||||
assert.Equal(t, "true", authSection.Key("disable_signout_menu").String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("should create client with default page size", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
remoteClient := client.(*remoteSettingService)
|
||||
assert.Equal(t, DefaultPageSize, remoteClient.pageSize)
|
||||
})
|
||||
|
||||
t.Run("should create client with custom page size", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
remoteClient := client.(*remoteSettingService)
|
||||
assert.Equal(t, int64(100), remoteClient.pageSize)
|
||||
})
|
||||
|
||||
t.Run("should use default page size when zero is provided", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
PageSize: 0,
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
remoteClient := client.(*remoteSettingService)
|
||||
assert.Equal(t, DefaultPageSize, remoteClient.pageSize)
|
||||
})
|
||||
|
||||
t.Run("should return error when config is invalid", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "", // Invalid: empty URL
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "URL cannot be empty")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDynamicClient(t *testing.T) {
|
||||
logger := log.NewNopLogger()
|
||||
|
||||
t.Run("should return error when SettingServiceURL is empty", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "URL cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("should return error when both TokenExchangeClient and WrapTransport are nil", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
TokenExchangeClient: nil,
|
||||
WrapTransport: nil,
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "must set either TokenExchangeClient or WrapTransport")
|
||||
})
|
||||
|
||||
t.Run("should create client with WrapTransport", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
})
|
||||
|
||||
t.Run("should not fail when QPS and Burst are not provided", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
})
|
||||
|
||||
t.Run("should not fail when custom QPS and Burst are provided", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
QPS: 10.0,
|
||||
Burst: 20,
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
})
|
||||
|
||||
t.Run("should use WrapTransport when both WrapTransport and TokenExchangeClient are provided", func(t *testing.T) {
|
||||
wrapTransportCalled := false
|
||||
tokenExchangeClient := &authlib.TokenExchangeClient{}
|
||||
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
TokenExchangeClient: tokenExchangeClient,
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
||||
wrapTransportCalled = true
|
||||
return rt
|
||||
},
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
assert.True(t, wrapTransportCalled, "WrapTransport should be called and take precedence over TokenExchangeClient")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create an unstructured Setting object for tests
|
||||
func newUnstructuredSetting(namespace string, spec Setting) *unstructured.Unstructured {
|
||||
// Generate resource name in the format {section}--{key}
|
||||
name := fmt.Sprintf("%s--%s", spec.Section, spec.Key)
|
||||
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": name,
|
||||
"namespace": namespace,
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"section": spec.Section,
|
||||
"key": spec.Key,
|
||||
"value": spec.Value,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Always set section and key labels
|
||||
obj.SetLabels(map[string]string{
|
||||
"section": spec.Section,
|
||||
"key": spec.Key,
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
// Helper function to create a test client with the dynamic fake client
|
||||
func newTestClient(pageSize int64, objects ...runtime.Object) *remoteSettingService {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind, objects...)
|
||||
|
||||
return &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: pageSize,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
}
|
||||
@@ -581,10 +581,16 @@ func TestMain(m *testing.M) {
|
||||
// nolint:staticcheck
|
||||
testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally
|
||||
|
||||
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
|
||||
return nil, err
|
||||
skipTruncate := false
|
||||
if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present {
|
||||
skipTruncate = strings.ToLower(skip) == "true"
|
||||
}
|
||||
if !skipTruncate {
|
||||
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
testSQLStore.engine.ResetSequenceGenerator()
|
||||
}
|
||||
testSQLStore.engine.ResetSequenceGenerator()
|
||||
|
||||
if err := testSQLStore.Reset(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -47,11 +47,11 @@ type CreateTeamCommand struct {
|
||||
}
|
||||
|
||||
type UpdateTeamCommand struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string
|
||||
ExternalUID string
|
||||
OrgID int64 `json:"-"`
|
||||
ID int64 `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ExternalUID string `json:"-"`
|
||||
OrgID int64 `json:"-"`
|
||||
}
|
||||
|
||||
type DeleteTeamCommand struct {
|
||||
|
||||
@@ -312,6 +312,10 @@ type GetTeamByIDParams struct {
|
||||
// in:path
|
||||
// required:true
|
||||
TeamID string `json:"team_id"`
|
||||
// in:query
|
||||
// required:false
|
||||
// default: false
|
||||
AccessControl bool `json:"accesscontrol"`
|
||||
}
|
||||
|
||||
// swagger:parameters deleteTeamByID
|
||||
|
||||
@@ -150,6 +150,11 @@ type Cfg struct {
|
||||
PluginsPath string
|
||||
EnterpriseLicensePath string
|
||||
|
||||
// Classic Provisioning settings
|
||||
ClassicProvisioningDashboardsServerLockMaxIntervalSeconds int64
|
||||
ClassicProvisioningDashboardsServerLockMinWaitMs int64
|
||||
ClassicProvisioningDashboardsServerLockMaxWaitMs int64
|
||||
|
||||
// SMTP email settings
|
||||
Smtp SmtpSettings
|
||||
|
||||
@@ -610,6 +615,8 @@ type Cfg struct {
|
||||
HttpsSkipVerify bool
|
||||
ResourceServerJoinRingTimeout time.Duration
|
||||
EnableSearch bool
|
||||
OverridesFilePath string
|
||||
OverridesReloadInterval time.Duration
|
||||
|
||||
// Secrets Management
|
||||
SecretsManagement SecretsManagerSettings
|
||||
@@ -1221,6 +1228,8 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.readClassicProvisioningSettings(iniFile)
|
||||
|
||||
// read dashboard settings
|
||||
dashboards := iniFile.Section("dashboards")
|
||||
cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20)
|
||||
@@ -2107,6 +2116,12 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readClassicProvisioningSettings(iniFile *ini.File) {
|
||||
cfg.ClassicProvisioningDashboardsServerLockMinWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_min_wait_ms").MustInt64(100)
|
||||
cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_wait_ms").MustInt64(1000)
|
||||
cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_interval_seconds").MustInt64(15)
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
|
||||
provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "")
|
||||
cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath)
|
||||
|
||||
@@ -94,6 +94,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
cfg.HttpsSkipVerify = section.Key("https_skip_verify").MustBool(false)
|
||||
cfg.ResourceServerJoinRingTimeout = section.Key("resource_server_join_ring_timeout").MustDuration(10 * time.Second)
|
||||
|
||||
// quotas/limits config
|
||||
cfg.OverridesFilePath = section.Key("overrides_path").String()
|
||||
cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second)
|
||||
|
||||
cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0)
|
||||
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
|
||||
}
|
||||
|
||||
@@ -618,7 +618,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) {
|
||||
}, "actor-uid")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name))
|
||||
require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name))
|
||||
|
||||
value := secretv1beta1.NewExposedSecureValue("v1")
|
||||
sv1, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(&secretv1beta1.SecureValue{
|
||||
@@ -643,7 +643,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) {
|
||||
},
|
||||
}, "actor-uid")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name))
|
||||
require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name))
|
||||
|
||||
// - Read secure value created with inactive keeper
|
||||
readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(sv1.Namespace), sv1.Name)
|
||||
|
||||
@@ -202,30 +202,58 @@ then run:
|
||||
kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml
|
||||
```
|
||||
|
||||
### Run as a GRPC service
|
||||
### Run as a separate GRPC service
|
||||
|
||||
#### Start GRPC storage-server
|
||||
It is recommended to use a separate config file for the storage-server. Create a file `conf/storage-server.ini` with the following content:
|
||||
|
||||
Make sure you have the gRPC address in the `[grafana-apiserver]` section of your config file:
|
||||
```ini
|
||||
app_mode = development
|
||||
|
||||
target = storage-server
|
||||
|
||||
[database]
|
||||
type = mysql
|
||||
host = 127.0.0.1:3306
|
||||
name = unified-storage
|
||||
user = root
|
||||
password = rootpass
|
||||
skip_migrations = true
|
||||
ensure_default_org_and_user = false
|
||||
|
||||
[grpc_server]
|
||||
network = "tcp"
|
||||
address = "127.0.0.1:10000"
|
||||
|
||||
[grafana-apiserver]
|
||||
; your gRPC server address
|
||||
address = localhost:10000
|
||||
```
|
||||
storage_type = unified
|
||||
|
||||
You also need the `[grpc_server_authentication]` section to authenticate incoming requests:
|
||||
```ini
|
||||
[grpc_server_authentication]
|
||||
; http url to Grafana's signing keys to validate incoming id tokens
|
||||
signing_keys_url = http://localhost:3000/api/signing-keys/keys
|
||||
signing_keys_url = http://localhost:3011/api/signing-keys/keys
|
||||
mode = "on-prem"
|
||||
|
||||
[feature_toggles]
|
||||
kubernetesDashboards = true
|
||||
kubernetesFolders = true
|
||||
unifiedStorage = true
|
||||
unifiedStorageHistoryPruner = true
|
||||
unifiedStorageSearch = true
|
||||
unifiedStorageSearchPermissionFiltering = false
|
||||
unifiedStorageSearchSprinkles = false
|
||||
|
||||
[unified_storage]
|
||||
enable_search = true
|
||||
https_skip_verify = true
|
||||
```
|
||||
|
||||
This currently only works with a separate database configuration (see previous section).
|
||||
You should also have a MySQL database running. You can create one with our docker blocks by running:
|
||||
```bash
|
||||
make devenv sources=mysql
|
||||
```
|
||||
The database credentials in the example above will work with the default mysql docker block. You'll also need to create a database named `unified-storage`.
|
||||
|
||||
Start the storage-server with:
|
||||
```sh
|
||||
GF_DEFAULT_TARGET=storage-server ./bin/grafana server target
|
||||
./bin/grafana server target --config conf/storage-server.ini
|
||||
```
|
||||
|
||||
The GRPC service will listen on port 10000
|
||||
|
||||
@@ -54,6 +54,9 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, sec
|
||||
// Create BadgerDB with in-memory mode
|
||||
db, err := badger.Open(badger.DefaultOptions("").
|
||||
WithInMemory(true).
|
||||
WithMemTableSize(256 << 10). // 256KB memtable size
|
||||
WithValueThreshold(16 << 10). // 16KB threshold for storing values in LSM vs value log
|
||||
WithNumMemtables(2). // Keep only 2 memtables in memory
|
||||
WithLogger(nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -211,6 +211,19 @@ func newClient(opts options.StorageOptions,
|
||||
serverOptions.QOSQueue = queue
|
||||
}
|
||||
|
||||
// only enable if an overrides file path is provided
|
||||
if cfg.OverridesFilePath != "" {
|
||||
overridesSvc, err := resource.NewOverridesService(ctx, cfg.Logger, reg, tracer, resource.ReloadOptions{
|
||||
FilePath: cfg.OverridesFilePath,
|
||||
ReloadPeriod: cfg.OverridesReloadInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverOptions.OverridesService = overridesSvc
|
||||
}
|
||||
|
||||
server, err := sql.NewResourceServer(serverOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration
|
||||
type foldersAndDashboardsTestCase struct {
|
||||
parentFolderUID string
|
||||
childFolderUID string
|
||||
dashboardUID string
|
||||
libPanelUID string
|
||||
}
|
||||
|
||||
// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator
|
||||
func newFoldersAndDashboardsTestCase() resourceMigratorTestCase {
|
||||
return &foldersAndDashboardsTestCase{
|
||||
parentFolderUID: "parent-folder-uid",
|
||||
childFolderUID: "child-folder-uid",
|
||||
dashboardUID: "", // Will be generated during setup
|
||||
libPanelUID: "", // Will be generated during setup
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) name() string {
|
||||
return "folders-dashboards"
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource {
|
||||
return []schema.GroupVersionResource{
|
||||
{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
},
|
||||
{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
t.Helper()
|
||||
|
||||
// Create parent folder
|
||||
parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "")
|
||||
|
||||
// Create child folder (nested under parent)
|
||||
child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID)
|
||||
|
||||
// Create library panel in child folder
|
||||
tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID)
|
||||
|
||||
// Create dashboard with library panel in child folder
|
||||
tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel",
|
||||
tc.libPanelUID, "Test LP in dashboard", child.UID)
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) {
|
||||
t.Helper()
|
||||
|
||||
// Build maps of UIDs by resource type
|
||||
folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID}
|
||||
dashboardUIDs := []string{tc.dashboardUID}
|
||||
|
||||
expectedFolderCount := 0
|
||||
if shouldExist {
|
||||
expectedFolderCount = len(folderUIDs)
|
||||
}
|
||||
orgID := helper.Org1.OrgID
|
||||
namespace := authlib.OrgNamespaceFormatter(orgID)
|
||||
|
||||
// Verify folders
|
||||
folderCli := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: namespace,
|
||||
GVR: schema.GroupVersionResource{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
},
|
||||
})
|
||||
verifyResourceCount(t, folderCli, expectedFolderCount)
|
||||
for _, uid := range folderUIDs {
|
||||
verifyResource(t, folderCli, uid, shouldExist)
|
||||
}
|
||||
|
||||
// Verify dashboards
|
||||
expectedDashboardCount := 0
|
||||
if shouldExist {
|
||||
expectedDashboardCount = len(dashboardUIDs)
|
||||
}
|
||||
dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: namespace,
|
||||
GVR: schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
})
|
||||
verifyResourceCount(t, dashboardCli, expectedDashboardCount)
|
||||
for _, uid := range dashboardUIDs {
|
||||
verifyResource(t, dashboardCli, uid, shouldExist)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestFolder creates a folder with specified UID and optional parent
|
||||
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder {
|
||||
t.Helper()
|
||||
|
||||
payload := fmt.Sprintf(`{
|
||||
"title": "%s",
|
||||
"uid": "%s"`, title, uid)
|
||||
|
||||
if parentUID != "" {
|
||||
payload += fmt.Sprintf(`,
|
||||
"parentUid": "%s"`, parentUID)
|
||||
}
|
||||
|
||||
payload += "}"
|
||||
|
||||
folderCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/folders",
|
||||
Body: []byte(payload),
|
||||
}, &folder.Folder{})
|
||||
|
||||
require.NotNil(t, folderCreate.Result)
|
||||
require.Equal(t, uid, folderCreate.Result.UID)
|
||||
|
||||
return folderCreate.Result
|
||||
}
|
||||
|
||||
// createTestLibraryPanel creates a library panel in a folder
|
||||
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string {
|
||||
t.Helper()
|
||||
|
||||
libPanelPayload := fmt.Sprintf(`{
|
||||
"kind": 1,
|
||||
"name": "%s",
|
||||
"folderUid": "%s",
|
||||
"model": {
|
||||
"type": "text",
|
||||
"title": "%s"
|
||||
}
|
||||
}`, name, folderUID, name)
|
||||
|
||||
libCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/library-elements",
|
||||
Body: []byte(libPanelPayload),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.NotNil(t, libCreate.Response)
|
||||
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
|
||||
|
||||
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
|
||||
require.NotEmpty(t, libPanelUID)
|
||||
|
||||
return libPanelUID
|
||||
}
|
||||
|
||||
// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel
|
||||
func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string {
|
||||
t.Helper()
|
||||
|
||||
dashPayload := fmt.Sprintf(`{
|
||||
"dashboard": {
|
||||
"title": "%s",
|
||||
"panels": [{
|
||||
"id": 1,
|
||||
"libraryPanel": {
|
||||
"uid": "%s",
|
||||
"name": "%s"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"folderUid": "%s",
|
||||
"overwrite": false
|
||||
}`, dashTitle, libPanelUID, libPanelName, folderUID)
|
||||
|
||||
dashCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/dashboards/db",
|
||||
Body: []byte(dashPayload),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.NotNil(t, dashCreate.Response)
|
||||
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
|
||||
|
||||
dashUID := (*dashCreate.Result)["uid"].(string)
|
||||
require.NotEmpty(t, dashUID)
|
||||
return dashUID
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
// resourceMigratorTestCase defines the interface for testing a resource migrator.
|
||||
type resourceMigratorTestCase interface {
|
||||
// name returns the test case name
|
||||
name() string
|
||||
// resources returns the GVRs that this migrator handles
|
||||
resources() []schema.GroupVersionResource
|
||||
// setup creates test resources in legacy storage (Mode0)
|
||||
setup(t *testing.T, helper *apis.K8sTestHelper)
|
||||
// verify checks that resources exist (or don't exist) in unified storage
|
||||
verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool)
|
||||
}
|
||||
|
||||
// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage.
|
||||
// The test follows a three-step process:
|
||||
// Step 1: inserts legacy data (migration disabled at startup)
|
||||
// Step 2: verifies that the data is not in unified storage
|
||||
// Step 3: migration runs at startup, and the test verifies that the data is in unified storage
|
||||
func TestIntegrationMigrations(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
migrationTestCases := []resourceMigratorTestCase{
|
||||
newFoldersAndDashboardsTestCase(),
|
||||
}
|
||||
|
||||
runMigrationTestSuite(t, migrationTestCases)
|
||||
}
|
||||
|
||||
// runMigrationTestSuite executes the migration test suite for the given test cases
|
||||
func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
|
||||
if db.IsTestDbSQLite() {
|
||||
// Share the same SQLite DB file between steps
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := tmpDir + "/shared-migration-test-suite.db"
|
||||
|
||||
oldVal := os.Getenv("SQLITE_TEST_DB")
|
||||
require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath))
|
||||
t.Cleanup(func() {
|
||||
if oldVal == "" {
|
||||
_ = os.Unsetenv("SQLITE_TEST_DB")
|
||||
} else {
|
||||
_ = os.Setenv("SQLITE_TEST_DB", oldVal)
|
||||
}
|
||||
})
|
||||
t.Logf("Using shared database path: %s", dbPath)
|
||||
}
|
||||
|
||||
// Store UIDs created by each test case
|
||||
type testCaseState struct {
|
||||
tc resourceMigratorTestCase
|
||||
}
|
||||
testStates := make([]testCaseState, len(testCases))
|
||||
for i, tc := range testCases {
|
||||
testStates[i].tc = tc
|
||||
}
|
||||
|
||||
// reuse org users throughout the tests
|
||||
var org1 *apis.OrgUsers
|
||||
var orgB *apis.OrgUsers
|
||||
t.Run("Step 1: Create data in legacy", func(t *testing.T) {
|
||||
// Enforce Mode0 for all migrated resources
|
||||
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
|
||||
for _, tc := range testCases {
|
||||
for _, gvr := range tc.resources() {
|
||||
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
|
||||
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
|
||||
DualWriterMode: grafanarest.Mode0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up test environment with Mode0 (writes only to legacy)
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
DisableDataMigrations: true,
|
||||
DisableDBCleanup: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: unifiedConfig,
|
||||
})
|
||||
t.Cleanup(helper.Shutdown)
|
||||
org1 = &helper.Org1
|
||||
orgB = &helper.OrgB
|
||||
|
||||
for i := range testStates {
|
||||
state := &testStates[i]
|
||||
t.Run(state.tc.name(), func(t *testing.T) {
|
||||
state.tc.setup(t, helper)
|
||||
// Verify resources were created in legacy storage
|
||||
state.tc.verify(t, helper, true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1
|
||||
oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE")
|
||||
require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true"))
|
||||
t.Cleanup(func() {
|
||||
if oldSkipTruncate == "" {
|
||||
_ = os.Unsetenv("SKIP_DB_TRUNCATE")
|
||||
} else {
|
||||
_ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) {
|
||||
// Build unified storage config for Mode5
|
||||
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
|
||||
for _, tc := range testCases {
|
||||
for _, gvr := range tc.resources() {
|
||||
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
|
||||
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
|
||||
DualWriterMode: grafanarest.Mode5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
|
||||
GrafanaOpts: testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
DisableDataMigrations: true,
|
||||
DisableDBCleanup: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: unifiedConfig,
|
||||
},
|
||||
Org1Users: org1,
|
||||
OrgBUsers: orgB,
|
||||
})
|
||||
t.Cleanup(helper.Shutdown)
|
||||
|
||||
for _, state := range testStates {
|
||||
t.Run(state.tc.name(), func(t *testing.T) {
|
||||
// Verify resources don't exist in unified storage yet
|
||||
state.tc.verify(t, helper, false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) {
|
||||
// Migrations will run automatically at startup and mode 5 is enforced by the config
|
||||
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
|
||||
GrafanaOpts: testinfra.GrafanaOpts{
|
||||
// EnableLog: true,
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
DisableDataMigrations: false, // Run migrations at startup
|
||||
APIServerStorageType: "unified",
|
||||
},
|
||||
Org1Users: org1,
|
||||
OrgBUsers: orgB,
|
||||
})
|
||||
t.Cleanup(helper.Shutdown)
|
||||
|
||||
for _, state := range testStates {
|
||||
t.Run(state.tc.name(), func(t *testing.T) {
|
||||
// Verify resources now exist in unified storage after migration
|
||||
state.tc.verify(t, helper, true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// verifyResourceCount verifies that the expected number of resources exist in K8s storage
|
||||
func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) {
|
||||
t.Helper()
|
||||
|
||||
l, err := client.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
resources, err := meta.ExtractList(l)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedCount, len(resources))
|
||||
}
|
||||
|
||||
// verifyResource verifies that a resource with the given UID exists in K8s storage
|
||||
func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) {
|
||||
t.Helper()
|
||||
|
||||
_, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
|
||||
if shouldExist {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/util/xorm"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@@ -72,6 +73,17 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro
|
||||
|
||||
m.log.Info("Starting migration for all organizations", "org_count", len(orgs), "resources", m.resources)
|
||||
|
||||
if mg.Dialect.DriverName() == migrator.SQLite {
|
||||
// reuse transaction in SQLite to avoid "database is locked" errors
|
||||
tx, err := sess.Tx()
|
||||
if err != nil {
|
||||
m.log.Error("Failed to get transaction from session", "error", err)
|
||||
return fmt.Errorf("failed to get transaction: %w", err)
|
||||
}
|
||||
ctx = resource.ContextWithTransaction(ctx, tx.Tx)
|
||||
m.log.Info("Stored migrator transaction in context for bulk operations (SQLite compatibility)")
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := m.migrateOrg(ctx, sess, org); err != nil {
|
||||
return err
|
||||
@@ -107,6 +119,10 @@ func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session,
|
||||
m.log.Error("Migration failed", "org_id", org.ID, "error", err, "duration", time.Since(startTime))
|
||||
return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, err)
|
||||
}
|
||||
if response.Error != nil {
|
||||
m.log.Error("Migration reported error", "org_id", org.ID, "error", response.Error.String(), "duration", time.Since(startTime))
|
||||
return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, fmt.Errorf("migration error: %s", response.Error.Message))
|
||||
}
|
||||
|
||||
// Validate the migration results
|
||||
if err := m.validateMigration(migrationCtx, sess, response); err != nil {
|
||||
|
||||
@@ -85,8 +85,13 @@ func RegisterMigrations(
|
||||
|
||||
// Run all registered migrations (blocking)
|
||||
sec := cfg.Raw.Section("database")
|
||||
migrationLocking := sec.Key("migration_locking").MustBool(true)
|
||||
if mg.Dialect.DriverName() == sqlstoremigrator.SQLite {
|
||||
// disable migration locking for SQLite to avoid "database is locked" errors in the bulk operations
|
||||
migrationLocking = false
|
||||
}
|
||||
if err := mg.RunMigrations(ctx,
|
||||
sec.Key("migration_locking").MustBool(true),
|
||||
migrationLocking,
|
||||
sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil {
|
||||
return fmt.Errorf("unified storage data migration failed: %w", err)
|
||||
}
|
||||
@@ -98,12 +103,14 @@ func RegisterMigrations(
|
||||
func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) {
|
||||
folders := schema.GroupResource{Group: "folder.grafana.app", Resource: "folders"}
|
||||
dashboards := schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}
|
||||
driverName := mg.Dialect.DriverName()
|
||||
|
||||
folderCountValidator := NewCountValidator(
|
||||
client,
|
||||
folders,
|
||||
"dashboard",
|
||||
"org_id = ? and is_folder = true",
|
||||
driverName,
|
||||
)
|
||||
|
||||
dashboardCountValidator := NewCountValidator(
|
||||
@@ -111,9 +118,10 @@ func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator
|
||||
dashboards,
|
||||
"dashboard",
|
||||
"org_id = ? and is_folder = false",
|
||||
driverName,
|
||||
)
|
||||
|
||||
folderTreeValidator := NewFolderTreeValidator(client, folders)
|
||||
folderTreeValidator := NewFolderTreeValidator(client, folders, driverName)
|
||||
|
||||
dashboardsAndFolders := NewResourceMigration(
|
||||
migrator,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/util/xorm"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@@ -57,6 +58,7 @@ type CountValidator struct {
|
||||
resource schema.GroupResource
|
||||
table string
|
||||
whereClause string
|
||||
driverName string
|
||||
}
|
||||
|
||||
func NewCountValidator(
|
||||
@@ -64,6 +66,7 @@ func NewCountValidator(
|
||||
resource schema.GroupResource,
|
||||
table string,
|
||||
whereClause string,
|
||||
driverName string,
|
||||
) Validator {
|
||||
return &CountValidator{
|
||||
name: "CountValidator",
|
||||
@@ -71,6 +74,7 @@ func NewCountValidator(
|
||||
resource: resource,
|
||||
table: table,
|
||||
whereClause: whereClause,
|
||||
driverName: driverName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,22 +124,32 @@ func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, respo
|
||||
return fmt.Errorf("failed to count %s: %w", v.table, err)
|
||||
}
|
||||
|
||||
// Get unified storage count using GetStats API
|
||||
statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{
|
||||
Namespace: summary.Namespace,
|
||||
Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w",
|
||||
summary.Group, summary.Resource, summary.Namespace, err)
|
||||
}
|
||||
|
||||
// Find the count for this specific resource type
|
||||
var unifiedCount int64
|
||||
for _, stat := range statsResp.Stats {
|
||||
if stat.Group == summary.Group && stat.Resource == summary.Resource {
|
||||
unifiedCount = stat.Count
|
||||
break
|
||||
if v.driverName == migrator.SQLite {
|
||||
unifiedCount, err = sess.Table("resource").
|
||||
Where("namespace = ? AND `group` = ? AND resource = ?",
|
||||
summary.Namespace, summary.Group, summary.Resource).
|
||||
Count()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count resource table for %s/%s in namespace %s: %w",
|
||||
summary.Group, summary.Resource, summary.Namespace, err)
|
||||
}
|
||||
} else {
|
||||
// Get unified storage count using GetStats API
|
||||
statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{
|
||||
Namespace: summary.Namespace,
|
||||
Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w",
|
||||
summary.Group, summary.Resource, summary.Namespace, err)
|
||||
}
|
||||
// Find the count for this specific resource type
|
||||
for _, stat := range statsResp.Stats {
|
||||
if stat.Group == summary.Group && stat.Resource == summary.Resource {
|
||||
unifiedCount = stat.Count
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,19 +176,22 @@ func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, respo
|
||||
}
|
||||
|
||||
type FolderTreeValidator struct {
|
||||
name string
|
||||
client resourcepb.ResourceIndexClient
|
||||
resource schema.GroupResource
|
||||
name string
|
||||
client resourcepb.ResourceIndexClient
|
||||
resource schema.GroupResource
|
||||
driverName string
|
||||
}
|
||||
|
||||
func NewFolderTreeValidator(
|
||||
client resourcepb.ResourceIndexClient,
|
||||
resource schema.GroupResource,
|
||||
driverName string,
|
||||
) Validator {
|
||||
return &FolderTreeValidator{
|
||||
name: "FolderTreeValidator",
|
||||
client: client,
|
||||
resource: resource,
|
||||
name: "FolderTreeValidator",
|
||||
client: client,
|
||||
resource: resource,
|
||||
driverName: driverName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +202,12 @@ type legacyFolder struct {
|
||||
Title string `xorm:"title"`
|
||||
}
|
||||
|
||||
type unifiedFolder struct {
|
||||
GUID string `xorm:"guid"`
|
||||
Name string `xorm:"name"`
|
||||
Folder string `xorm:"folder"`
|
||||
}
|
||||
|
||||
func (v *FolderTreeValidator) Name() string {
|
||||
return v.name
|
||||
}
|
||||
@@ -218,7 +241,12 @@ func (v *FolderTreeValidator) Validate(ctx context.Context, sess *xorm.Session,
|
||||
}
|
||||
|
||||
// Build unified storage folder parent map
|
||||
unifiedParentMap, err := v.buildUnifiedFolderParentMap(ctx, summary.Namespace, log)
|
||||
var unifiedParentMap map[string]string
|
||||
if v.driverName == migrator.SQLite {
|
||||
unifiedParentMap, err = v.buildUnifiedFolderParentMapSQLite(sess, summary.Namespace, log)
|
||||
} else {
|
||||
unifiedParentMap, err = v.buildUnifiedFolderParentMap(ctx, summary.Namespace, log)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build unified folder parent map: %w", err)
|
||||
}
|
||||
@@ -348,3 +376,30 @@ func (v *FolderTreeValidator) buildUnifiedFolderParentMap(ctx context.Context, n
|
||||
|
||||
return parentMap, nil
|
||||
}
|
||||
|
||||
func (v *FolderTreeValidator) buildUnifiedFolderParentMapSQLite(sess *xorm.Session, namespace string, log log.Logger) (map[string]string, error) {
|
||||
var folders []unifiedFolder
|
||||
err := sess.Table("resource").
|
||||
Cols("guid", "name", "folder").
|
||||
Where("namespace = ? AND resource = ?", namespace, "folder").
|
||||
Find(&folders)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query unified folders: %w", err)
|
||||
}
|
||||
|
||||
parentMap := make(map[string]string)
|
||||
for _, folder := range folders {
|
||||
parentMap[folder.Name] = folder.Folder
|
||||
}
|
||||
|
||||
if len(parentMap) == 0 {
|
||||
log.Debug("No unified folders found for namespace", "namespace", namespace)
|
||||
return make(map[string]string), nil
|
||||
}
|
||||
|
||||
log.Debug("Built unified folder parent map",
|
||||
"folder_count", len(parentMap),
|
||||
"namespace", namespace)
|
||||
|
||||
return parentMap, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/dskit/backoff"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
|
||||
@@ -13,8 +14,8 @@ import (
|
||||
|
||||
const (
|
||||
defaultLookbackPeriod = 30 * time.Second
|
||||
defaultPollInterval = 100 * time.Millisecond
|
||||
defaultEventCacheSize = 10000
|
||||
defaultMinBackoff = 100 * time.Millisecond
|
||||
defaultMaxBackoff = 5 * time.Second
|
||||
defaultBufferSize = 10000
|
||||
)
|
||||
|
||||
@@ -29,15 +30,17 @@ type notifierOptions struct {
|
||||
|
||||
type watchOptions struct {
|
||||
LookbackPeriod time.Duration // How far back to look for events
|
||||
PollInterval time.Duration // How often to poll for new events
|
||||
BufferSize int // How many events to buffer
|
||||
MinBackoff time.Duration // Minimum interval between polling requests
|
||||
MaxBackoff time.Duration // Maximum interval between polling requests
|
||||
}
|
||||
|
||||
func defaultWatchOptions() watchOptions {
|
||||
return watchOptions{
|
||||
LookbackPeriod: defaultLookbackPeriod,
|
||||
PollInterval: defaultPollInterval,
|
||||
BufferSize: defaultBufferSize,
|
||||
MinBackoff: defaultMinBackoff,
|
||||
MaxBackoff: defaultMaxBackoff,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +65,13 @@ func (n *notifier) cacheKey(evt Event) string {
|
||||
}
|
||||
|
||||
func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
if opts.PollInterval <= 0 {
|
||||
opts.PollInterval = defaultPollInterval
|
||||
if opts.MinBackoff <= 0 {
|
||||
opts.MinBackoff = defaultMinBackoff
|
||||
}
|
||||
if opts.MaxBackoff <= 0 || opts.MaxBackoff <= opts.MinBackoff {
|
||||
opts.MaxBackoff = defaultMaxBackoff
|
||||
}
|
||||
|
||||
cacheTTL := opts.LookbackPeriod
|
||||
cacheCleanupInterval := 2 * opts.LookbackPeriod
|
||||
|
||||
@@ -81,11 +88,21 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
// Initialize backoff with minimum backoff interval
|
||||
currentInterval := opts.MinBackoff
|
||||
backoffConfig := backoff.Config{
|
||||
MinBackoff: opts.MinBackoff,
|
||||
MaxBackoff: opts.MaxBackoff,
|
||||
MaxRetries: 0, // infinite retries
|
||||
}
|
||||
bo := backoff.New(ctx, backoffConfig)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(opts.PollInterval):
|
||||
case <-time.After(currentInterval):
|
||||
foundEvents := false
|
||||
for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) {
|
||||
if err != nil {
|
||||
n.log.Error("Failed to list events since", "error", err)
|
||||
@@ -102,6 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
continue
|
||||
}
|
||||
|
||||
foundEvents = true
|
||||
if evt.ResourceVersion > lastRV {
|
||||
lastRV = evt.ResourceVersion + 1
|
||||
}
|
||||
@@ -113,6 +131,14 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply backoff logic: reset to min when events are found, increase when no events
|
||||
if foundEvents {
|
||||
bo.Reset()
|
||||
currentInterval = opts.MinBackoff
|
||||
} else {
|
||||
currentInterval = bo.NextDelay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -32,7 +32,6 @@ func TestDefaultWatchOptions(t *testing.T) {
|
||||
opts := defaultWatchOptions()
|
||||
|
||||
assert.Equal(t, defaultLookbackPeriod, opts.LookbackPeriod)
|
||||
assert.Equal(t, defaultPollInterval, opts.PollInterval)
|
||||
assert.Equal(t, defaultBufferSize, opts.BufferSize)
|
||||
}
|
||||
|
||||
@@ -158,8 +157,9 @@ func TestNotifier_Watch_NoEvents(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: 100 * time.Millisecond,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 50 * time.Millisecond,
|
||||
MaxBackoff: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
events := notifier.Watch(ctx, opts)
|
||||
@@ -210,8 +210,9 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: 100 * time.Millisecond,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 50 * time.Millisecond,
|
||||
MaxBackoff: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Start watching
|
||||
@@ -265,8 +266,9 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: time.Second,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Start watching
|
||||
@@ -326,8 +328,9 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: 100 * time.Millisecond,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
events := notifier.Watch(ctx, opts)
|
||||
@@ -369,8 +372,9 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: time.Second,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Start watching
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/dskit/runtimeconfig"
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const DEFAULT_RESOURCE_LIMIT = 1000
|
||||
|
||||
type OverridesService struct {
|
||||
manager *runtimeconfig.Manager
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
}
|
||||
|
||||
type ReloadOptions struct {
|
||||
FilePath string
|
||||
ReloadPeriod time.Duration
|
||||
}
|
||||
|
||||
// ResourceQuota represents quota limits for a specific resource
|
||||
type ResourceQuota struct {
|
||||
Limit int `yaml:"limit"`
|
||||
}
|
||||
|
||||
// NamespaceOverrides represents all overrides for a tenant
|
||||
type NamespaceOverrides struct {
|
||||
Quotas map[string]ResourceQuota `yaml:"quotas"`
|
||||
}
|
||||
|
||||
// Overrides represents the entire overrides configuration file
|
||||
type Overrides struct {
|
||||
Namespaces map[string]NamespaceOverrides
|
||||
}
|
||||
|
||||
/*
|
||||
This service loads overrides (currently just quotas) from a YAML file with the following yaml structure:
|
||||
|
||||
"123":
|
||||
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 1500
|
||||
*/
|
||||
func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Registerer, tracer trace.Tracer, opts ReloadOptions) (*OverridesService, error) {
|
||||
// shouldn't be empty since we use file path existence to determine if we should enable the service
|
||||
if opts.FilePath == "" {
|
||||
return nil, fmt.Errorf("overrides file path is required")
|
||||
}
|
||||
if opts.ReloadPeriod == 0 {
|
||||
opts.ReloadPeriod = time.Second * 30
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(opts.FilePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("overrides file does not exist: %s", opts.FilePath)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to stat overrides file: %w", err)
|
||||
}
|
||||
|
||||
config := runtimeconfig.Config{
|
||||
ReloadPeriod: opts.ReloadPeriod,
|
||||
LoadPath: []string{opts.FilePath},
|
||||
Loader: func(r io.Reader) (interface{}, error) {
|
||||
var tenants map[string]NamespaceOverrides
|
||||
decoder := yaml.NewDecoder(r)
|
||||
if err := decoder.Decode(&tenants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Overrides{Namespaces: tenants}, nil
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := runtimeconfig.New(config, "tenant-overrides", reg, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &OverridesService{
|
||||
manager: manager,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *OverridesService) init(ctx context.Context) error {
|
||||
return services.StartAndAwaitRunning(ctx, q.manager)
|
||||
}
|
||||
|
||||
func (q *OverridesService) stop(ctx context.Context) error {
|
||||
return services.StopAndAwaitTerminated(ctx, q.manager)
|
||||
}
|
||||
|
||||
func (q *OverridesService) GetQuota(_ context.Context, nsr NamespacedResource) (ResourceQuota, error) {
|
||||
if nsr.Namespace == "" || nsr.Resource == "" || nsr.Group == "" {
|
||||
return ResourceQuota{}, fmt.Errorf("invalid namespaced resource: %+v", nsr)
|
||||
}
|
||||
|
||||
overrides, ok := q.manager.GetConfig().(*Overrides)
|
||||
if !ok {
|
||||
return ResourceQuota{}, fmt.Errorf("failed to get quota overrides from config manager")
|
||||
}
|
||||
|
||||
tenantId := strings.TrimPrefix(nsr.Namespace, "stacks-")
|
||||
groupResource := nsr.Group + "/" + nsr.Resource
|
||||
if tenantOverrides, ok := overrides.Namespaces[tenantId]; ok {
|
||||
if resourceQuota, ok := tenantOverrides.Quotas[groupResource]; ok {
|
||||
return resourceQuota, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ResourceQuota{Limit: DEFAULT_RESOURCE_LIMIT}, nil
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewQuotaService(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ReloadOptions
|
||||
setupFile func(t *testing.T) string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "success with valid file",
|
||||
opts: ReloadOptions{},
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "success with custom reload period",
|
||||
opts: ReloadOptions{
|
||||
ReloadPeriod: time.Minute,
|
||||
},
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte{}, 0644))
|
||||
return tmpFile
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "error when file path is empty",
|
||||
opts: ReloadOptions{
|
||||
FilePath: "",
|
||||
},
|
||||
setupFile: func(t *testing.T) string { return "" },
|
||||
expectError: true,
|
||||
errorMsg: "overrides file path is required",
|
||||
},
|
||||
{
|
||||
name: "error when file does not exist",
|
||||
opts: ReloadOptions{
|
||||
FilePath: "/nonexistent/path/overrides.yaml",
|
||||
},
|
||||
setupFile: func(t *testing.T) string { return "/nonexistent/path/overrides.yaml" },
|
||||
expectError: true,
|
||||
errorMsg: "overrides file does not exist",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
reg := prometheus.NewRegistry()
|
||||
tcr := tracing.NewNoopTracerService()
|
||||
|
||||
filePath := tt.setupFile(t)
|
||||
if filePath != "" && tt.opts.FilePath == "" {
|
||||
tt.opts.FilePath = filePath
|
||||
}
|
||||
|
||||
service, err := NewOverridesService(ctx, logger, reg, tcr, tt.opts)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
assert.Nil(t, service)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, service)
|
||||
assert.NotNil(t, service.manager)
|
||||
assert.NotNil(t, service.logger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaService_ConfigReload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
reg := prometheus.NewRegistry()
|
||||
tcr := tracing.NewNoopTracerService()
|
||||
|
||||
// Create a temporary config file
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
initialConfig := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(initialConfig), 0644))
|
||||
|
||||
// Create service with a very short reload period
|
||||
service, err := NewOverridesService(ctx, logger, reg, tcr, ReloadOptions{
|
||||
FilePath: tmpFile,
|
||||
ReloadPeriod: 100 * time.Millisecond, // Very short reload period for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, service)
|
||||
|
||||
// Initialize the service
|
||||
err = service.init(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func(service *OverridesService, ctx context.Context) {
|
||||
err := service.stop(ctx)
|
||||
require.NoError(t, err)
|
||||
}(service, ctx)
|
||||
|
||||
// Verify initial config
|
||||
nsr := NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
quota, err := service.GetQuota(ctx, nsr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1500, quota.Limit, "initial quota should be 1500")
|
||||
|
||||
// Update the config file with new values
|
||||
updatedConfig := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 2500
|
||||
"456":
|
||||
quotas:
|
||||
grafana.folder.app/folders:
|
||||
limit: 3000
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(updatedConfig), 0644))
|
||||
|
||||
// Wait for the config to be reloaded (wait longer than reload period)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify the config was updated for existing tenant
|
||||
quota, err = service.GetQuota(ctx, nsr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2500, quota.Limit, "quota should be updated to 2500")
|
||||
|
||||
// Verify new tenant config is also loaded
|
||||
nsr2 := NamespacedResource{
|
||||
Namespace: "stacks-456",
|
||||
Group: "grafana.folder.app",
|
||||
Resource: "folders",
|
||||
}
|
||||
quota2, err := service.GetQuota(ctx, nsr2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3000, quota2.Limit, "new tenant quota should be 3000")
|
||||
}
|
||||
|
||||
func TestQuotaService_GetQuota(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFile func(t *testing.T) string
|
||||
nsr NamespacedResource
|
||||
expectedLimit int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "returns custom quota for matching tenant and resource",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: 1500,
|
||||
expectError: false,
|
||||
description: "should return custom limit for matching tenant",
|
||||
},
|
||||
{
|
||||
name: "returns default quota when tenant not found",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-456",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: DEFAULT_RESOURCE_LIMIT,
|
||||
expectError: false,
|
||||
description: "should return default limit when tenant not found",
|
||||
},
|
||||
{
|
||||
name: "returns default quota when resource not found",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.folder.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
expectedLimit: DEFAULT_RESOURCE_LIMIT,
|
||||
expectError: false,
|
||||
description: "should return default limit when resource not found",
|
||||
},
|
||||
{
|
||||
name: "handles namespace without stacks- prefix",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: 1500,
|
||||
expectError: false,
|
||||
description: "should handle namespace without stacks- prefix",
|
||||
},
|
||||
{
|
||||
name: "returns default quota when config is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := ""
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: DEFAULT_RESOURCE_LIMIT,
|
||||
expectError: false,
|
||||
description: "should return default limit when config is empty",
|
||||
},
|
||||
{
|
||||
name: "handles multiple resources for same tenant",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.folder.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
expectedLimit: 2500,
|
||||
expectError: false,
|
||||
description: "should return correct limit for specific resource",
|
||||
},
|
||||
{
|
||||
name: "returns error when namespace is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when namespace is empty",
|
||||
},
|
||||
{
|
||||
name: "returns error when group is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when group is empty",
|
||||
},
|
||||
{
|
||||
name: "returns error when resource is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when resource is empty",
|
||||
},
|
||||
{
|
||||
name: "returns error when all fields are empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "",
|
||||
Group: "",
|
||||
Resource: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when all fields are empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
reg := prometheus.NewRegistry()
|
||||
tcr := tracing.NewNoopTracerService()
|
||||
opts := ReloadOptions{
|
||||
FilePath: tt.setupFile(t),
|
||||
}
|
||||
|
||||
service, err := NewOverridesService(ctx, logger, reg, tcr, opts)
|
||||
require.NoError(t, err, "failed to create quota service")
|
||||
err = service.init(ctx)
|
||||
require.NoError(t, err, "failed to initialize quota service")
|
||||
|
||||
quota, err := service.GetQuota(ctx, tt.nsr)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err, tt.description)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg, tt.description)
|
||||
assert.Equal(t, ResourceQuota{}, quota, "should return empty quota on error")
|
||||
} else {
|
||||
require.NoError(t, err, tt.description)
|
||||
assert.Equal(t, tt.expectedLimit, quota.Limit, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -220,6 +222,9 @@ type ResourceServerOptions struct {
|
||||
// Search options
|
||||
Search SearchOptions
|
||||
|
||||
// Quota service
|
||||
OverridesService *OverridesService
|
||||
|
||||
// Diagnostics
|
||||
Diagnostics resourcepb.DiagnosticsServer
|
||||
|
||||
@@ -342,6 +347,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
|
||||
reg: opts.Reg,
|
||||
queue: opts.QOSQueue,
|
||||
queueConfig: opts.QOSConfig,
|
||||
overridesService: opts.OverridesService,
|
||||
|
||||
artificialSuccessfulWriteDelay: opts.Search.IndexMinUpdateInterval,
|
||||
}
|
||||
@@ -366,19 +372,20 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
|
||||
var _ ResourceServer = &server{}
|
||||
|
||||
type server struct {
|
||||
log log.Logger
|
||||
backend StorageBackend
|
||||
blob BlobSupport
|
||||
secure secrets.InlineSecureValueSupport
|
||||
search *searchSupport
|
||||
diagnostics resourcepb.DiagnosticsServer
|
||||
access claims.AccessClient
|
||||
writeHooks WriteAccessHooks
|
||||
lifecycle LifecycleHooks
|
||||
now func() int64
|
||||
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
|
||||
storageMetrics *StorageMetrics
|
||||
indexMetrics *BleveIndexMetrics
|
||||
log log.Logger
|
||||
backend StorageBackend
|
||||
blob BlobSupport
|
||||
secure secrets.InlineSecureValueSupport
|
||||
search *searchSupport
|
||||
diagnostics resourcepb.DiagnosticsServer
|
||||
access claims.AccessClient
|
||||
writeHooks WriteAccessHooks
|
||||
lifecycle LifecycleHooks
|
||||
now func() int64
|
||||
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
|
||||
storageMetrics *StorageMetrics
|
||||
indexMetrics *BleveIndexMetrics
|
||||
overridesService *OverridesService
|
||||
|
||||
// Background watch task -- this has permissions for everything
|
||||
ctx context.Context
|
||||
@@ -411,6 +418,11 @@ func (s *server) Init(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// initialize tenant overrides service
|
||||
if s.initErr == nil && s.overridesService != nil {
|
||||
s.initErr = s.overridesService.init(ctx)
|
||||
}
|
||||
|
||||
// initialize the search index
|
||||
if s.initErr == nil && s.search != nil {
|
||||
s.initErr = s.search.init(ctx)
|
||||
@@ -444,6 +456,13 @@ func (s *server) Stop(ctx context.Context) error {
|
||||
s.search.stop()
|
||||
}
|
||||
|
||||
if s.overridesService != nil {
|
||||
if err := s.overridesService.stop(ctx); err != nil {
|
||||
stopFailed = true
|
||||
s.initErr = fmt.Errorf("service stopeed with error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stops the streaming
|
||||
s.cancel()
|
||||
|
||||
@@ -647,6 +666,13 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re
|
||||
ctx, span := tracer.Start(ctx, "resource.server.Create")
|
||||
defer span.End()
|
||||
|
||||
// check quotas and log for now
|
||||
s.checkQuota(ctx, NamespacedResource{
|
||||
Namespace: req.Key.Namespace,
|
||||
Group: req.Key.Group,
|
||||
Resource: req.Key.Resource,
|
||||
})
|
||||
|
||||
if r := verifyRequestKey(req.Key); r != nil {
|
||||
return nil, fmt.Errorf("invalid request key: %s", r.Message)
|
||||
}
|
||||
@@ -1549,3 +1575,31 @@ func (s *server) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildInde
|
||||
|
||||
return s.search.RebuildIndexes(ctx, req)
|
||||
}
|
||||
|
||||
func (s *server) checkQuota(ctx context.Context, nsr NamespacedResource) {
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.AddEvent("checkQuota", trace.WithAttributes(
|
||||
attribute.String("namespace", nsr.Namespace),
|
||||
attribute.String("group", nsr.Group),
|
||||
attribute.String("resource", nsr.Resource),
|
||||
))
|
||||
|
||||
if s.overridesService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
quota, err := s.overridesService.GetQuota(ctx, nsr)
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("failed to get quota for resource", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.backend.GetResourceStats(ctx, nsr, 0)
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("failed to get resource stats for quota checking", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err)
|
||||
return
|
||||
}
|
||||
if len(stats) > 0 && stats[0].Count >= int64(quota.Limit) {
|
||||
s.log.FromContext(ctx).Info("Quota exceeded on create", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "quota", quota.Limit, "count", stats[0].Count, "stats_resource", stats[0].Resource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type transactionContextKey struct{}
|
||||
|
||||
// ContextWithTransaction returns a new context with the transaction stored directly.
|
||||
// This is used for SQLite migrations where the transaction needs to be shared
|
||||
// between the migration code and unified storage operations within the same process.
|
||||
func ContextWithTransaction(ctx context.Context, tx *sql.Tx) context.Context {
|
||||
return context.WithValue(ctx, transactionContextKey{}, tx)
|
||||
}
|
||||
|
||||
// TransactionFromContext retrieves the transaction from context
|
||||
func TransactionFromContext(ctx context.Context) *sql.Tx {
|
||||
if v := ctx.Value(transactionContextKey{}); v != nil {
|
||||
if tx, ok := v.(*sql.Tx); ok {
|
||||
return tx
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/lib/pq"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"go.uber.org/atomic"
|
||||
@@ -263,7 +264,11 @@ func (b *backend) Stop(_ context.Context) error {
|
||||
|
||||
// GetResourceStats implements Backend.
|
||||
func (b *backend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) {
|
||||
ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats")
|
||||
ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats", trace.WithAttributes(
|
||||
attribute.String("namespace", nsr.Namespace),
|
||||
attribute.String("group", nsr.Group),
|
||||
attribute.String("resource", nsr.Resource),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
req := &sqlStatsRequest{
|
||||
|
||||
+143
-107
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fullstorydev/grpchan/inprocgrpc"
|
||||
"github.com/google/uuid"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
@@ -111,6 +113,19 @@ func (b *backend) ProcessBulk(ctx context.Context, setting resource.BulkSettings
|
||||
}
|
||||
defer b.bulkLock.Finish(setting.Collection)
|
||||
|
||||
// If provided, reuse the inproc transaction for SQLite
|
||||
if clientCtx := inprocgrpc.ClientContext(ctx); clientCtx != nil && b.dialect.DialectName() == "sqlite" {
|
||||
if externalTx := resource.TransactionFromContext(clientCtx); externalTx != nil {
|
||||
b.log.Info("Using SQLite transaction from client context")
|
||||
rsp := &resourcepb.BulkResponse{}
|
||||
err := b.processBulkWithTx(ctx, dbimpl.NewTx(externalTx), setting, iter, rsp)
|
||||
if err != nil {
|
||||
rsp.Error = resource.AsErrorResult(err)
|
||||
}
|
||||
return rsp
|
||||
}
|
||||
}
|
||||
|
||||
// We may want to first write parquet, then read parquet
|
||||
if b.dialect.DialectName() == "sqlite" {
|
||||
file, err := os.CreateTemp("", "grafana-bulk-export-*.parquet")
|
||||
@@ -151,109 +166,134 @@ func (b *backend) ProcessBulk(ctx context.Context, setting resource.BulkSettings
|
||||
func (b *backend) processBulk(ctx context.Context, setting resource.BulkSettings, iter resource.BulkRequestIterator) *resourcepb.BulkResponse {
|
||||
rsp := &resourcepb.BulkResponse{}
|
||||
err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
rollbackWithError := func(err error) error {
|
||||
txerr := tx.Rollback()
|
||||
if txerr != nil {
|
||||
b.log.Warn("rollback", "error", txerr)
|
||||
} else {
|
||||
b.log.Info("rollback")
|
||||
return b.processBulkWithTx(ctx, tx, setting, iter, rsp)
|
||||
})
|
||||
if err != nil {
|
||||
rsp.Error = resource.AsErrorResult(err)
|
||||
}
|
||||
return rsp
|
||||
}
|
||||
|
||||
// processBulkWithTx performs the bulk operation using the provided transaction.
|
||||
// This is used both when creating our own transaction and when reusing an external one.
|
||||
func (b *backend) processBulkWithTx(ctx context.Context, tx db.Tx, setting resource.BulkSettings, iter resource.BulkRequestIterator, rsp *resourcepb.BulkResponse) error {
|
||||
rollbackWithError := func(err error) error {
|
||||
txerr := tx.Rollback()
|
||||
if txerr != nil {
|
||||
b.log.Warn("rollback", "error", txerr)
|
||||
} else {
|
||||
b.log.Info("rollback")
|
||||
}
|
||||
return err
|
||||
}
|
||||
bulk := &bulkWroker{
|
||||
ctx: ctx,
|
||||
tx: tx,
|
||||
dialect: b.dialect,
|
||||
logger: logging.FromContext(ctx),
|
||||
}
|
||||
|
||||
// Calculate the RV based on incoming request timestamps
|
||||
rv := newBulkRV()
|
||||
|
||||
summaries := make(map[string]*resourcepb.BulkResponse_Summary, len(setting.Collection))
|
||||
|
||||
// First clear everything in the transaction
|
||||
if setting.RebuildCollection {
|
||||
for _, key := range setting.Collection {
|
||||
summary, err := bulk.deleteCollection(key)
|
||||
if err != nil {
|
||||
return rollbackWithError(err)
|
||||
}
|
||||
summaries[resource.NSGR(key)] = summary
|
||||
rsp.Summary = append(rsp.Summary, summary)
|
||||
}
|
||||
} else {
|
||||
for _, key := range setting.Collection {
|
||||
summaries[resource.NSGR(key)] = &resourcepb.BulkResponse_Summary{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj := &unstructured.Unstructured{}
|
||||
|
||||
// Write each event into the history
|
||||
for iter.Next() {
|
||||
if iter.RollbackRequested() {
|
||||
return rollbackWithError(nil)
|
||||
}
|
||||
req := iter.Request()
|
||||
if req == nil {
|
||||
return rollbackWithError(fmt.Errorf("missing request"))
|
||||
}
|
||||
rsp.Processed++
|
||||
|
||||
if req.Action == resourcepb.BulkRequest_UNKNOWN {
|
||||
rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{
|
||||
Key: req.Key,
|
||||
Action: req.Action,
|
||||
Error: "unknown action",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
err := obj.UnmarshalJSON(req.Value)
|
||||
if err != nil {
|
||||
rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{
|
||||
Key: req.Key,
|
||||
Action: req.Action,
|
||||
Error: "unable to unmarshal json",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Write the event to history
|
||||
if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{
|
||||
SQLTemplate: sqltemplate.New(b.dialect),
|
||||
WriteEvent: resource.WriteEvent{
|
||||
Key: req.Key,
|
||||
Type: resourcepb.WatchEvent_Type(req.Action),
|
||||
Value: req.Value,
|
||||
PreviousRV: -1, // Used for WATCH, but we want to skip watch events
|
||||
},
|
||||
Folder: req.Folder,
|
||||
GUID: uuid.New().String(),
|
||||
ResourceVersion: rv.next(obj),
|
||||
}); err != nil {
|
||||
return rollbackWithError(fmt.Errorf("insert into resource history: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the resource table from history
|
||||
for _, key := range setting.Collection {
|
||||
k := fmt.Sprintf("%s/%s/%s", key.Namespace, key.Group, key.Resource)
|
||||
summary := summaries[k]
|
||||
if summary == nil {
|
||||
return rollbackWithError(fmt.Errorf("missing summary key for: %s", k))
|
||||
}
|
||||
|
||||
err := bulk.syncCollection(key, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bulk := &bulkWroker{
|
||||
ctx: ctx,
|
||||
tx: tx,
|
||||
dialect: b.dialect,
|
||||
logger: logging.FromContext(ctx),
|
||||
}
|
||||
|
||||
// Calculate the RV based on incoming request timestamps
|
||||
rv := newBulkRV()
|
||||
|
||||
summaries := make(map[string]*resourcepb.BulkResponse_Summary, len(setting.Collection))
|
||||
|
||||
// First clear everything in the transaction
|
||||
if setting.RebuildCollection {
|
||||
for _, key := range setting.Collection {
|
||||
summary, err := bulk.deleteCollection(key)
|
||||
if err != nil {
|
||||
return rollbackWithError(err)
|
||||
if b.dialect.DialectName() == "sqlite" {
|
||||
nextRV, err := b.rvManager.lock(ctx, tx, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
b.log.Error("error locking RV", "error", err, "key", resource.NSGR(key))
|
||||
} else {
|
||||
b.log.Info("successfully locked RV", "nextRV", nextRV, "key", resource.NSGR(key))
|
||||
// Save the incremented RV
|
||||
if err := b.rvManager.saveRV(ctx, tx, key.Group, key.Resource, nextRV); err != nil {
|
||||
b.log.Error("error saving RV", "error", err, "key", resource.NSGR(key))
|
||||
} else {
|
||||
b.log.Info("successfully saved RV", "rv", nextRV, "key", resource.NSGR(key))
|
||||
}
|
||||
summaries[resource.NSGR(key)] = summary
|
||||
rsp.Summary = append(rsp.Summary, summary)
|
||||
}
|
||||
} else {
|
||||
for _, key := range setting.Collection {
|
||||
summaries[resource.NSGR(key)] = &resourcepb.BulkResponse_Summary{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj := &unstructured.Unstructured{}
|
||||
|
||||
// Write each event into the history
|
||||
for iter.Next() {
|
||||
if iter.RollbackRequested() {
|
||||
return rollbackWithError(nil)
|
||||
}
|
||||
req := iter.Request()
|
||||
if req == nil {
|
||||
return rollbackWithError(fmt.Errorf("missing request"))
|
||||
}
|
||||
rsp.Processed++
|
||||
|
||||
if req.Action == resourcepb.BulkRequest_UNKNOWN {
|
||||
rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{
|
||||
Key: req.Key,
|
||||
Action: req.Action,
|
||||
Error: "unknown action",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
err := obj.UnmarshalJSON(req.Value)
|
||||
if err != nil {
|
||||
rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{
|
||||
Key: req.Key,
|
||||
Action: req.Action,
|
||||
Error: "unable to unmarshal json",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Write the event to history
|
||||
if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{
|
||||
SQLTemplate: sqltemplate.New(b.dialect),
|
||||
WriteEvent: resource.WriteEvent{
|
||||
Key: req.Key,
|
||||
Type: resourcepb.WatchEvent_Type(req.Action),
|
||||
Value: req.Value,
|
||||
PreviousRV: -1, // Used for WATCH, but we want to skip watch events
|
||||
},
|
||||
Folder: req.Folder,
|
||||
GUID: uuid.New().String(),
|
||||
ResourceVersion: rv.next(obj),
|
||||
}); err != nil {
|
||||
return rollbackWithError(fmt.Errorf("insert into resource history: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the resource table from history
|
||||
for _, key := range setting.Collection {
|
||||
k := fmt.Sprintf("%s/%s/%s", key.Namespace, key.Group, key.Resource)
|
||||
summary := summaries[k]
|
||||
if summary == nil {
|
||||
return rollbackWithError(fmt.Errorf("missing summary key for: %s", k))
|
||||
}
|
||||
|
||||
err := bulk.syncCollection(key, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure the collection RV is above our last written event
|
||||
_, err = b.rvManager.ExecWithRV(ctx, key, func(tx db.Tx) (string, error) {
|
||||
return "", nil
|
||||
@@ -261,19 +301,15 @@ func (b *backend) processBulk(ctx context.Context, setting resource.BulkSettings
|
||||
if err != nil {
|
||||
b.log.Warn("error increasing RV", "error", err)
|
||||
}
|
||||
|
||||
// Update the last import time. This is important to trigger reindexing
|
||||
// of the resource for a given namespace.
|
||||
if err := b.updateLastImportTime(ctx, tx, key, time.Now()); err != nil {
|
||||
return rollbackWithError(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
rsp.Error = resource.AsErrorResult(err)
|
||||
|
||||
// Update the last import time. This is important to trigger reindexing
|
||||
// of the resource for a given namespace.
|
||||
if err := b.updateLastImportTime(ctx, tx, key, time.Now()); err != nil {
|
||||
return rollbackWithError(err)
|
||||
}
|
||||
}
|
||||
return rsp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backend) updateLastImportTime(ctx context.Context, tx db.Tx, key *resourcepb.ResourceKey, now time.Time) error {
|
||||
|
||||
@@ -48,6 +48,11 @@ type sqlTx struct {
|
||||
*sql.Tx
|
||||
}
|
||||
|
||||
// NewTx wraps an existing *sql.Tx with sqlTx
|
||||
func NewTx(tx *sql.Tx) db.Tx {
|
||||
return sqlTx{tx}
|
||||
}
|
||||
|
||||
func (tx sqlTx) QueryContext(ctx context.Context, query string, args ...any) (db.Rows, error) {
|
||||
// // codeql-suppress go/sql-query-built-from-user-controlled-sources "The query comes from a safe template source
|
||||
// and the parameters are passed as arguments."
|
||||
|
||||
@@ -30,19 +30,20 @@ type QOSEnqueueDequeuer interface {
|
||||
|
||||
// ServerOptions contains the options for creating a new ResourceServer
|
||||
type ServerOptions struct {
|
||||
Backend resource.StorageBackend
|
||||
DB infraDB.DB
|
||||
Cfg *setting.Cfg
|
||||
Tracer trace.Tracer
|
||||
Reg prometheus.Registerer
|
||||
AccessClient types.AccessClient
|
||||
SearchOptions resource.SearchOptions
|
||||
StorageMetrics *resource.StorageMetrics
|
||||
IndexMetrics *resource.BleveIndexMetrics
|
||||
Features featuremgmt.FeatureToggles
|
||||
QOSQueue QOSEnqueueDequeuer
|
||||
SecureValues secrets.InlineSecureValueSupport
|
||||
OwnsIndexFn func(key resource.NamespacedResource) (bool, error)
|
||||
Backend resource.StorageBackend
|
||||
OverridesService *resource.OverridesService
|
||||
DB infraDB.DB
|
||||
Cfg *setting.Cfg
|
||||
Tracer trace.Tracer
|
||||
Reg prometheus.Registerer
|
||||
AccessClient types.AccessClient
|
||||
SearchOptions resource.SearchOptions
|
||||
StorageMetrics *resource.StorageMetrics
|
||||
IndexMetrics *resource.BleveIndexMetrics
|
||||
Features featuremgmt.FeatureToggles
|
||||
QOSQueue QOSEnqueueDequeuer
|
||||
SecureValues secrets.InlineSecureValueSupport
|
||||
OwnsIndexFn func(key resource.NamespacedResource) (bool, error)
|
||||
}
|
||||
|
||||
func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
|
||||
@@ -119,6 +120,7 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
|
||||
serverOptions.IndexMetrics = opts.IndexMetrics
|
||||
serverOptions.QOSQueue = opts.QOSQueue
|
||||
serverOptions.OwnsIndexFn = opts.OwnsIndexFn
|
||||
serverOptions.OverridesService = opts.OverridesService
|
||||
|
||||
return resource.NewResourceServer(serverOptions)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,18 @@ func (s *service) starting(ctx context.Context) error {
|
||||
QOSQueue: s.queue,
|
||||
OwnsIndexFn: s.OwnsIndex,
|
||||
}
|
||||
|
||||
if s.cfg.OverridesFilePath != "" {
|
||||
overridesSvc, err := resource.NewOverridesService(context.Background(), s.log, s.reg, s.tracing, resource.ReloadOptions{
|
||||
FilePath: s.cfg.OverridesFilePath,
|
||||
ReloadPeriod: s.cfg.OverridesReloadInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverOptions.OverridesService = overridesSvc
|
||||
}
|
||||
|
||||
server, err := NewResourceServer(serverOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user