Merge branch 'main' of https://github.com/grafana/grafana into kristina/rtk-corr
# Conflicts: # packages/grafana-api-clients/package.json
This commit is contained in:
@@ -118,18 +118,18 @@ require (
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.13.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.38.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
|
||||
|
||||
+14
-14
@@ -314,15 +314,15 @@ 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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
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/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -330,8 +330,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
@@ -355,23 +355,23 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8=
|
||||
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -273,7 +273,7 @@ func setupSimpleHTTPServer(features featuremgmt.FeatureToggles) *HTTPServer {
|
||||
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
annotationsRepo: annotationstest.NewFakeAnnotationsRepo(),
|
||||
authInfoService: &authinfotest.FakeService{
|
||||
ExpectedLabels: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)},
|
||||
ExpectedRecentlyUsedLabel: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)},
|
||||
},
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
}
|
||||
|
||||
@@ -438,7 +438,6 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
|
||||
}
|
||||
|
||||
ctx = c.Req.Context()
|
||||
var err error
|
||||
|
||||
var userID int64
|
||||
if id, err := identity.UserIdentifier(c.GetID()); err == nil {
|
||||
@@ -518,12 +517,6 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
|
||||
return apierrors.ToDashboardErrorResponse(ctx, hs.pluginStore, saveErr)
|
||||
}
|
||||
|
||||
// connect library panels for this dashboard after the dashboard is stored and has an ID
|
||||
err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(ctx, c.SignedInUser, dashboard)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "Error while connecting library panels", err)
|
||||
}
|
||||
|
||||
c.TimeRequest(metrics.MApiDashboardSave)
|
||||
return response.JSON(http.StatusOK, util.DynMap{
|
||||
"status": "success",
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
@@ -39,7 +38,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||
libraryelementsfake "github.com/grafana/grafana/pkg/services/libraryelements/fake"
|
||||
"github.com/grafana/grafana/pkg/services/librarypanels"
|
||||
"github.com/grafana/grafana/pkg/services/licensing/licensingtest"
|
||||
"github.com/grafana/grafana/pkg/services/live"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -265,7 +263,6 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) {
|
||||
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
hs.starService = startest.NewStarServiceFake()
|
||||
|
||||
hs.LibraryPanelService = &mockLibraryPanelService{}
|
||||
hs.LibraryElementService = &libraryelementsfake.LibraryElementService{}
|
||||
|
||||
middleware := publicdashboards.NewFakePublicDashboardMiddleware(t)
|
||||
@@ -791,7 +788,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
|
||||
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
|
||||
Live: newTestLive(t, db.InitTestDB(t)),
|
||||
QuotaService: quotatest.New(false, nil),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
DashboardService: dashboardService,
|
||||
SQLStore: dbtest.NewFakeDB(),
|
||||
@@ -853,7 +849,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
ProvisioningService: fakeProvisioningService,
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
dashboardProvisioningService: dashboardProvisioningService,
|
||||
SQLStore: mockSQLStore,
|
||||
@@ -887,7 +882,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
ProvisioningService: fakeProvisioningService,
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
dashboardProvisioningService: dashboardProvisioningService,
|
||||
SQLStore: mockSQLStore,
|
||||
@@ -928,7 +922,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) {
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
SQLStore: mockSQLStore,
|
||||
AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
|
||||
@@ -1087,7 +1080,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
|
||||
Live: newTestLive(t, db.InitTestDB(t)),
|
||||
QuotaService: quotatest.New(false, nil),
|
||||
pluginStore: &pluginstore.FakePluginStore{},
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
DashboardService: dashboardService,
|
||||
folderService: folderService,
|
||||
@@ -1127,7 +1119,6 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
|
||||
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
|
||||
Live: newTestLive(t, db.InitTestDB(t)),
|
||||
QuotaService: quotatest.New(false, nil),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
DashboardService: mock,
|
||||
SQLStore: sqlStore,
|
||||
@@ -1177,15 +1168,3 @@ func (s mockDashboardProvisioningService) GetProvisionedDashboardDataByDashboard
|
||||
) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type mockLibraryPanelService struct{}
|
||||
|
||||
var _ librarypanels.Service = (*mockLibraryPanelService)(nil)
|
||||
|
||||
func (m *mockLibraryPanelService) ConnectLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockLibraryPanelService) ImportLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ func (hs *HTTPServer) QueryMetricsV2(c *contextmodel.ReqContext) response.Respon
|
||||
|
||||
var resp *backend.QueryDataResponse
|
||||
var err error
|
||||
|
||||
hs.log.Debug("QueryMetricsV2: request received", "time_in_query", handleTimeInQuery)
|
||||
if handleTimeInQuery {
|
||||
resp, err = hs.queryDataService.QueryDataNew(c.Req.Context(), c.SignedInUser, c.SkipDSCache, reqDTO)
|
||||
} else {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client"
|
||||
@@ -86,6 +87,7 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) {
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.queryDataService = qds
|
||||
hs.QuotaService = quotatest.New(false, nil)
|
||||
hs.log = log.New("test-logger")
|
||||
})
|
||||
|
||||
t.Run("Status code is 400 when data source response has an error", func(t *testing.T) {
|
||||
@@ -252,6 +254,7 @@ func TestDataSourceQueryError(t *testing.T) {
|
||||
err := r.Add(context.Background(), p)
|
||||
require.NoError(t, err)
|
||||
ds := &fakeDatasources.FakeDataSourceService{}
|
||||
hs.log = log.New("test-logger")
|
||||
hs.queryDataService = query.ProvideService(
|
||||
cfg,
|
||||
&fakeDatasources.FakeCacheService{},
|
||||
|
||||
@@ -78,7 +78,8 @@ type FrontendSettingsAzureDTO struct {
|
||||
}
|
||||
|
||||
type FrontendSettingsCachingDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CleanCacheEnabled bool `json:"cleanCacheEnabled"`
|
||||
}
|
||||
|
||||
type FrontendSettingsRecordedQueriesDTO struct {
|
||||
@@ -207,27 +208,28 @@ type FrontendSettingsDTO struct {
|
||||
DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"`
|
||||
PanelSeriesLimit int `json:"panelSeriesLimit"`
|
||||
|
||||
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
|
||||
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
|
||||
ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"`
|
||||
DisableLoginForm bool `json:"disableLoginForm"`
|
||||
DisableUserSignUp bool `json:"disableUserSignUp"`
|
||||
LoginHint string `json:"loginHint"`
|
||||
PasswordHint string `json:"passwordHint"`
|
||||
ExternalUserMngInfo string `json:"externalUserMngInfo"`
|
||||
ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"`
|
||||
ExternalUserMngLinkName string `json:"externalUserMngLinkName"`
|
||||
ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"`
|
||||
ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"`
|
||||
ViewersCanEdit bool `json:"viewersCanEdit"`
|
||||
DisableSanitizeHtml bool `json:"disableSanitizeHtml"`
|
||||
TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"`
|
||||
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"`
|
||||
EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"`
|
||||
PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"`
|
||||
PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"`
|
||||
ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"`
|
||||
ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"`
|
||||
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
|
||||
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
|
||||
ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"`
|
||||
ApplicationInsightsAutoRouteTracking bool `json:"applicationInsightsAutoRouteTracking"`
|
||||
DisableLoginForm bool `json:"disableLoginForm"`
|
||||
DisableUserSignUp bool `json:"disableUserSignUp"`
|
||||
LoginHint string `json:"loginHint"`
|
||||
PasswordHint string `json:"passwordHint"`
|
||||
ExternalUserMngInfo string `json:"externalUserMngInfo"`
|
||||
ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"`
|
||||
ExternalUserMngLinkName string `json:"externalUserMngLinkName"`
|
||||
ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"`
|
||||
ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"`
|
||||
ViewersCanEdit bool `json:"viewersCanEdit"`
|
||||
DisableSanitizeHtml bool `json:"disableSanitizeHtml"`
|
||||
TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"`
|
||||
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"`
|
||||
EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"`
|
||||
PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"`
|
||||
PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"`
|
||||
ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"`
|
||||
ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"`
|
||||
|
||||
Auth FrontendSettingsAuthDTO `json:"auth"`
|
||||
|
||||
@@ -235,29 +237,30 @@ type FrontendSettingsDTO struct {
|
||||
|
||||
LicenseInfo FrontendSettingsLicenseInfoDTO `json:"licenseInfo"`
|
||||
|
||||
FeatureToggles map[string]bool `json:"featureToggles"`
|
||||
AnonymousEnabled bool `json:"anonymousEnabled"`
|
||||
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`
|
||||
RendererAvailable bool `json:"rendererAvailable"`
|
||||
RendererVersion string `json:"rendererVersion"`
|
||||
RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"`
|
||||
RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"`
|
||||
RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"`
|
||||
Http2Enabled bool `json:"http2Enabled"`
|
||||
GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"`
|
||||
PluginCatalogURL string `json:"pluginCatalogURL"`
|
||||
PluginAdminEnabled bool `json:"pluginAdminEnabled"`
|
||||
PluginAdminExternalManageEnabled bool `json:"pluginAdminExternalManageEnabled"`
|
||||
PluginCatalogHiddenPlugins []string `json:"pluginCatalogHiddenPlugins"`
|
||||
PluginCatalogManagedPlugins []string `json:"pluginCatalogManagedPlugins"`
|
||||
PluginCatalogPreinstalledPlugins []setting.InstallPlugin `json:"pluginCatalogPreinstalledPlugins"`
|
||||
ExpressionsEnabled bool `json:"expressionsEnabled"`
|
||||
AwsAllowedAuthProviders []string `json:"awsAllowedAuthProviders"`
|
||||
AwsAssumeRoleEnabled bool `json:"awsAssumeRoleEnabled"`
|
||||
SupportBundlesEnabled bool `json:"supportBundlesEnabled"`
|
||||
SnapshotEnabled bool `json:"snapshotEnabled"`
|
||||
SecureSocksDSProxyEnabled bool `json:"secureSocksDSProxyEnabled"`
|
||||
ReportingStaticContext map[string]string `json:"reportingStaticContext"`
|
||||
FeatureToggles map[string]bool `json:"featureToggles"`
|
||||
AnonymousEnabled bool `json:"anonymousEnabled"`
|
||||
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`
|
||||
RendererAvailable bool `json:"rendererAvailable"`
|
||||
RendererVersion string `json:"rendererVersion"`
|
||||
RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"`
|
||||
RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"`
|
||||
RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"`
|
||||
Http2Enabled bool `json:"http2Enabled"`
|
||||
GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"`
|
||||
PluginCatalogURL string `json:"pluginCatalogURL"`
|
||||
PluginAdminEnabled bool `json:"pluginAdminEnabled"`
|
||||
PluginAdminExternalManageEnabled bool `json:"pluginAdminExternalManageEnabled"`
|
||||
PluginCatalogHiddenPlugins []string `json:"pluginCatalogHiddenPlugins"`
|
||||
PluginCatalogManagedPlugins []string `json:"pluginCatalogManagedPlugins"`
|
||||
PluginCatalogPreinstalledPlugins []setting.InstallPlugin `json:"pluginCatalogPreinstalledPlugins"`
|
||||
PluginCatalogPreinstalledAutoUpdate bool `json:"pluginCatalogPreinstalledAutoUpdate"`
|
||||
ExpressionsEnabled bool `json:"expressionsEnabled"`
|
||||
AwsAllowedAuthProviders []string `json:"awsAllowedAuthProviders"`
|
||||
AwsAssumeRoleEnabled bool `json:"awsAssumeRoleEnabled"`
|
||||
SupportBundlesEnabled bool `json:"supportBundlesEnabled"`
|
||||
SnapshotEnabled bool `json:"snapshotEnabled"`
|
||||
SecureSocksDSProxyEnabled bool `json:"secureSocksDSProxyEnabled"`
|
||||
ReportingStaticContext map[string]string `json:"reportingStaticContext"`
|
||||
|
||||
Azure FrontendSettingsAzureDTO `json:"azure"`
|
||||
|
||||
|
||||
+3
-14
@@ -271,26 +271,15 @@ func (hs *HTTPServer) UpdateFolder(c *contextmodel.ReqContext) response.Response
|
||||
// 403: forbiddenError
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
|
||||
err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
|
||||
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response {
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
err := hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrFolderHasConnectedLibraryElements) {
|
||||
return response.Error(http.StatusForbidden, "Folder could not be deleted because it contains library elements in use", err)
|
||||
}
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
/* TODO: after a decision regarding folder deletion permissions has been made
|
||||
(https://github.com/grafana/grafana-enterprise/issues/5144),
|
||||
remove the previous call to hs.LibraryElementService.DeleteLibraryElementsInFolder
|
||||
and remove "user" from the signature of DeleteInFolder in the folder RegistryService.
|
||||
Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
|
||||
*/
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
|
||||
if err != nil {
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, util.DynMap{
|
||||
"message": "Folder deleted",
|
||||
|
||||
+71
-68
@@ -197,53 +197,54 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
featureToggles["topnav"] = true
|
||||
|
||||
frontendSettings := &dtos.FrontendSettingsDTO{
|
||||
DefaultDatasource: defaultDS,
|
||||
Datasources: dataSources,
|
||||
MinRefreshInterval: hs.Cfg.MinRefreshInterval,
|
||||
Panels: panels,
|
||||
Apps: apps,
|
||||
AppUrl: hs.Cfg.AppURL,
|
||||
AppSubUrl: hs.Cfg.AppSubURL,
|
||||
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
|
||||
AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled,
|
||||
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
|
||||
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
|
||||
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
|
||||
LiveEnabled: hs.Cfg.LiveMaxConnections != 0,
|
||||
LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit,
|
||||
AutoAssignOrg: hs.Cfg.AutoAssignOrg,
|
||||
VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled,
|
||||
SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled,
|
||||
AzureAuthEnabled: hs.Cfg.AzureAuthEnabled,
|
||||
RbacEnabled: true,
|
||||
ExploreEnabled: hs.Cfg.ExploreEnabled,
|
||||
HelpEnabled: hs.Cfg.HelpEnabled,
|
||||
ProfileEnabled: hs.Cfg.ProfileEnabled,
|
||||
NewsFeedEnabled: hs.Cfg.NewsFeedEnabled,
|
||||
QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled,
|
||||
GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID,
|
||||
GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID,
|
||||
GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews,
|
||||
RudderstackWriteKey: hs.Cfg.RudderstackWriteKey,
|
||||
RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL,
|
||||
RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL,
|
||||
RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL,
|
||||
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
|
||||
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
|
||||
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
|
||||
PanelSeriesLimit: hs.Cfg.PanelSeriesLimit,
|
||||
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
|
||||
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
|
||||
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
|
||||
DisableLoginForm: hs.Cfg.DisableLoginForm,
|
||||
DisableUserSignUp: !hs.Cfg.AllowUserSignUp,
|
||||
LoginHint: hs.Cfg.LoginHint,
|
||||
PasswordHint: hs.Cfg.PasswordHint,
|
||||
ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo,
|
||||
ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl,
|
||||
ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName,
|
||||
ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics,
|
||||
ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams,
|
||||
DefaultDatasource: defaultDS,
|
||||
Datasources: dataSources,
|
||||
MinRefreshInterval: hs.Cfg.MinRefreshInterval,
|
||||
Panels: panels,
|
||||
Apps: apps,
|
||||
AppUrl: hs.Cfg.AppURL,
|
||||
AppSubUrl: hs.Cfg.AppSubURL,
|
||||
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
|
||||
AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled,
|
||||
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
|
||||
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
|
||||
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
|
||||
LiveEnabled: hs.Cfg.LiveMaxConnections != 0,
|
||||
LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit,
|
||||
AutoAssignOrg: hs.Cfg.AutoAssignOrg,
|
||||
VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled,
|
||||
SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled,
|
||||
AzureAuthEnabled: hs.Cfg.AzureAuthEnabled,
|
||||
RbacEnabled: true,
|
||||
ExploreEnabled: hs.Cfg.ExploreEnabled,
|
||||
HelpEnabled: hs.Cfg.HelpEnabled,
|
||||
ProfileEnabled: hs.Cfg.ProfileEnabled,
|
||||
NewsFeedEnabled: hs.Cfg.NewsFeedEnabled,
|
||||
QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled,
|
||||
GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID,
|
||||
GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID,
|
||||
GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews,
|
||||
RudderstackWriteKey: hs.Cfg.RudderstackWriteKey,
|
||||
RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL,
|
||||
RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL,
|
||||
RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL,
|
||||
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
|
||||
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
|
||||
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
|
||||
PanelSeriesLimit: hs.Cfg.PanelSeriesLimit,
|
||||
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
|
||||
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
|
||||
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
|
||||
ApplicationInsightsAutoRouteTracking: hs.Cfg.ApplicationInsightsAutoRouteTracking,
|
||||
DisableLoginForm: hs.Cfg.DisableLoginForm,
|
||||
DisableUserSignUp: !hs.Cfg.AllowUserSignUp,
|
||||
LoginHint: hs.Cfg.LoginHint,
|
||||
PasswordHint: hs.Cfg.PasswordHint,
|
||||
ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo,
|
||||
ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl,
|
||||
ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName,
|
||||
ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics,
|
||||
ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams,
|
||||
//nolint:staticcheck // ViewersCanEdit is deprecated but still used for backward compatibility
|
||||
ViewersCanEdit: hs.Cfg.ViewersCanEdit,
|
||||
DisableSanitizeHtml: hs.Cfg.DisableSanitizeHtml,
|
||||
@@ -290,26 +291,27 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
EnabledFeatures: hs.License.EnabledFeatures(),
|
||||
},
|
||||
|
||||
FeatureToggles: featureToggles,
|
||||
AnonymousEnabled: hs.Cfg.Anonymous.Enabled,
|
||||
AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit,
|
||||
RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()),
|
||||
RendererVersion: hs.RenderService.Version(),
|
||||
RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth,
|
||||
RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight,
|
||||
RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale,
|
||||
Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme,
|
||||
GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent,
|
||||
PluginCatalogURL: hs.Cfg.PluginCatalogURL,
|
||||
PluginAdminEnabled: hs.Cfg.PluginAdminEnabled,
|
||||
PluginAdminExternalManageEnabled: hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled,
|
||||
PluginCatalogHiddenPlugins: hs.Cfg.PluginCatalogHiddenPlugins,
|
||||
PluginCatalogManagedPlugins: hs.managedPluginsService.ManagedPlugins(c.Req.Context()),
|
||||
PluginCatalogPreinstalledPlugins: append(hs.Cfg.PreinstallPluginsAsync, hs.Cfg.PreinstallPluginsSync...),
|
||||
ExpressionsEnabled: hs.Cfg.ExpressionsEnabled,
|
||||
AwsAllowedAuthProviders: hs.Cfg.AWSAllowedAuthProviders,
|
||||
AwsAssumeRoleEnabled: hs.Cfg.AWSAssumeRoleEnabled,
|
||||
SupportBundlesEnabled: isSupportBundlesEnabled(hs),
|
||||
FeatureToggles: featureToggles,
|
||||
AnonymousEnabled: hs.Cfg.Anonymous.Enabled,
|
||||
AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit,
|
||||
RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()),
|
||||
RendererVersion: hs.RenderService.Version(),
|
||||
RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth,
|
||||
RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight,
|
||||
RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale,
|
||||
Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme,
|
||||
GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent,
|
||||
PluginCatalogURL: hs.Cfg.PluginCatalogURL,
|
||||
PluginAdminEnabled: hs.Cfg.PluginAdminEnabled,
|
||||
PluginAdminExternalManageEnabled: hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled,
|
||||
PluginCatalogHiddenPlugins: hs.Cfg.PluginCatalogHiddenPlugins,
|
||||
PluginCatalogManagedPlugins: hs.managedPluginsService.ManagedPlugins(c.Req.Context()),
|
||||
PluginCatalogPreinstalledPlugins: append(hs.Cfg.PreinstallPluginsAsync, hs.Cfg.PreinstallPluginsSync...),
|
||||
PluginCatalogPreinstalledAutoUpdate: hs.Cfg.PreinstallAutoUpdate,
|
||||
ExpressionsEnabled: hs.Cfg.ExpressionsEnabled,
|
||||
AwsAllowedAuthProviders: hs.Cfg.AWSAllowedAuthProviders,
|
||||
AwsAssumeRoleEnabled: hs.Cfg.AWSAssumeRoleEnabled,
|
||||
SupportBundlesEnabled: isSupportBundlesEnabled(hs),
|
||||
|
||||
Azure: dtos.FrontendSettingsAzureDTO{
|
||||
Cloud: hs.Cfg.Azure.Cloud,
|
||||
@@ -322,7 +324,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
},
|
||||
|
||||
Caching: dtos.FrontendSettingsCachingDTO{
|
||||
Enabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("enabled").MustBool(true),
|
||||
Enabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("enabled").MustBool(true),
|
||||
CleanCacheEnabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("clean_cache_enabled").MustBool(true),
|
||||
},
|
||||
RecordedQueries: dtos.FrontendSettingsRecordedQueriesDTO{
|
||||
Enabled: hs.Cfg.SectionWithEnvOverrides("recorded_queries").Key("enabled").MustBool(true),
|
||||
|
||||
@@ -164,7 +164,6 @@ type HTTPServer struct {
|
||||
LoggerMiddleware loggermw.Logger
|
||||
SQLStore db.DB
|
||||
AlertNG *ngalert.AlertNG
|
||||
LibraryPanelService librarypanels.Service
|
||||
LibraryElementService libraryelements.Service
|
||||
SocialService social.Service
|
||||
Listener net.Listener
|
||||
@@ -319,7 +318,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
ContextHandler: contextHandler,
|
||||
LoggerMiddleware: loggerMiddleware,
|
||||
AlertNG: alertNG,
|
||||
LibraryPanelService: libraryPanelService,
|
||||
LibraryElementService: libraryElementService,
|
||||
QuotaService: quotaService,
|
||||
tracer: tracer,
|
||||
|
||||
+4
-5
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -184,12 +185,12 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
||||
NewGrafanaVersionExists: hs.grafanaUpdateChecker.UpdateAvailable(),
|
||||
AppName: setting.ApplicationName,
|
||||
AppNameBodyClass: "app-grafana",
|
||||
FavIcon: "public/img/fav32.png",
|
||||
AppleTouchIcon: "public/img/apple-touch-icon.png",
|
||||
FavIcon: template.URL(assets.ContentDeliveryURL + "public/build/img/fav32.png"), // #nosec G203
|
||||
AppleTouchIcon: template.URL(assets.ContentDeliveryURL + "public/build/img/apple-touch-icon.png"), // #nosec G203
|
||||
AppTitle: "Grafana",
|
||||
NavTree: navTree,
|
||||
Nonce: c.RequestNonce,
|
||||
LoadingLogo: "public/img/grafana_icon.svg",
|
||||
LoadingLogo: template.URL(assets.ContentDeliveryURL + "public/build/img/grafana_icon.svg"), // #nosec G203
|
||||
IsDevelopmentEnv: hs.Cfg.Env == setting.Dev,
|
||||
Assets: assets,
|
||||
}
|
||||
@@ -215,8 +216,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
||||
|
||||
hs.HooksService.RunIndexDataHooks(&data, c)
|
||||
|
||||
data.NavTree.ApplyCostManagementIA()
|
||||
data.NavTree.ApplyHelpVersion(data.Settings.BuildInfo.VersionString) // RunIndexDataHooks can modify the version string
|
||||
data.NavTree.Sort()
|
||||
|
||||
return &data, nil
|
||||
|
||||
@@ -314,7 +314,7 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or
|
||||
filteredUsers = append(filteredUsers, user)
|
||||
}
|
||||
|
||||
modules, err := hs.authInfoService.GetUserLabels(c.Req.Context(), login.GetUserLabelsQuery{
|
||||
modules, err := hs.authInfoService.GetUsersRecentlyUsedLabel(c.Req.Context(), login.GetUserLabelsQuery{
|
||||
UserIDs: authLabelsUserIDs,
|
||||
})
|
||||
|
||||
|
||||
+1
-6
@@ -144,13 +144,8 @@ type playlistK8sHandler struct {
|
||||
//-----------------------------------------------------------------------------------------
|
||||
|
||||
func newPlaylistK8sHandler(hs *HTTPServer) *playlistK8sHandler {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: v0alpha1.PlaylistKind().Group(),
|
||||
Version: v0alpha1.PlaylistKind().Version(),
|
||||
Resource: v0alpha1.PlaylistKind().Plural(),
|
||||
}
|
||||
return &playlistK8sHandler{
|
||||
gvr: gvr,
|
||||
gvr: v0alpha1.PlaylistKind().GroupVersionResource(),
|
||||
namespacer: request.GetNamespaceMapper(hs.Cfg),
|
||||
clientConfigProvider: hs.clientConfigProvider,
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestIntegrationCallResource(t *testing.T) {
|
||||
Backend: true,
|
||||
},
|
||||
}))
|
||||
middlewares := pluginsintegration.CreateMiddlewares(cfg, &oauthtokentest.Service{}, tracing.InitializeTracerForTest(), &caching.OSSCachingService{}, featuremgmt.WithFeatures(), prometheus.DefaultRegisterer, pluginRegistry)
|
||||
middlewares := pluginsintegration.CreateMiddlewares(cfg, &oauthtokentest.Service{}, tracing.InitializeTracerForTest(), caching.ProvideCachingServiceClient(&caching.OSSCachingService{}, nil), featuremgmt.WithFeatures(), prometheus.DefaultRegisterer, pluginRegistry)
|
||||
pc, err := backend.HandlerFromMiddlewares(&pluginfakes.FakePluginClient{
|
||||
CallResourceHandlerFunc: backend.CallResourceHandlerFunc(func(ctx context.Context,
|
||||
req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
|
||||
+6
-11
@@ -11,7 +11,7 @@ import (
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
|
||||
"github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1"
|
||||
"github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
@@ -119,13 +119,8 @@ type shortURLK8sHandler struct {
|
||||
}
|
||||
|
||||
func newShortURLK8sHandler(hs *HTTPServer) *shortURLK8sHandler {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: v1alpha1.ShortURLKind().Group(),
|
||||
Version: v1alpha1.ShortURLKind().Version(),
|
||||
Resource: v1alpha1.ShortURLKind().Plural(),
|
||||
}
|
||||
return &shortURLK8sHandler{
|
||||
gvr: gvr,
|
||||
gvr: v1beta1.ShortURLKind().GroupVersionResource(),
|
||||
namespacer: request.GetNamespaceMapper(hs.Cfg),
|
||||
clientConfigProvider: hs.clientConfigProvider,
|
||||
cfg: hs.Cfg,
|
||||
@@ -169,9 +164,9 @@ func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmode
|
||||
}
|
||||
|
||||
result := client.RESTClient().Get().
|
||||
Prefix("apis", v1alpha1.APIGroup, v1alpha1.APIVersion).
|
||||
Prefix("apis", v1beta1.APIGroup, v1beta1.APIVersion).
|
||||
Namespace(sk8s.namespacer(c.OrgID)).
|
||||
Resource(v1alpha1.ShortURLKind().Plural()).
|
||||
Resource(v1beta1.ShortURLKind().Plural()).
|
||||
Name(uid).
|
||||
SubResource("goto").
|
||||
Param("redirect", "false"). // returns the URL and then we will do the redirect
|
||||
@@ -188,7 +183,7 @@ func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmode
|
||||
return
|
||||
}
|
||||
|
||||
value := &v1alpha1.GetGoto{}
|
||||
value := &v1beta1.GetGoto{}
|
||||
if err = json.Unmarshal(body, value); err != nil {
|
||||
c.JsonApiErr(500, "unmarshal", err)
|
||||
return
|
||||
@@ -217,7 +212,7 @@ func (sk8s *shortURLK8sHandler) createKubernetesShortURLsHandler(c *contextmodel
|
||||
|
||||
c.Logger.Debug("Creating short URL", "path", cmd.Path)
|
||||
obj := shorturl.LegacyCreateCommandToUnstructured(cmd)
|
||||
obj.SetGenerateName("u") // becomes a prefix
|
||||
obj.SetGenerateName("s") // becomes a prefix
|
||||
|
||||
out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"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/shorturls"
|
||||
@@ -31,7 +32,7 @@ func TestShortURLAPIEndpoint(t *testing.T) {
|
||||
Path: cmd.Path,
|
||||
}
|
||||
service := &fakeShortURLService{
|
||||
createShortURLFunc: func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
|
||||
createShortURLFunc: func(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
|
||||
return createResp, nil
|
||||
},
|
||||
createConvertShortURLToDTO: func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL {
|
||||
@@ -81,7 +82,7 @@ func createShortURLScenario(t *testing.T, desc string, url string, routePattern
|
||||
}
|
||||
|
||||
type fakeShortURLService struct {
|
||||
createShortURLFunc func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error)
|
||||
createShortURLFunc func(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error)
|
||||
createConvertShortURLToDTO func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL
|
||||
}
|
||||
|
||||
@@ -89,11 +90,11 @@ func (s *fakeShortURLService) List(ctx context.Context, orgID int64) ([]*shortur
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) {
|
||||
func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
|
||||
func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
|
||||
if s.createShortURLFunc != nil {
|
||||
return s.createShortURLFunc(ctx, user, cmd)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response
|
||||
}
|
||||
return response.Error(http.StatusInternalServerError, "Failed to get user", err)
|
||||
}
|
||||
|
||||
result := user.UserProfileDTO{
|
||||
ID: usr.ID,
|
||||
UID: usr.UID,
|
||||
@@ -128,6 +129,11 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response
|
||||
UpdatedAt: usr.Updated,
|
||||
CreatedAt: usr.Created,
|
||||
}
|
||||
// Populate AuthLabels using all historically used auth modules ordered by most recent.
|
||||
if modules, err := hs.authInfoService.GetUserAuthModuleLabels(c.Req.Context(), usr.ID); err == nil {
|
||||
result.AuthLabels = modules
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, &result)
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,44 @@ func TestIntegrationUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}, mock)
|
||||
|
||||
// Multiple historical auth labels should appear ordered by recency
|
||||
loggedInUserScenario(t, "When calling GET returns with multiple auth labels", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) {
|
||||
createUserCmd := user.CreateUserCommand{
|
||||
Email: fmt.Sprint("multi", "@test.com"),
|
||||
Name: "multi",
|
||||
Login: "multi",
|
||||
IsAdmin: true,
|
||||
}
|
||||
orgSvc, err := orgimpl.ProvideService(sqlStore, sc.cfg, quotatest.New(false, nil))
|
||||
require.NoError(t, err)
|
||||
userSvc, err := userimpl.ProvideService(
|
||||
sqlStore, orgSvc, sc.cfg, nil, nil, tracing.InitializeTracerForTest(),
|
||||
quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
usr, err := userSvc.Create(context.Background(), &createUserCmd)
|
||||
require.Nil(t, err)
|
||||
|
||||
sc.handlerFunc = hs.GetUserByLoginOrEmail
|
||||
|
||||
userMock := usertest.NewUserServiceFake()
|
||||
userMock.ExpectedUser = &user.User{ID: usr.ID, Email: usr.Email, Login: usr.Login, Name: usr.Name}
|
||||
sc.userService = userMock
|
||||
hs.userService = userMock
|
||||
|
||||
fakeAuth := &authinfotest.FakeService{ExpectedAuthModuleLabels: []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)}}
|
||||
hs.authInfoService = fakeAuth
|
||||
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{"loginOrEmail": usr.Email}).exec()
|
||||
|
||||
var resp user.UserProfileDTO
|
||||
require.Equal(t, http.StatusOK, sc.resp.Code)
|
||||
err = json.Unmarshal(sc.resp.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
expected := []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)}
|
||||
require.Equal(t, expected, resp.AuthLabels)
|
||||
}, mock)
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "/api/users", "/api/users", func(sc *scenarioContext) {
|
||||
userMock.ExpectedSearchUsers = mockResult
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ func (u *Unstructured) SetUnstructuredContent(content map[string]any) {
|
||||
|
||||
// MarshalJSON ensures that the unstructured object produces proper
|
||||
// JSON when passed to Go's standard JSON library.
|
||||
func (u *Unstructured) MarshalJSON() ([]byte, error) {
|
||||
func (u Unstructured) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(u.Object)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
|
||||
@@ -96,16 +96,16 @@ 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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
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=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
@@ -74,6 +74,21 @@ func newInternalIdentity(name string, namespace string, orgID int64, opts ...Ide
|
||||
return staticRequester
|
||||
}
|
||||
|
||||
// WithServiceIdentityForSingleNamespace sets an identity representing the service itself in provided namespace and store it in context.
|
||||
// This is useful for background tasks that has to communicate with other services in the same namespace. It also returns a Requester with
|
||||
// static permissions so it can be used in legacy code paths.
|
||||
func WithServiceIdentityForSingleNamespace(ctx context.Context, namespace string, opts ...IdentityOpts) (context.Context, Requester) {
|
||||
r := newInternalIdentity(serviceName, namespace, 1, opts...)
|
||||
return WithRequester(ctx, r), r
|
||||
}
|
||||
|
||||
// WithServiceIdentityForSingleNamespaceContext sets an identity representing the service itself in context, restricted to a namespace.
|
||||
// Use when using a middleware that signs tokens with the same restriction.
|
||||
func WithServiceIdentityForSingleNamespaceContext(ctx context.Context, namespace string, opts ...IdentityOpts) context.Context {
|
||||
ctx, _ = WithServiceIdentityForSingleNamespace(ctx, namespace, opts...)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context.
|
||||
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
|
||||
// static permissions so it can be used in legacy code paths.
|
||||
@@ -125,6 +140,10 @@ var serviceIdentityPermissions = getWildcardPermissions(
|
||||
"datasources:query",
|
||||
"datasources:read",
|
||||
"datasources:delete",
|
||||
"library.panels:create", // ActionLibraryPanelsCreate
|
||||
"library.panels:read", // ActionLibraryPanelsRead
|
||||
"library.panels:write", // ActionLibraryPanelsWrite
|
||||
"library.panels:delete", // ActionLibraryPanelsDelete
|
||||
"alert.provisioning:write",
|
||||
"alert.provisioning.secrets:read",
|
||||
"users:read", // accesscontrol.ActionUsersRead,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
)
|
||||
|
||||
// Generic query request with shared time across all values
|
||||
@@ -28,6 +29,14 @@ type QueryDataResponse struct {
|
||||
backend.QueryDataResponse `json:",inline"`
|
||||
}
|
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type SQLSchemas struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
|
||||
// Backend wrapper (external dependency)
|
||||
expr.SQLSchemas `json:"sqlSchemas,inline"`
|
||||
}
|
||||
|
||||
// GetResponseCode return the right status code for the response by checking the responses.
|
||||
func GetResponseCode(rsp *backend.QueryDataResponse) int {
|
||||
if rsp == nil {
|
||||
|
||||
@@ -262,3 +262,29 @@ func (in *QueryTypeDefinitionList) DeepCopyObject() runtime.Object {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SQLSchemas) DeepCopyInto(out *SQLSchemas) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
out.SQLSchemas = in.SQLSchemas.DeepCopy()
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SQLSchemas.
|
||||
func (in *SQLSchemas) DeepCopy() *SQLSchemas {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SQLSchemas)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *SQLSchemas) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
||||
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition": schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinitionList": schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.SQLSchemas": schema_pkg_apis_query_v0alpha1_SQLSchemas(ref),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,3 +483,29 @@ func schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref common.Reference
|
||||
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_query_v0alpha1_SQLSchemas(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-20250926065801-df98203cff37
|
||||
github.com/grafana/grafana-app-sdk/logging v0.46.0
|
||||
github.com/grafana/grafana-app-sdk/logging v0.48.1
|
||||
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
|
||||
@@ -84,14 +84,15 @@ require (
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.13.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools 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/grpc v1.76.0 // indirect
|
||||
|
||||
+12
-12
@@ -71,8 +71,8 @@ github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbP
|
||||
github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/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.46.0 h1:JhQ+ZK5orcmM+dZ3YZdT9uCizJEFU2I6JBNUSFWvCC8=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.46.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
|
||||
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/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=
|
||||
@@ -207,8 +207,8 @@ 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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -219,8 +219,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
@@ -237,21 +237,21 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -15,5 +15,6 @@ func ValidatePackage(ctx context.Context, d *dagger.Client, service *dagger.Serv
|
||||
|
||||
return c.WithServiceBinding("grafana", service).
|
||||
WithEnvVariable("GRAFANA_URL", "http://grafana:3000").
|
||||
WithEnvVariable("PW_TEST_HTML_REPORT_OPEN", "never").
|
||||
WithExec([]string{"yarn", "e2e:acceptance"}), nil
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ go 1.25.3
|
||||
// Override docker/docker to avoid:
|
||||
// go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires
|
||||
// github.com/docker/docker@v0.0.0-00010101000000-000000000000: invalid version: unknown revision 000000000000
|
||||
replace github.com/docker/docker => github.com/moby/moby v27.5.1+incompatible
|
||||
replace github.com/docker/docker => github.com/moby/moby v28.0.1+incompatible
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect; @grafana/grafana-backend-group
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect; @grafana/grafana-backend-group
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect; @grafana/grafana-backend-group
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect; @grafana/grafana-backend-group
|
||||
golang.org/x/net v0.45.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources
|
||||
golang.org/x/net v0.46.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources
|
||||
golang.org/x/sync v0.17.0 // @grafana/alerting-backend
|
||||
golang.org/x/text v0.30.0 // indirect; @grafana/grafana-backend-group
|
||||
google.golang.org/grpc v1.76.0 // indirect; @grafana/plugins-platform-backend
|
||||
@@ -38,7 +38,7 @@ require (
|
||||
dagger.io/dagger v0.18.8
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.22
|
||||
github.com/urfave/cli/v3 v3.4.1
|
||||
github.com/urfave/cli/v3 v3.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
+4
-4
@@ -53,8 +53,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
|
||||
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||
github.com/urfave/cli/v3 v3.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw=
|
||||
github.com/urfave/cli/v3 v3.5.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
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=
|
||||
@@ -95,8 +95,8 @@ go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOV
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
|
||||
@@ -6,10 +6,10 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
|
||||
golang.org/x/tools v0.37.0
|
||||
golang.org/x/tools v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
|
||||
+4
-4
@@ -6,10 +6,10 @@ require (
|
||||
cuelang.org/go v0.11.1
|
||||
github.com/dave/dst v0.27.3
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d
|
||||
github.com/grafana/cog v0.0.43
|
||||
github.com/grafana/cog v0.0.44
|
||||
github.com/grafana/cuetsy v0.1.11
|
||||
github.com/matryer/is v1.4.1
|
||||
golang.org/x/tools v0.37.0
|
||||
golang.org/x/tools v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -47,8 +47,8 @@ require (
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
github.com/xlab/treeprint v1.2.0 // indirect
|
||||
github.com/yalue/merged_fs v1.3.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
+8
-8
@@ -31,8 +31,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/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
|
||||
github.com/grafana/cog v0.0.43 h1:6EDzJVc8hbP3+AjPnRnAK6mKfyWgqLKbi/jmiStilFk=
|
||||
github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
|
||||
github.com/grafana/cog v0.0.44 h1:N8UP7g6XBHZXf1wY7AOOWC2HdqlTPBJPJiAZQrkf4XQ=
|
||||
github.com/grafana/cog v0.0.44/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
|
||||
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ=
|
||||
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws=
|
||||
github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk=
|
||||
@@ -100,16 +100,16 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
|
||||
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
|
||||
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
|
||||
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -278,18 +278,6 @@ func TestNaNBecomesNull(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestErrorsFromGoMySQLServerAreFlagged(t *testing.T) {
|
||||
const GmsNotImplemented = "TRUNCATE" // not implemented in go-mysql-server as of 2025-04-11
|
||||
|
||||
db := DB{}
|
||||
|
||||
query := `SELECT ` + GmsNotImplemented + `(123.456, 2);`
|
||||
|
||||
_, err := db.QueryFrames(context.Background(), &testTracer{}, "sqlExpressionRefId", query, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "error from the sql expression engine")
|
||||
}
|
||||
|
||||
func TestFrameToSQLAndBack_JSONRoundtrip(t *testing.T) {
|
||||
expectedFrame := &data.Frame{
|
||||
RefID: "json_test",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build arm
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
mysql "github.com/dolthub/go-mysql-server/sql"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
// TODO: Implement for 32-bit arm
|
||||
func MySQLColToFieldType(col *mysql.Column) (data.FieldType, error) {
|
||||
return data.FieldTypeUnknown, errors.New("arm not implemented")
|
||||
}
|
||||
|
||||
func SchemaFromFrame(frame *data.Frame) mysql.Schema {
|
||||
return mysql.Schema{}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (ft *FrameTable) String() string {
|
||||
return ft.Name()
|
||||
}
|
||||
|
||||
func schemaFromFrame(frame *data.Frame) mysql.Schema {
|
||||
func SchemaFromFrame(frame *data.Frame) mysql.Schema {
|
||||
schema := make(mysql.Schema, len(frame.Fields))
|
||||
|
||||
for i, field := range frame.Fields {
|
||||
@@ -48,7 +48,7 @@ func schemaFromFrame(frame *data.Frame) mysql.Schema {
|
||||
// Schema implements the mysql.Table interface
|
||||
func (ft *FrameTable) Schema() mysql.Schema {
|
||||
if ft.schema == nil {
|
||||
ft.schema = schemaFromFrame(ft.Frame)
|
||||
ft.schema = SchemaFromFrame(ft.Frame)
|
||||
}
|
||||
return ft.schema
|
||||
}
|
||||
|
||||
@@ -183,19 +183,25 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
|
||||
// Conditional functions
|
||||
case "if", "coalesce", "ifnull", "nullif":
|
||||
return
|
||||
case "least":
|
||||
return
|
||||
|
||||
// Aggregation functions
|
||||
case "sum", "avg", "count", "min", "max":
|
||||
return
|
||||
case "stddev", "std", "stddev_pop":
|
||||
case "stddev", "std", "stddev_pop", "stddev_sample":
|
||||
return
|
||||
case "variance", "var_pop":
|
||||
case "variance", "var_pop", "var_samp":
|
||||
return
|
||||
case "group_concat":
|
||||
return
|
||||
case "row_number", "rank", "dense_rank", "lead", "lag":
|
||||
|
||||
// Window Functions
|
||||
case "row_number", "rank", "dense_rank", "percent_rank":
|
||||
return
|
||||
case "first_value", "last_value":
|
||||
case "first_value", "last_value", "ntile":
|
||||
return
|
||||
case "lead", "lag":
|
||||
return
|
||||
|
||||
// Mathematical functions
|
||||
@@ -205,14 +211,16 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
|
||||
return
|
||||
case "sqrt", "pow", "power":
|
||||
return
|
||||
case "mod", "log", "log10", "exp":
|
||||
case "mod", "log", "log2", "log10", "exp":
|
||||
return
|
||||
case "sign", "ln", "truncate":
|
||||
return
|
||||
case "sin", "cos", "tan":
|
||||
case "sin", "cos", "tan", "cot":
|
||||
return
|
||||
case "asin", "acos", "atan", "atan2":
|
||||
return
|
||||
case "conv", "degrees", "radians":
|
||||
return
|
||||
case "rand", "pi":
|
||||
return
|
||||
|
||||
@@ -235,23 +243,29 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
|
||||
return
|
||||
case "ascii", "ord", "char":
|
||||
return
|
||||
case "regexp_substr":
|
||||
case "elt", "quote":
|
||||
return
|
||||
case "from_base64", "format":
|
||||
return
|
||||
case "regexp_substr", "regexp_replace", "regexp_instr", "regexp_like":
|
||||
return
|
||||
|
||||
// Date functions
|
||||
case "str_to_date":
|
||||
return
|
||||
case "date_format":
|
||||
case "date_format", "get_format":
|
||||
return
|
||||
case "date_add", "date_sub":
|
||||
case "date_add", "adddate", "date_sub", "subdate":
|
||||
return
|
||||
case "year", "month", "day", "weekday":
|
||||
case "year", "month", "day", "weekday", "last_day":
|
||||
return
|
||||
case "yearweek", "weekofyear":
|
||||
return
|
||||
case "datediff":
|
||||
return
|
||||
case "unix_timestamp", "from_unixtime":
|
||||
return
|
||||
case "extract", "hour", "minute", "second":
|
||||
case "extract", "hour", "minute", "second", "microsecond":
|
||||
return
|
||||
case "dayname", "monthname", "dayofweek", "dayofmonth", "dayofyear":
|
||||
return
|
||||
@@ -259,20 +273,36 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
|
||||
return
|
||||
case "timestampdiff", "timestampadd":
|
||||
return
|
||||
case "from_days", "to_days":
|
||||
return
|
||||
case "time_format", "time", "timediff":
|
||||
return
|
||||
|
||||
// Type conversion
|
||||
case "cast", "convert":
|
||||
return
|
||||
|
||||
// JSON functions
|
||||
case "json_extract", "json_object", "json_array", "json_merge_patch", "json_valid":
|
||||
case "json_extract", "json_object", "json_array", "json_valid":
|
||||
return
|
||||
case "json_merge", "json_merge_patch", "json_merge_preserve":
|
||||
return
|
||||
case "json_contains", "json_length", "json_type", "json_keys":
|
||||
return
|
||||
case "json_contains_path", "json_depth":
|
||||
return
|
||||
case "json_search", "json_quote", "json_unquote":
|
||||
return
|
||||
case "json_set", "json_insert", "json_replace", "json_remove":
|
||||
return
|
||||
case "json_array_append", "json_array_insert":
|
||||
return
|
||||
case "json_objectagg", "json_arrayagg":
|
||||
return
|
||||
case "json_overlaps":
|
||||
return
|
||||
case "json_pretty", "json_value":
|
||||
return
|
||||
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -92,6 +92,16 @@ func TestAllowQuery(t *testing.T) {
|
||||
q: "SELECT * FROM mockGitHubIssuesDSResponse, JSON_TABLE(labels, '$[*]' COLUMNS(val VARCHAR(255) PATH '$')) AS jt WHERE CAST(jt.val AS CHAR) LIKE 'type%'",
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "json aggregation",
|
||||
q: `SELECT JSON_ARRAYAGG(JSON_OBJECT('color', color, 'value', value)) AS result
|
||||
FROM (
|
||||
SELECT 'red' AS color, 10 AS value UNION ALL
|
||||
SELECT 'blue', 20 UNION ALL
|
||||
SELECT 'green', 30
|
||||
) AS t;`,
|
||||
err: nil,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -215,9 +225,11 @@ SELECT
|
||||
STDDEV(value) AS agg_stddev,
|
||||
STD(value) AS agg_std,
|
||||
STDDEV_POP(value) AS agg_stddev_pop,
|
||||
STDDEV_SAMPLE(value) AS agg_stddev_sample,
|
||||
VARIANCE(value) AS agg_variance,
|
||||
VAR_POP(value) AS agg_var_pop,
|
||||
|
||||
VAR_SAMP(value) AS agg_var_samp,
|
||||
|
||||
-- Mathematical functions
|
||||
ABS(value) AS math_abs,
|
||||
ROUND(value, 2) AS math_round,
|
||||
@@ -229,6 +241,7 @@ SELECT
|
||||
POWER(value, 2) AS math_power,
|
||||
MOD(value, 10) AS math_mod,
|
||||
LOG(value) AS math_log,
|
||||
LOG2(value) AS math_log2,
|
||||
LOG10(value) AS math_log10,
|
||||
EXP(value) AS math_exp,
|
||||
SIGN(value) AS math_sign,
|
||||
@@ -245,13 +258,25 @@ SELECT
|
||||
-- Date functions
|
||||
STR_TO_DATE('2023-01-01', '%Y-%m-%d') AS date_str_to_date,
|
||||
DATE_FORMAT('2025-01-01 00:00:00', '%Y-%m-%d') AS date_format,
|
||||
DATE_FORMAT('2003-10-03',GET_FORMAT(DATE,'EUR')) AS date_format_eur,
|
||||
'2025-01-01 00:00:00' AS date_now,
|
||||
DATE_ADD(created_at, INTERVAL 1 DAY) AS date_add,
|
||||
DATE_SUB(created_at, INTERVAL 1 DAY) AS date_sub,
|
||||
ADDDATE(created_at, INTERVAL 1 DAY) AS date_adddate,
|
||||
SUBDATE(created_at, INTERVAL 1 DAY) AS date_subdate,
|
||||
YEAR(created_at) AS date_year,
|
||||
MONTH(created_at) AS date_month,
|
||||
DAY(created_at) AS date_day,
|
||||
MICROSECOND(created_at) AS date_microsecond,
|
||||
FROM_DAYS(738123) AS date_from_days,
|
||||
TO_DAYS(created_at) AS date_to_days,
|
||||
TIME(created_at) AS date_time,
|
||||
TIME_FORMAT(created_at, '%H:%i:%s') AS date_time_format,
|
||||
TIMEDIFF(created_at, '2025-01-01 00:00:00') AS date_timediff,
|
||||
WEEKDAY(created_at) AS date_weekday,
|
||||
LAST_DAY(created_at) AS date_last_day,
|
||||
YEARWEEK(created_at) AS date_yearweek,
|
||||
WEEKOFYEAR(created_at) AS date_weekofyear,
|
||||
DATEDIFF('2025-01-01 00:00:00', created_at) AS date_datediff,
|
||||
UNIX_TIMESTAMP(created_at) AS date_unix_timestamp,
|
||||
FROM_UNIXTIME(1634567890) AS date_from_unixtime,
|
||||
@@ -266,6 +291,8 @@ LIMIT 10`
|
||||
var example_json_functions = `SELECT
|
||||
JSON_OBJECT('key1', 'value1', 'key2', 10) AS json_obj,
|
||||
JSON_ARRAY(1, 'abc', NULL, TRUE) AS json_arr,
|
||||
JSON_ARRAY_APPEND('{"a": 1}', '$.b', 2) AS json_array_append,
|
||||
JSON_ARRAY_INSERT('{"a": 1}', '$.b', 2) AS json_array_insert,
|
||||
JSON_EXTRACT('{"id": 123, "name": "test"}', '$.id') AS json_ext,
|
||||
JSON_UNQUOTE(JSON_EXTRACT('{"name": "test"}', '$.name')) AS json_unq,
|
||||
JSON_CONTAINS('{"a": 1, "b": 2}', '{"a": 1}') AS json_contains,
|
||||
@@ -273,6 +300,13 @@ var example_json_functions = `SELECT
|
||||
JSON_REMOVE('{"a": 1, "b": 2}', '$.b') AS json_remove,
|
||||
JSON_LENGTH('{"a": 1, "b": {"c": 3}}') AS json_len,
|
||||
JSON_SEARCH('{"a": "xyz", "b": "abc"}', 'one', 'abc') AS json_search,
|
||||
JSON_MERGE('{"a": 1}', '{"b": 2}') AS json_merge,
|
||||
JSON_MERGE_PRESERVE('{"a": 1}', '{"b": 2}') AS json_merge_preserve,
|
||||
JSON_CONTAINS_PATH('{"a": 1, "b": 2}', 'one', '$.a') AS json_contains_path,
|
||||
JSON_DEPTH('{"a": 1, "b": {"c": 2}}') AS json_depth,
|
||||
JSON_OVERLAPS('{"a": 1, "b": 2}', '{"b": 2, "c": 3}') AS json_overlaps,
|
||||
JSON_PRETTY('{"a": 1, "b": 2}') AS json_pretty,
|
||||
JSON_VALUE('{"a": 1, "b": 2}', '$.a') AS json_value,
|
||||
JSON_TYPE('{"a": 1}') AS json_type`
|
||||
|
||||
var example_many_more_allowed_functions = `
|
||||
@@ -280,13 +314,17 @@ SELECT
|
||||
-- Math functions
|
||||
LN(10) as ln_val,
|
||||
TRUNCATE(12.345, 2) as truncate_val,
|
||||
CONV('a',16,2) as conv_val,
|
||||
SIN(0.5) as sin_val,
|
||||
COS(0.5) as cos_val,
|
||||
COT(0.5) as cot_val,
|
||||
TAN(0.5) as tan_val,
|
||||
ASIN(0.5) as asin_val,
|
||||
ACOS(0.5) as acos_val,
|
||||
ATAN(0.5) as atan_val,
|
||||
ATAN2(1, 2) as atan2_val,
|
||||
DEGREES(0.5) as degrees_val,
|
||||
RADIANS(0.5) as radians_val,
|
||||
RAND() as rand_val,
|
||||
PI() as pi_val,
|
||||
|
||||
@@ -307,8 +345,19 @@ SELECT
|
||||
ASCII('A') as ascii_val,
|
||||
ORD('A') as ord_val,
|
||||
CHAR(65) as char_val,
|
||||
ELT(2, 'one', 'two', 'three') as elt_val,
|
||||
FROM_BASE64('SGVsbG8sIFdvcmxkIQ==') as from_base64_val,
|
||||
FORMAT(12332.123456, 4) as format_val,
|
||||
QUOTE('hello') as quote_val,
|
||||
|
||||
-- Regex
|
||||
'a' REGEXP '^[a-d]' AS regexp_val,
|
||||
REGEXP_SUBSTR('hello world', 'world') as regexp_substr_val,
|
||||
|
||||
REGEXP_REPLACE('hello world', 'world', 'gopher') as regexp_replace_val,
|
||||
REGEXP_INSTR('dog cat dog', 'dog') as regexp_instr_val,
|
||||
REGEXP_LIKE('Michael!', '.*') as regexp_like_val,
|
||||
|
||||
|
||||
-- Date functions
|
||||
EXTRACT(YEAR FROM '2023-01-01') as extract_val,
|
||||
HOUR('12:34:56') as hour_val,
|
||||
@@ -350,8 +399,10 @@ SELECT
|
||||
ROW_NUMBER() OVER (ORDER BY val) as row_num,
|
||||
RANK() OVER (ORDER BY val) as rank_val,
|
||||
DENSE_RANK() OVER (ORDER BY val) as dense_rank_val,
|
||||
PERCENT_RANK() OVER (ORDER BY val) as percent_rank_val,
|
||||
LEAD(val) OVER (ORDER BY val) as lead_val,
|
||||
LAG(val) OVER (ORDER BY val) as lag_val,
|
||||
FIRST_VALUE(val) OVER (ORDER BY val) as first_val,
|
||||
NTILE(2) OVER (ORDER BY val) as ntile_val,
|
||||
LAST_VALUE(val) OVER (ORDER BY val ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as last_val
|
||||
FROM dummy_data;`
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package expr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||
"github.com/grafana/grafana/pkg/expr/sql"
|
||||
)
|
||||
|
||||
// BasicColumn represents the column type for data that is input to a SQL expression.
|
||||
type BasicColumn struct {
|
||||
Name string `json:"name"`
|
||||
MySQLType string `json:"mysqlType"`
|
||||
Nullable bool `json:"nullable"`
|
||||
DataFrameFieldType data.FieldType `json:"dataFrameFieldType"`
|
||||
}
|
||||
|
||||
// SchemaInfo provides information and some sample data for data that could be an input
|
||||
// to a SQL expression.
|
||||
type SchemaInfo struct {
|
||||
Columns []BasicColumn `json:"columns"`
|
||||
SampleRows [][]any `json:"sampleRows"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SQLSchemas returns info about what the Schema for a DS query will be like if the
|
||||
// query were to be used an input to SQL expressions. So effectively post SQL expressions input
|
||||
// conversion.
|
||||
// There is a a manual DeepCopy at the end of this file that will need to be updated when this our the
|
||||
// underlying structs are change. The hack script will also need to be run to update the Query service API
|
||||
// generated types.
|
||||
type SQLSchemas map[string]SchemaInfo
|
||||
|
||||
// GetSQLSchemas returns what the schemas are for SQL expressions for all DS queries
|
||||
// in the request. It executes the queries to get the schemas.
|
||||
// Intended use is for autocomplete and AI, so used during the authoring/editing experience only.
|
||||
func (s *Service) GetSQLSchemas(ctx context.Context, req Request) (SQLSchemas, error) {
|
||||
// Extract DS Nodes and Execute Them
|
||||
// Building the pipeline is maybe not best, as it can have more errors.
|
||||
filtered := make([]Query, 0, len(req.Queries))
|
||||
for _, q := range req.Queries {
|
||||
if NodeTypeFromDatasourceUID(q.DataSource.UID) == TypeDatasourceNode {
|
||||
filtered = append(filtered, q)
|
||||
}
|
||||
}
|
||||
req.Queries = filtered
|
||||
pipeline, err := s.buildPipeline(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var schemas = make(SQLSchemas)
|
||||
|
||||
for _, node := range pipeline {
|
||||
// For now, execute calls convert at the end, so we are being lazy and running the full conversion. Longer run we want to run without
|
||||
// full conversion and just get the schema. Maybe conversion should be
|
||||
dsNode := node.(*DSNode)
|
||||
// Make all input to SQL
|
||||
dsNode.isInputToSQLExpr = true
|
||||
|
||||
// TODO: check where time is coming from, don't recall
|
||||
res, err := dsNode.Execute(ctx, time.Now(), mathexp.Vars{}, s)
|
||||
if err != nil {
|
||||
schemas[dsNode.RefID()] = SchemaInfo{Error: err.Error()}
|
||||
continue
|
||||
// we want to continue and get the schemas we can
|
||||
}
|
||||
if res.Error != nil {
|
||||
schemas[dsNode.RefID()] = SchemaInfo{Error: res.Error.Error()}
|
||||
continue
|
||||
// we want to continue and get the schemas we can
|
||||
}
|
||||
|
||||
frames := res.Values.AsDataFrames(dsNode.RefID())
|
||||
if len(frames) == 0 {
|
||||
schemas[dsNode.RefID()] = SchemaInfo{Error: "no data"}
|
||||
}
|
||||
frame := frames[0]
|
||||
|
||||
schema := sql.SchemaFromFrame(frame)
|
||||
columns := make([]BasicColumn, 0, len(schema))
|
||||
for _, col := range schema {
|
||||
fT, _ := sql.MySQLColToFieldType(col)
|
||||
columns = append(columns, BasicColumn{
|
||||
Name: col.Name,
|
||||
MySQLType: col.Type.String(),
|
||||
Nullable: col.Nullable,
|
||||
DataFrameFieldType: fT,
|
||||
})
|
||||
}
|
||||
|
||||
// Cap at 3 rows.
|
||||
const maxRows = 3
|
||||
n := frame.Rows()
|
||||
if n > maxRows {
|
||||
n = maxRows
|
||||
}
|
||||
sampleRows := make([][]any, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
sampleRows = append(sampleRows, frame.RowCopy(i))
|
||||
}
|
||||
|
||||
schemas[dsNode.RefID()] = SchemaInfo{Columns: columns, SampleRows: sampleRows}
|
||||
}
|
||||
|
||||
return schemas, nil
|
||||
}
|
||||
|
||||
// DeepCopy returns a deep copy of the schema.
|
||||
// Used AI to make it, the kubernetes one doesn't like any or interface{}
|
||||
func (s SQLSchemas) DeepCopy() SQLSchemas {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(SQLSchemas, len(s))
|
||||
for k, v := range s {
|
||||
out[k] = SchemaInfo{
|
||||
Columns: copyColumns(v.Columns),
|
||||
SampleRows: deepCopySampleRows2D(v.SampleRows),
|
||||
Error: v.Error,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyColumns(in []BasicColumn) []BasicColumn {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]BasicColumn, len(in))
|
||||
copy(out, in) // BasicColumn is value-only, so this suffices
|
||||
return out
|
||||
}
|
||||
|
||||
// Deep-copy [][]any preserving nil vs empty slices and cloning elements.
|
||||
func deepCopySampleRows2D(in [][]any) [][]any {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([][]any, len(in))
|
||||
for i, row := range in {
|
||||
if row == nil {
|
||||
// preserve nil inner slice
|
||||
continue
|
||||
}
|
||||
newRow := make([]any, len(row))
|
||||
for j, v := range row {
|
||||
newRow[j] = deepCopyAny(v)
|
||||
}
|
||||
out[i] = newRow
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Recursively clone pointers, maps, slices, arrays, and interfaces.
|
||||
// Structs are copied by value (shallow for their internals).
|
||||
func deepCopyAny(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return deepCopyRV(reflect.ValueOf(v)).Interface()
|
||||
}
|
||||
|
||||
func deepCopyRV(rv reflect.Value) reflect.Value {
|
||||
if !rv.IsValid() {
|
||||
return rv
|
||||
}
|
||||
|
||||
switch rv.Kind() {
|
||||
case reflect.Ptr:
|
||||
if rv.IsNil() {
|
||||
return rv
|
||||
}
|
||||
elemCopy := deepCopyRV(rv.Elem())
|
||||
newPtr := reflect.New(rv.Type().Elem())
|
||||
if elemCopy.Type().AssignableTo(newPtr.Elem().Type()) {
|
||||
newPtr.Elem().Set(elemCopy)
|
||||
} else if elemCopy.Type().ConvertibleTo(newPtr.Elem().Type()) {
|
||||
newPtr.Elem().Set(elemCopy.Convert(newPtr.Elem().Type()))
|
||||
} else {
|
||||
newPtr.Elem().Set(rv.Elem()) // fallback: shallow
|
||||
}
|
||||
return newPtr
|
||||
|
||||
case reflect.Interface:
|
||||
if rv.IsNil() {
|
||||
return rv
|
||||
}
|
||||
return deepCopyRV(rv.Elem())
|
||||
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return reflect.Zero(rv.Type())
|
||||
}
|
||||
newMap := reflect.MakeMapWithSize(rv.Type(), rv.Len())
|
||||
for _, k := range rv.MapKeys() {
|
||||
newMap.SetMapIndex(deepCopyRV(k), deepCopyRV(rv.MapIndex(k)))
|
||||
}
|
||||
return newMap
|
||||
|
||||
case reflect.Slice:
|
||||
if rv.IsNil() {
|
||||
return reflect.Zero(rv.Type())
|
||||
}
|
||||
n := rv.Len()
|
||||
newSlice := reflect.MakeSlice(rv.Type(), n, n)
|
||||
for i := 0; i < n; i++ {
|
||||
newSlice.Index(i).Set(deepCopyRV(rv.Index(i)))
|
||||
}
|
||||
return newSlice
|
||||
|
||||
case reflect.Array:
|
||||
n := rv.Len()
|
||||
newArr := reflect.New(rv.Type()).Elem()
|
||||
for i := 0; i < n; i++ {
|
||||
newArr.Index(i).Set(deepCopyRV(rv.Index(i)))
|
||||
}
|
||||
return newArr
|
||||
|
||||
case reflect.Struct:
|
||||
// Value copy (OK unless the struct contains references you also want deep-copied).
|
||||
return rv
|
||||
|
||||
default:
|
||||
// Scalars (string, bool, numbers), etc.
|
||||
return rv
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,5 @@ import (
|
||||
|
||||
_ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1"
|
||||
_ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1"
|
||||
_ "github.com/testcontainers/testcontainers-go"
|
||||
)
|
||||
|
||||
+12
-1
@@ -191,6 +191,9 @@ type Panel struct {
|
||||
TimeShift *string `json:"timeShift,omitempty"`
|
||||
// Controls if the timeFrom or timeShift overrides are shown in the panel header
|
||||
HideTimeOverride *bool `json:"hideTimeOverride,omitempty"`
|
||||
// Compare the current time range with a previous period
|
||||
// For example "1d" to compare current period but shifted back 1 day
|
||||
TimeCompare *string `json:"timeCompare,omitempty"`
|
||||
// Dynamically load the panel
|
||||
LibraryPanel *LibraryPanelRef `json:"libraryPanel,omitempty"`
|
||||
// Sets panel queries cache timeout.
|
||||
@@ -206,7 +209,8 @@ type Panel struct {
|
||||
// NewPanel creates a new Panel object.
|
||||
func NewPanel() *Panel {
|
||||
return &Panel{
|
||||
Transparent: (func(input bool) *bool { return &input })(false),
|
||||
Transparent: (func(input bool) *bool { return &input })(false),
|
||||
RepeatDirection: (func(input PanelRepeatDirection) *PanelRepeatDirection { return &input })(PanelRepeatDirectionH),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,6 +968,8 @@ type AnnotationQuery struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
// Set to 1 for the standard annotation query all dashboards have by default.
|
||||
BuiltIn *float64 `json:"builtIn,omitempty"`
|
||||
// Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.
|
||||
Placement *string `json:"placement,omitempty"`
|
||||
}
|
||||
|
||||
// NewAnnotationQuery creates a new AnnotationQuery object.
|
||||
@@ -973,6 +979,7 @@ func NewAnnotationQuery() *AnnotationQuery {
|
||||
Enable: true,
|
||||
Hide: (func(input bool) *bool { return &input })(false),
|
||||
BuiltIn: (func(input float64) *float64 { return &input })(0),
|
||||
Placement: (func(input string) *string { return &input })(AnnotationQueryPlacement),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,6 +1022,10 @@ func NewAnnotationTarget() *AnnotationTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// Annotation Query placement. Defines where the annotation query should be displayed.
|
||||
// - "inControlsMenu" renders the annotation query in the dashboard controls dropdown menu
|
||||
const AnnotationQueryPlacement = "inControlsMenu"
|
||||
|
||||
// A dashboard snapshot shares an interactive dashboard publicly.
|
||||
// It is a read-only version of a dashboard, and is not editable.
|
||||
// It is possible to create a snapshot of a snapshot.
|
||||
|
||||
@@ -3,11 +3,22 @@ package middleware
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var (
|
||||
hostRedirectCounter = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "host_redirect_total",
|
||||
Help: "Number of requests redirected due to host header mismatch",
|
||||
Namespace: "grafana",
|
||||
})
|
||||
)
|
||||
|
||||
func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
|
||||
return func(c *contextmodel.ReqContext) {
|
||||
// ignore local render calls
|
||||
@@ -21,6 +32,8 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
|
||||
}
|
||||
|
||||
if !strings.EqualFold(h, cfg.Domain) {
|
||||
hostRedirectCounter.Inc()
|
||||
c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain)
|
||||
c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ func RunRepoController(deps server.OperatorDependencies) error {
|
||||
statusPatcher,
|
||||
deps.Registerer,
|
||||
tracer,
|
||||
controllerCfg.parallelOperations,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repository controller: %w", err)
|
||||
@@ -107,6 +108,7 @@ func RunRepoController(deps server.OperatorDependencies) error {
|
||||
type repoControllerConfig struct {
|
||||
provisioningControllerConfig
|
||||
workerCount int
|
||||
parallelOperations int
|
||||
allowedTargets []string
|
||||
allowImageRendering bool
|
||||
minSyncInterval time.Duration
|
||||
@@ -128,6 +130,7 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) (
|
||||
provisioningControllerConfig: *controllerCfg,
|
||||
allowedTargets: allowedTargets,
|
||||
workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1),
|
||||
parallelOperations: cfg.SectionWithEnvOverrides("operator").Key("parallel_operations").MustInt(10),
|
||||
allowImageRendering: cfg.SectionWithEnvOverrides("provisioning").Key("allow_image_rendering").MustBool(false),
|
||||
minSyncInterval: cfg.SectionWithEnvOverrides("provisioning").Key("min_sync_interval").MustDuration(1 * time.Minute),
|
||||
}, nil
|
||||
|
||||
@@ -7,7 +7,7 @@ replace github.com/grafana/grafana/pkg/codegen => ../../codegen
|
||||
require (
|
||||
cuelang.org/go v0.11.1
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d
|
||||
github.com/grafana/cog v0.0.43
|
||||
github.com/grafana/cog v0.0.44
|
||||
github.com/grafana/cuetsy v0.1.11
|
||||
github.com/grafana/grafana/pkg/codegen v0.0.0-20250514132646-acbc7b54ed9e
|
||||
)
|
||||
@@ -43,11 +43,11 @@ require (
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
github.com/xlab/treeprint v1.2.0 // indirect
|
||||
github.com/yalue/merged_fs v1.3.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.27.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+10
-10
@@ -30,8 +30,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/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
|
||||
github.com/grafana/cog v0.0.43 h1:6EDzJVc8hbP3+AjPnRnAK6mKfyWgqLKbi/jmiStilFk=
|
||||
github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
|
||||
github.com/grafana/cog v0.0.44 h1:N8UP7g6XBHZXf1wY7AOOWC2HdqlTPBJPJiAZQrkf4XQ=
|
||||
github.com/grafana/cog v0.0.44/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
|
||||
github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk=
|
||||
github.com/grafana/cuetsy v0.1.11/go.mod h1:Ix97+CPD8ws9oSSxR3/Lf4ahU1I4Np83kjJmDVnLZvc=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -94,20 +94,20 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
|
||||
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
|
||||
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
|
||||
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -35,6 +35,11 @@ func NewPatternListInspector(detectorsProvider angulardetector.DetectorsProvider
|
||||
}
|
||||
|
||||
func (i *PatternsListInspector) Inspect(ctx context.Context, p *plugins.Plugin) (isAngular bool, err error) {
|
||||
// CDN plugins are ignored because they should not be using Angular
|
||||
if p.Class == plugins.ClassCDN {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
f, err := p.FS.Open("module.js")
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrFileNotExist) {
|
||||
|
||||
@@ -27,17 +27,17 @@ func (d *fakeDetector) String() string {
|
||||
}
|
||||
|
||||
func TestPatternsListInspector(t *testing.T) {
|
||||
plugin := &plugins.Plugin{
|
||||
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
plugin *plugins.Plugin
|
||||
fakeDetectors []*fakeDetector
|
||||
exp func(t *testing.T, r bool, err error, fakeDetectors []*fakeDetector)
|
||||
}{
|
||||
{
|
||||
name: "calls the detectors in sequence until true is returned",
|
||||
plugin: &plugins.Plugin{
|
||||
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
|
||||
},
|
||||
fakeDetectors: []*fakeDetector{
|
||||
{returns: false},
|
||||
{returns: true},
|
||||
@@ -53,6 +53,9 @@ func TestPatternsListInspector(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "calls the detectors in sequence and returns false as default",
|
||||
plugin: &plugins.Plugin{
|
||||
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
|
||||
},
|
||||
fakeDetectors: []*fakeDetector{
|
||||
{returns: false},
|
||||
{returns: false},
|
||||
@@ -65,13 +68,30 @@ func TestPatternsListInspector(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty detectors should return false",
|
||||
name: "empty detectors should return false",
|
||||
plugin: &plugins.Plugin{
|
||||
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
|
||||
},
|
||||
fakeDetectors: nil,
|
||||
exp: func(t *testing.T, r bool, err error, fakeDetectors []*fakeDetector) {
|
||||
require.NoError(t, err)
|
||||
require.False(t, r, "inspector should return false")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CDN plugins return false without calling detectors",
|
||||
plugin: &plugins.Plugin{
|
||||
Class: plugins.ClassCDN,
|
||||
},
|
||||
fakeDetectors: []*fakeDetector{
|
||||
{returns: true},
|
||||
},
|
||||
exp: func(t *testing.T, r bool, err error, fakeDetectors []*fakeDetector) {
|
||||
require.NoError(t, err)
|
||||
require.False(t, r, "inspector should return false for CDN plugins")
|
||||
require.Equal(t, 0, fakeDetectors[0].calls, "detectors should not be called for CDN plugins")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
detectors := make([]angulardetector.AngularDetector, 0, len(tc.fakeDetectors))
|
||||
@@ -79,7 +99,7 @@ func TestPatternsListInspector(t *testing.T) {
|
||||
detectors = append(detectors, angulardetector.AngularDetector(d))
|
||||
}
|
||||
inspector := NewPatternListInspector(&angulardetector.StaticDetectorsProvider{Detectors: detectors})
|
||||
r, err := inspector.Inspect(context.Background(), plugin)
|
||||
r, err := inspector.Inspect(context.Background(), tc.plugin)
|
||||
tc.exp(t, r, err, tc.fakeDetectors)
|
||||
})
|
||||
}
|
||||
|
||||
+8
-6
@@ -18,6 +18,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.16.3 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.4.1 // indirect
|
||||
@@ -47,6 +48,7 @@ require (
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a // indirect
|
||||
github.com/grafana/otel-profiling-go v0.5.1 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
||||
@@ -97,16 +99,16 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.13.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.235.0 // 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
|
||||
|
||||
+18
-18
@@ -1,6 +1,6 @@
|
||||
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
|
||||
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
|
||||
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
|
||||
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=
|
||||
@@ -126,8 +126,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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/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.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA=
|
||||
@@ -319,20 +319,20 @@ 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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||
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/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -352,20 +352,20 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8=
|
||||
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -374,8 +374,8 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
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.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo=
|
||||
google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
|
||||
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=
|
||||
|
||||
@@ -30,6 +30,7 @@ func ProvideRegistryServiceSink(
|
||||
_ *provisioning.APIBuilder,
|
||||
_ *ofrep.APIBuilder,
|
||||
_ *secret.DependencyRegisterer,
|
||||
_ *provisioning.DependencyRegisterer,
|
||||
) *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
)
|
||||
|
||||
@@ -11,9 +12,20 @@ type datasourceInfoProvider struct {
|
||||
datasourceService datasources.DataSourceService
|
||||
}
|
||||
|
||||
func (d *datasourceInfoProvider) GetDataSourceInfo(_ context.Context) []schemaversion.DataSourceInfo {
|
||||
query := datasources.GetAllDataSourcesQuery{}
|
||||
dataSources, err := d.datasourceService.GetAllDataSources(context.Background(), &query)
|
||||
func (d *datasourceInfoProvider) GetDataSourceInfo(ctx context.Context) []schemaversion.DataSourceInfo {
|
||||
// Extract namespace info from context to get OrgID
|
||||
nsInfo, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
// If namespace info is not available, return empty list
|
||||
return []schemaversion.DataSourceInfo{}
|
||||
}
|
||||
|
||||
// Use GetDataSources with OrgID query instead of GetAllDataSources
|
||||
// This ensures tenant-aware datasource retrieval
|
||||
query := datasources.GetDataSourcesQuery{
|
||||
OrgID: nsInfo.OrgID,
|
||||
}
|
||||
dataSources, err := d.datasourceService.GetDataSources(ctx, &query)
|
||||
|
||||
if err != nil {
|
||||
return []schemaversion.DataSourceInfo{}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/utils/ptr"
|
||||
@@ -40,7 +41,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ DashboardAccess = (*dashboardSqlAccess)(nil)
|
||||
_ DashboardAccess = (*dashboardSqlAccess)(nil)
|
||||
tracer = otel.Tracer("github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy")
|
||||
)
|
||||
|
||||
type dashboardRow struct {
|
||||
@@ -105,6 +107,9 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *DashboardQuery) (*rowsWrapper, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.getRows")
|
||||
defer span.End()
|
||||
|
||||
if len(query.Labels) > 0 {
|
||||
return nil, fmt.Errorf("labels not yet supported")
|
||||
// if query.Requirements.Folder != nil {
|
||||
@@ -416,6 +421,9 @@ func getUserID(v sql.NullString, id sql.NullInt64) string {
|
||||
|
||||
// DeleteDashboard implements DashboardAccess.
|
||||
func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardV1.Dashboard, bool, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.DeleteDashboard")
|
||||
defer span.End()
|
||||
|
||||
dash, _, err := a.GetDashboard(ctx, orgId, uid, 0)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -432,6 +440,9 @@ func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, u
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard) (*dashboards.SaveDashboardCommand, bool, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.buildSaveDashboardCommand")
|
||||
defer span.End()
|
||||
|
||||
created := false
|
||||
user, ok := claims.AuthInfoFrom(ctx)
|
||||
if !ok || user == nil {
|
||||
@@ -495,6 +506,9 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard, failOnExisting bool) (*dashboardV1.Dashboard, bool, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.SaveDashboard")
|
||||
defer span.End()
|
||||
|
||||
user, ok := claims.AuthInfoFrom(ctx)
|
||||
if !ok || user == nil {
|
||||
return nil, false, fmt.Errorf("no user found in context")
|
||||
@@ -565,6 +579,9 @@ type panel struct {
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetLibraryPanels")
|
||||
defer span.End()
|
||||
|
||||
limit := int(query.Limit)
|
||||
query.Limit += 1 // for continue
|
||||
if query.OrgID == 0 {
|
||||
|
||||
@@ -78,6 +78,9 @@ func isDashboardKey(key *resourcepb.ResourceKey, requireName bool) error {
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.WriteEvent) (rv int64, err error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.WriteEvent")
|
||||
defer span.End()
|
||||
|
||||
info, err := claims.ParseNamespace(event.Key.Namespace)
|
||||
if err == nil {
|
||||
err = isDashboardKey(event.Key, true)
|
||||
@@ -170,6 +173,9 @@ func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.Writ
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string, v int64) (*dashboard.Dashboard, int64, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetDashboard")
|
||||
defer span.End()
|
||||
|
||||
sql, err := a.sql(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -197,6 +203,9 @@ func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid
|
||||
|
||||
// Read implements ResourceStoreServer.
|
||||
func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *resource.BackendReadResponse {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ReadResource")
|
||||
defer span.End()
|
||||
|
||||
rsp := &resource.BackendReadResponse{}
|
||||
info, err := claims.ParseNamespace(req.Key.Namespace)
|
||||
if err == nil {
|
||||
@@ -238,16 +247,25 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.R
|
||||
|
||||
// ListHistory implements StorageBackend.
|
||||
func (a *dashboardSqlAccess) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListHistory")
|
||||
defer span.End()
|
||||
|
||||
return a.ListIterator(ctx, req, cb)
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) ListModifiedSince(ctx context.Context, key resource.NamespacedResource, sinceRv int64) (int64, iter.Seq2[*resource.ModifiedResource, error]) {
|
||||
_, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListModifiedSince")
|
||||
defer span.End()
|
||||
|
||||
return 0, func(yield func(*resource.ModifiedResource, error) bool) {
|
||||
yield(nil, errors.New("not implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[resource.ResourceLastImportTime, error] {
|
||||
_, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetResourceLastImportTimes")
|
||||
defer span.End()
|
||||
|
||||
return func(yield func(resource.ResourceLastImportTime, error) bool) {
|
||||
yield(resource.ResourceLastImportTime{}, errors.New("not implemented"))
|
||||
}
|
||||
@@ -255,6 +273,9 @@ func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) ite
|
||||
|
||||
// List implements StorageBackend.
|
||||
func (a *dashboardSqlAccess) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListIterator")
|
||||
defer span.End()
|
||||
|
||||
if req.ResourceVersion != 0 {
|
||||
return 0, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend")
|
||||
}
|
||||
@@ -348,10 +369,16 @@ func (a *dashboardSqlAccess) WatchWriteEvents(ctx context.Context) (<-chan *reso
|
||||
|
||||
// Simple wrapper for index implementation
|
||||
func (a *dashboardSqlAccess) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resource.BackendReadResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Read")
|
||||
defer span.End()
|
||||
|
||||
return a.ReadResource(ctx, req), nil
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Search")
|
||||
defer span.End()
|
||||
|
||||
return a.dashboardSearchClient.Search(ctx, req)
|
||||
}
|
||||
|
||||
@@ -365,5 +392,8 @@ func (a *dashboardSqlAccess) CountManagedObjects(context.Context, *resourcepb.Co
|
||||
|
||||
// GetStats implements ResourceServer.
|
||||
func (a *dashboardSqlAccess) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetStats")
|
||||
defer span.End()
|
||||
|
||||
return a.dashboardSearchClient.GetStats(ctx, req)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
@@ -62,7 +63,7 @@ func ParseSortName(sortName string) (string, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", false, fmt.Errorf("no matching sort field found for: %s", sortName)
|
||||
return "", false, apierrors.NewBadRequest(fmt.Sprintf("no matching sort field found for: %s", sortName))
|
||||
}
|
||||
|
||||
// nolint:gocyclo
|
||||
@@ -97,11 +98,11 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
|
||||
case folders.RESOURCE:
|
||||
queryType = searchstore.TypeFolder
|
||||
default:
|
||||
return nil, fmt.Errorf("bad type request")
|
||||
return nil, apierrors.NewBadRequest("bad type request")
|
||||
}
|
||||
|
||||
if len(req.Federated) > 1 {
|
||||
return nil, fmt.Errorf("bad type request")
|
||||
return nil, apierrors.NewBadRequest("bad type request")
|
||||
}
|
||||
|
||||
if len(req.Federated) == 1 &&
|
||||
@@ -117,7 +118,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
|
||||
sortByField := ""
|
||||
if len(req.SortBy) != 0 {
|
||||
if len(req.SortBy) > 1 {
|
||||
return nil, fmt.Errorf("only one sort field is supported")
|
||||
return nil, apierrors.NewBadRequest("only one sort field is supported")
|
||||
}
|
||||
sort := req.SortBy[0]
|
||||
sortByField = strings.TrimPrefix(sort.Field, resource.SEARCH_FIELD_PREFIX)
|
||||
@@ -208,13 +209,13 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
|
||||
case resource.SEARCH_FIELD_SOURCE_PATH:
|
||||
// only one value is supported in legacy search
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one repo path query is supported")
|
||||
return nil, apierrors.NewBadRequest("only one repo path query is supported")
|
||||
}
|
||||
query.SourcePath = vals[0]
|
||||
|
||||
case resource.SEARCH_FIELD_MANAGER_KIND:
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one manager kind supported")
|
||||
return nil, apierrors.NewBadRequest("only one manager kind supported")
|
||||
}
|
||||
query.ManagedBy = utils.ManagerKind(vals[0])
|
||||
|
||||
@@ -226,18 +227,38 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
|
||||
|
||||
// only one value is supported in legacy search
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one repo name is supported")
|
||||
return nil, apierrors.NewBadRequest("only one repo name is supported")
|
||||
}
|
||||
query.ManagerIdentity = vals[0]
|
||||
|
||||
case unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE:
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one library panel uid is supported")
|
||||
return nil, apierrors.NewBadRequest("only one library panel uid is supported")
|
||||
}
|
||||
|
||||
// Make sure the query does not include incompatible combinations
|
||||
for _, f := range req.Options.Fields {
|
||||
switch f.Key {
|
||||
case resource.SEARCH_FIELD_NAME:
|
||||
return nil, apierrors.NewBadRequest("libraryPanel query must not include explicit names")
|
||||
}
|
||||
}
|
||||
|
||||
query.DashboardUIDs, err = c.getLibraryPanelConnections(ctx, user, vals[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(query.DashboardUIDs) == 0 {
|
||||
// Empty results
|
||||
return &resourcepb.ResourceSearchResponse{
|
||||
TotalHits: 0,
|
||||
Results: &resourcepb.ResourceTable{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return c.getLibraryPanelConnections(ctx, user, vals[0], req.Options.Key.Namespace)
|
||||
case resource.SEARCH_FIELD_TITLE_PHRASE:
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one title supported")
|
||||
return nil, apierrors.NewBadRequest("only one title supported")
|
||||
}
|
||||
|
||||
query.Title = vals[0]
|
||||
@@ -256,7 +277,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
|
||||
// legacy sql query, since legacy search does not support this
|
||||
if query.ManagerIdentity != "" || len(query.ManagerIdentityNotIn) > 0 || query.ManagedBy != "" {
|
||||
if query.ManagedBy == utils.ManagerKindUnknown {
|
||||
return nil, fmt.Errorf("query by manager identity also requires manager.kind parameter")
|
||||
return nil, apierrors.NewBadRequest("query by manager identity also requires manager.kind parameter")
|
||||
}
|
||||
|
||||
// for plugin and orphaned dashboards, we will only return the manager kind alongside the regular search response
|
||||
@@ -312,7 +333,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
|
||||
for _, dashboard := range provisioningData {
|
||||
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
|
||||
Key: getResourceKey(&dashboards.DashboardSearchProjection{
|
||||
UID: dashboard.Dashboard.UID,
|
||||
UID: dashboard.UID,
|
||||
}, req.Options.Key.Namespace),
|
||||
Cells: c.createDetailedProvisioningCells(dashboard, query),
|
||||
})
|
||||
@@ -362,50 +383,36 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string
|
||||
}
|
||||
}
|
||||
|
||||
// retrieves all the dashboards that are connected to the given library panel
|
||||
func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID, namespace string) (*resourcepb.ResourceSearchResponse, error) {
|
||||
// retrieves all dashboard UIDs connected to a given library panel
|
||||
func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID string) ([]string, error) {
|
||||
connections, err := c.dashboardStore.GetDashboardsByLibraryPanelUID(ctx, libraryElementUID, user.GetOrgID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := c.getColumns("", &dashboards.FindPersistedDashboardsQuery{})
|
||||
list := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: columns,
|
||||
},
|
||||
uids := make([]string, len(connections))
|
||||
for i, dashboard := range connections {
|
||||
uids[i] = dashboard.UID
|
||||
}
|
||||
|
||||
for _, dashboard := range connections {
|
||||
cells := c.createCommonCells("", dashboard.FolderUID, dashboard.ID, nil) // nolint:staticcheck
|
||||
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
|
||||
Key: getResourceKey(&dashboards.DashboardSearchProjection{
|
||||
UID: dashboard.UID,
|
||||
}, namespace),
|
||||
Cells: cells,
|
||||
})
|
||||
}
|
||||
|
||||
list.TotalHits = int64(len(list.Results.Rows))
|
||||
return list, nil
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest, _ ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
|
||||
info, err := claims.ParseNamespace(req.Namespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read namespace")
|
||||
return nil, apierrors.NewInternalError(fmt.Errorf("unable to read namespace: %w", err))
|
||||
}
|
||||
if info.OrgID == 0 {
|
||||
return nil, fmt.Errorf("invalid OrgID found in namespace")
|
||||
return nil, apierrors.NewInternalError(fmt.Errorf("invalid OrgID found in namespace"))
|
||||
}
|
||||
|
||||
if len(req.Kinds) != 1 {
|
||||
return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback")
|
||||
return nil, apierrors.NewBadRequest("only can query for dashboard kind in legacy fallback")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(req.Kinds[0], "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid kind")
|
||||
return nil, apierrors.NewBadRequest("invalid kind")
|
||||
}
|
||||
|
||||
var count int64
|
||||
@@ -415,7 +422,7 @@ func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.Re
|
||||
case folders.GROUP:
|
||||
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid group")
|
||||
return nil, apierrors.NewBadRequest("invalid group")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -512,7 +519,7 @@ func (c *DashboardSearchClient) createProvisioningCells(dashboard *dashboards.Da
|
||||
}
|
||||
|
||||
func (c *DashboardSearchClient) createDetailedProvisioningCells(dashboard *dashboards.DashboardProvisioningSearchResults, query *dashboards.FindPersistedDashboardsQuery) [][]byte {
|
||||
cells := c.createCommonCells(dashboard.Dashboard.Title, dashboard.Dashboard.FolderUID, dashboard.Dashboard.ID, []byte("[]"))
|
||||
cells := c.createCommonCells(dashboard.Title, dashboard.FolderUID, dashboard.ID, []byte("[]"))
|
||||
return append(cells,
|
||||
[]byte(query.ManagedBy),
|
||||
[]byte(dashboard.Provisioner),
|
||||
|
||||
@@ -454,7 +454,9 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
t.Run("Should retrieve dashboards by provisioner name through a different function", func(t *testing.T) {
|
||||
mockStore.On("GetProvisionedDashboardsByName", mock.Anything, "test", mock.Anything).Return([]*dashboards.DashboardProvisioningSearchResults{
|
||||
{
|
||||
Dashboard: dashboards.Dashboard{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
|
||||
UID: "uid",
|
||||
Title: "Test Dashboard",
|
||||
FolderUID: "folder1",
|
||||
ExternalID: "test",
|
||||
Provisioner: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
},
|
||||
@@ -579,6 +581,11 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
{UID: "dashboard2", FolderUID: "folder2", ID: 2},
|
||||
}, nil).Once()
|
||||
|
||||
mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{
|
||||
{UID: "dashboard1", FolderUID: "folder1", ID: 1},
|
||||
{UID: "dashboard2", FolderUID: "folder2", ID: 2},
|
||||
}, nil).Once()
|
||||
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: dashboardKey,
|
||||
@@ -638,6 +645,54 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "only one library panel uid is supported")
|
||||
require.Nil(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Should reject library panel query when combined with explicit dashboard names", func(t *testing.T) {
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: dashboardKey,
|
||||
Fields: []*resourcepb.Requirement{
|
||||
{
|
||||
Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE,
|
||||
Operator: "=",
|
||||
Values: []string{"test-library-panel"},
|
||||
},
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_NAME,
|
||||
Operator: "=",
|
||||
Values: []string{"dashboard-uid"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resp, err := client.Search(ctx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "libraryPanel query must not include explicit names")
|
||||
require.Nil(t, resp)
|
||||
// Note: mockStore should NOT be called since validation happens before any store calls
|
||||
})
|
||||
|
||||
t.Run("Should return empty results when library panel has no connected dashboards", func(t *testing.T) {
|
||||
mockStore.On("GetDashboardsByLibraryPanelUID", mock.Anything, "unused-library-panel", int64(2)).Return([]*dashboards.DashboardRef{}, nil).Once()
|
||||
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: dashboardKey,
|
||||
Fields: []*resourcepb.Requirement{
|
||||
{
|
||||
Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE,
|
||||
Operator: "=",
|
||||
Values: []string{"unused-library-panel"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resp, err := client.Search(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, int64(0), resp.TotalHits)
|
||||
require.Empty(t, resp.Results.Rows)
|
||||
mockStore.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSortName(t *testing.T) {
|
||||
|
||||
@@ -5,16 +5,17 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,9 +30,9 @@ var (
|
||||
)
|
||||
|
||||
type LibraryPanelStore struct {
|
||||
Access legacy.DashboardAccess
|
||||
ResourceInfo utils.ResourceInfo
|
||||
AccessControl accesscontrol.AccessControl
|
||||
Access legacy.DashboardAccess
|
||||
ResourceInfo utils.ResourceInfo
|
||||
service libraryelements.Service
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) New() runtime.Object {
|
||||
@@ -57,15 +58,70 @@ func (s *LibraryPanelStore) ConvertToTable(ctx context.Context, object runtime.O
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("method not yet implemented")
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd, err := libraryelements.ToCreateLibraryElementCommand(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// NOTE: this includes all access control checks
|
||||
out, err := s.service.CreateElement(ctx, user, *cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.UID == "" {
|
||||
return nil, fmt.Errorf("created library panel has empty UID")
|
||||
}
|
||||
return s.Get(ctx, out.UID, &metav1.GetOptions{})
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
return nil, false, fmt.Errorf("method not yet implemented")
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
old, err := s.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// NOTE: this includes all access control checks
|
||||
obj, err := objInfo.UpdatedObject(ctx, old)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd, err := libraryelements.ToPatchLibraryElementCommand(obj)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
out, err := s.service.PatchLibraryElement(ctx, user, *cmd, name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if out.UID == "" {
|
||||
return nil, false, fmt.Errorf("created library panel has empty UID")
|
||||
}
|
||||
obj, err = s.Get(ctx, out.UID, &metav1.GetOptions{})
|
||||
return obj, false, err
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
return nil, false, fmt.Errorf("method not yet implemented")
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// NOTE: this includes all access control checks
|
||||
_, err = s.service.DeleteLibraryElement(ctx, user, name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
|
||||
@@ -26,6 +26,19 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute
|
||||
if op != admission.Create && op != admission.Update {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch a.GetResource().Resource {
|
||||
case dashboardV0.DASHBOARD_RESOURCE:
|
||||
return b.mutateDashboard(ctx, a)
|
||||
|
||||
case dashboardV0.LIBRARY_PANEL_RESOURCE:
|
||||
return nil // nothing needed
|
||||
}
|
||||
|
||||
return fmt.Errorf("unexpected resource: %+v", a.GetResource())
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) mutateDashboard(ctx context.Context, a admission.Attributes) (err error) {
|
||||
var internalID int64
|
||||
obj := a.GetObject()
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
|
||||
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
@@ -180,10 +179,10 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
||||
err := b.Mutate(context.Background(), admission.NewAttributesRecord(
|
||||
tt.inputObj,
|
||||
nil,
|
||||
schema.GroupVersionKind{},
|
||||
dashv1.DashboardResourceInfo.GroupVersionKind(),
|
||||
"",
|
||||
"test",
|
||||
schema.GroupVersionResource{},
|
||||
dashv1.DashboardResourceInfo.GroupVersionResource(),
|
||||
"",
|
||||
tt.operation,
|
||||
operationOptions,
|
||||
|
||||
@@ -50,8 +50,10 @@ import (
|
||||
dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements"
|
||||
"github.com/grafana/grafana/pkg/services/librarypanels"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -68,6 +70,8 @@ var (
|
||||
_ builder.APIGroupVersionsProvider = (*DashboardsAPIBuilder)(nil)
|
||||
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
|
||||
_ builder.APIGroupRouteProvider = (*DashboardsAPIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*DashboardsAPIBuilder)(nil)
|
||||
_ builder.APIGroupValidation = (*DashboardsAPIBuilder)(nil)
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -108,6 +112,8 @@ type DashboardsAPIBuilder struct {
|
||||
minRefreshInterval string
|
||||
dualWriter dualwrite.Service
|
||||
folderClientProvider client.K8sHandlerProvider
|
||||
libraryPanels libraryelements.Service // for legacy library panels
|
||||
publicDashboardService publicdashboards.Service
|
||||
|
||||
isStandalone bool // skips any handling including anything to do with legacy storage
|
||||
}
|
||||
@@ -135,6 +141,8 @@ func RegisterAPIService(
|
||||
libraryPanelSvc librarypanels.Service,
|
||||
restConfigProvider apiserver.RestConfigProvider,
|
||||
userService user.Service,
|
||||
libraryPanels libraryelements.Service,
|
||||
publicDashboardService publicdashboards.Service,
|
||||
) *DashboardsAPIBuilder {
|
||||
dbp := legacysql.NewDatabaseProvider(sql)
|
||||
namespacer := request.GetNamespaceMapper(cfg)
|
||||
@@ -157,6 +165,8 @@ func RegisterAPIService(
|
||||
minRefreshInterval: cfg.MinRefreshInterval,
|
||||
dualWriter: dual,
|
||||
folderClientProvider: newSimpleFolderClientProvider(folderClient),
|
||||
libraryPanels: libraryPanels,
|
||||
publicDashboardService: publicDashboardService,
|
||||
|
||||
legacy: &DashboardStorage{
|
||||
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
|
||||
@@ -221,7 +231,7 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
}
|
||||
|
||||
// Register the explicit conversions
|
||||
if err := conversion.RegisterConversions(scheme); err != nil {
|
||||
if err := conversion.RegisterConversions(scheme, migration.GetDataSourceInfoProvider()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -229,26 +239,35 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
return []string{dashv0.DashboardKind().Plural()}
|
||||
return []string{
|
||||
dashv0.DashboardKind().Plural(),
|
||||
dashv0.LIBRARY_PANEL_RESOURCE,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates dashboard operations for the apiserver
|
||||
func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
op := a.GetOperation()
|
||||
|
||||
// Handle different operations
|
||||
switch op {
|
||||
case admission.Delete:
|
||||
return b.validateDelete(ctx, a)
|
||||
case admission.Create:
|
||||
return b.validateCreate(ctx, a, o)
|
||||
case admission.Update:
|
||||
return b.validateUpdate(ctx, a, o)
|
||||
case admission.Connect:
|
||||
return nil
|
||||
switch a.GetResource().Resource {
|
||||
case dashv0.DASHBOARD_RESOURCE:
|
||||
// Handle different operations
|
||||
switch op {
|
||||
case admission.Delete:
|
||||
return b.validateDelete(ctx, a)
|
||||
case admission.Create:
|
||||
return b.validateCreate(ctx, a, o)
|
||||
case admission.Update:
|
||||
return b.validateUpdate(ctx, a, o)
|
||||
case admission.Connect:
|
||||
return nil
|
||||
}
|
||||
|
||||
case dashv0.LIBRARY_PANEL_RESOURCE:
|
||||
return nil // OK for now
|
||||
}
|
||||
|
||||
return nil
|
||||
return fmt.Errorf("unsupported validation: %+v", a.GetResource())
|
||||
}
|
||||
|
||||
// validateDelete checks if a dashboard can be deleted
|
||||
@@ -637,17 +656,19 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
||||
b.accessControl,
|
||||
opts.Scheme,
|
||||
newDTOFunc,
|
||||
b.publicDashboardService,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Expose read only library panels
|
||||
if libraryPanels != nil {
|
||||
// Expose read library panels
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if libraryPanels != nil && b.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
legacyLibraryStore := &LibraryPanelStore{
|
||||
Access: b.legacy.Access,
|
||||
ResourceInfo: *libraryPanels,
|
||||
AccessControl: b.accessControl,
|
||||
Access: b.legacy.Access,
|
||||
ResourceInfo: *libraryPanels,
|
||||
service: b.libraryPanels,
|
||||
}
|
||||
|
||||
unifiedLibraryStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *libraryPanels, opts.OptsGetter)
|
||||
|
||||
@@ -121,6 +121,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
|
||||
Schema: spec.ArrayProperty(spec.StringProperty()),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "libraryPanel",
|
||||
In: "query",
|
||||
Description: "find dashboards that reference a given libraryPanel",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "sort",
|
||||
@@ -363,6 +372,15 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}}
|
||||
}
|
||||
|
||||
// The libraryPanel filter
|
||||
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
|
||||
searchRequest.Options.Fields = []*resourcepb.Requirement{{
|
||||
Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE,
|
||||
Operator: "=",
|
||||
Values: libraryPanel,
|
||||
}}
|
||||
}
|
||||
|
||||
// The names filter
|
||||
names := queryParams["name"]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
@@ -28,13 +29,14 @@ type dtoBuilder = func(dashboard runtime.Object, access *dashboard.DashboardAcce
|
||||
|
||||
// The DTO returns everything the UI needs in a single request
|
||||
type DTOConnector struct {
|
||||
getter rest.Getter
|
||||
legacy legacy.DashboardAccess
|
||||
unified resource.ResourceClient
|
||||
largeObjects apistore.LargeObjectSupport
|
||||
accessControl accesscontrol.AccessControl
|
||||
scheme *runtime.Scheme
|
||||
builder dtoBuilder
|
||||
getter rest.Getter
|
||||
legacy legacy.DashboardAccess
|
||||
unified resource.ResourceClient
|
||||
largeObjects apistore.LargeObjectSupport
|
||||
accessControl accesscontrol.AccessControl
|
||||
scheme *runtime.Scheme
|
||||
builder dtoBuilder
|
||||
publicDashboardService publicdashboards.Service
|
||||
}
|
||||
|
||||
func NewDTOConnector(
|
||||
@@ -45,15 +47,17 @@ func NewDTOConnector(
|
||||
accessControl accesscontrol.AccessControl,
|
||||
scheme *runtime.Scheme,
|
||||
builder dtoBuilder,
|
||||
publicDashboardService publicdashboards.Service,
|
||||
) (rest.Storage, error) {
|
||||
return &DTOConnector{
|
||||
getter: getter,
|
||||
legacy: legacyAccess,
|
||||
accessControl: accessControl,
|
||||
unified: resourceClient,
|
||||
largeObjects: largeObjects,
|
||||
builder: builder,
|
||||
scheme: scheme,
|
||||
getter: getter,
|
||||
legacy: legacyAccess,
|
||||
accessControl: accessControl,
|
||||
unified: resourceClient,
|
||||
largeObjects: largeObjects,
|
||||
builder: builder,
|
||||
scheme: scheme,
|
||||
publicDashboardService: publicDashboardService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -154,6 +158,11 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
|
||||
access.Slug = slugify.Slugify(title)
|
||||
access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug)
|
||||
|
||||
pubDash, err := r.publicDashboardService.FindByDashboardUid(ctx, user.GetOrgID(), name)
|
||||
if err == nil && pubDash != nil {
|
||||
access.IsPublic = true
|
||||
}
|
||||
|
||||
dash, err := r.builder(rawobj, access)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
|
||||
@@ -3,10 +3,8 @@ package datasource
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -16,14 +14,12 @@ import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
openapi "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/utils/strings/slices"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
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/configprovider"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
@@ -31,7 +27,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds"
|
||||
)
|
||||
|
||||
@@ -53,7 +48,6 @@ type DataSourceAPIBuilder struct {
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
cfgProvider configprovider.ConfigProvider,
|
||||
features featuremgmt.FeatureToggles,
|
||||
apiRegistrar builder.APIRegistrar,
|
||||
pluginClient plugins.Client, // access to everything
|
||||
@@ -61,54 +55,29 @@ func RegisterAPIService(
|
||||
contextProvider PluginContextWrapper,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
reg prometheus.Registerer,
|
||||
pluginSources sources.Registry,
|
||||
) (*DataSourceAPIBuilder, error) {
|
||||
// We want to expose just a limited set of plugins
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
explicitPluginList := features.IsEnabledGlobally(featuremgmt.FlagDatasourceAPIServers)
|
||||
|
||||
// This requires devmode!
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !explicitPluginList && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil, nil // skip registration unless opting into experimental apis
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
var builder *DataSourceAPIBuilder
|
||||
|
||||
cfg, err := cfgProvider.Get(context.Background())
|
||||
pluginJSONs, err := getDatasourcePlugins(pluginSources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pluginJSONs, err := getCorePlugins(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := []string{
|
||||
"grafana-testdata-datasource",
|
||||
"prometheus",
|
||||
"graphite",
|
||||
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
|
||||
}
|
||||
|
||||
for _, pluginJSON := range pluginJSONs {
|
||||
if explicitPluginList && !slices.Contains(ids, pluginJSON.ID) {
|
||||
continue // skip this one
|
||||
}
|
||||
|
||||
if !pluginJSON.Backend {
|
||||
continue // skip frontend only plugins
|
||||
}
|
||||
|
||||
if pluginJSON.Type != plugins.TypeDataSource {
|
||||
continue // skip non-datasource plugins
|
||||
}
|
||||
|
||||
client, ok := pluginClient.(PluginClient)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
|
||||
}
|
||||
|
||||
builder, err = NewDataSourceAPIBuilder(pluginJSON,
|
||||
builder, err = NewDataSourceAPIBuilder(
|
||||
pluginJSON,
|
||||
client,
|
||||
datasources.GetDatasourceProvider(pluginJSON),
|
||||
contextProvider,
|
||||
@@ -299,21 +268,32 @@ func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() openapi.GetOpenAPIDefinit
|
||||
}
|
||||
}
|
||||
|
||||
func getCorePlugins(cfg *setting.Cfg) ([]plugins.JSONData, error) {
|
||||
coreDataSourcesPath := filepath.Join(cfg.StaticRootPath, "app", "plugins", "datasource")
|
||||
coreDataSourcesSrc := sources.NewLocalSource(
|
||||
plugins.ClassCore,
|
||||
[]string{coreDataSourcesPath},
|
||||
)
|
||||
func getDatasourcePlugins(pluginSources sources.Registry) ([]plugins.JSONData, error) {
|
||||
var pluginJSONs []plugins.JSONData
|
||||
|
||||
res, err := coreDataSourcesSrc.Discover(context.Background())
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to load core data source plugins")
|
||||
}
|
||||
// It's possible that the same plugin will be found in different sources.
|
||||
// Registering the same plugin twice in the API is Probably A Bad Thing,
|
||||
// so this map keeps track of uniques, so we can skip duplicates.
|
||||
var uniquePlugins = map[string]bool{}
|
||||
|
||||
pluginJSONs := make([]plugins.JSONData, 0, len(res))
|
||||
for _, p := range res {
|
||||
pluginJSONs = append(pluginJSONs, p.Primary.JSONData)
|
||||
for _, pluginSource := range pluginSources.List(context.Background()) {
|
||||
res, err := pluginSource.Discover(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range res {
|
||||
if !p.Primary.JSONData.Backend || p.Primary.JSONData.Type != plugins.TypeDataSource {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := uniquePlugins[p.Primary.JSONData.ID]; found {
|
||||
backend.Logger.Info("Found duplicate plugin %s when registering API groups.", p.Primary.JSONData.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
uniquePlugins[p.Primary.JSONData.ID] = true
|
||||
pluginJSONs = append(pluginJSONs, p.Primary.JSONData)
|
||||
}
|
||||
}
|
||||
return pluginJSONs, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
datasource "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type subHealthREST struct {
|
||||
@@ -44,7 +46,19 @@ func (r *subHealthREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
// FIXME: this endpoint has not been tested yet, so it is not enabled by default.
|
||||
var healthEnabled = false
|
||||
|
||||
func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
if !healthEnabled {
|
||||
return nil, &apierrors.StatusError{
|
||||
ErrStatus: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusNotImplemented,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pluginCtx, err := r.builder.getPluginContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
query_headers "github.com/grafana/grafana/pkg/registry/apis/query"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@@ -91,7 +90,7 @@ func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Ob
|
||||
rsp, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{
|
||||
Queries: queries,
|
||||
PluginContext: pluginCtx,
|
||||
Headers: query_headers.ExtractKnownHeaders(req.Header),
|
||||
Headers: map[string]string{},
|
||||
})
|
||||
|
||||
// all errors get converted into k8 errors when sent in responder.Error and lose important context like downstream info
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -16,53 +14,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func TestSubQueryConnect(t *testing.T) {
|
||||
sqr := subQueryREST{
|
||||
builder: &DataSourceAPIBuilder{
|
||||
client: mockClient{
|
||||
lastCalledWithHeaders: &map[string]string{},
|
||||
},
|
||||
datasources: mockDatasources{},
|
||||
contextProvider: mockContextProvider{},
|
||||
},
|
||||
}
|
||||
|
||||
mr := mockResponder{}
|
||||
handler, err := sqr.Connect(context.Background(), "dsname", nil, mr)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/some-path", nil)
|
||||
req.Header.Set(models.FromAlertHeaderName, "true")
|
||||
req.Header.Set(models.CacheSkipHeaderName, "true")
|
||||
req.Header.Set("X-Rule-Name", "name-1")
|
||||
req.Header.Set("X-Rule-Uid", "abc")
|
||||
req.Header.Set("X-Rule-Folder", "folder-1")
|
||||
req.Header.Set("X-Rule-Source", "grafana-ruler")
|
||||
req.Header.Set("X-Rule-Type", "type-1")
|
||||
req.Header.Set("X-Rule-Version", "version-1")
|
||||
req.Header.Set("X-Grafana-Org-Id", "1")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("some-unexpected-header", "some-value")
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// test that headers are forwarded and cased appropriately
|
||||
require.Equal(t, map[string]string{
|
||||
models.FromAlertHeaderName: "true",
|
||||
models.CacheSkipHeaderName: "true",
|
||||
"X-Rule-Name": "name-1",
|
||||
"X-Rule-Uid": "abc",
|
||||
"X-Rule-Folder": "folder-1",
|
||||
"X-Rule-Source": "grafana-ruler",
|
||||
"X-Rule-Type": "type-1",
|
||||
"X-Rule-Version": "version-1",
|
||||
"X-Grafana-Org-Id": "1",
|
||||
}, *sqr.builder.client.(mockClient).lastCalledWithHeaders)
|
||||
}
|
||||
|
||||
func TestSubQueryConnectWhenDatasourceNotFound(t *testing.T) {
|
||||
sqr := subQueryREST{
|
||||
builder: &DataSourceAPIBuilder{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
@@ -46,7 +47,21 @@ func (r *subResourceREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, true, ""
|
||||
}
|
||||
|
||||
// FIXME: this endpoint has not been tested yet, so it is not enabled by default.
|
||||
// It is especially important to make sure the `ClearAuthHeadersMiddleware` is active,
|
||||
// when using this endpoint.
|
||||
var resourceEnabled = false
|
||||
|
||||
func (r *subResourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
if !resourceEnabled {
|
||||
return nil, &apierrors.StatusError{
|
||||
ErrStatus: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusNotImplemented,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pluginCtx, err := r.builder.getPluginContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -183,17 +183,85 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
||||
func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statsResponse *resourcepb.ResourceStatsResponse_Stats
|
||||
statsResponse []*resourcepb.ResourceStatsResponse_Stats
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "should allow deletion when folder is empty",
|
||||
statsResponse: &resourcepb.ResourceStatsResponse_Stats{Count: 0},
|
||||
name: "should allow deletion when folder is empty",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 0, Resource: "dashboards"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return folder not empty when the folder is not empty",
|
||||
statsResponse: &resourcepb.ResourceStatsResponse_Stats{Count: 2},
|
||||
wantErr: true,
|
||||
name: "should return folder not empty when folder contains dashboards",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 2, Resource: "dashboards", Group: "dashboard.grafana.app"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return folder not empty when folder contains alertrules",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 3, Resource: "alertrules", Group: "alerting.grafana.app"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return folder not empty when folder contains library_elements",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 1, Resource: "library_elements", Group: "library.grafana.app"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return folder not empty when folder contains folders",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 2, Resource: "folders", Group: "folders.grafana.app"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return folder not empty when folder has mixed resources with validated types",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 10, Resource: "folders", Group: "folders.grafana.app"},
|
||||
{Count: 2, Resource: "dashboards", Group: "dashboard.grafana.app"},
|
||||
{Count: 5, Resource: "playlists", Group: "playlist.grafana.app"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return folder not empty when folder has multiple validated resource types",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 1, Resource: "dashboards", Group: "dashboard.grafana.app"},
|
||||
{Count: 2, Resource: "alertrules", Group: "alerting.grafana.app"},
|
||||
{Count: 1, Resource: "library_elements", Group: "library.grafana.app"},
|
||||
{Count: 1, Resource: "folders", Group: "folders.grafana.app"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should allow deletion when all validated resource types are empty",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 0, Resource: "dashboards", Group: "dashboard.grafana.app"},
|
||||
{Count: 0, Resource: "alertrules", Group: "alerting.grafana.app"},
|
||||
{Count: 0, Resource: "library_elements", Group: "library.grafana.app"},
|
||||
{Count: 0, Resource: "folders", Group: "folders.grafana.app"},
|
||||
{Count: 10, Resource: "playlists", Group: "playlist.grafana.app"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should allow deletion when folder only contains non-validated resource types",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{Count: 5, Resource: "playlists", Group: "playlist.grafana.app"},
|
||||
{Count: 3, Resource: "other", Group: "other.grafana.app"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should allow deletion when stats array is empty",
|
||||
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,7 +280,7 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
|
||||
us := grafanarest.NewMockStorage(t)
|
||||
sm := resource.NewMockResourceClient(t)
|
||||
sm.On("GetStats", mock.Anything, &resourcepb.ResourceStatsRequest{Namespace: obj.Namespace, Folder: obj.Name}).Return(
|
||||
&resourcepb.ResourceStatsResponse{Stats: []*resourcepb.ResourceStatsResponse_Stats{tt.statsResponse}},
|
||||
&resourcepb.ResourceStatsResponse{Stats: tt.statsResponse},
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
|
||||
@@ -120,6 +120,14 @@ func validateOnUpdate(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
|
||||
// Check that the folder being moved is not an ancestor of the target parent.
|
||||
// This prevents circular references (e.g., moving A under B when B is already under A).
|
||||
for _, ancestor := range info.Items {
|
||||
if ancestor.Name == obj.Name {
|
||||
return fmt.Errorf("cannot move folder under its own descendant, this would create a circular reference")
|
||||
}
|
||||
}
|
||||
|
||||
// if by moving a folder we exceed the max depth, return an error
|
||||
if len(info.Items) > maxDepth+1 {
|
||||
return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached")
|
||||
@@ -144,9 +152,11 @@ func validateOnDelete(ctx context.Context,
|
||||
return fmt.Errorf("could not verify if folder is empty: %v", resp.Error)
|
||||
}
|
||||
|
||||
allowedResourceTypes := []string{"alertrules", "dashboards", "library_elements", "folders"}
|
||||
|
||||
for _, v := range resp.Stats {
|
||||
if v.Count > 0 {
|
||||
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d resources", v.Count)
|
||||
if slices.Contains(allowedResourceTypes, v.Resource) && v.Count > 0 {
|
||||
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s", v.Count, v.Resource)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -264,6 +264,71 @@ func TestValidateUpdate(t *testing.T) {
|
||||
maxDepth: folder.MaxNestedFolderDepth,
|
||||
expectedErr: "[folder.maximum-depth-reached]",
|
||||
},
|
||||
{
|
||||
name: "error when moving folder under its own descendant (direct child)",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "parent",
|
||||
Annotations: map[string]string{
|
||||
utils.AnnoKeyFolder: "child",
|
||||
},
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "parent folder",
|
||||
},
|
||||
},
|
||||
old: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "parent",
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "parent folder",
|
||||
},
|
||||
},
|
||||
// When querying parents of "child", we get the chain: child -> parent -> root
|
||||
// This means "parent" is an ancestor of "child", so we can't move "parent" under "child"
|
||||
parents: &folders.FolderInfoList{
|
||||
Items: []folders.FolderInfo{
|
||||
{Name: "child", Parent: "parent"},
|
||||
{Name: "parent", Parent: folder.GeneralFolderUID},
|
||||
{Name: folder.GeneralFolderUID},
|
||||
},
|
||||
},
|
||||
expectedErr: "cannot move folder under its own descendant",
|
||||
},
|
||||
{
|
||||
name: "error when moving folder under its grandchild",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "grandparent",
|
||||
Annotations: map[string]string{
|
||||
utils.AnnoKeyFolder: "grandchild",
|
||||
},
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "grandparent folder",
|
||||
},
|
||||
},
|
||||
old: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "grandparent",
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "grandparent folder",
|
||||
},
|
||||
},
|
||||
// When querying parents of "grandchild", we get: grandchild -> child -> grandparent -> root
|
||||
// This means "grandparent" is in the ancestry, so we can't move it under "grandchild"
|
||||
parents: &folders.FolderInfoList{
|
||||
Items: []folders.FolderInfo{
|
||||
{Name: "grandchild", Parent: "child"},
|
||||
{Name: "child", Parent: "grandparent"},
|
||||
{Name: "grandparent", Parent: folder.GeneralFolderUID},
|
||||
{Name: folder.GeneralFolderUID},
|
||||
},
|
||||
},
|
||||
expectedErr: "cannot move folder under its own descendant",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -320,7 +385,7 @@ func TestValidateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "stats error",
|
||||
name: "stats error - nil stats",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
@@ -331,7 +396,7 @@ func TestValidateDelete(t *testing.T) {
|
||||
},
|
||||
expectedErr: "could not verify if folder is empty",
|
||||
}, {
|
||||
name: "stats error",
|
||||
name: "stats error - search error",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
@@ -342,7 +407,7 @@ func TestValidateDelete(t *testing.T) {
|
||||
},
|
||||
expectedErr: "error running stats",
|
||||
}, {
|
||||
name: "stats error",
|
||||
name: "stats error - error result",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
@@ -357,7 +422,64 @@ func TestValidateDelete(t *testing.T) {
|
||||
},
|
||||
expectedErr: "could not verify if folder is empty",
|
||||
}, {
|
||||
name: "folder not empty",
|
||||
name: "folder not empty - contains dashboards",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
},
|
||||
},
|
||||
searcher: &mockSearchClient{
|
||||
stats: &resourcepb.ResourceStatsResponse{
|
||||
Stats: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Count: 10, // not empty
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "[folder.not-empty]",
|
||||
}, {
|
||||
name: "folder not empty - contains alertrules",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
},
|
||||
},
|
||||
searcher: &mockSearchClient{
|
||||
stats: &resourcepb.ResourceStatsResponse{
|
||||
Stats: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{
|
||||
Group: "alerting.grafana.app",
|
||||
Resource: "alertrules",
|
||||
Count: 5, // not empty
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "[folder.not-empty]",
|
||||
}, {
|
||||
name: "folder not empty - contains library_elements",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
},
|
||||
},
|
||||
searcher: &mockSearchClient{
|
||||
stats: &resourcepb.ResourceStatsResponse{
|
||||
Stats: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{
|
||||
Group: "library.grafana.app",
|
||||
Resource: "library_elements",
|
||||
Count: 3, // not empty
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "[folder.not-empty]",
|
||||
}, {
|
||||
name: "folder not empty - contains folders",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
@@ -369,7 +491,54 @@ func TestValidateDelete(t *testing.T) {
|
||||
{
|
||||
Group: "folders.grafana.app",
|
||||
Resource: "folders",
|
||||
Count: 10, // not empty
|
||||
Count: 2, // not empty
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "[folder.not-empty]",
|
||||
}, {
|
||||
name: "folder can be deleted when it only contains non-validated resource types",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
},
|
||||
},
|
||||
searcher: &mockSearchClient{
|
||||
stats: &resourcepb.ResourceStatsResponse{
|
||||
Stats: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Count: 10, // has content but not a validated resource type
|
||||
},
|
||||
{
|
||||
Group: "other.grafana.app",
|
||||
Resource: "other",
|
||||
Count: 5, // has content but not a validated resource type
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "folder not empty - mixed resources with validated types",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nnn",
|
||||
},
|
||||
},
|
||||
searcher: &mockSearchClient{
|
||||
stats: &resourcepb.ResourceStatsResponse{
|
||||
Stats: []*resourcepb.ResourceStatsResponse_Stats{
|
||||
{
|
||||
Group: "folders.grafana.app",
|
||||
Resource: "folders",
|
||||
Count: 10, // now validated
|
||||
},
|
||||
{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Count: 2, // validated and has content
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
|
||||
|
||||
// Identity specific resources
|
||||
legacyAuthorizer := gfauthorizer.NewResourceAuthorizer(legacyAccessClient)
|
||||
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = legacyAuthorizer
|
||||
resourceAuthorizer[iamv0.TeamBindingResourceInfo.GetName()] = legacyAuthorizer
|
||||
resourceAuthorizer["display"] = legacyAuthorizer
|
||||
|
||||
@@ -36,6 +35,8 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
|
||||
resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer
|
||||
|
||||
return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer}
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
v1 "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"
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyName = errors.New("name cannot be empty")
|
||||
errInvalidBasicRole = errors.New("invalid basic role")
|
||||
errUnknownKind = errors.New("unknown permission kind")
|
||||
|
||||
defaultWriteTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
func toZanzanaSubject(kind iamv0.ResourcePermissionSpecPermissionKind, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", errEmptyName
|
||||
}
|
||||
switch kind {
|
||||
case iamv0.ResourcePermissionSpecPermissionKindUser:
|
||||
return zanzana.NewTupleEntry(zanzana.TypeUser, name, ""), nil
|
||||
case iamv0.ResourcePermissionSpecPermissionKindServiceAccount:
|
||||
return zanzana.NewTupleEntry(zanzana.TypeServiceAccount, name, ""), nil
|
||||
case iamv0.ResourcePermissionSpecPermissionKindTeam:
|
||||
return zanzana.NewTupleEntry(zanzana.TypeTeam, name, ""), nil
|
||||
case iamv0.ResourcePermissionSpecPermissionKindBasicRole:
|
||||
basicRole := zanzana.TranslateBasicRole(name)
|
||||
if basicRole == "" {
|
||||
return "", fmt.Errorf("%w: %s", errInvalidBasicRole, name)
|
||||
}
|
||||
|
||||
// e.g role:basic_viewer#assignee
|
||||
return zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, zanzana.RelationAssignee), nil
|
||||
}
|
||||
|
||||
// should not happen since we are after create
|
||||
// validation webhook should have caught invalid kinds
|
||||
return "", errUnknownKind
|
||||
}
|
||||
|
||||
func toZanzanaType(apiGroup string) string {
|
||||
if apiGroup == "folder.grafana.app" {
|
||||
return zanzana.TypeFolder
|
||||
}
|
||||
return zanzana.TypeResource
|
||||
}
|
||||
|
||||
func NewResourceTuple(object string, resource iamv0.ResourcePermissionspecResource, perm iamv0.ResourcePermissionspecPermission) (*v1.TupleKey, error) {
|
||||
// Typ is "folder" or "resource"
|
||||
typ := toZanzanaType(resource.ApiGroup)
|
||||
|
||||
// subject
|
||||
subject, err := toZanzanaSubject(perm.Kind, perm.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := &v1.TupleKey{
|
||||
// e.g. "user:{uid}", "serviceaccount:{uid}", "team:{uid}", "basicrole:{viewer|editor|admin}"
|
||||
User: subject,
|
||||
// "view", "edit", "admin"
|
||||
Relation: strings.ToLower(perm.Verb),
|
||||
// e.g. "folder:{name}" or "resource:{apiGroup}/{resource}/{name}"
|
||||
Object: object,
|
||||
}
|
||||
|
||||
// For resources we add a condition to filter by apiGroup/resource
|
||||
// e.g "group_filter": {"group_resource": "dashboards.grafana.app/dashboards"}
|
||||
if typ == zanzana.TypeResource {
|
||||
key.Condition = &v1.RelationshipCondition{
|
||||
Name: "group_filter",
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"group_resource": structpb.NewStringValue(
|
||||
resource.ApiGroup + "/" + resource.Resource,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rp, ok := obj.(*iamv0.ResourcePermission)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
// This limits the amount of concurrent writes to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.Observe(time.Since(wait).Seconds()) // Record wait time
|
||||
|
||||
go func(rp *iamv0.ResourcePermission) {
|
||||
defer func() {
|
||||
// Release the ticket after write is done
|
||||
<-b.zTickets
|
||||
}()
|
||||
|
||||
resource := rp.Spec.Resource
|
||||
permissions := rp.Spec.Permissions
|
||||
|
||||
object := zanzana.NewObjectEntry(toZanzanaType(resource.ApiGroup), resource.ApiGroup, resource.Resource, "", resource.Name)
|
||||
|
||||
tuples := make([]*v1.TupleKey, 0, len(permissions))
|
||||
for _, p := range permissions {
|
||||
tuple, err := NewResourceTuple(object, resource, p)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to create resource permission tuple",
|
||||
"namespace", rp.Namespace,
|
||||
"object", object,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
tuples = append(tuples, tuple)
|
||||
}
|
||||
|
||||
// Avoid writing if there are no valid tuples
|
||||
if len(tuples) == 0 {
|
||||
b.logger.Warn("no valid tuples to write", "namespace", rp.Namespace, "resource", object)
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("writing resource permission to zanzana",
|
||||
"namespace", rp.Namespace,
|
||||
"object", object,
|
||||
"tuplesCnt", len(tuples),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: rp.Namespace,
|
||||
Writes: &v1.WriteRequestWrites{
|
||||
TupleKeys: tuples,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to write resource permission to zanzana",
|
||||
"err", err,
|
||||
"namespace", rp.Namespace,
|
||||
"object", object,
|
||||
"tuplesCnt", len(tuples),
|
||||
)
|
||||
}
|
||||
}(rp.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format
|
||||
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
|
||||
func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) {
|
||||
// Convert IAM permissions to zanzana.RolePermission format
|
||||
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
|
||||
for _, perm := range permissions {
|
||||
// Split the scope to get kind, attribute, identifier
|
||||
kind, _, identifier := accesscontrol.SplitScope(perm.Scope)
|
||||
rolePerms = append(rolePerms, zanzana.RolePermission{
|
||||
Action: perm.Action,
|
||||
Kind: kind,
|
||||
Identifier: identifier,
|
||||
})
|
||||
}
|
||||
|
||||
// Translate to Zanzana tuples
|
||||
openfgaTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert directly to v1 tuples using common utility
|
||||
v1Tuples := common.ToAuthzExtTupleKeys(openfgaTuples)
|
||||
|
||||
return v1Tuples, nil
|
||||
}
|
||||
|
||||
// AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA)
|
||||
// It handles both Role and CoreRole types
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract permissions based on the object type
|
||||
var roleUID, namespace string
|
||||
var permissions []iamv0.CoreRolespecPermission
|
||||
var roleType string
|
||||
|
||||
// Try CoreRole first
|
||||
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
|
||||
roleUID = coreRole.Name
|
||||
namespace = coreRole.Namespace
|
||||
// Deep copy permissions to avoid race conditions
|
||||
permissions = make([]iamv0.CoreRolespecPermission, len(coreRole.Spec.Permissions))
|
||||
copy(permissions, coreRole.Spec.Permissions)
|
||||
roleType = "core role"
|
||||
} else if role, ok := obj.(*iamv0.Role); ok {
|
||||
// Try Role
|
||||
roleUID = role.Name
|
||||
namespace = role.Namespace
|
||||
|
||||
// Convert and copy permissions to avoid race conditions
|
||||
permissions = make([]iamv0.CoreRolespecPermission, len(role.Spec.Permissions))
|
||||
for i, p := range role.Spec.Permissions {
|
||||
permissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
roleType = "role"
|
||||
} else {
|
||||
// Not a supported role type
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
}()
|
||||
|
||||
tuples, err := convertRolePermissionsToTuples(roleUID, permissions)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert role permissions to tuples",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"err", err,
|
||||
"permissionsCnt", len(permissions),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid writing if there are no valid tuples
|
||||
if len(tuples) == 0 {
|
||||
b.logger.Debug("no valid tuples to write for role",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"permissionsCnt", len(permissions),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("writing role permissions to zanzana",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(tuples),
|
||||
"permissionsCnt", len(permissions),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: namespace,
|
||||
Writes: &v1.WriteRequestWrites{
|
||||
TupleKeys: tuples,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to write role permissions to zanzana",
|
||||
"err", err,
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(tuples),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
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/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type FakeZanzanaClient struct {
|
||||
zanzana.Client
|
||||
writeCallback func(context.Context, *v1.WriteRequest) error
|
||||
}
|
||||
|
||||
// Write implements zanzana.Client.
|
||||
func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error {
|
||||
return f.writeCallback(ctx, req)
|
||||
}
|
||||
|
||||
func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) {
|
||||
t.Helper()
|
||||
for _, exp := range expected {
|
||||
found := false
|
||||
for _, act := range actual {
|
||||
if act.User == exp.User &&
|
||||
act.Relation == exp.Relation &&
|
||||
act.Object == exp.Object {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
require.Fail(t, "Expected tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterResourcePermissionCreate(t *testing.T) {
|
||||
t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
folderPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testFolderEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
expectedTuples := []*v1.TupleKey{
|
||||
{User: "user:u1", Relation: "view", Object: "folder:fold1"},
|
||||
{User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"},
|
||||
}
|
||||
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testFolderEntries}
|
||||
b.AfterResourcePermissionCreate(&folderPerm, nil)
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entries for dashboard resource permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
dashPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
object := "resource:dashboard.grafana.app/dashboards/dash1"
|
||||
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Verify all tuples have the group_filter condition
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.NotNil(t, tuple.Condition, "Condition should not be nil for tuple %+v", tuple)
|
||||
require.Equal(t, "group_filter", tuple.Condition.Name)
|
||||
}
|
||||
|
||||
expectedTuples := []*v1.TupleKey{
|
||||
{User: "service-account:sa1", Relation: "view", Object: object},
|
||||
{User: "team:team1", Relation: "edit", Object: object},
|
||||
}
|
||||
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashEntries}
|
||||
b.AfterResourcePermissionCreate(&dashPerm, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterCoreRoleCreate(t *testing.T) {
|
||||
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role",
|
||||
Description: "Test role for folders",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCoreRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
expectedTuples := []*v1.TupleKey{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entries for core role with dashboard permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Dashboard Role",
|
||||
Description: "Test role for dashboards",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashboardRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check subject is role with assignee relation
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
require.Contains(t, tuple.Object, "dashboard")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
})
|
||||
|
||||
t.Run("should handle wildcard scopes", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "wildcard-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Wildcard Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testWildcardEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Writes.TupleKeys[0]
|
||||
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
|
||||
// Wildcard should create a group_resource tuple
|
||||
require.Contains(t, tuple.Object, "group_resource:")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
})
|
||||
|
||||
t.Run("should skip untranslatable permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "mixed-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Mixed Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMixedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
// Should only have 1 tuple (the untranslatable one should be skipped)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Writes.TupleKeys[0]
|
||||
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder1", tuple.Object)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterRoleCreate(t *testing.T) {
|
||||
t.Run("should create zanzana entries for role with folder permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role",
|
||||
Description: "Custom role for folders",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
expectedTuples := []*v1.TupleKey{
|
||||
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
|
||||
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
|
||||
}
|
||||
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries}
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entries for role with dashboard permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dash-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Dashboard Custom Role",
|
||||
Description: "Custom role for dashboards",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
|
||||
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check subject is role with assignee relation
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries}
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
})
|
||||
|
||||
t.Run("should merge folder resource tuples with same object and user", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "merge-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Merge Test Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
// These should create folder resource tuples that get merged
|
||||
{Action: "dashboards:read", Scope: "folders:uid:parent-folder"},
|
||||
{Action: "dashboards:write", Scope: "folders:uid:parent-folder"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMergedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
// After merging, we should have tuples for the folder resource actions
|
||||
require.Greater(t, len(req.Writes.TupleKeys), 0)
|
||||
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:merge-role-uid#assignee", tuple.User)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries}
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package legacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -129,18 +130,18 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo,
|
||||
return nil, fmt.Errorf("expected non zero orgID")
|
||||
}
|
||||
|
||||
sql, err := s.sql(ctx)
|
||||
sqlConn, err := s.sql(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := newListTeams(sql, &query)
|
||||
req := newListTeams(sqlConn, &query)
|
||||
q, err := sqltemplate.Execute(sqlQueryTeamsTemplate, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeamsTemplate.Name(), err)
|
||||
}
|
||||
|
||||
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
|
||||
rows, err := sqlConn.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
|
||||
defer func() {
|
||||
if rows != nil {
|
||||
_ = rows.Close()
|
||||
@@ -155,11 +156,21 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo,
|
||||
var lastID int64
|
||||
for rows.Next() {
|
||||
t := team.Team{}
|
||||
err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &t.ExternalUID, &t.IsProvisioned, &t.Created, &t.Updated)
|
||||
var externalUID sql.NullString
|
||||
var isProvisioned sql.NullBool
|
||||
err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &externalUID, &isProvisioned, &t.Created, &t.Updated)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if externalUID.Valid {
|
||||
t.ExternalUID = externalUID.String
|
||||
}
|
||||
|
||||
if isProvisioned.Valid {
|
||||
t.IsProvisioned = isProvisioned.Bool
|
||||
}
|
||||
|
||||
lastID = t.ID
|
||||
res.Teams = append(res.Teams, t)
|
||||
if len(res.Teams) > int(query.Pagination.Limit)-1 {
|
||||
@@ -170,7 +181,7 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo,
|
||||
}
|
||||
|
||||
if query.UID == "" {
|
||||
res.RV, err = sql.GetResourceVersion(ctx, "team", "updated")
|
||||
res.RV, err = sqlConn.GetResourceVersion(ctx, "team", "updated")
|
||||
}
|
||||
|
||||
return res, err
|
||||
|
||||
@@ -14,19 +14,53 @@ const (
|
||||
|
||||
var (
|
||||
registerOnce sync.Once
|
||||
hooksWaitHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
hooksWaitHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: metricsNamespace,
|
||||
Subsystem: metricsSubSystem,
|
||||
Name: "hooks_wait_duration_seconds",
|
||||
Help: "Time spent in the hooks waiting for a ticket to start processing",
|
||||
Buckets: prometheus.ExponentialBuckets(0.001, 2, 5), // 1ms to ~16s
|
||||
})
|
||||
}, []string{"resource_type", "operation"})
|
||||
|
||||
// hooksDurationHistogram tracks the total duration of hook operations
|
||||
hooksDurationHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: metricsNamespace,
|
||||
Subsystem: metricsSubSystem,
|
||||
Name: "hooks_operation_duration_seconds",
|
||||
Help: "Time spent executing hook operations (create, update, delete)",
|
||||
Buckets: prometheus.ExponentialBuckets(0.001, 2, 10), // 1ms to ~1s
|
||||
}, []string{"resource_type", "operation", "status"})
|
||||
|
||||
// hooksOperationCounter tracks the number of hook operations
|
||||
hooksOperationCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: metricsNamespace,
|
||||
Subsystem: metricsSubSystem,
|
||||
Name: "hooks_operations_total",
|
||||
Help: "Total number of hook operations by resource type, operation, and status",
|
||||
}, []string{"resource_type", "operation", "status"})
|
||||
|
||||
// hooksTuplesCounter tracks the number of tuples written/deleted
|
||||
hooksTuplesCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: metricsNamespace,
|
||||
Subsystem: metricsSubSystem,
|
||||
Name: "hooks_tuples_total",
|
||||
Help: "Total number of tuples written or deleted by resource type and operation type",
|
||||
}, []string{"resource_type", "operation", "action"})
|
||||
)
|
||||
|
||||
func registerMetrics(reg prometheus.Registerer) {
|
||||
registerOnce.Do(func() {
|
||||
if err := reg.Register(hooksWaitHistogram); err != nil {
|
||||
log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err)
|
||||
metrics := []prometheus.Collector{
|
||||
hooksWaitHistogram,
|
||||
hooksDurationHistogram,
|
||||
hooksOperationCounter,
|
||||
hooksTuplesCounter,
|
||||
}
|
||||
|
||||
for _, metric := range metrics {
|
||||
if err := reg.Register(metric); err != nil {
|
||||
log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,14 +35,19 @@ type RoleStorageBackend interface{ resource.StorageBackend }
|
||||
// Used by wire to identify the storage backend for role bindings.
|
||||
type RoleBindingStorageBackend interface{ resource.StorageBackend }
|
||||
|
||||
// ExternalGroupMappingStorageBackend uses the resource.StorageBackend interface to provide storage for external group mappings.
|
||||
// Used by wire to identify the storage backend for external group mappings.
|
||||
type ExternalGroupMappingStorageBackend interface{ resource.StorageBackend }
|
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type IdentityAccessManagementAPIBuilder struct {
|
||||
// Stores
|
||||
store legacy.LegacyIdentityStore
|
||||
coreRolesStorage CoreRoleStorageBackend
|
||||
rolesStorage RoleStorageBackend
|
||||
resourcePermissionsStorage resource.StorageBackend
|
||||
roleBindingsStorage RoleBindingStorageBackend
|
||||
store legacy.LegacyIdentityStore
|
||||
coreRolesStorage CoreRoleStorageBackend
|
||||
rolesStorage RoleStorageBackend
|
||||
resourcePermissionsStorage resource.StorageBackend
|
||||
roleBindingsStorage RoleBindingStorageBackend
|
||||
externalGroupMappingStorage ExternalGroupMappingStorageBackend
|
||||
|
||||
// Access Control
|
||||
authorizer authorizer.Authorizer
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/resourcepermission"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/sso"
|
||||
@@ -63,6 +64,7 @@ func RegisterAPIService(
|
||||
coreRolesStorage CoreRoleStorageBackend,
|
||||
rolesStorage RoleStorageBackend,
|
||||
roleBindingsStorage RoleBindingStorageBackend,
|
||||
externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend,
|
||||
dual dualwrite.Service,
|
||||
unified resource.ResourceClient,
|
||||
userService legacyuser.Service,
|
||||
@@ -74,46 +76,54 @@ func RegisterAPIService(
|
||||
registerMetrics(reg)
|
||||
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
store: store,
|
||||
coreRolesStorage: coreRolesStorage,
|
||||
rolesStorage: rolesStorage,
|
||||
resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider),
|
||||
roleBindingsStorage: roleBindingsStorage,
|
||||
sso: ssoService,
|
||||
authorizer: authorizer,
|
||||
legacyAccessClient: legacyAccessClient,
|
||||
accessClient: accessClient,
|
||||
zClient: zClient,
|
||||
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
|
||||
display: user.NewLegacyDisplayREST(store),
|
||||
reg: reg,
|
||||
logger: log.New("iam.apis"),
|
||||
features: features,
|
||||
enableDualWriter: true,
|
||||
dual: dual,
|
||||
unified: unified,
|
||||
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features),
|
||||
store: store,
|
||||
coreRolesStorage: coreRolesStorage,
|
||||
rolesStorage: rolesStorage,
|
||||
resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider),
|
||||
roleBindingsStorage: roleBindingsStorage,
|
||||
externalGroupMappingStorage: externalGroupMappingStorageBackend,
|
||||
sso: ssoService,
|
||||
authorizer: authorizer,
|
||||
legacyAccessClient: legacyAccessClient,
|
||||
accessClient: accessClient,
|
||||
zClient: zClient,
|
||||
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
|
||||
display: user.NewLegacyDisplayREST(store),
|
||||
reg: reg,
|
||||
logger: log.New("iam.apis"),
|
||||
features: features,
|
||||
enableDualWriter: true,
|
||||
dual: dual,
|
||||
unified: unified,
|
||||
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features),
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
|
||||
return builder, nil
|
||||
}
|
||||
|
||||
// TODO zClient, zTickets, reg
|
||||
func NewAPIService(
|
||||
accessClient types.AccessClient,
|
||||
dbProvider legacysql.LegacyDatabaseProvider,
|
||||
features featuremgmt.FeatureToggles,
|
||||
zClient zanzana.Client,
|
||||
reg prometheus.Registerer,
|
||||
) *IdentityAccessManagementAPIBuilder {
|
||||
store := legacy.NewLegacySQLStores(dbProvider)
|
||||
resourcePermissionsStorage := resourcepermission.ProvideStorageBackend(dbProvider)
|
||||
resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient)
|
||||
noopStorage := noopstorage.ProvideStorageBackend()
|
||||
registerMetrics(reg)
|
||||
return &IdentityAccessManagementAPIBuilder{
|
||||
store: store,
|
||||
display: user.NewLegacyDisplayREST(store),
|
||||
resourcePermissionsStorage: resourcePermissionsStorage,
|
||||
logger: log.New("iam.apis"),
|
||||
features: features,
|
||||
store: store,
|
||||
display: user.NewLegacyDisplayREST(store),
|
||||
resourcePermissionsStorage: resourcePermissionsStorage,
|
||||
externalGroupMappingStorage: noopStorage,
|
||||
logger: log.New("iam.apis"),
|
||||
features: features,
|
||||
zClient: zClient,
|
||||
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
|
||||
reg: reg,
|
||||
authorizer: authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
// For now only authorize resourcepermissions resource
|
||||
@@ -219,6 +229,14 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
|
||||
// Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling hooks for TeamBinding to sync to Zanzana")
|
||||
teamBindingStore.AfterCreate = b.AfterTeamBindingCreate
|
||||
teamBindingStore.AfterDelete = b.AfterTeamBindingDelete
|
||||
teamBindingStore.BeginUpdate = b.BeginTeamBindingUpdate
|
||||
}
|
||||
|
||||
storage[teamBindingResource.StoragePath()] = teamBindingDW
|
||||
}
|
||||
|
||||
@@ -233,6 +251,13 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana")
|
||||
store.AfterCreate = b.AfterUserCreate
|
||||
store.BeginUpdate = b.BeginUserUpdate
|
||||
store.AfterDelete = b.AfterUserDelete
|
||||
}
|
||||
|
||||
dw, err := opts.DualWriteBuilder(userResource.GroupResource(), legacyStore, store)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -269,6 +294,27 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso)
|
||||
}
|
||||
|
||||
externalGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo
|
||||
externalGroupMappingLegacyStore, err := NewLocalStore(externalGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingLegacyStore
|
||||
|
||||
if b.enableDualWriter {
|
||||
externalGroupMappingStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, externalGroupMappingResource, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
externalGroupMappingDW, err := opts.DualWriteBuilder(externalGroupMappingResource.GroupResource(), externalGroupMappingLegacyStore, externalGroupMappingStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingDW
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) {
|
||||
// v0alpha1
|
||||
@@ -277,8 +323,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling AfterCreate hook for CoreRole to sync to Zanzana")
|
||||
b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana")
|
||||
coreRoleStore.AfterCreate = b.AfterRoleCreate
|
||||
coreRoleStore.AfterDelete = b.AfterRoleDelete
|
||||
coreRoleStore.BeginUpdate = b.BeginRoleUpdate
|
||||
}
|
||||
storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore
|
||||
|
||||
@@ -287,8 +335,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling AfterCreate hook for Role to sync to Zanzana")
|
||||
b.logger.Info("Enabling hooks for Role to sync to Zanzana")
|
||||
roleStore.AfterCreate = b.AfterRoleCreate
|
||||
roleStore.AfterDelete = b.AfterRoleDelete
|
||||
roleStore.BeginUpdate = b.BeginRoleUpdate
|
||||
}
|
||||
storage[iamv0.RoleInfo.StoragePath()] = roleStore
|
||||
|
||||
@@ -300,21 +350,59 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) {
|
||||
resourcePermissionStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage)
|
||||
if err != nil {
|
||||
if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, b.enableDualWriter, enableZanzanaSync); err != nil {
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling AfterCreate hook for ResourcePermission to sync to Zanzana")
|
||||
resourcePermissionStore.AfterCreate = b.AfterResourcePermissionCreate
|
||||
}
|
||||
storage[iamv0.ResourcePermissionInfo.StoragePath()] = resourcePermissionStore
|
||||
}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup(
|
||||
apiGroupInfo *genericapiserver.APIGroupInfo,
|
||||
opts builder.APIGroupOptions,
|
||||
storage map[string]rest.Storage,
|
||||
enableDualWriter bool,
|
||||
enableZanzanaSync bool,
|
||||
) error {
|
||||
var store rest.Storage
|
||||
// Create the legacy store first
|
||||
legacyStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Register the hooks for Zanzana sync
|
||||
// FIXME: The hooks are registered on the legacy store
|
||||
// Once we fully migrate to unified storage, we can move these hooks to the unified store
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling AfterCreate, BeginUpdate, and AfterDelete hooks for ResourcePermission to sync to Zanzana")
|
||||
legacyStore.AfterCreate = b.AfterResourcePermissionCreate
|
||||
legacyStore.BeginUpdate = b.BeginResourcePermissionUpdate
|
||||
legacyStore.AfterDelete = b.AfterResourcePermissionDelete
|
||||
}
|
||||
|
||||
// Set the default store to the legacy store
|
||||
store = legacyStore
|
||||
|
||||
if enableDualWriter {
|
||||
// Create the dual write store (UniStore + LegacyStore)
|
||||
uniStore, err := grafanaregistry.NewRegistryStore(apiGroupInfo.Scheme, iamv0.ResourcePermissionInfo, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err = opts.DualWriteBuilder(iamv0.ResourcePermissionInfo.GroupResource(), legacyStore, uniStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
storage[iamv0.ResourcePermissionInfo.StoragePath()] = store
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return func(rc common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||
dst := legacyiamv0.GetOpenAPIDefinitions(rc)
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyName = errors.New("name cannot be empty")
|
||||
|
||||
defaultWriteTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rp, ok := obj.(*iamv0.ResourcePermission)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to resourcePermission type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "resourcepermission"
|
||||
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()) // Record wait time
|
||||
|
||||
go func(rp *iamv0.ResourcePermission) {
|
||||
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())
|
||||
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
|
||||
}()
|
||||
|
||||
resource := rp.Spec.Resource
|
||||
permissions := rp.Spec.Permissions
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(permissions))
|
||||
for _, p := range permissions {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: resource.ApiGroup,
|
||||
Resource: resource.Resource,
|
||||
Name: resource.Name,
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(p.Kind),
|
||||
Name: p.Name,
|
||||
Verb: p.Verb,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(operations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("writing resource permission to zanzana",
|
||||
"namespace", rp.Namespace,
|
||||
"resource", resource,
|
||||
"operationsCount", len(operations),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: rp.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to write resource permission to zanzana",
|
||||
"err", err,
|
||||
"namespace", rp.Namespace,
|
||||
"resource", resource,
|
||||
"operationsCount", len(operations),
|
||||
)
|
||||
} else {
|
||||
// Record successful tuple writes
|
||||
hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Add(float64(len(operations)))
|
||||
}
|
||||
}(rp.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// BeginResourcePermissionUpdate is a pre-update hook that prepares zanzana updates
|
||||
// It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds
|
||||
func (b *IdentityAccessManagementAPIBuilder) BeginResourcePermissionUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
||||
if b.zClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extract permissions from both old and new objects
|
||||
oldRP, ok := oldObj.(*iamv0.ResourcePermission)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newRP, ok := obj.(*iamv0.ResourcePermission)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Convert old permissions to delete operations
|
||||
deleteOperations := make([]*v1.MutateOperation, 0, len(oldRP.Spec.Permissions))
|
||||
if len(oldRP.Spec.Permissions) > 0 {
|
||||
oldResource := oldRP.Spec.Resource
|
||||
for _, p := range oldRP.Spec.Permissions {
|
||||
deleteOperations = append(deleteOperations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: oldResource.ApiGroup,
|
||||
Resource: oldResource.Resource,
|
||||
Name: oldResource.Name,
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(p.Kind),
|
||||
Name: p.Name,
|
||||
Verb: p.Verb,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convert new permissions to create operations
|
||||
createOperations := make([]*v1.MutateOperation, 0, len(newRP.Spec.Permissions))
|
||||
if len(newRP.Spec.Permissions) > 0 {
|
||||
newResource := newRP.Spec.Resource
|
||||
for _, p := range newRP.Spec.Permissions {
|
||||
createOperations = append(createOperations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: newResource.ApiGroup,
|
||||
Resource: newResource.Resource,
|
||||
Name: newResource.Name,
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(p.Kind),
|
||||
Name: p.Name,
|
||||
Verb: p.Verb,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return a finish function that performs the zanzana write only on success
|
||||
return func(ctx context.Context, success bool) {
|
||||
if !success {
|
||||
// Update failed, don't write to zanzana
|
||||
return
|
||||
}
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
// This limits the amount of concurrent connections to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues("resourcepermission", "update").Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues("resourcepermission", "update", status).Observe(time.Since(start).Seconds())
|
||||
hooksOperationCounter.WithLabelValues("resourcepermission", "update", status).Inc()
|
||||
}()
|
||||
|
||||
b.logger.Debug("updating resource permission in zanzana",
|
||||
"namespace", newRP.Namespace,
|
||||
"oldPermissionsCnt", len(oldRP.Spec.Permissions),
|
||||
"newPermissionsCnt", len(newRP.Spec.Permissions),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Prepare write request
|
||||
req := &v1.MutateRequest{
|
||||
Namespace: newRP.Namespace,
|
||||
Operations: append(deleteOperations, createOperations...),
|
||||
}
|
||||
|
||||
if len(req.Operations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(deleteOperations) > 0 {
|
||||
b.logger.Debug("deleting existing resource permissions from zanzana",
|
||||
"namespace", newRP.Namespace,
|
||||
"operationsCount", len(deleteOperations),
|
||||
)
|
||||
}
|
||||
|
||||
if len(createOperations) > 0 {
|
||||
b.logger.Debug("writing new resource permissions to zanzana",
|
||||
"namespace", newRP.Namespace,
|
||||
"operationsCount", len(createOperations),
|
||||
)
|
||||
}
|
||||
|
||||
// Only make the request if there are deletes or writes
|
||||
if len(req.Operations) > 0 {
|
||||
err := b.zClient.Mutate(ctx, req)
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to update resource permission in zanzana",
|
||||
"err", err,
|
||||
"namespace", newRP.Namespace,
|
||||
)
|
||||
} else {
|
||||
// Record successful tuple operations
|
||||
if len(deleteOperations) > 0 {
|
||||
hooksTuplesCounter.WithLabelValues("resourcepermission", "update", "delete").Add(float64(len(deleteOperations)))
|
||||
}
|
||||
if len(createOperations) > 0 {
|
||||
hooksTuplesCounter.WithLabelValues("resourcepermission", "update", "write").Add(float64(len(createOperations)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.logger.Debug("no tuples to update in zanzana", "namespace", newRP.Namespace)
|
||||
}
|
||||
}()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AfterResourcePermissionDelete is a post-delete hook that removes the resource permission from Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rp, ok := obj.(*iamv0.ResourcePermission)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to resourcePermission type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "resourcepermission"
|
||||
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()) // Record wait time
|
||||
|
||||
go func(rp *iamv0.ResourcePermission) {
|
||||
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())
|
||||
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
|
||||
}()
|
||||
|
||||
resource := rp.Spec.Resource
|
||||
permissions := rp.Spec.Permissions
|
||||
|
||||
// Generate delete tuples from the permissions
|
||||
deleteOperations := make([]*v1.MutateOperation, 0, len(permissions))
|
||||
for _, p := range permissions {
|
||||
deleteOperations = append(deleteOperations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: resource.ApiGroup,
|
||||
Resource: resource.Resource,
|
||||
Name: resource.Name,
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(p.Kind),
|
||||
Name: p.Name,
|
||||
Verb: p.Verb,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Avoid writing if there are no valid tuples
|
||||
if len(deleteOperations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("deleting resource permission from zanzana",
|
||||
"namespace", rp.Namespace,
|
||||
"resource", resource,
|
||||
"operationsCount", len(deleteOperations),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: rp.Namespace,
|
||||
Operations: deleteOperations,
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to delete resource permission from zanzana",
|
||||
"err", err,
|
||||
"namespace", rp.Namespace,
|
||||
"resource", resource,
|
||||
"operationsCount", len(deleteOperations),
|
||||
)
|
||||
} else {
|
||||
// Record successful tuple deletions
|
||||
hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Add(float64(len(deleteOperations)))
|
||||
}
|
||||
}(rp.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
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/grafana/grafana/pkg/services/authz/zanzana"
|
||||
)
|
||||
|
||||
type FakeZanzanaClient struct {
|
||||
zanzana.Client
|
||||
writeCallback func(context.Context, *v1.WriteRequest) error
|
||||
readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error)
|
||||
mutateCallback func(context.Context, *v1.MutateRequest) error
|
||||
}
|
||||
|
||||
// Read implements zanzana.Client.
|
||||
func (f *FakeZanzanaClient) Read(ctx context.Context, req *v1.ReadRequest) (*v1.ReadResponse, error) {
|
||||
if f.readCallback != nil {
|
||||
return f.readCallback(ctx, req)
|
||||
}
|
||||
return &v1.ReadResponse{}, nil
|
||||
}
|
||||
|
||||
// Write implements zanzana.Client.
|
||||
func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error {
|
||||
return f.writeCallback(ctx, req)
|
||||
}
|
||||
|
||||
// Mutate implements zanzana.Client.
|
||||
func (f *FakeZanzanaClient) Mutate(ctx context.Context, req *v1.MutateRequest) error {
|
||||
if f.mutateCallback != nil {
|
||||
return f.mutateCallback(ctx, req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAfterResourcePermissionCreate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
folderPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testFolderEntries := 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, 2)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
expectedOperations := []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "u1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindBasicRole),
|
||||
Name: "Editor",
|
||||
Verb: "Edit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.ElementsMatch(t, expectedOperations, req.Operations)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testFolderEntries}
|
||||
b.AfterResourcePermissionCreate(&folderPerm, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entries for dashboard resource permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
dashPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashEntries := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
// object := "resource:dashboard.grafana.app/dashboards/dash1"
|
||||
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
expectedOperations := []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindServiceAccount),
|
||||
Name: "sa1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindTeam),
|
||||
Name: "team1",
|
||||
Verb: "Edit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.ElementsMatch(t, expectedOperations, req.Operations)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testDashEntries}
|
||||
b.AfterResourcePermissionCreate(&dashPerm, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginResourcePermissionUpdate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should update zanzana entries for folder resource permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldFolderPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newFolderPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u2", Verb: "Edit"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "View"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testFolderWrite := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// Should delete old permission
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 3)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
expectedOperations := []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "u1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindTeam),
|
||||
Name: "team1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "u2",
|
||||
Verb: "Edit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, expectedOperations, req.Operations)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testFolderWrite}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginResourcePermissionUpdate(context.Background(), &newFolderPerm, &oldFolderPerm, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
t.Run("should update zanzana entries for dashboard resource permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldDashPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newDashPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashWrite := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Should delete old permission
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
expectedOperations := []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "u1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindServiceAccount),
|
||||
Name: "sa1",
|
||||
Verb: "Edit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, expectedOperations, req.Operations)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testDashWrite}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginResourcePermissionUpdate(context.Background(), &newDashPerm, &oldDashPerm, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterResourcePermissionDelete(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should delete zanzana entries for folder resource permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
folderPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testFolderDelete := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// Should have deletes but no writes
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
expectedOperations := []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "u1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindBasicRole),
|
||||
Name: "Editor",
|
||||
Verb: "Edit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.ElementsMatch(t, expectedOperations, req.Operations)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testFolderDelete}
|
||||
b.AfterResourcePermissionDelete(&folderPerm, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entries for dashboard resource permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
dashPerm := iamv0.ResourcePermission{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.ResourcePermissionSpec{
|
||||
Resource: iamv0.ResourcePermissionspecResource{
|
||||
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permissions: []iamv0.ResourcePermissionspecPermission{
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"},
|
||||
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashDelete := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Should have deletes but no writes
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
expectedOperations := []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindServiceAccount),
|
||||
Name: "sa1",
|
||||
Verb: "View",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindTeam),
|
||||
Name: "team1",
|
||||
Verb: "Edit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.ElementsMatch(t, expectedOperations, req.Operations)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testDashDelete}
|
||||
b.AfterResourcePermissionDelete(&dashPerm, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
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"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
v1 "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"
|
||||
)
|
||||
|
||||
// convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format
|
||||
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
|
||||
func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) {
|
||||
// Convert IAM permissions to zanzana.RolePermission format
|
||||
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
|
||||
for _, perm := range permissions {
|
||||
// Split the scope to get kind, attribute, identifier
|
||||
kind, _, identifier := accesscontrol.SplitScope(perm.Scope)
|
||||
rolePerms = append(rolePerms, zanzana.RolePermission{
|
||||
Action: perm.Action,
|
||||
Kind: kind,
|
||||
Identifier: identifier,
|
||||
})
|
||||
}
|
||||
|
||||
// Translate to Zanzana tuples
|
||||
openfgaTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert directly to v1 tuples using common utility
|
||||
v1Tuples := common.ToAuthzExtTupleKeys(openfgaTuples)
|
||||
|
||||
return v1Tuples, nil
|
||||
}
|
||||
|
||||
// AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA)
|
||||
// It handles both Role and CoreRole types
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var rType string
|
||||
var rt *iamv0.CoreRole
|
||||
|
||||
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
|
||||
rt = coreRole.DeepCopy()
|
||||
rType = "coreRole"
|
||||
} else if regRole, ok := obj.(*iamv0.Role); ok {
|
||||
regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions))
|
||||
for i, p := range regRole.Spec.Permissions {
|
||||
regRolePermissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
rt = &iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: regRole.Name,
|
||||
Namespace: regRole.Namespace,
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Permissions: regRolePermissions,
|
||||
},
|
||||
}
|
||||
rType = "role"
|
||||
} else {
|
||||
// Not a supported role type
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(rType, "create").Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(role *iamv0.CoreRole, roleType string) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
hooksDurationHistogram.WithLabelValues(rType, "create", status).Observe(time.Since(start).Seconds())
|
||||
hooksOperationCounter.WithLabelValues(rType, "create", status).Inc()
|
||||
}()
|
||||
|
||||
tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert role permissions to tuples",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"err", err,
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
status = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid writing if there are no valid tuples
|
||||
if len(tuples) == 0 {
|
||||
b.logger.Debug("no valid tuples to write for role",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
status = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("writing role permissions to zanzana",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(tuples),
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: role.Namespace,
|
||||
Writes: &v1.WriteRequestWrites{
|
||||
TupleKeys: tuples,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to write role permissions to zanzana",
|
||||
"err", err,
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(tuples),
|
||||
)
|
||||
status = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
// Record successful tuple writes
|
||||
hooksTuplesCounter.WithLabelValues(rType, "create", "write").Add(float64(len(tuples)))
|
||||
}(rt.DeepCopy(), rType)
|
||||
}
|
||||
|
||||
// AfterRoleDelete is a post-delete hook that removes the role permissions from Zanzana (openFGA)
|
||||
// It handles both Role and CoreRole types
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var rType string
|
||||
var rt *iamv0.CoreRole
|
||||
|
||||
// Try CoreRole first
|
||||
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
|
||||
rt = coreRole.DeepCopy()
|
||||
rType = "coreRole"
|
||||
} else if regRole, ok := obj.(*iamv0.Role); ok {
|
||||
regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions))
|
||||
for i, p := range regRole.Spec.Permissions {
|
||||
regRolePermissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
rt = &iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: regRole.Name,
|
||||
Namespace: regRole.Namespace,
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Permissions: regRolePermissions,
|
||||
},
|
||||
}
|
||||
rType = "role"
|
||||
} else {
|
||||
// Not a supported role type
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues("role", "delete").Observe(time.Since(wait).Seconds()) // Record wait time
|
||||
|
||||
go func(role *iamv0.CoreRole, roleType string) {
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
}()
|
||||
|
||||
b.logger.Debug("deleting role permissions from zanzana",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
|
||||
tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert role permissions to tuples for deletion",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"err", err,
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid deleting if there are no valid tuples
|
||||
if len(tuples) == 0 {
|
||||
b.logger.Debug("no valid tuples to delete for role",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert tuples to TupleKeyWithoutCondition for deletion
|
||||
deleteTuples := toTupleKeysWithoutCondition(tuples)
|
||||
|
||||
b.logger.Debug("deleting role permissions from zanzana",
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(deleteTuples),
|
||||
"permissionsCnt", len(role.Spec.Permissions),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: role.Namespace,
|
||||
Deletes: &v1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to delete role permissions from zanzana",
|
||||
"err", err,
|
||||
"namespace", role.Namespace,
|
||||
"roleUID", role.Name,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(deleteTuples),
|
||||
)
|
||||
}
|
||||
}(rt.DeepCopy(), rType)
|
||||
}
|
||||
|
||||
// beginRoleUpdate is a pre-update hook that prepares zanzana updates
|
||||
// It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds
|
||||
// It handles both Role and CoreRole types
|
||||
func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
||||
if b.zClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var oldRole, newRole *iamv0.CoreRole
|
||||
var roleType string
|
||||
|
||||
if oldCoreRole, ok := oldObj.(*iamv0.CoreRole); ok { // Try CoreRole first
|
||||
oldRole = oldCoreRole.DeepCopy()
|
||||
newCoreRole, ok := obj.(*iamv0.CoreRole)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
newRole = newCoreRole.DeepCopy()
|
||||
roleType = "coreRole"
|
||||
} else if oldRegRole, ok := oldObj.(*iamv0.Role); ok { // Try Role
|
||||
oldRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(oldRegRole.Spec.Permissions))
|
||||
for i, p := range oldRegRole.Spec.Permissions {
|
||||
oldRegRolePermissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
oldRole = &iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: oldRegRole.Name,
|
||||
Namespace: oldRegRole.Namespace,
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Permissions: oldRegRolePermissions,
|
||||
},
|
||||
}
|
||||
newRegRole, ok := obj.(*iamv0.Role)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
newRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(newRegRole.Spec.Permissions))
|
||||
for i, p := range newRegRole.Spec.Permissions {
|
||||
newRegRolePermissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
newRole = &iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: newRegRole.Name,
|
||||
Namespace: newRegRole.Namespace,
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Permissions: newRegRolePermissions,
|
||||
},
|
||||
}
|
||||
roleType = "role"
|
||||
} else {
|
||||
// Not a supported role type
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return a finish function that performs the zanzana write only on success
|
||||
return func(ctx context.Context, success bool) {
|
||||
if !success {
|
||||
// Update failed, don't write to zanzana
|
||||
return
|
||||
}
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(roleType, "update").Observe(time.Since(wait).Seconds()) // Record wait time
|
||||
|
||||
go func(old *iamv0.CoreRole, new *iamv0.CoreRole) {
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
}()
|
||||
roleUID, namespace := old.Name, old.Namespace
|
||||
oldPermissions, newPermissions := old.Spec.Permissions, new.Spec.Permissions
|
||||
|
||||
// Convert old permissions to tuples for deletion
|
||||
var oldTuples []*v1.TupleKey
|
||||
if len(oldPermissions) > 0 {
|
||||
var err error
|
||||
oldTuples, err = convertRolePermissionsToTuples(roleUID, oldPermissions)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert old role permissions to tuples",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert new permissions to tuples for writing
|
||||
newTuples, err := convertRolePermissionsToTuples(roleUID, newPermissions)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert new role permissions to tuples",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"err", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("updating role permissions in zanzana",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"oldPermissionsCnt", len(oldPermissions),
|
||||
"newPermissionsCnt", len(newPermissions),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Prepare write request
|
||||
req := &v1.WriteRequest{
|
||||
Namespace: namespace,
|
||||
}
|
||||
|
||||
// Add deletes for old tuples
|
||||
if len(oldTuples) > 0 {
|
||||
deleteTuples := toTupleKeysWithoutCondition(oldTuples)
|
||||
req.Deletes = &v1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
}
|
||||
b.logger.Debug("deleting existing role permissions from zanzana",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(deleteTuples),
|
||||
)
|
||||
}
|
||||
|
||||
// Add writes for new tuples
|
||||
if len(newTuples) > 0 {
|
||||
req.Writes = &v1.WriteRequestWrites{
|
||||
TupleKeys: newTuples,
|
||||
}
|
||||
b.logger.Debug("writing new role permissions to zanzana",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(newTuples),
|
||||
)
|
||||
}
|
||||
|
||||
// Only make the request if there are deletes or writes
|
||||
if req.Deletes != nil || req.Writes != nil {
|
||||
err = b.zClient.Write(ctx, req)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to update role permissions in zanzana",
|
||||
"err", err,
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
)
|
||||
}
|
||||
}
|
||||
}(oldRole.DeepCopy(), newRole.DeepCopy())
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tupleToTupleKeyWithoutCondition converts a TupleKey to TupleKeyWithoutCondition
|
||||
// This is needed for delete operations which don't support conditions
|
||||
func tupleToTupleKeyWithoutCondition(tuple *v1.TupleKey) *v1.TupleKeyWithoutCondition {
|
||||
return &v1.TupleKeyWithoutCondition{
|
||||
User: tuple.User,
|
||||
Relation: tuple.Relation,
|
||||
Object: tuple.Object,
|
||||
}
|
||||
}
|
||||
|
||||
// toTupleKeysWithoutCondition converts v1.TupleKey to v1.TupleKeyWithoutCondition
|
||||
// by stripping the condition field, which is required for delete operations
|
||||
func toTupleKeysWithoutCondition(tuples []*v1.TupleKey) []*v1.TupleKeyWithoutCondition {
|
||||
result := make([]*v1.TupleKeyWithoutCondition, len(tuples))
|
||||
for i, t := range tuples {
|
||||
result[i] = tupleToTupleKeyWithoutCondition(t)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
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 requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) {
|
||||
t.Helper()
|
||||
for _, exp := range expected {
|
||||
found := false
|
||||
for _, act := range actual {
|
||||
if act.User == exp.User &&
|
||||
act.Relation == exp.Relation &&
|
||||
act.Object == exp.Object {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
require.Fail(t, "Expected tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireDeleteTuplesMatch(t *testing.T, actual []*v1.TupleKeyWithoutCondition, expected []*v1.TupleKeyWithoutCondition, msgAndArgs ...interface{}) {
|
||||
t.Helper()
|
||||
for _, exp := range expected {
|
||||
found := false
|
||||
for _, act := range actual {
|
||||
if act.User == exp.User &&
|
||||
act.Relation == exp.Relation &&
|
||||
act.Object == exp.Object {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
require.Fail(t, "Expected delete tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterCoreRoleCreate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role",
|
||||
Description: "Test role for folders",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCoreRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
expectedTuples := []*v1.TupleKey{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entries for core role with dashboard permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Dashboard Role",
|
||||
Description: "Test role for dashboards",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashboardRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check subject is role with assignee relation
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
require.Contains(t, tuple.Object, "dashboard")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle wildcard scopes", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "wildcard-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Wildcard Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testWildcardEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Writes.TupleKeys[0]
|
||||
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
|
||||
// Wildcard should create a group_resource tuple
|
||||
require.Contains(t, tuple.Object, "group_resource:")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should skip untranslatable permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "mixed-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Mixed Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMixedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
// Should only have 1 tuple (the untranslatable one should be skipped)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Writes.TupleKeys[0]
|
||||
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder1", tuple.Object)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries}
|
||||
b.AfterRoleCreate(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterRoleCreate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should create zanzana entries for role with folder permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role",
|
||||
Description: "Custom role for folders",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
expectedTuples := []*v1.TupleKey{
|
||||
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
|
||||
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
|
||||
}
|
||||
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries}
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entries for role with dashboard permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dash-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Dashboard Custom Role",
|
||||
Description: "Custom role for dashboards",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
|
||||
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check subject is role with assignee relation
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries}
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should merge folder resource tuples with same object and user", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "merge-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Merge Test Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
// These should create folder resource tuples that get merged
|
||||
{Action: "dashboards:read", Scope: "folders:uid:parent-folder"},
|
||||
{Action: "dashboards:write", Scope: "folders:uid:parent-folder"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMergedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
// After merging, we should have tuples for the folder resource actions
|
||||
require.Greater(t, len(req.Writes.TupleKeys), 0)
|
||||
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:merge-role-uid#assignee", tuple.User)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries}
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginCoreRoleUpdate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should update zanzana entries when permissions change", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role Updated",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// Verify deletes (old permissions)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
|
||||
// Verify writes (new permissions)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
|
||||
expectedWrites := []*v1.TupleKey{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
|
||||
}
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedWrites)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testUpdate}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle adding new permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "expand-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Expand Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "expand-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Expand Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testExpand := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// Should delete old permission
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
|
||||
// Should write all new permissions
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 3)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testExpand}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle removing all permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "clear-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Clear Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "clear-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Clear Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{},
|
||||
},
|
||||
}
|
||||
|
||||
testClear := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
// Should delete old permissions
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
|
||||
// Should have no writes
|
||||
require.Nil(t, req.Writes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testClear}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginRoleUpdate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should update zanzana entries when permissions change", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role Updated",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// Verify deletes (old permissions)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:custom-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
|
||||
// Verify writes (new permissions) - dashboards use resource type
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
|
||||
// All writes should be for dashboards
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:custom-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
require.Contains(t, tuple.Object, "dashboard")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testUpdate}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle completely new permission set", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "swap-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Swap Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "swap-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Swap Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:write", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testSwap := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Should delete old permission
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
require.Equal(t, "role:swap-role-uid#assignee", req.Deletes.TupleKeys[0].User)
|
||||
require.Equal(t, "folder:folder1", req.Deletes.TupleKeys[0].Object)
|
||||
|
||||
// Should write new permissions
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:swap-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder2", tuple.Object)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testSwap}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle adding permissions to empty role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "empty-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Empty Role",
|
||||
Permissions: []iamv0.RolespecPermission{},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "empty-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Empty Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testAddToEmpty := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// Should have no deletes
|
||||
require.Nil(t, req.Deletes)
|
||||
|
||||
// Should write new permission
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.Equal(t, "role:empty-role-uid#assignee", req.Writes.TupleKeys[0].User)
|
||||
require.Equal(t, "folder:folder1", req.Writes.TupleKeys[0].Object)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testAddToEmpty}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call the finish function with success=true to trigger the zanzana write
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterCoreRoleDelete(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should delete zanzana entries for core role with folder permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role",
|
||||
Description: "Test role for folders",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCoreRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entries for core role with dashboard permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Dashboard Role",
|
||||
Description: "Test role for dashboards",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashboardRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check all deletes have the correct subject
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle wildcard scopes on delete", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "wildcard-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Wildcard Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testWildcardDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Deletes.TupleKeys[0]
|
||||
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "group_resource:")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should skip untranslatable permissions on delete", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "mixed-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Mixed Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMixedDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
// Should only delete 1 tuple (the untranslatable one should be skipped)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Deletes.TupleKeys[0]
|
||||
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder1", tuple.Object)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMixedDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterRoleDelete(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Run("should delete zanzana entries for role with folder permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role",
|
||||
Description: "Custom role for folders",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
|
||||
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
|
||||
}
|
||||
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testRoleDeletes}
|
||||
b.AfterRoleDelete(&role, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entries for role with dashboard permissions", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dash-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Dashboard Custom Role",
|
||||
Description: "Custom role for dashboards",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
|
||||
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check all deletes have the correct subject
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleDeletes}
|
||||
b.AfterRoleDelete(&role, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should handle multiple permissions on delete", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "multi-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Multi Permission Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMultiDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 3)
|
||||
|
||||
// All should be for the same role and folder
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
require.Equal(t, "role:multi-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder1", tuple.Object)
|
||||
}
|
||||
|
||||
// Check all expected relations are present
|
||||
relations := make(map[string]bool)
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
relations[tuple.Relation] = true
|
||||
}
|
||||
require.True(t, relations["get"], "Expected 'get' relation")
|
||||
require.True(t, relations["update"], "Expected 'update' relation")
|
||||
require.True(t, relations["delete"], "Expected 'delete' relation")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMultiDeletes}
|
||||
b.AfterRoleDelete(&role, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
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"
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
|
||||
tb, ok := obj.(*iamv0.TeamBinding)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to TeamBinding type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "teambinding"
|
||||
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(tb *iamv0.TeamBinding) {
|
||||
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())
|
||||
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,
|
||||
"subject", tb.Spec.Subject.Name,
|
||||
"teamRef", tb.Spec.TeamRef.Name,
|
||||
"permission", tb.Spec.Permission,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: tb.Namespace,
|
||||
Writes: &v1.WriteRequestWrites{
|
||||
TupleKeys: []*v1.TupleKey{tuple},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to write team binding to zanzana",
|
||||
"err", err,
|
||||
"namespace", tb.Namespace,
|
||||
"name", tb.Name,
|
||||
"subject", tb.Spec.Subject.Name,
|
||||
"teamRef", tb.Spec.TeamRef.Name,
|
||||
"permission", tb.Spec.Permission,
|
||||
)
|
||||
} else {
|
||||
// Record successful tuple write
|
||||
hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc()
|
||||
}
|
||||
}(tb.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// BeginTeamBindingUpdate is a pre-update hook that prepares zanzana updates
|
||||
// It converts old and new team bindings to tuples and performs the zanzana write after K8s update succeeds
|
||||
func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
||||
if b.zClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extract team bindings from both old and new objects
|
||||
oldTB, ok := oldObj.(*iamv0.TeamBinding)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newTB, ok := obj.(*iamv0.TeamBinding)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if oldTB.Spec.Subject.Name == newTB.Spec.Subject.Name && oldTB.Spec.TeamRef.Name == newTB.Spec.TeamRef.Name && oldTB.Spec.Permission == newTB.Spec.Permission {
|
||||
return nil, nil // No changes to the team binding
|
||||
}
|
||||
|
||||
if newTB.Spec.Subject.Name == "" || newTB.Spec.TeamRef.Name == "" {
|
||||
b.logger.Error("invalid team binding",
|
||||
"namespace", newTB.Namespace,
|
||||
"name", newTB.Name,
|
||||
"subject", newTB.Spec.Subject.Name,
|
||||
"teamRef", newTB.Spec.TeamRef.Name,
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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("teambinding", "update").Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues("teambinding", "update", status).Observe(time.Since(start).Seconds())
|
||||
hooksOperationCounter.WithLabelValues("teambinding", "update", status).Inc()
|
||||
}()
|
||||
|
||||
b.logger.Debug("updating team binding in zanzana",
|
||||
"namespace", newTB.Namespace,
|
||||
"name", newTB.Name,
|
||||
"oldSubject", oldTB.Spec.Subject.Name,
|
||||
"newSubject", newTB.Spec.Subject.Name,
|
||||
"oldTeamRef", oldTB.Spec.TeamRef.Name,
|
||||
"newTeamRef", newTB.Spec.TeamRef.Name,
|
||||
"oldPermission", oldTB.Spec.Permission,
|
||||
"newPermission", newTB.Spec.Permission,
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name)
|
||||
}
|
||||
}()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AfterTeamBindingDelete is a post-delete hook that removes the team binding from Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tb, ok := obj.(*iamv0.TeamBinding)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to TeamBinding type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "teambinding"
|
||||
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(tb *iamv0.TeamBinding) {
|
||||
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())
|
||||
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,
|
||||
"subject", tb.Spec.Subject.Name,
|
||||
"teamRef", tb.Spec.TeamRef.Name,
|
||||
"permission", tb.Spec.Permission,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: tb.Namespace,
|
||||
Deletes: &v1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuple,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to delete team binding from zanzana",
|
||||
"err", err,
|
||||
"namespace", tb.Namespace,
|
||||
"name", tb.Name,
|
||||
"subject", tb.Spec.Subject.Name,
|
||||
"teamRef", tb.Spec.TeamRef.Name,
|
||||
)
|
||||
} else {
|
||||
// Record successful tuple deletion
|
||||
hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc()
|
||||
}
|
||||
}(tb.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
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) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should create zanzana entry for team binding with member permission", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
External: false,
|
||||
},
|
||||
}
|
||||
|
||||
testMemberBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 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",
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding}
|
||||
b.AfterTeamBindingCreate(&teamBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entry for team binding with admin permission", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-2",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionAdmin,
|
||||
External: true,
|
||||
},
|
||||
}
|
||||
|
||||
testAdminBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 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",
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding}
|
||||
b.AfterTeamBindingCreate(&teamBinding, 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,
|
||||
}
|
||||
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-3",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-3",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should update zanzana entry when permission changes from member to admin", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
testPermissionUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// 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"},
|
||||
)
|
||||
|
||||
// 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"},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should update zanzana entry when user changes", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
testUserUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// 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"},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should update zanzana entry when team changes", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-2",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
testTeamUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
// 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"},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should not write to zanzana when update fails", func(t *testing.T) {
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-4",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-4",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
// Should not be called when success=false
|
||||
require.Fail(t, "Write should not be called when update fails")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call finish function with success=false
|
||||
finishFunc(context.Background(), false)
|
||||
// No wait needed since write should not be called
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-5",
|
||||
Namespace: "org-5",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-5",
|
||||
Namespace: "org-5",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
finishFunc, err := builder.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when zClient is nil
|
||||
})
|
||||
|
||||
t.Run("should handle empty old binding subject name gracefully", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-6",
|
||||
Namespace: "org-6",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "", // Empty name - conversion will be skipped
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-6",
|
||||
Namespace: "org-6",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-6", req.Namespace)
|
||||
|
||||
// Should not delete old binding (it was skipped due to empty name)
|
||||
require.Nil(t, req.Deletes)
|
||||
|
||||
// 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"},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding}
|
||||
|
||||
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc) // Should still return finish function
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-7",
|
||||
Namespace: "org-7",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-7",
|
||||
Namespace: "org-7",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange}
|
||||
|
||||
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
|
||||
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.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-8",
|
||||
Namespace: "org-8",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-8",
|
||||
Namespace: "org-8",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "", // Empty name - should cause early return
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
|
||||
|
||||
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
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write 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) {
|
||||
oldBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-9",
|
||||
Namespace: "org-9",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-9",
|
||||
Namespace: "org-9",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "", // Empty name - should cause early return
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
|
||||
|
||||
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
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterTeamBindingDelete(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)
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-1",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-1",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
External: false,
|
||||
},
|
||||
}
|
||||
|
||||
testMemberDelete := func(ctx context.Context, req *v1.WriteRequest) 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.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"},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete}
|
||||
b.AfterTeamBindingDelete(&teamBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entry for team binding with admin permission", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-2",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-2",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionAdmin,
|
||||
External: true,
|
||||
},
|
||||
}
|
||||
|
||||
testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// 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"},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete}
|
||||
b.AfterTeamBindingDelete(&teamBinding, 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,
|
||||
}
|
||||
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-3",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "team-3",
|
||||
},
|
||||
Permission: iamv0.TeamBindingTeamPermissionMember,
|
||||
},
|
||||
}
|
||||
|
||||
// Should not panic or error when zClient is nil
|
||||
builder.AfterTeamBindingDelete(&teamBinding, nil)
|
||||
})
|
||||
|
||||
t.Run("should handle conversion error gracefully", func(t *testing.T) {
|
||||
// TeamBinding with empty team ref name should fail conversion
|
||||
teamBinding := iamv0.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-4",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.TeamBindingSpec{
|
||||
Subject: iamv0.TeamBindingspecSubject{
|
||||
Name: "user-4",
|
||||
},
|
||||
TeamRef: iamv0.TeamBindingTeamRef{
|
||||
Name: "", // Empty name should cause 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")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: 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")
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// AfterUserCreate is a post-create hook that writes the user's basic role assignment to Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to User type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if user has no role assigned
|
||||
if user.Spec.Role == "" {
|
||||
b.logger.Debug("user has no role assigned, skipping basic role sync",
|
||||
"namespace", user.Namespace,
|
||||
"name", user.Name,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "user"
|
||||
operation := "create"
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(namespace, subjectName, role, resourceType, operation string) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
|
||||
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
|
||||
}()
|
||||
|
||||
b.logger.Debug("writing user basic role to zanzana",
|
||||
"namespace", namespace,
|
||||
"name", subjectName,
|
||||
"role", role,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: namespace,
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_UpdateUserOrgRole{
|
||||
UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{User: subjectName, Role: role},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to write user basic role to zanzana",
|
||||
"err", err,
|
||||
"namespace", namespace,
|
||||
"name", subjectName,
|
||||
"role", role,
|
||||
)
|
||||
} else {
|
||||
hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc()
|
||||
}
|
||||
}(user.Namespace, user.Name, user.Spec.Role, resourceType, operation)
|
||||
}
|
||||
|
||||
// BeginUserUpdate is a pre-update hook that gets called on user updates
|
||||
// It compares old and new roles and performs the zanzana write after K8s update succeeds
|
||||
func (b *IdentityAccessManagementAPIBuilder) BeginUserUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
||||
if b.zClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
oldUser, ok := oldObj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newUser, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If role hasn't changed, no need to update
|
||||
if oldUser.Spec.Role == newUser.Spec.Role {
|
||||
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("user", "update").Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(namespace, subjectName, oldRole, newRole string) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
hooksDurationHistogram.WithLabelValues("user", "update", status).Observe(time.Since(start).Seconds())
|
||||
hooksOperationCounter.WithLabelValues("user", "update", status).Inc()
|
||||
}()
|
||||
|
||||
b.logger.Debug("updating user basic role in zanzana",
|
||||
"namespace", namespace,
|
||||
"name", subjectName,
|
||||
"oldRole", oldRole,
|
||||
"newRole", newRole,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: namespace,
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_UpdateUserOrgRole{
|
||||
UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{User: subjectName, Role: newRole},
|
||||
},
|
||||
}, {
|
||||
Operation: &v1.MutateOperation_DeleteUserOrgRole{
|
||||
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{User: subjectName, Role: oldRole},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to update user basic role in zanzana",
|
||||
"err", err,
|
||||
"namespace", namespace,
|
||||
"name", subjectName,
|
||||
"role", newRole,
|
||||
"oldRole", oldRole,
|
||||
)
|
||||
}
|
||||
}(oldUser.Namespace, oldUser.Name, oldUser.Spec.Role, newUser.Spec.Role)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AfterUserDelete is a post-delete hook that removes the user's basic role assignment from Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to User type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "user"
|
||||
operation := "delete"
|
||||
|
||||
// Skip if user had no role assigned
|
||||
if user.Spec.Role == "" {
|
||||
b.logger.Debug("user had no role assigned, skipping basic role sync",
|
||||
"namespace", user.Namespace,
|
||||
"name", user.Name,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(namespace, subjectName, role string) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
|
||||
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
|
||||
}()
|
||||
|
||||
b.logger.Debug("deleting user basic role from zanzana",
|
||||
"namespace", namespace,
|
||||
"name", subjectName,
|
||||
"role", role,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: namespace,
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteUserOrgRole{
|
||||
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{User: subjectName, Role: role},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to delete user basic role from zanzana",
|
||||
"err", err,
|
||||
"namespace", namespace,
|
||||
"name", subjectName,
|
||||
"role", role,
|
||||
)
|
||||
} else {
|
||||
hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc()
|
||||
}
|
||||
}(user.Namespace, user.Name, user.Spec.Role)
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
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 TestAfterUserCreate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should create zanzana entry for user with Admin role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "df2p421det1q8c",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
testAdminRole := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
op := req.Operations[0]
|
||||
require.NotNil(t, op)
|
||||
updateOp := op.GetUpdateUserOrgRole()
|
||||
require.NotNil(t, updateOp)
|
||||
require.Equal(t, "df2p421det1q8c", updateOp.User)
|
||||
require.Equal(t, "Admin", updateOp.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testAdminRole}
|
||||
b.AfterUserCreate(&user, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entry for user with Editor role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "user123",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Editor",
|
||||
},
|
||||
}
|
||||
|
||||
testEditorRole := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
op := req.Operations[0]
|
||||
require.NotNil(t, op)
|
||||
updateOp := op.GetUpdateUserOrgRole()
|
||||
require.NotNil(t, updateOp)
|
||||
require.Equal(t, "user123", updateOp.User)
|
||||
require.Equal(t, "Editor", updateOp.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testEditorRole}
|
||||
b.AfterUserCreate(&user, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should create zanzana entry for user with Viewer role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "viewer456",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Viewer",
|
||||
},
|
||||
}
|
||||
|
||||
testViewerRole := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
op := req.Operations[0]
|
||||
require.NotNil(t, op)
|
||||
updateOp := op.GetUpdateUserOrgRole()
|
||||
require.NotNil(t, updateOp)
|
||||
require.Equal(t, "viewer456", updateOp.User)
|
||||
require.Equal(t, "Viewer", updateOp.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testViewerRole}
|
||||
b.AfterUserCreate(&user, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should skip when user has no role", func(t *testing.T) {
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "norole789",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "",
|
||||
},
|
||||
}
|
||||
|
||||
// Should not call zanzana client
|
||||
b.zClient = nil
|
||||
b.AfterUserCreate(&user, nil)
|
||||
// If we get here without panic, the test passes
|
||||
})
|
||||
|
||||
t.Run("should skip when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
// Should return early without calling zanzana
|
||||
builder.AfterUserCreate(&user, nil)
|
||||
// If we get here without panic, the test passes
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginUserUpdate(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 changes from Viewer to Admin", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Viewer",
|
||||
},
|
||||
}
|
||||
|
||||
newUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
testRoleChange := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// First operation should be UpdateUserOrgRole with new role
|
||||
updateOp := req.Operations[0].GetUpdateUserOrgRole()
|
||||
require.NotNil(t, updateOp)
|
||||
require.Equal(t, "testuser", updateOp.User)
|
||||
require.Equal(t, "Admin", updateOp.Role)
|
||||
|
||||
// Second operation should be DeleteUserOrgRole with old role
|
||||
deleteOp := req.Operations[1].GetDeleteUserOrgRole()
|
||||
require.NotNil(t, deleteOp)
|
||||
require.Equal(t, "testuser", deleteOp.User)
|
||||
require.Equal(t, "Viewer", deleteOp.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleChange}
|
||||
|
||||
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should update role when new role is empty", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Editor",
|
||||
},
|
||||
}
|
||||
|
||||
newUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "",
|
||||
},
|
||||
}
|
||||
|
||||
testRemoveRole := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// First operation should be UpdateUserOrgRole with empty role
|
||||
updateOp := req.Operations[0].GetUpdateUserOrgRole()
|
||||
require.NotNil(t, updateOp)
|
||||
require.Equal(t, "testuser2", updateOp.User)
|
||||
require.Equal(t, "", updateOp.Role)
|
||||
|
||||
// Second operation should be DeleteUserOrgRole with old role
|
||||
deleteOp := req.Operations[1].GetDeleteUserOrgRole()
|
||||
require.NotNil(t, deleteOp)
|
||||
require.Equal(t, "testuser2", deleteOp.User)
|
||||
require.Equal(t, "Editor", deleteOp.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRemoveRole}
|
||||
|
||||
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should be able to add a new role when old role was empty", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "",
|
||||
},
|
||||
}
|
||||
|
||||
newUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
testAddRole := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
require.Len(t, req.Operations, 2)
|
||||
|
||||
// First operation should be UpdateUserOrgRole with new role
|
||||
updateOp := req.Operations[0].GetUpdateUserOrgRole()
|
||||
require.NotNil(t, updateOp)
|
||||
require.Equal(t, "testuser3", updateOp.User)
|
||||
require.Equal(t, "Admin", updateOp.Role)
|
||||
|
||||
// Second operation should be DeleteUserOrgRole with empty old role
|
||||
deleteOp := req.Operations[1].GetDeleteUserOrgRole()
|
||||
require.NotNil(t, deleteOp)
|
||||
require.Equal(t, "testuser3", deleteOp.User)
|
||||
require.Equal(t, "", deleteOp.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testAddRole}
|
||||
|
||||
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should skip update when role hasn't changed", func(t *testing.T) {
|
||||
oldUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser4",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Editor",
|
||||
},
|
||||
}
|
||||
|
||||
newUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser4",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Editor",
|
||||
},
|
||||
}
|
||||
|
||||
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when no update needed
|
||||
})
|
||||
|
||||
t.Run("should not call zanzana when update fails", func(t *testing.T) {
|
||||
oldUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser5",
|
||||
Namespace: "org-5",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Viewer",
|
||||
},
|
||||
}
|
||||
|
||||
newUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser5",
|
||||
Namespace: "org-5",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
callCount := 0
|
||||
testNoCall := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoCall}
|
||||
|
||||
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
// Call with success=false - should not trigger zanzana write
|
||||
finishFunc(context.Background(), false)
|
||||
require.Equal(t, 0, callCount, "zanzana should not be called when update fails")
|
||||
})
|
||||
|
||||
t.Run("should skip when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
oldUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Viewer",
|
||||
},
|
||||
}
|
||||
|
||||
newUser := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
finishFunc, err := builder.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when zClient is nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterUserDelete(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should delete zanzana entry for user with Admin role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "df2p421det1q8c",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
testDeleteAdmin := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
op := req.Operations[0]
|
||||
require.NotNil(t, op)
|
||||
deleteOp := op.GetDeleteUserOrgRole()
|
||||
require.NotNil(t, deleteOp)
|
||||
require.Equal(t, "df2p421det1q8c", deleteOp.User)
|
||||
require.Equal(t, "Admin", deleteOp.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteAdmin}
|
||||
b.AfterUserDelete(&user, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entry for user with Editor role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "editor123",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Editor",
|
||||
},
|
||||
}
|
||||
|
||||
testDeleteEditor := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
op := req.Operations[0]
|
||||
require.NotNil(t, op)
|
||||
deleteOp := op.GetDeleteUserOrgRole()
|
||||
require.NotNil(t, deleteOp)
|
||||
require.Equal(t, "editor123", deleteOp.User)
|
||||
require.Equal(t, "Editor", deleteOp.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteEditor}
|
||||
b.AfterUserDelete(&user, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entry for user with Viewer role", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "viewer456",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Viewer",
|
||||
},
|
||||
}
|
||||
|
||||
testDeleteViewer := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
require.Len(t, req.Operations, 1)
|
||||
|
||||
op := req.Operations[0]
|
||||
require.NotNil(t, op)
|
||||
deleteOp := op.GetDeleteUserOrgRole()
|
||||
require.NotNil(t, deleteOp)
|
||||
require.Equal(t, "viewer456", deleteOp.User)
|
||||
require.Equal(t, "Viewer", deleteOp.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteViewer}
|
||||
b.AfterUserDelete(&user, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should skip when user has no role", func(t *testing.T) {
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "norole789",
|
||||
Namespace: "org-4",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "",
|
||||
},
|
||||
}
|
||||
|
||||
// Should not call zanzana client
|
||||
b.zClient = nil
|
||||
b.AfterUserDelete(&user, nil)
|
||||
// If we get here without panic, the test passes
|
||||
})
|
||||
|
||||
t.Run("should skip when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
user := iamv0.User{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Role: "Admin",
|
||||
},
|
||||
}
|
||||
|
||||
// Should return early without calling zanzana
|
||||
builder.AfterUserDelete(&user, nil)
|
||||
// If we get here without panic, the test passes
|
||||
})
|
||||
}
|
||||
@@ -248,16 +248,6 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
valid := b.validateNamespace(r)
|
||||
b.logger.Debug("validating namespace in oneFlagHandler handler", "valid", valid)
|
||||
if !valid {
|
||||
_ = tracing.Errorf(span, namespaceMismatchMsg)
|
||||
span.SetAttributes(semconv.HTTPStatusCode(http.StatusUnauthorized))
|
||||
b.logger.Error(namespaceMismatchMsg)
|
||||
http.Error(w, namespaceMismatchMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
flagKey := mux.Vars(r)["flagKey"]
|
||||
if flagKey == "" {
|
||||
_ = tracing.Errorf(span, "flagKey parameter is required")
|
||||
@@ -266,6 +256,16 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
valid, ns := b.validateNamespace(r)
|
||||
b.logger.Debug("validating namespace in oneFlagHandler handler", "namespace", ns, "valid", valid, "flag", flagKey)
|
||||
if !valid {
|
||||
_ = tracing.Errorf(span, namespaceMismatchMsg)
|
||||
span.SetAttributes(semconv.HTTPStatusCode(http.StatusUnauthorized))
|
||||
b.logger.Error(namespaceMismatchMsg)
|
||||
http.Error(w, namespaceMismatchMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("flag_key", flagKey))
|
||||
|
||||
isAuthedReq := b.isAuthenticatedRequest(r)
|
||||
@@ -294,8 +294,8 @@ func (b *APIBuilder) allFlagsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
valid := b.validateNamespace(r)
|
||||
b.logger.Debug("validating namespace in allFlagsHandler handler", "valid", valid)
|
||||
valid, ns := b.validateNamespace(r)
|
||||
b.logger.Debug("validating namespace in allFlagsHandler handler", "namespace", ns, "valid", valid)
|
||||
|
||||
if !valid {
|
||||
_ = tracing.Errorf(span, namespaceMismatchMsg)
|
||||
@@ -359,14 +359,14 @@ func (b *APIBuilder) isAuthenticatedRequest(r *http.Request) bool {
|
||||
}
|
||||
|
||||
// validateNamespace checks if the namespace in the evaluation context matches the namespace in the request
|
||||
func (b *APIBuilder) validateNamespace(r *http.Request) bool {
|
||||
func (b *APIBuilder) validateNamespace(r *http.Request) (bool, string) {
|
||||
_, span := tracing.Start(r.Context(), "ofrep.validateNamespace")
|
||||
defer span.End()
|
||||
|
||||
var namespace string
|
||||
user, ok := types.AuthInfoFrom(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if user.GetNamespace() != "" {
|
||||
@@ -381,7 +381,7 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
|
||||
_ = tracing.Errorf(span, "failed to read request body: %w", err)
|
||||
b.logger.Error("Error reading evaluation request body", "error", err)
|
||||
span.SetAttributes(attribute.Bool("validation.success", false))
|
||||
return false
|
||||
return false, ""
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
@@ -391,9 +391,9 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
|
||||
// "default" namespace case can only occur in on-prem grafana
|
||||
if (namespace == "default" && evalCtxNamespace == "") || (evalCtxNamespace == namespace) {
|
||||
span.SetAttributes(attribute.Bool("validation.success", true))
|
||||
return true
|
||||
return true, evalCtxNamespace
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.Bool("validation.success", false))
|
||||
return false
|
||||
return false, evalCtxNamespace
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
)
|
||||
|
||||
const (
|
||||
// Repositories
|
||||
ActionProvisioningRepositoriesCreate = "provisioning.repositories:create" // CREATE.
|
||||
ActionProvisioningRepositoriesWrite = "provisioning.repositories:write" // UPDATE.
|
||||
ActionProvisioningRepositoriesRead = "provisioning.repositories:read" // GET + LIST.
|
||||
ActionProvisioningRepositoriesDelete = "provisioning.repositories:delete" // DELETE.
|
||||
|
||||
// Jobs
|
||||
ActionProvisioningJobsCreate = "provisioning.jobs:create" // CREATE.
|
||||
ActionProvisioningJobsWrite = "provisioning.jobs:write" // UPDATE.
|
||||
ActionProvisioningJobsRead = "provisioning.jobs:read" // GET + LIST.
|
||||
ActionProvisioningJobsDelete = "provisioning.jobs:delete" // DELETE.
|
||||
|
||||
// Historic Jobs
|
||||
ActionProvisioningHistoricJobsRead = "provisioning.historicjobs:read" // GET + LIST.
|
||||
)
|
||||
|
||||
func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
// Repositories
|
||||
repositoriesReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.repositories:reader",
|
||||
DisplayName: "Repositories Reader",
|
||||
Description: "Read and list provisioning repositories.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningRepositoriesRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
repositoriesWriter := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.repositories:writer",
|
||||
DisplayName: "Repositories Writer",
|
||||
Description: "Create, update and delete provisioning repositories.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningRepositoriesCreate,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningRepositoriesRead,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningRepositoriesWrite,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningRepositoriesDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Jobs
|
||||
jobsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.jobs:reader",
|
||||
DisplayName: "Jobs Reader",
|
||||
Description: "Read and list provisioning jobs.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningJobsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
jobsWriter := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.jobs:writer",
|
||||
DisplayName: "Jobs Writer",
|
||||
Description: "Create, update and delete provisioning jobs.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningJobsCreate,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningJobsRead,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningJobsWrite,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningJobsDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Historic Jobs
|
||||
historicJobsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.historicjobs:reader",
|
||||
DisplayName: "Historic Jobs Reader",
|
||||
Description: "Read and list provisioning historic jobs.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningHistoricJobsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
return service.DeclareFixedRoles(
|
||||
repositoriesReader,
|
||||
repositoriesWriter,
|
||||
jobsReader,
|
||||
jobsWriter,
|
||||
historicJobsReader,
|
||||
)
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/dskit/concurrency"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
@@ -27,6 +30,7 @@ type finalizer struct {
|
||||
lister resources.ResourceLister
|
||||
clientFactory resources.ClientFactory
|
||||
metrics *finalizerMetrics
|
||||
maxWorkers int
|
||||
}
|
||||
|
||||
func (f *finalizer) process(ctx context.Context,
|
||||
@@ -113,27 +117,73 @@ func (f *finalizer) processExistingItems(
|
||||
|
||||
// Safe deletion order
|
||||
sortResourceListForDeletion(items)
|
||||
count := 0
|
||||
|
||||
var dashboards, folderItems []*provisioning.ResourceListItem
|
||||
for _, item := range items.Items {
|
||||
res, _, err := clients.ForResource(ctx, schema.GroupVersionResource{
|
||||
if item.Group == folders.GroupVersion.Group {
|
||||
folderItems = append(folderItems, &item)
|
||||
} else {
|
||||
dashboards = append(dashboards, &item)
|
||||
}
|
||||
}
|
||||
|
||||
processItem := func(jobCtx context.Context, item *provisioning.ResourceListItem) error {
|
||||
res, _, err := clients.ForResource(jobCtx, schema.GroupVersionResource{
|
||||
Group: item.Group,
|
||||
Resource: item.Resource,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("error getting client for resource", "resource", item.Resource, "error", err)
|
||||
return count, err
|
||||
return err
|
||||
}
|
||||
|
||||
err = cb(res, &item)
|
||||
err = cb(res, item)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
logger.Info("resource not found, skipping", "name", item.Name, "group", item.Group, "resource", item.Resource)
|
||||
return nil
|
||||
}
|
||||
logger.Error("error processing item", "name", item.Name, "error", err)
|
||||
return count, fmt.Errorf("processing item: %w", err)
|
||||
} else {
|
||||
return fmt.Errorf("processing item: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
processGroup := func(group []*provisioning.ResourceListItem) (int, error) {
|
||||
var processed int64
|
||||
err := concurrency.ForEachJob(ctx, len(group), f.maxWorkers, func(ctx context.Context, idx int) error {
|
||||
jobCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
item := group[idx]
|
||||
if err := processItem(jobCtx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
atomic.AddInt64(&processed, 1)
|
||||
return nil
|
||||
})
|
||||
return int(processed), err
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
if len(dashboards) > 0 {
|
||||
processed, err := processGroup(dashboards)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
count += processed
|
||||
}
|
||||
|
||||
if len(folderItems) > 0 {
|
||||
for _, item := range folderItems {
|
||||
if err := processItem(ctx, item); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
logger.Info("processed orphan items", "items", count)
|
||||
|
||||
logger.Info("processed items", "items", count)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@@ -14,6 +19,8 @@ import (
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
@@ -140,7 +147,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
|
||||
resourceLister.
|
||||
On("List", context.Background(), "default", "my-repo").
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Once().
|
||||
Return(&provisioning.ResourceList{
|
||||
Items: []provisioning.ResourceListItem{
|
||||
@@ -164,12 +171,12 @@ func TestFinalizer_process(t *testing.T) {
|
||||
}
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(clients, nil)
|
||||
|
||||
clients.
|
||||
On("ForResource", context.Background(), schema.GroupVersionResource{
|
||||
On("ForResource", mock.Anything, schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}).
|
||||
@@ -196,7 +203,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
|
||||
resourceLister.
|
||||
On("List", context.Background(), "default", "my-repo").
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Once().
|
||||
Return(&provisioning.ResourceList{
|
||||
Items: []provisioning.ResourceListItem{
|
||||
@@ -220,12 +227,12 @@ func TestFinalizer_process(t *testing.T) {
|
||||
}
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(clients, nil)
|
||||
|
||||
clients.
|
||||
On("ForResource", context.Background(), schema.GroupVersionResource{
|
||||
On("ForResource", mock.Anything, schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}).
|
||||
@@ -253,7 +260,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
clientFactory := resources.NewMockClientFactory(t)
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(nil, assert.AnError)
|
||||
|
||||
@@ -275,7 +282,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
|
||||
resourceLister.
|
||||
On("List", context.Background(), "default", "my-repo").
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Once().
|
||||
Return(nil, assert.AnError)
|
||||
|
||||
@@ -286,7 +293,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
clients := resources.NewMockResourceClients(t)
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(clients, nil)
|
||||
|
||||
@@ -308,7 +315,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
|
||||
resourceLister.
|
||||
On("List", context.Background(), "default", "my-repo").
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Once().
|
||||
Return(&provisioning.ResourceList{
|
||||
Items: []provisioning.ResourceListItem{
|
||||
@@ -327,12 +334,12 @@ func TestFinalizer_process(t *testing.T) {
|
||||
clients := resources.NewMockResourceClients(t)
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(clients, nil)
|
||||
|
||||
clients.
|
||||
On("ForResource", context.Background(), schema.GroupVersionResource{
|
||||
On("ForResource", mock.Anything, schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}).
|
||||
@@ -357,7 +364,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
|
||||
resourceLister.
|
||||
On("List", context.Background(), "default", "my-repo").
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Once().
|
||||
Return(&provisioning.ResourceList{
|
||||
Items: []provisioning.ResourceListItem{
|
||||
@@ -381,12 +388,12 @@ func TestFinalizer_process(t *testing.T) {
|
||||
}
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(clients, nil)
|
||||
|
||||
clients.
|
||||
On("ForResource", context.Background(), schema.GroupVersionResource{
|
||||
On("ForResource", mock.Anything, schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}).
|
||||
@@ -414,7 +421,7 @@ func TestFinalizer_process(t *testing.T) {
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
|
||||
resourceLister.
|
||||
On("List", context.Background(), "default", "my-repo").
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Once().
|
||||
Return(&provisioning.ResourceList{
|
||||
Items: []provisioning.ResourceListItem{
|
||||
@@ -438,12 +445,12 @@ func TestFinalizer_process(t *testing.T) {
|
||||
}
|
||||
|
||||
clientFactory.
|
||||
On("Clients", context.Background(), "default").
|
||||
On("Clients", mock.Anything, "default").
|
||||
Once().
|
||||
Return(clients, nil)
|
||||
|
||||
clients.
|
||||
On("ForResource", context.Background(), schema.GroupVersionResource{
|
||||
On("ForResource", mock.Anything, schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}).
|
||||
@@ -560,3 +567,142 @@ func TestSortResourceListForDeletion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizer_processExistingItems_Concurrency(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
dashboardCount int
|
||||
folderCount int
|
||||
maxWorkers int
|
||||
expectedConcurrency bool
|
||||
}{
|
||||
{
|
||||
name: "Multiple dashboards processed concurrently",
|
||||
dashboardCount: 10,
|
||||
folderCount: 0,
|
||||
maxWorkers: 5,
|
||||
expectedConcurrency: true,
|
||||
},
|
||||
{
|
||||
name: "Single worker processes dashboards sequentially",
|
||||
dashboardCount: 5,
|
||||
folderCount: 0,
|
||||
maxWorkers: 1,
|
||||
expectedConcurrency: false,
|
||||
},
|
||||
{
|
||||
name: "Folders processed sequentially regardless of maxWorkers",
|
||||
dashboardCount: 0,
|
||||
folderCount: 5,
|
||||
maxWorkers: 10,
|
||||
expectedConcurrency: false,
|
||||
},
|
||||
{
|
||||
name: "Mixed dashboards and folders - dashboards concurrent, folders sequential",
|
||||
dashboardCount: 10,
|
||||
folderCount: 3,
|
||||
maxWorkers: 5,
|
||||
expectedConcurrency: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Will be used to track concurrent executions
|
||||
var (
|
||||
concurrentCount int64
|
||||
maxConcurrent int64
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
items := provisioning.ResourceList{Items: []provisioning.ResourceListItem{}}
|
||||
|
||||
for i := 0; i < tc.dashboardCount; i++ {
|
||||
items.Items = append(items.Items, provisioning.ResourceListItem{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: fmt.Sprintf("dashboard-%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < tc.folderCount; i++ {
|
||||
items.Items = append(items.Items, provisioning.ResourceListItem{
|
||||
Group: folders.GroupVersion.Group,
|
||||
Resource: "folders",
|
||||
Name: fmt.Sprintf("folder-%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
resourceLister := resources.NewMockResourceLister(t)
|
||||
resourceLister.
|
||||
On("List", mock.Anything, "default", "my-repo").
|
||||
Return(&items, nil)
|
||||
|
||||
clientFactory := resources.NewMockClientFactory(t)
|
||||
clients := resources.NewMockResourceClients(t)
|
||||
|
||||
client := &mockDynamicClient{
|
||||
deleteFunc: func(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error {
|
||||
// Track concurrent executions
|
||||
current := atomic.AddInt64(&concurrentCount, 1)
|
||||
defer atomic.AddInt64(&concurrentCount, -1)
|
||||
|
||||
mu.Lock()
|
||||
if current > maxConcurrent {
|
||||
maxConcurrent = current
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Simulate slow client to allow concurrency to build up
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
clientFactory.
|
||||
On("Clients", mock.Anything, "default").
|
||||
Return(clients, nil)
|
||||
|
||||
clients.
|
||||
On("ForResource", mock.Anything, mock.Anything).
|
||||
Return(client, schema.GroupVersionKind{}, nil)
|
||||
|
||||
metrics := registerFinalizerMetrics(prometheus.NewRegistry())
|
||||
f := &finalizer{
|
||||
lister: resourceLister,
|
||||
clientFactory: clientFactory,
|
||||
metrics: &metrics,
|
||||
maxWorkers: tc.maxWorkers,
|
||||
}
|
||||
|
||||
repo := &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
}
|
||||
|
||||
count, err := f.processExistingItems(
|
||||
context.Background(),
|
||||
repo,
|
||||
f.removeResources(context.Background(), logging.DefaultLogger),
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.dashboardCount+tc.folderCount, count)
|
||||
|
||||
if tc.expectedConcurrency {
|
||||
// When concurrent, max concurrent should be > 1
|
||||
assert.Greater(t, maxConcurrent, int64(1),
|
||||
"Expected concurrent execution but maxConcurrent was %d", maxConcurrent)
|
||||
// Should not exceed maxWorkers
|
||||
assert.LessOrEqual(t, maxConcurrent, int64(tc.maxWorkers))
|
||||
} else {
|
||||
// When sequential, max concurrent should be 1
|
||||
assert.Equal(t, int64(1), maxConcurrent,
|
||||
"Expected sequential execution but maxConcurrent was %d", maxConcurrent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
@@ -84,6 +85,7 @@ func NewRepositoryController(
|
||||
statusPatcher StatusPatcher,
|
||||
registry prometheus.Registerer,
|
||||
tracer tracing.Tracer,
|
||||
parallelOperations int,
|
||||
) (*RepositoryController, error) {
|
||||
finalizerMetrics := registerFinalizerMetrics(registry)
|
||||
|
||||
@@ -104,6 +106,7 @@ func NewRepositoryController(
|
||||
lister: resourceLister,
|
||||
clientFactory: clients,
|
||||
metrics: &finalizerMetrics,
|
||||
maxWorkers: parallelOperations,
|
||||
},
|
||||
jobs: jobs,
|
||||
logger: logging.DefaultLogger.With("logger", loggerName),
|
||||
@@ -138,6 +141,9 @@ func repoKeyFunc(obj any) (string, error) {
|
||||
}
|
||||
|
||||
// Run starts the RepositoryController.
|
||||
//
|
||||
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
|
||||
// until shutdown. Individual processing operations already have their own spans.
|
||||
func (rc *RepositoryController) Run(ctx context.Context, workerCount int) {
|
||||
defer utilruntime.HandleCrash()
|
||||
defer rc.queue.ShutDown()
|
||||
@@ -384,50 +390,75 @@ func shouldUseIncrementalSync(ctx context.Context, versioned repository.Versione
|
||||
}
|
||||
|
||||
func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) error {
|
||||
ctx, span := rc.tracer.Start(ctx, "provisioning.controller.add_sync_job")
|
||||
defer span.End()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("repository", obj.GetName()),
|
||||
attribute.String("namespace", obj.Namespace),
|
||||
attribute.Bool("incremental", syncOptions != nil && syncOptions.Incremental),
|
||||
)
|
||||
|
||||
job, err := rc.jobs.Insert(ctx, obj.Namespace, provisioning.JobSpec{
|
||||
Repository: obj.GetName(),
|
||||
Action: provisioning.JobActionPull,
|
||||
Pull: syncOptions,
|
||||
})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
logging.FromContext(ctx).Info("sync job already exists, nothing triggered")
|
||||
logging.FromContext(ctx).Info("sync job already exists")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
// FIXME: should we update the status of the repository if we fail to add the job?
|
||||
return fmt.Errorf("error adding sync job: %w", err)
|
||||
}
|
||||
|
||||
logging.FromContext(ctx).Info("sync job triggered", "job", job.Name)
|
||||
span.SetAttributes(attribute.String("job.name", job.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) *provisioning.SyncStatus {
|
||||
func (rc *RepositoryController) determineSyncStatusOps(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) []map[string]interface{} {
|
||||
const unhealthyMessage = "Repository is unhealthy"
|
||||
|
||||
hasUnhealthyMessage := len(obj.Status.Sync.Message) > 0 && obj.Status.Sync.Message[0] == unhealthyMessage
|
||||
var patchOperations []map[string]interface{}
|
||||
|
||||
switch {
|
||||
case syncOptions != nil:
|
||||
return &provisioning.SyncStatus{
|
||||
State: provisioning.JobStatePending,
|
||||
LastRef: obj.Status.Sync.LastRef,
|
||||
Started: time.Now().UnixMilli(),
|
||||
}
|
||||
// We will try to trigger a new sync job if we have sync options
|
||||
patchOperations = append(patchOperations, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync/state",
|
||||
"value": provisioning.JobStatePending,
|
||||
})
|
||||
patchOperations = append(patchOperations, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync/started",
|
||||
"value": int64(0),
|
||||
})
|
||||
case healthStatus.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it
|
||||
// FIXME: is this the clearest way to do this? Should we introduce another status or way of way of handling more
|
||||
// specific errors?
|
||||
return &provisioning.SyncStatus{
|
||||
LastRef: obj.Status.Sync.LastRef,
|
||||
}
|
||||
patchOperations = append(patchOperations, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync/message",
|
||||
"value": []string{},
|
||||
})
|
||||
case !healthStatus.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it
|
||||
return &provisioning.SyncStatus{
|
||||
State: provisioning.JobStateError,
|
||||
Message: []string{unhealthyMessage},
|
||||
LastRef: obj.Status.Sync.LastRef,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
patchOperations = append(patchOperations, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync/state",
|
||||
"value": provisioning.JobStateError,
|
||||
})
|
||||
patchOperations = append(patchOperations, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync/message",
|
||||
"value": []string{unhealthyMessage},
|
||||
})
|
||||
}
|
||||
|
||||
return patchOperations
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
@@ -507,13 +538,7 @@ func (rc *RepositoryController) process(item *queueItem) error {
|
||||
|
||||
// determine the sync strategy and sync status to apply
|
||||
syncOptions := rc.determineSyncStrategy(ctx, obj, repo, shouldResync, healthStatus)
|
||||
if syncStatus := rc.determineSyncStatus(obj, syncOptions, healthStatus); syncStatus != nil {
|
||||
patchOperations = append(patchOperations, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync",
|
||||
"value": syncStatus,
|
||||
})
|
||||
}
|
||||
patchOperations = append(patchOperations, rc.determineSyncStatusOps(obj, syncOptions, healthStatus)...)
|
||||
|
||||
// Apply all patch operations
|
||||
if len(patchOperations) > 0 {
|
||||
@@ -523,6 +548,8 @@ func (rc *RepositoryController) process(item *queueItem) error {
|
||||
}
|
||||
}
|
||||
|
||||
// QUESTION: should we trigger the sync job after we have applied all patch operations or before?
|
||||
// Is there are risk of race condition here?
|
||||
// Trigger sync job after we have applied all patch operations
|
||||
if syncOptions != nil {
|
||||
if err := rc.addSyncJob(ctx, obj, syncOptions); err != nil {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// DependencyRegisterer is set to satisfy wire gen and make sure the `RegisterDependencies` is called.
|
||||
type DependencyRegisterer struct{}
|
||||
|
||||
func RegisterDependencies(
|
||||
cfg *setting.Cfg,
|
||||
accessControlService accesscontrol.Service,
|
||||
features featuremgmt.FeatureToggles,
|
||||
) (*DependencyRegisterer, error) {
|
||||
if err := registerAccessControlRoles(accessControlService); err != nil {
|
||||
return nil, fmt.Errorf("registering access control roles: %w", err)
|
||||
}
|
||||
|
||||
return &DependencyRegisterer{}, nil
|
||||
}
|
||||
@@ -132,28 +132,36 @@ func (c *jobsConnector) Connect(
|
||||
}
|
||||
spec.Repository = name
|
||||
|
||||
// If a sync job is being created, we should update its status to pending.
|
||||
job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// For pull jobs update the sync status
|
||||
// patch the sync status 'state' to 'pending', and reset the 'started' field, leaving other fields unchanged.
|
||||
// Intentionally maintain the previous job name until the jobs is picked up.
|
||||
if spec.Pull != nil {
|
||||
err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync",
|
||||
"value": &provisioning.SyncStatus{
|
||||
State: provisioning.JobStatePending,
|
||||
LastRef: cfg.Status.Sync.LastRef,
|
||||
Started: time.Now().UnixMilli(),
|
||||
err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg,
|
||||
map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/sync/state",
|
||||
"value": provisioning.JobStatePending,
|
||||
},
|
||||
})
|
||||
map[string]interface{}{
|
||||
// Use "replace" instead of "remove" since "remove" fails if the path does not exist (RFC 6902).
|
||||
// "started" field uses "omitempty", so it may be missing in the JSON.
|
||||
"op": "replace",
|
||||
"path": "/status/sync/started",
|
||||
"value": int64(0),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
responder.Object(http.StatusAccepted, job)
|
||||
}), 30*time.Second), nil
|
||||
}
|
||||
|
||||
@@ -75,9 +75,12 @@ func NewConcurrentJobDriver(
|
||||
|
||||
// Run starts multiple job drivers concurrently and handles cleanup coordination.
|
||||
// This is a blocking function that will run until the context is canceled or an error occurs.
|
||||
//
|
||||
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
|
||||
// until shutdown. Individual job processing and cleanup operations already have their own spans.
|
||||
func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
|
||||
logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers)
|
||||
logger.Info("starting concurrent job driver with lease-based cleanup", "cleanup_interval", c.cleanupInterval)
|
||||
logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval)
|
||||
|
||||
// Set up cleanup ticker - runs more frequently with lease-based approach
|
||||
cleanupTicker := time.NewTicker(c.cleanupInterval)
|
||||
@@ -85,7 +88,7 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
|
||||
|
||||
// Initial cleanup
|
||||
if err := c.store.Cleanup(ctx); err != nil {
|
||||
logger.Error("failed to clean up old jobs at start", "error", err)
|
||||
logger.Error("failed initial cleanup", "error", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -99,10 +102,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
|
||||
select {
|
||||
case <-cleanupTicker.C:
|
||||
if err := c.store.Cleanup(ctx); err != nil {
|
||||
logger.Error("failed to cleanup jobs", "error", err)
|
||||
logger.Error("failed cleanup", "error", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
logger.Debug("cleanup goroutine stopping")
|
||||
logger.Debug("cleanup routine stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -133,13 +136,13 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
|
||||
return
|
||||
}
|
||||
|
||||
driverLogger.Debug("starting job driver")
|
||||
driverLogger.Info("start job driver")
|
||||
if err := driver.Run(driverCtx); err != nil {
|
||||
driverLogger.Error("job driver failed", "error", err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
driverLogger.Debug("job driver stopped")
|
||||
driverLogger.Info("job driver stopped")
|
||||
}(i)
|
||||
}
|
||||
|
||||
@@ -157,6 +160,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("all job driver workers stopped")
|
||||
return ctx.Err()
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("all job drivers gracefully stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("concurrent job driver stopped unexpectedly")
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/apifmt"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
)
|
||||
|
||||
// Store is an abstraction for the storage API.
|
||||
@@ -74,6 +77,11 @@ type jobDriver struct {
|
||||
|
||||
// notifications channel for job create events
|
||||
notifications chan struct{}
|
||||
|
||||
// Mutex to protect concurrent access to job processing
|
||||
mu sync.Mutex
|
||||
// currentJob is the job currently being processed
|
||||
currentJob *provisioning.Job
|
||||
}
|
||||
|
||||
func NewJobDriver(
|
||||
@@ -99,6 +107,9 @@ func NewJobDriver(
|
||||
// Run drives jobs to completion. This is a blocking function.
|
||||
// It will run until the context is canceled or an error occurs.
|
||||
// This is a thread-safe function; it may be called from multiple goroutines.
|
||||
//
|
||||
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
|
||||
// until shutdown. Individual job processing operations already have their own spans.
|
||||
func (d *jobDriver) Run(ctx context.Context) error {
|
||||
jobTicker := time.NewTicker(d.jobInterval)
|
||||
defer jobTicker.Stop()
|
||||
@@ -116,7 +127,8 @@ func (d *jobDriver) Run(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
logger.Info("job driver stopped")
|
||||
return nil // Context cancellation is expected during shutdown
|
||||
case <-jobTicker.C:
|
||||
d.processJobsUntilDoneOrError(ctx)
|
||||
case <-d.notifications:
|
||||
@@ -128,6 +140,11 @@ func (d *jobDriver) Run(ctx context.Context) error {
|
||||
// This will keep processing jobs until there are none left (or we hit an error)
|
||||
func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) {
|
||||
for {
|
||||
// Check if context is cancelled before attempting to claim jobs
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := d.claimAndProcessOneJob(ctx)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoJobs) {
|
||||
@@ -139,25 +156,39 @@ func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.claim_and_process_one_job")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx)
|
||||
|
||||
// Claim a job to work on.
|
||||
job, rollback, err := d.store.Claim(ctx)
|
||||
claimedJob, rollback, err := d.store.Claim(ctx)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoJobs) {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return apifmt.Errorf("failed to claim job: %w", err)
|
||||
}
|
||||
// Ensure that the job is cleaned up if we fail to complete it.
|
||||
// The rollback function does not care about cancellations.
|
||||
defer rollback()
|
||||
|
||||
logger = logger.With("job", job.GetName(), "namespace", job.GetNamespace())
|
||||
namespace := claimedJob.GetNamespace()
|
||||
logger = logger.With("job", claimedJob.GetName(), "namespace", namespace)
|
||||
ctx = logging.Context(ctx, logger)
|
||||
logger.Debug("claimed a job")
|
||||
d.currentJob = claimedJob
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.name", claimedJob.GetName()),
|
||||
attribute.String("job.namespace", namespace),
|
||||
attribute.String("job.repository", claimedJob.Spec.Repository),
|
||||
attribute.String("job.action", string(claimedJob.Spec.Action)),
|
||||
)
|
||||
|
||||
// Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it.
|
||||
// Incidentally, this also limits our permissions to only the namespace of the job.
|
||||
ctx = request.WithNamespace(ctx, job.GetNamespace())
|
||||
ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
|
||||
ctx = request.WithNamespace(ctx, namespace)
|
||||
ctx, _, err = identity.WithProvisioningIdentity(ctx, namespace)
|
||||
if err != nil {
|
||||
return apifmt.Errorf("failed to grant provisioning identity: %w", err)
|
||||
}
|
||||
@@ -169,50 +200,72 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
|
||||
leaseRenewalCtx, cancelLeaseRenewal := context.WithCancel(jobctx)
|
||||
leaseExpired := make(chan struct{})
|
||||
|
||||
go d.leaseRenewalLoop(leaseRenewalCtx, job, logger, leaseExpired)
|
||||
go d.leaseRenewalLoop(leaseRenewalCtx, logger, leaseExpired)
|
||||
defer cancelLeaseRenewal()
|
||||
|
||||
recorder := newJobProgressRecorder(d.onProgress(job))
|
||||
recorder := newJobProgressRecorder(d.onProgress())
|
||||
recorder.SetMessage(ctx, "start job")
|
||||
|
||||
// Process the job with lease loss detection
|
||||
start := time.Now()
|
||||
job.Status.Started = start.UnixMilli()
|
||||
err = d.processJobWithLeaseCheck(jobctx, job, recorder, leaseExpired)
|
||||
err = d.processJobWithLeaseCheck(jobctx, recorder, leaseExpired)
|
||||
end := time.Now()
|
||||
logger.Debug("job processed", "duration", end.Sub(start), "error", err)
|
||||
logger.Debug("job processed", "duration", end.Sub(recorder.Started()), "error", err)
|
||||
|
||||
// Capture job timeout
|
||||
if jobctx.Err() != nil && err == nil {
|
||||
// Check if parent context was cancelled (graceful shutdown)
|
||||
if ctx.Err() != nil {
|
||||
logger.Debug("context cancel - job will retry")
|
||||
// Don't complete the job - let it be retried by another worker
|
||||
d.mu.Lock()
|
||||
d.currentJob = nil
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capture job timeout (but not parent context cancellation)
|
||||
if jobctx.Err() != nil && err == nil && ctx.Err() == nil {
|
||||
err = jobctx.Err()
|
||||
}
|
||||
|
||||
job.Status = recorder.Complete(ctx, err)
|
||||
// Record job processing error on span
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
|
||||
// Complete the job
|
||||
d.mu.Lock()
|
||||
d.currentJob.Status = recorder.Complete(ctx, err)
|
||||
defer func() {
|
||||
d.currentJob = nil
|
||||
d.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Save the finished job
|
||||
err = d.historicJobs.WriteJob(ctx, job.DeepCopy())
|
||||
err = d.historicJobs.WriteJob(ctx, d.currentJob.DeepCopy())
|
||||
if err != nil {
|
||||
// We're not going to return this as it is not critical. Not ideal, but not critical.
|
||||
logger.Warn("failed to create historic job", "historic_job", *job, "error", err)
|
||||
} else {
|
||||
logger.Debug("created historic job", "historic_job", *job)
|
||||
logger.Warn("failed to write historic job", "error", err)
|
||||
}
|
||||
|
||||
// Mark the job as completed.
|
||||
if err := d.store.Complete(ctx, job); err != nil {
|
||||
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
if err := d.store.Complete(ctx, d.currentJob); err != nil {
|
||||
span.RecordError(err)
|
||||
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", d.currentJob.GetName(), d.currentJob.GetNamespace(), err)
|
||||
}
|
||||
logger.Debug("job completed")
|
||||
logger.Info("job complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// leaseRenewalLoop continuously renews the lease for a job until the context is cancelled.
|
||||
// If lease renewal fails persistently, it signals via the leaseExpired channel.
|
||||
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job, logger logging.Logger, leaseExpired chan struct{}) {
|
||||
//
|
||||
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
|
||||
// for the lifetime of a job. Individual RenewLease calls already have their own spans.
|
||||
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, leaseExpired chan struct{}) {
|
||||
ticker := time.NewTicker(d.leaseRenewalInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
logger.Debug("starting lease renewal loop", "renewal_interval", d.leaseRenewalInterval)
|
||||
logger.Debug("start lease renewal loop", "renewal_interval", d.leaseRenewalInterval)
|
||||
|
||||
consecutiveFailures := 0
|
||||
maxFailures := 3 // Allow a few failures before giving up
|
||||
@@ -220,10 +273,18 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job,
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Debug("lease renewal loop stopping")
|
||||
logger.Debug("lease renewal loop stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
err := d.store.RenewLease(ctx, job)
|
||||
d.mu.Lock()
|
||||
if d.currentJob == nil {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
err := d.store.RenewLease(ctx, d.currentJob)
|
||||
d.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
consecutiveFailures++
|
||||
if apierrors.IsNotFound(err) ||
|
||||
@@ -246,18 +307,17 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job,
|
||||
logger.Debug("lease renewal recovered", "previous_failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
logger.Debug("lease renewed successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processJobWithLeaseCheck processes a job but aborts if the lease expires.
|
||||
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
|
||||
// processJobWithLeaseCheck processes a job but aborts if the lease expires or context is cancelled.
|
||||
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
|
||||
// Run the job processing in a goroutine so we can monitor lease expiry
|
||||
resultChan := make(chan error, 1)
|
||||
go func() {
|
||||
resultChan <- d.processJob(ctx, job, recorder)
|
||||
resultChan <- d.processJob(ctx, recorder)
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -266,25 +326,48 @@ func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provision
|
||||
case <-leaseExpired:
|
||||
return apifmt.Errorf("job aborted due to lease expiry")
|
||||
case <-ctx.Done():
|
||||
// Return context error directly - caller will determine if this is due to graceful shutdown
|
||||
// or job timeout based on which context was cancelled
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder) error {
|
||||
func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder) error {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.process_job")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx)
|
||||
d.mu.Lock()
|
||||
if d.currentJob == nil {
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Here it's safe to copy as only job spec is used for processing
|
||||
job := d.currentJob.DeepCopy()
|
||||
repoName := d.currentJob.Spec.Repository
|
||||
namespace := d.currentJob.Namespace
|
||||
d.mu.Unlock()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.repository", repoName),
|
||||
attribute.String("job.action", string(job.Spec.Action)),
|
||||
)
|
||||
|
||||
for _, worker := range d.workers {
|
||||
if !worker.IsSupported(ctx, *job) {
|
||||
continue
|
||||
}
|
||||
|
||||
repo, err := d.repoGetter.GetRepository(ctx, job.Namespace, job.Spec.Repository)
|
||||
repo, err := d.repoGetter.GetRepository(ctx, namespace, repoName)
|
||||
if err != nil {
|
||||
return apifmt.Errorf("failed to get repository '%s': %w", job.Spec.Repository, err)
|
||||
span.RecordError(err)
|
||||
return apifmt.Errorf("failed to get repository '%s': %w", repoName, err)
|
||||
}
|
||||
|
||||
r := repo.Config()
|
||||
if r.DeletionTimestamp != nil && !r.DeletionTimestamp.IsZero() {
|
||||
logger.Info("repository is marked for deletion, skipping processing job",
|
||||
logger.Info("repository marked for deletion - skip job",
|
||||
"name", r.Name,
|
||||
"namespace", r.Namespace,
|
||||
"deletionTimestamp", r.DeletionTimestamp,
|
||||
@@ -292,51 +375,76 @@ func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recor
|
||||
return nil
|
||||
}
|
||||
|
||||
return worker.Process(ctx, repo, *job, recorder)
|
||||
err = worker.Process(ctx, repo, *job, recorder)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return apifmt.Errorf("no workers were registered to handle the job")
|
||||
err := apifmt.Errorf("no workers were registered to handle the job")
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *jobDriver) onProgress(job *provisioning.Job) ProgressFn {
|
||||
func (d *jobDriver) onProgress() ProgressFn {
|
||||
return func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.update_progress")
|
||||
defer span.End()
|
||||
|
||||
logging.FromContext(ctx).Debug("job progress", "status", status)
|
||||
|
||||
const maxRetries = 3
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Use the current job for the first attempt, fetch fresh for retries
|
||||
currentJob := job
|
||||
d.mu.Lock()
|
||||
if d.currentJob == nil {
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the current job for the first attempt; on retry attempts, fetch fresh data from the store to resolve conflicts
|
||||
if attempt > 0 {
|
||||
// Fetch the latest version to resolve conflicts
|
||||
latest, err := d.store.Get(ctx, job.GetNamespace(), job.GetName())
|
||||
latest, err := d.store.Get(ctx, d.currentJob.GetNamespace(), d.currentJob.GetName())
|
||||
if err != nil {
|
||||
d.mu.Unlock()
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Job was completed/deleted, nothing to update
|
||||
return nil
|
||||
}
|
||||
return apifmt.Errorf("failed to fetch job for progress update: %w", err)
|
||||
}
|
||||
currentJob = latest
|
||||
|
||||
*d.currentJob = *latest
|
||||
}
|
||||
|
||||
job := d.currentJob
|
||||
// Update status on the current job
|
||||
currentJob.Status = status
|
||||
|
||||
updated, err := d.store.Update(ctx, currentJob)
|
||||
job.Status = status
|
||||
updated, err := d.store.Update(ctx, job)
|
||||
if err != nil {
|
||||
if apierrors.IsConflict(err) && attempt < maxRetries-1 {
|
||||
// Conflict detected, retry with fresh data
|
||||
logging.FromContext(ctx).Debug("progress update conflict, retrying", "attempt", attempt+1)
|
||||
continue
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return apifmt.Errorf("failed to update job progress: %w", err)
|
||||
}
|
||||
|
||||
// Update succeeded, update our local copy
|
||||
*job = *updated
|
||||
*d.currentJob = *updated
|
||||
d.mu.Unlock()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.state", string(status.State)),
|
||||
attribute.Int("attempt", attempt+1),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return apifmt.Errorf("failed to update job progress after %d attempts", maxRetries)
|
||||
err := apifmt.Errorf("failed to update job progress after %d attempts", maxRetries)
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package jobs
|
||||
|
||||
import (
|
||||
context "context"
|
||||
time "time"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockJobProgressRecorder is an autogenerated mock type for the JobProgressRecorder type
|
||||
@@ -271,6 +273,51 @@ func (_c *MockJobProgressRecorder_SetTotal_Call) RunAndReturn(run func(context.C
|
||||
return _c
|
||||
}
|
||||
|
||||
// Started provides a mock function with no fields
|
||||
func (_m *MockJobProgressRecorder) Started() time.Time {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Started")
|
||||
}
|
||||
|
||||
var r0 time.Time
|
||||
if rf, ok := ret.Get(0).(func() time.Time); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(time.Time)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockJobProgressRecorder_Started_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Started'
|
||||
type MockJobProgressRecorder_Started_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Started is a helper method to define mock.On call
|
||||
func (_e *MockJobProgressRecorder_Expecter) Started() *MockJobProgressRecorder_Started_Call {
|
||||
return &MockJobProgressRecorder_Started_Call{Call: _e.mock.On("Started")}
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_Started_Call) Run(run func()) *MockJobProgressRecorder_Started_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_Started_Call) Return(_a0 time.Time) *MockJobProgressRecorder_Started_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_Started_Call) RunAndReturn(run func() time.Time) *MockJobProgressRecorder_Started_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// StrictMaxErrors provides a mock function with given fields: maxErrors
|
||||
func (_m *MockJobProgressRecorder) StrictMaxErrors(maxErrors int) {
|
||||
_m.Called(maxErrors)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/utils"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
@@ -9,6 +11,10 @@ type JobMetrics struct {
|
||||
registry prometheus.Registerer
|
||||
processedTotal *prometheus.CounterVec
|
||||
durationHist *prometheus.HistogramVec
|
||||
|
||||
incrementalSyncPhaseDurationHist *prometheus.HistogramVec // phases of incremental sync
|
||||
fullSyncPhaseDurationHist *prometheus.HistogramVec // phases of full sync
|
||||
syncDurationHist *prometheus.HistogramVec // total sync durations
|
||||
}
|
||||
|
||||
type QueueMetrics struct {
|
||||
@@ -72,12 +78,44 @@ func RegisterJobMetrics(registry prometheus.Registerer) JobMetrics {
|
||||
},
|
||||
[]string{"action", "resources_changed_bucket"},
|
||||
)
|
||||
registry.MustRegister(durationHist)
|
||||
|
||||
incrementalSyncPhaseDurationHist := prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "grafana_provisioning_jobs_incremental_sync_phase_duration_seconds",
|
||||
Help: "Duration of job phases for incremental sync",
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 10*60, 8), // 1ms -> 10m
|
||||
},
|
||||
[]string{"phase"},
|
||||
)
|
||||
registry.MustRegister(incrementalSyncPhaseDurationHist)
|
||||
|
||||
fullSyncPhaseDurationHist := prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "grafana_provisioning_jobs_full_sync_phase_duration_seconds",
|
||||
Help: "Duration of job phases for full sync",
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 10*60, 8), // 1ms -> 10m
|
||||
},
|
||||
[]string{"phase"},
|
||||
)
|
||||
registry.MustRegister(fullSyncPhaseDurationHist)
|
||||
|
||||
syncDurationHist := prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "grafana_provisioning_jobs_sync_duration_seconds",
|
||||
Help: "Duration of sync (full or incremental)",
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 10*60, 8), // 1ms -> 10m
|
||||
},
|
||||
[]string{"type"},
|
||||
)
|
||||
registry.MustRegister(syncDurationHist)
|
||||
|
||||
return JobMetrics{
|
||||
registry: registry,
|
||||
processedTotal: processedTotal,
|
||||
durationHist: durationHist,
|
||||
registry: registry,
|
||||
processedTotal: processedTotal,
|
||||
durationHist: durationHist,
|
||||
incrementalSyncPhaseDurationHist: incrementalSyncPhaseDurationHist,
|
||||
fullSyncPhaseDurationHist: fullSyncPhaseDurationHist,
|
||||
syncDurationHist: syncDurationHist,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +128,18 @@ func (m *JobMetrics) RecordJob(jobAction string, outcome string, resourceCountCh
|
||||
}
|
||||
}
|
||||
|
||||
func (m *JobMetrics) RecordIncrementalSyncPhase(phase IncrementalSyncPhase, duration time.Duration) {
|
||||
m.incrementalSyncPhaseDurationHist.WithLabelValues(phase.String()).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
func (m *JobMetrics) RecordFullSyncPhase(phase FullSyncPhase, duration time.Duration) {
|
||||
m.fullSyncPhaseDurationHist.WithLabelValues(phase.String()).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
func (m *JobMetrics) RecordSyncDuration(syncType SyncType, duration time.Duration) {
|
||||
m.syncDurationHist.WithLabelValues(syncType.String()).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
func recordConcurrentDriverMetric(registry prometheus.Registerer, numDrivers int) {
|
||||
concurrentDriver := prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
@@ -101,3 +151,72 @@ func recordConcurrentDriverMetric(registry prometheus.Registerer, numDrivers int
|
||||
registry.MustRegister(concurrentDriver)
|
||||
concurrentDriver.WithLabelValues().Set(float64(numDrivers))
|
||||
}
|
||||
|
||||
type SyncType int
|
||||
|
||||
const (
|
||||
SyncTypeUnknown SyncType = iota // to prevent zero value being valid
|
||||
SyncTypeFull
|
||||
SyncTypeIncremental
|
||||
)
|
||||
|
||||
func (t SyncType) String() string {
|
||||
switch t {
|
||||
case SyncTypeFull:
|
||||
return "full"
|
||||
case SyncTypeIncremental:
|
||||
return "incremental"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type FullSyncPhase int
|
||||
|
||||
const (
|
||||
FullSyncPhaseUnknown FullSyncPhase = iota // to prevent zero value being valid
|
||||
FullSyncPhaseCompare
|
||||
FullSyncPhaseFileDeletions
|
||||
FullSyncPhaseFolderDeletions
|
||||
FullSyncPhaseFolderCreations
|
||||
FullSyncPhaseFileCreations
|
||||
)
|
||||
|
||||
func (p FullSyncPhase) String() string {
|
||||
switch p {
|
||||
case FullSyncPhaseCompare:
|
||||
return "compare"
|
||||
case FullSyncPhaseFileDeletions:
|
||||
return "file_deletions"
|
||||
case FullSyncPhaseFolderDeletions:
|
||||
return "folder_deletions"
|
||||
case FullSyncPhaseFolderCreations:
|
||||
return "folder_creations"
|
||||
case FullSyncPhaseFileCreations:
|
||||
return "file_creations"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type IncrementalSyncPhase int
|
||||
|
||||
const (
|
||||
IncrementalSyncPhaseUnknown IncrementalSyncPhase = iota // to prevent zero value being valid
|
||||
IncrementalSyncPhaseCompare
|
||||
IncrementalSyncPhaseApply
|
||||
IncrementalSyncPhaseCleanup
|
||||
)
|
||||
|
||||
func (p IncrementalSyncPhase) String() string {
|
||||
switch p {
|
||||
case IncrementalSyncPhaseCompare:
|
||||
return "compare"
|
||||
case IncrementalSyncPhaseApply:
|
||||
return "apply"
|
||||
case IncrementalSyncPhaseCleanup:
|
||||
return "cleanup"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
@@ -100,6 +102,16 @@ func NewJobStore(provisioningClient client.ProvisioningV0alpha1Interface, expiry
|
||||
// If err is not nil, the job and rollback values are always nil.
|
||||
// The err may be ErrNoJobs if there are no jobs to claim.
|
||||
func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.claim")
|
||||
defer func() {
|
||||
if err != nil && !errors.Is(err, ErrNoJobs) {
|
||||
span.RecordError(err)
|
||||
}
|
||||
span.End()
|
||||
}()
|
||||
|
||||
logger := logging.FromContext(ctx).With("operation", "claim")
|
||||
|
||||
requirement, err := labels.NewRequirement(LabelJobClaim, selection.DoesNotExist, nil)
|
||||
if err != nil {
|
||||
return nil, nil, apifmt.Errorf("could not create requirement: %w", err)
|
||||
@@ -114,9 +126,12 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
|
||||
}
|
||||
|
||||
if len(jobs.Items) == 0 {
|
||||
logger.Debug("no jobs available to claim")
|
||||
return nil, nil, ErrNoJobs
|
||||
}
|
||||
|
||||
logger.Debug("found jobs available", "count", len(jobs.Items))
|
||||
|
||||
for _, job := range jobs.Items {
|
||||
if job.Labels == nil {
|
||||
job.Labels = make(map[string]string)
|
||||
@@ -145,6 +160,20 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
|
||||
return nil, nil, apifmt.Errorf("failed to claim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
logger.Info("job claim complete",
|
||||
"job", updatedJob.GetName(),
|
||||
"namespace", updatedJob.GetNamespace(),
|
||||
"repository", updatedJob.Spec.Repository,
|
||||
"action", updatedJob.Spec.Action,
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.name", updatedJob.GetName()),
|
||||
attribute.String("job.namespace", updatedJob.GetNamespace()),
|
||||
attribute.String("job.repository", updatedJob.Spec.Repository),
|
||||
attribute.String("job.action", string(updatedJob.Spec.Action)),
|
||||
)
|
||||
|
||||
return updatedJob.DeepCopy(), func() {
|
||||
// Rolling back does not need to care about the parent's cancellation state.
|
||||
// This will also use the parent context (i.e. from the for loop!), ensuring we have permissions to do this.
|
||||
@@ -181,50 +210,99 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
|
||||
}
|
||||
|
||||
// We failed to claim any jobs.
|
||||
logger.Debug("no jobs claimed - all already claimed by others")
|
||||
return nil, nil, ErrNoJobs
|
||||
}
|
||||
|
||||
// Update saves the job back to the store.
|
||||
func (s *persistentStore) Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.update")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx).With(
|
||||
"operation", "update",
|
||||
"job", job.GetName(),
|
||||
"namespace", job.GetNamespace(),
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.name", job.GetName()),
|
||||
attribute.String("job.namespace", job.GetNamespace()),
|
||||
)
|
||||
|
||||
// Set up the provisioning identity for this namespace
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
updatedJob, err := s.client.Jobs(job.GetNamespace()).Update(ctx, job, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
logger.Debug("update job complete")
|
||||
return updatedJob, nil
|
||||
}
|
||||
|
||||
// Get retrieves a job by name for conflict resolution.
|
||||
func (s *persistentStore) Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.get")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx).With(
|
||||
"operation", "get",
|
||||
"job", name,
|
||||
"namespace", namespace,
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.name", name),
|
||||
attribute.String("job.namespace", namespace),
|
||||
)
|
||||
|
||||
// Set up provisioning identity to access jobs across all namespaces
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to grant provisioning identity for job lookup: %w", err)
|
||||
}
|
||||
|
||||
// Use Get to directly fetch the job by name
|
||||
job, err := s.client.Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to get job by name '%s': %w", name, err)
|
||||
}
|
||||
|
||||
logger.Debug("get job complete")
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// Complete marks a job as completed and moves it to the historic job store.
|
||||
// When in the historic store, there is no more claim on the job.
|
||||
func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) error {
|
||||
logger := logging.FromContext(ctx).With("namespace", job.GetNamespace(), "job", job.GetName())
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.complete")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx).With(
|
||||
"operation", "complete",
|
||||
"namespace", job.GetNamespace(),
|
||||
"job", job.GetName(),
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.name", job.GetName()),
|
||||
attribute.String("job.namespace", job.GetNamespace()),
|
||||
attribute.String("job.action", string(job.Spec.Action)),
|
||||
)
|
||||
|
||||
// Set up the provisioning identity for this namespace
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
@@ -235,6 +313,7 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
|
||||
// This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store.
|
||||
err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
logger.Debug("deleted job from job store")
|
||||
@@ -246,26 +325,44 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
|
||||
delete(job.Labels, LabelJobClaim)
|
||||
s.queueMetrics.DecreaseQueueSize(string(job.Spec.Action))
|
||||
|
||||
logger.Debug("job completion done")
|
||||
logger.Debug("complete job complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenewLease renews the lease for a claimed job, extending its expiry time.
|
||||
// Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired).
|
||||
func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.renew_lease")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx).With(
|
||||
"operation", "renew_lease",
|
||||
"job", job.GetName(),
|
||||
"namespace", job.GetNamespace(),
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.name", job.GetName()),
|
||||
attribute.String("job.namespace", job.GetNamespace()),
|
||||
)
|
||||
|
||||
if job.Labels == nil || job.Labels[LabelJobClaim] == "" {
|
||||
return apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace())
|
||||
err := apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace())
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set up the provisioning identity for this namespace
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
// Fetch the latest version to avoid conflicts
|
||||
latestJob, err := s.client.Jobs(job.GetNamespace()).Get(ctx, job.GetName(), metav1.GetOptions{})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
|
||||
}
|
||||
@@ -274,7 +371,9 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
|
||||
|
||||
// Verify we still own the lease
|
||||
if latestJob.Labels == nil || latestJob.Labels[LabelJobClaim] == "" {
|
||||
return apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace())
|
||||
err := apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace())
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the claim timestamp to current time
|
||||
@@ -284,85 +383,213 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
|
||||
// Update the job in storage with the latest resource version
|
||||
_, err = s.client.Jobs(job.GetNamespace()).Update(ctx, updatedJob, metav1.UpdateOptions{})
|
||||
if apierrors.IsConflict(err) {
|
||||
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace())
|
||||
err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace())
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
if apierrors.IsNotFound(err) {
|
||||
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
|
||||
err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
// Update the job's claim timestamp and resource version in memory
|
||||
job.Labels[LabelJobClaim] = updatedJob.Labels[LabelJobClaim]
|
||||
job.ResourceVersion = updatedJob.ResourceVersion
|
||||
|
||||
logger.Debug("renew lease complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup finds jobs with expired leases and marks them as failed.
|
||||
// This replaces the old cleanup mechanism and should be called more frequently.
|
||||
func (s *persistentStore) Cleanup(ctx context.Context) error {
|
||||
// Set up provisioning identity to access jobs across all namespaces
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
|
||||
if err != nil {
|
||||
return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
|
||||
}
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup")
|
||||
defer span.End()
|
||||
|
||||
// Find jobs with expired leases (older than expiry time)
|
||||
expiry := s.clock().Add(-s.expiry).UnixMilli()
|
||||
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
|
||||
if err != nil {
|
||||
return apifmt.Errorf("could not create requirement: %w", err)
|
||||
}
|
||||
startTime := s.clock()
|
||||
logger := logging.FromContext(ctx).With("operation", "cleanup")
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
jobs, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
|
||||
LabelSelector: labels.NewSelector().Add(*requirement).String(),
|
||||
Limit: 100, // Process in batches
|
||||
})
|
||||
cancel()
|
||||
// List expired jobs
|
||||
jobs, err := s.listExpiredJobs(ctx)
|
||||
if err != nil {
|
||||
return apifmt.Errorf("failed to list jobs with expired leases: %w", err)
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// If no jobs found, cleanup is complete
|
||||
if len(jobs.Items) == 0 {
|
||||
if len(jobs) == 0 {
|
||||
duration := s.clock().Sub(startTime)
|
||||
logger.Info("cleanup complete - no expired jobs found", "duration", duration)
|
||||
span.SetAttributes(
|
||||
attribute.Int("count", 0),
|
||||
attribute.Int64("duration_ms", duration.Milliseconds()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, job := range jobs.Items {
|
||||
// Mark job as failed due to lease expiry and archive it
|
||||
job := job.DeepCopy()
|
||||
job.Status.State = provisioning.JobStateError
|
||||
job.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
|
||||
logger.Info("found expired jobs", "count", len(jobs))
|
||||
|
||||
// Set namespace context for the completion
|
||||
ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
|
||||
if err != nil {
|
||||
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
// Use Complete to properly archive the failed job
|
||||
if err := s.Complete(ctx, job); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Job was already completed/deleted by another process
|
||||
continue
|
||||
}
|
||||
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
// Clean up each expired job
|
||||
for _, job := range jobs {
|
||||
if err := s.cleanUpExpiredJob(ctx, job); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
duration := s.clock().Sub(startTime)
|
||||
logger.Info("cleanup complete",
|
||||
"duration", duration,
|
||||
"count", len(jobs),
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int("count", len(jobs)),
|
||||
attribute.Int64("duration_ms", duration.Milliseconds()),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listExpiredJobs returns jobs with expired leases.
|
||||
func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) {
|
||||
logger := logging.FromContext(ctx)
|
||||
|
||||
// Set up provisioning identity to access jobs across all namespaces
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
|
||||
if err != nil {
|
||||
return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Find jobs with expired leases (older than expiry time)
|
||||
expiry := s.clock().Add(-s.expiry).UnixMilli()
|
||||
expiryTime := time.UnixMilli(expiry)
|
||||
logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339))
|
||||
|
||||
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
|
||||
if err != nil {
|
||||
return nil, apifmt.Errorf("could not create requirement: %w", err)
|
||||
}
|
||||
|
||||
listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs")
|
||||
defer listSpan.End()
|
||||
|
||||
listSpan.SetAttributes(
|
||||
attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)),
|
||||
attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())),
|
||||
)
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
|
||||
LabelSelector: labels.NewSelector().Add(*requirement).String(),
|
||||
Limit: 100, // Process in batches
|
||||
})
|
||||
if err != nil {
|
||||
listSpan.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err)
|
||||
}
|
||||
|
||||
listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items)))
|
||||
return jobList.Items, nil
|
||||
}
|
||||
|
||||
// cleanUpExpiredJob marks a single expired job as failed and archives it.
|
||||
func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error {
|
||||
// Calculate how long the job has been expired
|
||||
var expiredFor time.Duration
|
||||
var claimTimestamp time.Time
|
||||
if claimTime, exists := job.Labels[LabelJobClaim]; exists {
|
||||
claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64)
|
||||
if parseErr == nil {
|
||||
claimTimestamp = time.UnixMilli(claimMillis)
|
||||
expiredFor = s.clock().Sub(claimTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
logger := logging.FromContext(ctx).With(
|
||||
"job", job.GetName(),
|
||||
"namespace", job.GetNamespace(),
|
||||
"repository", job.Spec.Repository,
|
||||
"action", job.Spec.Action,
|
||||
"expired_for", expiredFor,
|
||||
)
|
||||
|
||||
if !claimTimestamp.IsZero() {
|
||||
logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job")
|
||||
defer jobSpan.End()
|
||||
|
||||
jobSpan.SetAttributes(
|
||||
attribute.String("job.name", job.GetName()),
|
||||
attribute.String("job.namespace", job.GetNamespace()),
|
||||
attribute.String("job.repository", job.Spec.Repository),
|
||||
attribute.String("job.action", string(job.Spec.Action)),
|
||||
attribute.String("job.expired_for", expiredFor.String()),
|
||||
)
|
||||
|
||||
// Mark job as failed due to lease expiry and archive it
|
||||
jobCopy := job.DeepCopy()
|
||||
jobCopy.Status.State = provisioning.JobStateError
|
||||
jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
|
||||
|
||||
// Set namespace context for the completion
|
||||
jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace())
|
||||
if err != nil {
|
||||
jobSpan.RecordError(err)
|
||||
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
// Use Complete to properly archive the failed job
|
||||
if err := s.Complete(jobCtx, jobCopy); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Job was already completed/deleted by another process - this is expected
|
||||
logger.Warn("job already completed or deleted by another process")
|
||||
return nil
|
||||
}
|
||||
jobSpan.RecordError(err)
|
||||
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
logger.Info("clean up expired job complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) {
|
||||
ctx, span := tracing.Start(ctx, "provisioning.jobs.insert")
|
||||
defer span.End()
|
||||
|
||||
logger := logging.FromContext(ctx).With(
|
||||
"operation", "insert",
|
||||
"namespace", namespace,
|
||||
"repository", spec.Repository,
|
||||
"action", spec.Action,
|
||||
)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String("job.namespace", namespace),
|
||||
attribute.String("job.repository", spec.Repository),
|
||||
attribute.String("job.action", string(spec.Action)),
|
||||
)
|
||||
|
||||
if spec.Repository == "" {
|
||||
return nil, errors.New("missing repository in job")
|
||||
err := errors.New("missing repository in job")
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set up the provisioning identity for this namespace
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", namespace, err)
|
||||
}
|
||||
|
||||
@@ -376,19 +603,27 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro
|
||||
Spec: spec,
|
||||
}
|
||||
if err := mutateJobAction(job); err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
generateJobName(job) // Side-effect: updates the job's name.
|
||||
|
||||
logger = logger.With("job", job.GetName())
|
||||
span.SetAttributes(attribute.String("job.name", job.GetName()))
|
||||
|
||||
created, err := s.client.Jobs(namespace).Create(ctx, job, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("job '%s' in '%s' already exists: %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, apifmt.Errorf("failed to create job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
|
||||
}
|
||||
|
||||
s.queueMetrics.IncreaseQueueSize(string(job.Spec.Action))
|
||||
|
||||
logger.Info("insert job complete")
|
||||
return created, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -69,24 +69,36 @@ func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *jobProgressRecorder) Started() time.Time {
|
||||
return r.started
|
||||
}
|
||||
|
||||
func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
|
||||
var shouldLogError bool
|
||||
var logErr error
|
||||
|
||||
r.mu.Lock()
|
||||
r.resultCount++
|
||||
|
||||
logger := logging.FromContext(ctx).With("path", result.Path, "group", result.Group, "kind", result.Kind, "action", result.Action, "name", result.Name)
|
||||
if result.Error != nil {
|
||||
logger.Error("job resource operation failed", "err", result.Error)
|
||||
shouldLogError = true
|
||||
logErr = result.Error
|
||||
if len(r.errors) < 20 {
|
||||
r.errors = append(r.errors, result.Error.Error())
|
||||
}
|
||||
r.errorCount++
|
||||
} else {
|
||||
logger.Info("job resource operation succeeded")
|
||||
}
|
||||
|
||||
r.updateSummary(result)
|
||||
r.mu.Unlock()
|
||||
|
||||
logger := logging.FromContext(ctx).With("path", result.Path, "group", result.Group, "kind", result.Kind, "action", result.Action, "name", result.Name)
|
||||
if shouldLogError {
|
||||
logger.Error("job resource operation failed", "err", logErr)
|
||||
} else {
|
||||
logger.Info("job resource operation succeeded")
|
||||
}
|
||||
|
||||
r.maybeNotify(ctx)
|
||||
}
|
||||
|
||||
@@ -145,9 +157,6 @@ func (r *jobProgressRecorder) StrictMaxErrors(maxErrors int) {
|
||||
}
|
||||
|
||||
func (r *jobProgressRecorder) TooManyErrors() error {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if r.maxErrors > 0 && r.errorCount >= r.maxErrors {
|
||||
return fmt.Errorf("too many errors: %d", r.errorCount)
|
||||
}
|
||||
@@ -156,9 +165,6 @@ func (r *jobProgressRecorder) TooManyErrors() error {
|
||||
}
|
||||
|
||||
func (r *jobProgressRecorder) summary() []*provisioning.JobResourceSummary {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if len(r.summaries) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -247,13 +253,9 @@ func (r *jobProgressRecorder) maybeNotify(ctx context.Context) {
|
||||
|
||||
func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provisioning.JobStatus {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Initialize base job status
|
||||
jobStatus := provisioning.JobStatus{
|
||||
Started: r.started.UnixMilli(),
|
||||
// FIXME: if we call this method twice, the state will be different
|
||||
// This results in sync status to be different from job status
|
||||
Started: r.started.UnixMilli(),
|
||||
Finished: time.Now().UnixMilli(),
|
||||
State: provisioning.JobStateSuccess,
|
||||
Message: "completed successfully",
|
||||
@@ -268,9 +270,13 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
|
||||
jobStatus.Errors = r.errors
|
||||
jobStatus.URLs = r.refURLs
|
||||
|
||||
// Check for errors during execution
|
||||
tooManyErrors := r.maxErrors > 0 && r.errorCount >= r.maxErrors
|
||||
finalMessage := r.finalMessage
|
||||
|
||||
r.mu.RUnlock()
|
||||
|
||||
if len(jobStatus.Errors) > 0 && jobStatus.State != provisioning.JobStateError {
|
||||
if r.TooManyErrors() != nil {
|
||||
if tooManyErrors {
|
||||
jobStatus.Message = "completed with too many errors"
|
||||
jobStatus.State = provisioning.JobStateError
|
||||
} else {
|
||||
@@ -280,8 +286,8 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
|
||||
}
|
||||
|
||||
// Override message if progress have a more explicit message
|
||||
if r.finalMessage != "" && jobStatus.State != provisioning.JobStateError {
|
||||
jobStatus.Message = r.finalMessage
|
||||
if finalMessage != "" && jobStatus.State != provisioning.JobStateError {
|
||||
jobStatus.Message = finalMessage
|
||||
}
|
||||
|
||||
return jobStatus
|
||||
|
||||
@@ -2,6 +2,7 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
@@ -18,6 +19,7 @@ type RepoGetter interface {
|
||||
//
|
||||
//go:generate mockery --name JobProgressRecorder --structname MockJobProgressRecorder --inpackage --filename job_progress_recorder_mock.go --with-expecter
|
||||
type JobProgressRecorder interface {
|
||||
Started() time.Time
|
||||
Record(ctx context.Context, result JobResourceResult)
|
||||
ResetResults()
|
||||
SetFinalMessage(ctx context.Context, msg string)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user