Merge branch 'main' of https://github.com/grafana/grafana into kristina/rtk-corr

This commit is contained in:
Kristina Durivage
2025-11-28 14:09:32 -06:00
770 changed files with 21646 additions and 7586 deletions
+6 -6
View File
@@ -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
View File
@@ -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=
+21
View File
@@ -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)
+23
View File
@@ -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
View File
@@ -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")
+5 -7
View File
@@ -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
View File
@@ -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=
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+1 -1
View File
@@ -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())
+1 -1
View File
@@ -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)
+29
View File
@@ -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
}
+96
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+1 -1
View File
@@ -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(),
+2 -2
View File
@@ -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"),
},
},
{
+26 -2
View File
@@ -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
}
+6
View File
@@ -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 {
+9 -1
View File
@@ -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
)
+302
View File
@@ -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
})
}
+59 -147
View File
@@ -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 {
+221 -295
View File
@@ -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
})
}
+4
View File
@@ -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 {
+4
View File
@@ -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
}
+6
View File
@@ -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
}
+4 -1
View File
@@ -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
View File
@@ -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() {
+2
View File
@@ -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,
+17 -8
View File
@@ -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 {
+28 -78
View File
@@ -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,
+2 -17
View File
@@ -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, &copy, builders, apiResourceConfig)
return addBuilderRoutes(gv, &copy, 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
+1 -7
View File
@@ -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
}
+27 -6
View File
@@ -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,
},
}
)
+6 -3
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
80 dashboardSceneSolo GA @grafana/dashboards-squad false false true
81 dashboardScene GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts experimental @grafana/dashboards-squad false false false
83 kubernetesDashboardsV2 experimental @grafana/dashboards-squad false false false
84 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
85 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
86 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
87 panelGroupBy experimental @grafana/dashboards-squad false false true
88 panelFilterVariable experimental @grafana/dashboards-squad false false true
89 pdfTables preview @grafana/grafana-operator-experience-squad false false false
90 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
183 improvedExternalSessionHandlingSAML GA @grafana/identity-access-team false false false
184 teamHttpHeadersTempo experimental @grafana/identity-access-team false false false
185 grafanaAdvisor privatePreview @grafana/plugins-platform-backend false false false
186 elasticsearchImprovedParsing experimental @grafana/aws-datasources @grafana/partner-datasources false false false
187 datasourceConnectionsTab privatePreview @grafana/plugins-platform-backend false false true
188 fetchRulesUsingPost experimental @grafana/alerting-squad false false false
189 newLogsPanel GA @grafana/observability-logs false false true
247 interactiveLearning preview @grafana/pathfinder false false false
248 alertingTriage experimental @grafana/alerting-squad false false false
249 graphiteBackendMode privatePreview @grafana/partner-datasources false false false
250 azureResourcePickerUpdates preview GA @grafana/partner-datasources false false true
251 prometheusTypeMigration experimental @grafana/partner-datasources false true false
252 pluginContainers privatePreview @grafana/plugins-platform-backend false true false
253 tempoSearchBackendMigration GA @grafana/oss-big-tent false true false
259 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
260 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
261 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
262 newPanelPadding experimental preview @grafana/dashboards-squad false false false true
263 onlyStoreActionSets GA @grafana/identity-access-team false false false
264 panelTimeSettings experimental @grafana/dashboards-squad false false false
265 kubernetesAnnotations experimental @grafana/grafana-backend-services-squad false false false
268 ttlPluginInstanceManager experimental @grafana/plugins-platform-backend false false true
269 lokiQueryLimitsContext experimental @grafana/observability-logs false false true
270 rudderstackUpgrade experimental @grafana/grafana-frontend-platform false false true
271 kubernetesAlertingHistorian experimental @grafana/alerting-squad false true false
+8 -4
View File
@@ -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
View File
@@ -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 @@
}
}
]
}
}
+38
View File
@@ -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,
+55 -43
View File
@@ -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()
+2 -1
View File
@@ -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.
+5 -1
View File
@@ -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
},
+2 -2
View File
@@ -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"
+384
View File
@@ -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)
}
+542
View File
@@ -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(),
}
}
+9 -3
View File
@@ -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
+5 -5
View File
@@ -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 {
+4
View File
@@ -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
+15
View File
@@ -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)
+4
View File
@@ -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)
+40 -12
View File
@@ -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
+13
View File
@@ -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 {
+10 -2
View File
@@ -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,
+77 -22
View File
@@ -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
}
+33 -7
View File
@@ -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()
}
}
}
}()
+10 -6
View File
@@ -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
+127
View File
@@ -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
}
+427
View File
@@ -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)
}
})
}
}
+67 -13
View File
@@ -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
}
+6 -1
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -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."
+15 -13
View File
@@ -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)
}
+12
View File
@@ -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