Merge remote-tracking branch 'origin/main' into resource-store

This commit is contained in:
Ryan McKinley
2024-06-18 07:27:05 +03:00
91 changed files with 2134 additions and 452 deletions
+48
View File
@@ -1,3 +1,51 @@
# Authorization
This package contains the authorization server implementation.
## Feature toggles
The following feature toggles need to be activated:
```ini
[feature_toggles]
authZGRPCServer = true
grpcServer = true
```
## Configuration
To configure the authorization server and client, use the "authorization" section of the configuration ini file.
The `remote_address` setting, specifies the address where the authorization server is located (ex: `server.example.org:10000`).
The `mode` setting can be set to either `grpc` or `inproc`. When set to `grpc`, the client will connect to the specified address. When set to `inproc` the client will use inprocgrpc (relying on go channels) to wrap a local instantiation of the server.
The `listen` setting determines whether the authorization server should listen for incoming requests. When set to `true`, the authorization service will be registered to the Grafana GRPC server.
The default configuration does not register the authorization service on the Grafana GRPC server and binds the client to it `inproc`:
```ini
[authorization]
remote_address = ""
listen = false
mode = "inproc"
```
### Example
Here is an example to connect the authorization client to a remote grpc server.
```ini
[authorization]
remote_address = "server.example.org:10000"
mode = "grpc"
```
Here is an example to register the authorization service on the Grafana GRPC server and connect the client to it through grpc
```ini
[authorization]
remote_address = "localhost:10000"
listen = true
mode = "grpc"
```
+95 -3
View File
@@ -1,10 +1,21 @@
package authz
import (
"context"
"github.com/fullstorydev/grpchan"
"github.com/fullstorydev/grpchan/inprocgrpc"
grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
grpcUtils "github.com/grafana/grafana/pkg/services/store/entity/grpc"
"github.com/grafana/grafana/pkg/setting"
)
@@ -13,8 +24,10 @@ type Client interface {
}
type LegacyClient struct {
clientV1 authzv1.AuthzServiceClient
}
// ProvideAuthZClient provides an AuthZ client and creates the AuthZ service.
func ProvideAuthZClient(
cfg *setting.Cfg, features featuremgmt.FeatureToggles, acSvc accesscontrol.Service,
grpcServer grpcserver.Provider, tracer tracing.Tracer,
@@ -23,11 +36,90 @@ func ProvideAuthZClient(
return nil, nil
}
_, err := newLegacyServer(acSvc, features, grpcServer, tracer)
authCfg, err := ReadCfg(cfg)
if err != nil {
return nil, err
}
// TODO differentiate run local from run remote grpc
return &LegacyClient{}, nil
var client *LegacyClient
// Register the server
server, err := newLegacyServer(acSvc, features, grpcServer, tracer, authCfg)
if err != nil {
return nil, err
}
switch authCfg.mode {
case ModeInProc:
client = newInProcLegacyClient(server)
case ModeGRPC:
client, err = newGrpcLegacyClient(authCfg.remoteAddress)
if err != nil {
return nil, err
}
}
return client, err
}
// ProvideStandaloneAuthZClient provides a standalone AuthZ client, without registering the AuthZ service.
// You need to provide a remote address in the configuration
func ProvideStandaloneAuthZClient(
cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer,
) (Client, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) {
return nil, nil
}
authCfg, err := ReadCfg(cfg)
if err != nil {
return nil, err
}
return newGrpcLegacyClient(authCfg.remoteAddress)
}
func newInProcLegacyClient(server *legacyServer) *LegacyClient {
channel := &inprocgrpc.Channel{}
// TODO (gamab): change this once it's clear how to authenticate the client
// Choices are:
// - noAuth given it's in proc and we don't need the user
// - access_token verif only as it's consistent with when it's remote (we check the service is allowed to call the authz service)
// - access_token and id_token ? the id_token being only necessary when the user is trying to access the service straight away
// auth := grpcUtils.ProvideAuthenticator(cfg)
noAuth := func(ctx context.Context) (context.Context, error) {
return ctx, nil
}
channel.RegisterService(
grpchan.InterceptServer(
&authzv1.AuthzService_ServiceDesc,
grpcAuth.UnaryServerInterceptor(noAuth),
grpcAuth.StreamServerInterceptor(noAuth),
),
server,
)
conn := grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)
client := authzv1.NewAuthzServiceClient(conn)
return &LegacyClient{
clientV1: client,
}
}
func newGrpcLegacyClient(address string) (*LegacyClient, error) {
// Create a connection to the gRPC server
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
client := authzv1.NewAuthzServiceClient(conn)
return &LegacyClient{
clientV1: client,
}, nil
}
+43
View File
@@ -0,0 +1,43 @@
package authz
import (
"fmt"
"github.com/grafana/grafana/pkg/setting"
)
type Mode string
func (s Mode) IsValid() bool {
switch s {
case ModeGRPC, ModeInProc:
return true
}
return false
}
const (
ModeGRPC Mode = "grpc"
ModeInProc Mode = "inproc"
)
type Cfg struct {
remoteAddress string
listen bool
mode Mode
}
func ReadCfg(cfg *setting.Cfg) (*Cfg, error) {
section := cfg.SectionWithEnvOverrides("authorization")
mode := Mode(section.Key("mode").MustString(string(ModeInProc)))
if !mode.IsValid() {
return nil, fmt.Errorf("authorization: invalid mode %q", mode)
}
return &Cfg{
remoteAddress: section.Key("remote_address").MustString(""),
listen: section.Key("listen").MustBool(false),
mode: mode,
}, nil
}
+4 -2
View File
@@ -24,7 +24,7 @@ type legacyServer struct {
func newLegacyServer(
acSvc accesscontrol.Service, features featuremgmt.FeatureToggles,
grpcServer grpcserver.Provider, tracer tracing.Tracer,
grpcServer grpcserver.Provider, tracer tracing.Tracer, cfg *Cfg,
) (*legacyServer, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) {
return nil, nil
@@ -36,7 +36,9 @@ func newLegacyServer(
tracer: tracer,
}
grpcServer.GetServer().RegisterService(&authzv1.AuthzService_ServiceDesc, s)
if cfg.listen {
grpcServer.GetServer().RegisterService(&authzv1.AuthzService_ServiceDesc, s)
}
return s, nil
}
@@ -741,7 +741,9 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb
hit.Tags = append(hit.Tags, item.Term)
}
if item.Deleted != nil {
hit.RemainingTrashAtAge = util.RemainingDaysUntil((*item.Deleted).Add(daysInTrash))
deletedDate := (*item.Deleted).Add(daysInTrash)
hit.IsDeleted = true
hit.PermanentlyDeleteDate = &deletedDate
}
}
return hitList
+6
View File
@@ -1317,6 +1317,12 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "openSearchBackendFlowEnabled",
Description: "Enables the backend query flow for Open Search datasource plugin",
Stage: FeatureStagePublicPreview,
Owner: awsDatasourcesSquad,
},
}
)
+1
View File
@@ -175,3 +175,4 @@ pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,fals
azureMonitorPrometheusExemplars,experimental,@grafana/partner-datasources,false,false,false
pinNavItems,experimental,@grafana/grafana-frontend-platform,false,false,false
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
openSearchBackendFlowEnabled,preview,@grafana/aws-datasources,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
175 azureMonitorPrometheusExemplars experimental @grafana/partner-datasources false false false
176 pinNavItems experimental @grafana/grafana-frontend-platform false false false
177 authZGRPCServer experimental @grafana/identity-access-team false false false
178 openSearchBackendFlowEnabled preview @grafana/aws-datasources false false false
+4
View File
@@ -710,4 +710,8 @@ const (
// FlagAuthZGRPCServer
// Enables the gRPC server for authorization
FlagAuthZGRPCServer = "authZGRPCServer"
// FlagOpenSearchBackendFlowEnabled
// Enables the backend query flow for Open Search datasource plugin
FlagOpenSearchBackendFlowEnabled = "openSearchBackendFlowEnabled"
)
+12
View File
@@ -1599,6 +1599,18 @@
"codeowner": "@grafana/grafana-operator-experience-squad"
}
},
{
"metadata": {
"name": "openSearchBackendFlowEnabled",
"resourceVersion": "1718357852240",
"creationTimestamp": "2024-06-14T09:37:32Z"
},
"spec": {
"description": "Enables the backend query flow for Open Search datasource plugin",
"stage": "preview",
"codeowner": "@grafana/aws-datasources"
}
},
{
"metadata": {
"name": "panelFilterVariable",
+11 -9
View File
@@ -29,7 +29,8 @@ import (
)
type Service struct {
cfg *setting.Cfg
cfg *ldap.Config
adminUser string
userService user.Service
authInfoService login.AuthInfoService
ldapGroupsService ldap.Groups
@@ -47,7 +48,8 @@ func ProvideService(
sessionService auth.UserTokenService, bundleRegistry supportbundles.Service,
) *Service {
s := &Service{
cfg: cfg,
cfg: ldap.GetLDAPConfig(cfg),
adminUser: cfg.AdminUser,
userService: userService,
authInfoService: authInfoService,
ldapGroupsService: ldapGroupsService,
@@ -96,7 +98,7 @@ func ProvideService(
// 403: forbiddenError
// 500: internalServerError
func (s *Service) ReloadLDAPCfg(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@@ -122,7 +124,7 @@ func (s *Service) ReloadLDAPCfg(c *contextmodel.ReqContext) response.Response {
// 403: forbiddenError
// 500: internalServerError
func (s *Service) GetLDAPStatus(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@@ -169,7 +171,7 @@ func (s *Service) GetLDAPStatus(c *contextmodel.ReqContext) response.Response {
// 403: forbiddenError
// 500: internalServerError
func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@@ -206,7 +208,7 @@ func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Resp
userInfo, _, err := ldapClient.User(usr.Login)
if err != nil {
if errors.Is(err, multildap.ErrDidNotFindUser) { // User was not in the LDAP server - we need to take action:
if s.cfg.AdminUser == usr.Login { // User is *the* Grafana Admin. We cannot disable it.
if s.adminUser == usr.Login { // User is *the* Grafana Admin. We cannot disable it.
errMsg := fmt.Sprintf(`Refusing to sync grafana super admin "%s" - it would be disabled`, usr.Login)
s.log.Error(errMsg)
return response.Error(http.StatusBadRequest, errMsg, err)
@@ -250,7 +252,7 @@ func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Resp
// 403: forbiddenError
// 500: internalServerError
func (s *Service) GetUserFromLDAP(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@@ -330,8 +332,8 @@ func (s *Service) identityFromLDAPUser(user *login.ExternalUserInfo) *authn.Iden
SyncUser: true,
SyncTeams: true,
EnableUser: true,
SyncOrgRoles: !s.cfg.LDAPSkipOrgRoleSync,
AllowSignUp: s.cfg.LDAPAllowSignup,
SyncOrgRoles: !s.cfg.SkipOrgRoleSync,
AllowSignUp: s.cfg.AllowSignUp,
},
}
}
+12 -12
View File
@@ -95,7 +95,7 @@ func TestGetUserFromLDAPAPIEndpoint_UserNotFound(t *testing.T) {
ExpectedClient: &LDAPMock{
UserSearchResult: nil,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -160,7 +160,7 @@ func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) {
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -229,7 +229,7 @@ func TestGetUserFromLDAPAPIEndpoint(t *testing.T) {
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -314,7 +314,7 @@ func TestGetUserFromLDAPAPIEndpoint_WithTeamHandler(t *testing.T) {
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -368,7 +368,7 @@ func TestGetLDAPStatusAPIEndpoint(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -407,7 +407,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_Success(t *testing.T) {
ExpectedClient: &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
Login: "ldap-daniel",
}},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -442,7 +442,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotFound(t *testing.T) {
a.userService = userServiceMock
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -475,10 +475,10 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.userService = userServiceMock
a.cfg.AdminUser = "ldap-daniel"
a.adminUser = "ldap-daniel"
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -511,7 +511,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) {
a.authInfoService = &authinfotest.FakeService{ExpectedExternalUser: &login.ExternalUserInfo{IsDisabled: true, UserId: 34}}
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@@ -641,12 +641,12 @@ search_base_dns = ["dc=grafana,dc=org"]`)
t.Run(tt.desc, func(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.userService = &usertest.FakeUserService{ExpectedUser: &user.User{Login: "ldap-daniel", ID: 1}}
a.cfg.LDAPConfigFilePath = ldapConfigFile
a.cfg.ConfigFilePath = ldapConfigFile
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
Login: "ldap-daniel",
}},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
// Add minimal setup to pass handler
+6 -6
View File
@@ -73,12 +73,12 @@ func (s *Service) supportBundleCollector(context.Context) (*supportbundles.Suppo
bWriter.WriteString("```ini\n")
bWriter.WriteString(fmt.Sprintf("enabled = %v\n", s.cfg.LDAPAuthEnabled))
bWriter.WriteString(fmt.Sprintf("config_file = %s\n", s.cfg.LDAPConfigFilePath))
bWriter.WriteString(fmt.Sprintf("allow_sign_up = %v\n", s.cfg.LDAPAllowSignup))
bWriter.WriteString(fmt.Sprintf("sync_cron = %s\n", s.cfg.LDAPSyncCron))
bWriter.WriteString(fmt.Sprintf("active_sync_enabled = %v\n", s.cfg.LDAPActiveSyncEnabled))
bWriter.WriteString(fmt.Sprintf("skip_org_role_sync = %v\n", s.cfg.LDAPSkipOrgRoleSync))
bWriter.WriteString(fmt.Sprintf("enabled = %v\n", s.cfg.Enabled))
bWriter.WriteString(fmt.Sprintf("config_file = %s\n", s.cfg.ConfigFilePath))
bWriter.WriteString(fmt.Sprintf("allow_sign_up = %v\n", s.cfg.AllowSignUp))
bWriter.WriteString(fmt.Sprintf("sync_cron = %s\n", s.cfg.SyncCron))
bWriter.WriteString(fmt.Sprintf("active_sync_enabled = %v\n", s.cfg.ActiveSyncEnabled))
bWriter.WriteString(fmt.Sprintf("skip_org_role_sync = %v\n", s.cfg.SkipOrgRoleSync))
bWriter.WriteString("```\n\n")
+4 -5
View File
@@ -18,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -45,7 +44,7 @@ type IServer interface {
// Server is basic struct of LDAP authorization
type Server struct {
cfg *setting.Cfg
cfg *Config
Config *ServerConfig
Connection IConnection
log log.Logger
@@ -86,7 +85,7 @@ var (
)
// New creates the new LDAP connection
func New(config *ServerConfig, cfg *setting.Cfg) IServer {
func New(config *ServerConfig, cfg *Config) IServer {
return &Server{
Config: config,
cfg: cfg,
@@ -414,7 +413,7 @@ func (server *Server) users(logins []string) (
// If there are no ldap group mappings access is true
// otherwise a single group must match
func (server *Server) validateGrafanaUser(user *login.ExternalUserInfo) error {
if !server.cfg.LDAPSkipOrgRoleSync && len(server.Config.Groups) > 0 &&
if !server.cfg.SkipOrgRoleSync && len(server.Config.Groups) > 0 &&
(len(user.OrgRoles) == 0 && (user.IsGrafanaAdmin == nil || !*user.IsGrafanaAdmin)) {
server.log.Warn(
"User does not belong in any of the specified LDAP groups",
@@ -499,7 +498,7 @@ func (server *Server) buildGrafanaUser(user *ldap.Entry) (*login.ExternalUserInf
}
// Skipping org role sync
if server.cfg.LDAPSkipOrgRoleSync {
if server.cfg.SkipOrgRoleSync {
server.log.Debug("Skipping organization role mapping.")
return extUser, nil
}
+15 -11
View File
@@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/setting"
)
var defaultLogin = &login.LoginUserQuery{
@@ -31,8 +30,9 @@ func TestServer_Login_UserBind_Fail(t *testing.T) {
}
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
Config: &ServerConfig{
@@ -105,8 +105,9 @@ func TestServer_Login_ValidCredentials(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -142,8 +143,9 @@ func TestServer_Login_UnauthenticatedBind(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -189,8 +191,9 @@ func TestServer_Login_AuthenticatedBind(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -232,8 +235,9 @@ func TestServer_Login_UserWildcardBind(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
+18 -13
View File
@@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
)
func TestServer_getSearchRequest(t *testing.T) {
@@ -53,8 +52,9 @@ func TestServer_getSearchRequest(t *testing.T) {
func TestSerializeUsers(t *testing.T) {
t.Run("simple case", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -92,8 +92,9 @@ func TestSerializeUsers(t *testing.T) {
})
t.Run("without lastname", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -129,8 +130,9 @@ func TestSerializeUsers(t *testing.T) {
})
t.Run("mark user without matching group as disabled", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -163,8 +165,9 @@ func TestSerializeUsers(t *testing.T) {
func TestServer_validateGrafanaUser(t *testing.T) {
t.Run("no group config", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -183,8 +186,9 @@ func TestServer_validateGrafanaUser(t *testing.T) {
})
t.Run("user in group", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -210,8 +214,9 @@ func TestServer_validateGrafanaUser(t *testing.T) {
})
t.Run("user not in group", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
+23 -21
View File
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
const (
@@ -55,7 +54,7 @@ func TestNew(t *testing.T) {
result := New(&ServerConfig{
Attr: AttributeMap{},
SearchBaseDNs: []string{"BaseDNHere"},
}, &setting.Cfg{})
}, &Config{})
assert.Implements(t, (*IServer)(nil), result)
}
@@ -68,7 +67,7 @@ func TestServer_Dial(t *testing.T) {
ClientCert: "./testdata/parsable.cert",
ClientKey: "./testdata/parsable.pem",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -79,7 +78,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACert: "./testdata/invalid.cert",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -90,7 +89,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACert: "./testdata/nofile.cert",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -102,7 +101,7 @@ func TestServer_Dial(t *testing.T) {
ClientCert: "./testdata/invalid.cert",
ClientKey: "./testdata/invalid.pem",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -114,7 +113,7 @@ func TestServer_Dial(t *testing.T) {
ClientCert: "./testdata/nofile.cert",
ClientKey: "./testdata/parsable.pem",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -128,7 +127,7 @@ func TestServer_Dial(t *testing.T) {
ClientCertValue: validCert,
ClientKeyValue: validKey,
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -139,7 +138,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACertValue: []string{"invalid-certificate"},
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -150,7 +149,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACertValue: []string{"aW52YWxpZC1jZXJ0aWZpY2F0ZQ=="},
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -162,7 +161,7 @@ func TestServer_Dial(t *testing.T) {
ClientCertValue: "invalid-certificate",
ClientKeyValue: validKey,
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -174,7 +173,7 @@ func TestServer_Dial(t *testing.T) {
ClientCertValue: validCert,
ClientKeyValue: "aW52YWxpZC1rZXk=",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@@ -226,8 +225,9 @@ func TestServer_Users(t *testing.T) {
conn.setSearchResult(&result)
// Set up attribute map without surname and email
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -323,7 +323,7 @@ func TestServer_Users(t *testing.T) {
})
server := &Server{
cfg: setting.NewCfg(),
cfg: &Config{},
Config: &ServerConfig{
Attr: AttributeMap{
Username: "username",
@@ -370,8 +370,9 @@ func TestServer_Users(t *testing.T) {
}
})
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -464,8 +465,9 @@ func TestServer_Users(t *testing.T) {
})
isGrafanaAdmin := true
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@@ -506,7 +508,7 @@ func TestServer_Users(t *testing.T) {
require.True(t, res[0].IsDisabled)
})
t.Run("skip org role sync", func(t *testing.T) {
server.cfg.LDAPSkipOrgRoleSync = true
server.cfg.SkipOrgRoleSync = true
res, err := server.Users([]string{"groot"})
require.NoError(t, err)
@@ -517,7 +519,7 @@ func TestServer_Users(t *testing.T) {
require.False(t, res[0].IsDisabled)
})
t.Run("sync org role", func(t *testing.T) {
server.cfg.LDAPSkipOrgRoleSync = false
server.cfg.SkipOrgRoleSync = false
res, err := server.Users([]string{"groot"})
require.NoError(t, err)
require.Len(t, res, 1)
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/setting"
)
// GetConfig gets LDAP config
@@ -54,12 +53,12 @@ type IMultiLDAP interface {
// MultiLDAP is basic struct of LDAP authorization
type MultiLDAP struct {
configs []*ldap.ServerConfig
cfg *setting.Cfg
cfg *ldap.Config
log log.Logger
}
// New creates the new LDAP auth
func New(configs []*ldap.ServerConfig, cfg *setting.Cfg) IMultiLDAP {
func New(configs []*ldap.ServerConfig, cfg *ldap.Config) IMultiLDAP {
return &MultiLDAP{
configs: configs,
cfg: cfg,
+24 -25
View File
@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/setting"
//TODO(sh0rez): remove once import cycle resolved
_ "github.com/grafana/grafana/pkg/api/response"
@@ -19,7 +18,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, err := multi.Ping()
require.Error(t, err)
@@ -35,7 +34,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{Host: "10.0.0.1", Port: 361},
}, setting.NewCfg())
}, &ldap.Config{})
statuses, err := multi.Ping()
@@ -53,7 +52,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{Host: "10.0.0.1", Port: 361},
}, setting.NewCfg())
}, &ldap.Config{})
statuses, err := multi.Ping()
@@ -71,7 +70,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Error(t, err)
@@ -88,7 +87,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
@@ -104,7 +103,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -125,7 +124,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
result, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 1, mock.dialCalledTimes)
@@ -145,7 +144,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -164,7 +163,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -184,7 +183,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -202,7 +201,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 1, mock.dialCalledTimes)
@@ -219,7 +218,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, _, err := multi.User("test")
require.Error(t, err)
@@ -236,7 +235,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
@@ -251,7 +250,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
require.Equal(t, 2, mock.dialCalledTimes)
@@ -271,7 +270,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
require.Equal(t, 1, mock.dialCalledTimes)
@@ -298,7 +297,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
user, _, err := multi.User("test")
require.Equal(t, 1, mock.dialCalledTimes)
@@ -319,7 +318,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
require.Equal(t, 2, mock.dialCalledTimes)
@@ -338,7 +337,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -349,7 +348,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Error(t, err)
@@ -366,7 +365,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
@@ -381,7 +380,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -401,7 +400,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Equal(t, 1, mock.dialCalledTimes)
@@ -434,7 +433,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
users, err := multi.Users([]string{"test"})
require.Equal(t, 2, mock.dialCalledTimes)
@@ -512,7 +511,7 @@ func (mock *mockLDAP) Bind() error {
func setup() *mockLDAP {
mock := &mockLDAP{}
newLDAP = func(config *ldap.ServerConfig, cfg *setting.Cfg) ldap.IServer {
newLDAP = func(config *ldap.ServerConfig, cfg *ldap.Config) ldap.IServer {
return mock
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
)
type LDAPFakeService struct {
ExpectedConfig *ldap.Config
ExpectedConfig *ldap.ServersConfig
ExpectedClient multildap.IMultiLDAP
ExpectedError error
ExpectedUser *login.ExternalUserInfo
@@ -22,7 +22,7 @@ func (s *LDAPFakeService) ReloadConfig() error {
return s.ExpectedError
}
func (s *LDAPFakeService) Config() *ldap.Config {
func (s *LDAPFakeService) Config() *ldap.ServersConfig {
return s.ExpectedConfig
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
const defaultTimeout = 10
func readConfig(configFile string) (*ldap.Config, error) {
result := &ldap.Config{}
func readConfig(configFile string) (*ldap.ServersConfig, error) {
result := &ldap.ServersConfig{}
logger.Info("LDAP enabled, reading config file", "file", configFile)
+9 -9
View File
@@ -19,7 +19,7 @@ var (
// LDAP is the interface for the LDAP service.
type LDAP interface {
ReloadConfig() error
Config() *ldap.Config
Config() *ldap.ServersConfig
Client() multildap.IMultiLDAP
// Login authenticates the user against the LDAP server.
@@ -30,8 +30,8 @@ type LDAP interface {
type LDAPImpl struct {
client multildap.IMultiLDAP
cfg *setting.Cfg
ldapCfg *ldap.Config
cfg *ldap.Config
ldapCfg *ldap.ServersConfig
log log.Logger
// loadingMutex locks the reading of the config so multiple requests for reloading are sequential.
@@ -42,7 +42,7 @@ func ProvideService(cfg *setting.Cfg) *LDAPImpl {
s := &LDAPImpl{
client: nil,
ldapCfg: nil,
cfg: cfg,
cfg: ldap.GetLDAPConfig(cfg),
log: log.New("ldap.service"),
loadingMutex: &sync.Mutex{},
}
@@ -63,14 +63,14 @@ func ProvideService(cfg *setting.Cfg) *LDAPImpl {
}
func (s *LDAPImpl) ReloadConfig() error {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return nil
}
s.loadingMutex.Lock()
defer s.loadingMutex.Unlock()
config, err := readConfig(s.cfg.LDAPConfigFilePath)
config, err := readConfig(s.cfg.ConfigFilePath)
if err != nil {
return err
}
@@ -90,12 +90,12 @@ func (s *LDAPImpl) Client() multildap.IMultiLDAP {
return s.client
}
func (s *LDAPImpl) Config() *ldap.Config {
func (s *LDAPImpl) Config() *ldap.ServersConfig {
return s.ldapCfg
}
func (s *LDAPImpl) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return nil, ErrLDAPNotEnabled
}
@@ -108,7 +108,7 @@ func (s *LDAPImpl) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo,
}
func (s *LDAPImpl) User(username string) (*login.ExternalUserInfo, error) {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return nil, ErrLDAPNotEnabled
}
+29 -8
View File
@@ -15,8 +15,18 @@ import (
const defaultTimeout = 10
// Config holds list of connections to LDAP
// Config holds parameters from the .ini config file
type Config struct {
Enabled bool
ConfigFilePath string
AllowSignUp bool
SkipOrgRoleSync bool
SyncCron string
ActiveSyncEnabled bool
}
// ServersConfig holds list of connections to LDAP
type ServersConfig struct {
Servers []*ServerConfig `toml:"servers" json:"servers"`
}
@@ -83,16 +93,27 @@ var loadingMutex = &sync.Mutex{}
// We need to define in this space so `GetConfig` fn
// could be defined as singleton
var config *Config
var config *ServersConfig
func GetLDAPConfig(cfg *setting.Cfg) *Config {
return &Config{
Enabled: cfg.LDAPAuthEnabled,
ConfigFilePath: cfg.LDAPConfigFilePath,
AllowSignUp: cfg.LDAPAllowSignup,
SkipOrgRoleSync: cfg.LDAPSkipOrgRoleSync,
SyncCron: cfg.LDAPSyncCron,
ActiveSyncEnabled: cfg.LDAPActiveSyncEnabled,
}
}
// GetConfig returns the LDAP config if LDAP is enabled otherwise it returns nil. It returns either cached value of
// the config or it reads it and caches it first.
func GetConfig(cfg *setting.Cfg) (*Config, error) {
func GetConfig(cfg *Config) (*ServersConfig, error) {
if cfg != nil {
if !cfg.LDAPAuthEnabled {
if !cfg.Enabled {
return nil, nil
}
} else if !cfg.LDAPAuthEnabled {
} else if !cfg.Enabled {
return nil, nil
}
@@ -104,11 +125,11 @@ func GetConfig(cfg *setting.Cfg) (*Config, error) {
loadingMutex.Lock()
defer loadingMutex.Unlock()
return readConfig(cfg.LDAPConfigFilePath)
return readConfig(cfg.ConfigFilePath)
}
func readConfig(configFile string) (*Config, error) {
result := &Config{}
func readConfig(configFile string) (*ServersConfig, error) {
result := &ServersConfig{}
logger.Info("LDAP enabled, reading config file", "file", configFile)
+1 -1
View File
@@ -43,7 +43,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
}
if s.features.IsEnabled(ctx, featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Feature Toggles",
Text: "Feature toggles",
SubTitle: "View and edit feature toggles",
Id: "feature-toggles",
Url: s.cfg.AppSubURL + "/admin/featuretoggles",
@@ -408,6 +408,16 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts from an Alertmanager instance", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
}
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "History",
SubTitle: "History of events that were generated by your Grafana-managed alert rules. Silences and Mute timings are ignored.",
Id: "alerts-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
if c.SignedInUser.GetOrgRole() == org.RoleAdmin {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",
+6 -6
View File
@@ -97,8 +97,8 @@ func PrepareAlertStatuses(manager state.AlertInstanceManager, opts AlertStatuses
}
alertResponse.Data.Alerts = append(alertResponse.Data.Alerts, &apimodels.Alert{
Labels: alertState.GetLabels(labelOptions...),
Annotations: alertState.Annotations,
Labels: apimodels.LabelsFromMap(alertState.GetLabels(labelOptions...)),
Annotations: apimodels.LabelsFromMap(alertState.Annotations),
// TODO: or should we make this two fields? Using one field lets the
// frontend use the same logic for parsing text on annotations and this.
@@ -444,12 +444,12 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, groupKey ng
Name: rule.Title,
Query: ruleToQuery(log, rule),
Duration: rule.For.Seconds(),
Annotations: rule.Annotations,
Annotations: apimodels.LabelsFromMap(rule.Annotations),
}
newRule := apimodels.Rule{
Name: rule.Title,
Labels: rule.GetLabels(labelOptions...),
Labels: apimodels.LabelsFromMap(rule.GetLabels(labelOptions...)),
Health: "ok",
Type: rule.Type().String(),
LastEvaluation: time.Time{},
@@ -471,8 +471,8 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, groupKey ng
totals["error"] += 1
}
alert := apimodels.Alert{
Labels: alertState.GetLabels(labelOptions...),
Annotations: alertState.Annotations,
Labels: apimodels.LabelsFromMap(alertState.GetLabels(labelOptions...)),
Annotations: apimodels.LabelsFromMap(alertState.Annotations),
// TODO: or should we make this two fields? Using one field lets the
// frontend use the same logic for parsing text on annotations and this.
@@ -9,6 +9,7 @@ import (
"time"
v1 "github.com/prometheus/client_golang/api/prometheus/v1"
promlabels "github.com/prometheus/prometheus/model/labels"
)
// swagger:route GET /prometheus/grafana/api/v1/rules prometheus RouteGetGrafanaRuleStatuses
@@ -151,7 +152,7 @@ type AlertingRule struct {
Query string `json:"query,omitempty"`
Duration float64 `json:"duration,omitempty"`
// required: true
Annotations overrideLabels `json:"annotations,omitempty"`
Annotations promlabels.Labels `json:"annotations,omitempty"`
// required: true
ActiveAt *time.Time `json:"activeAt,omitempty"`
Alerts []Alert `json:"alerts,omitempty"`
@@ -166,8 +167,8 @@ type Rule struct {
// required: true
Name string `json:"name"`
// required: true
Query string `json:"query"`
Labels overrideLabels `json:"labels,omitempty"`
Query string `json:"query"`
Labels promlabels.Labels `json:"labels,omitempty"`
// required: true
Health string `json:"health"`
LastError string `json:"lastError,omitempty"`
@@ -181,9 +182,9 @@ type Rule struct {
// swagger:model
type Alert struct {
// required: true
Labels overrideLabels `json:"labels"`
Labels promlabels.Labels `json:"labels"`
// required: true
Annotations overrideLabels `json:"annotations"`
Annotations promlabels.Labels `json:"annotations"`
// required: true
State string `json:"state"`
ActiveAt *time.Time `json:"activeAt"`
@@ -300,31 +301,6 @@ func (by AlertsBy) TopK(alerts []Alert, k int) []Alert {
// is more important than "normal". If two alerts have the same importance
// then the ordering is based on their ActiveAt time and their labels.
func AlertsByImportance(a1, a2 *Alert) bool {
// labelsForComparison concatenates each key/value pair into a string and
// sorts them.
labelsForComparison := func(m map[string]string) []string {
s := make([]string, 0, len(m))
for k, v := range m {
s = append(s, k+v)
}
sort.Strings(s)
return s
}
// compareLabels returns true if labels1 are less than labels2. This happens
// when labels1 has fewer labels than labels2, or if the next label from
// labels1 is lexicographically less than the next label from labels2.
compareLabels := func(labels1, labels2 []string) bool {
if len(labels1) == len(labels2) {
for i := range labels1 {
if labels1[i] != labels2[i] {
return labels1[i] < labels2[i]
}
}
}
return len(labels1) < len(labels2)
}
// The importance of an alert is first based on the importance of their states.
// This ordering is intended to show the most important alerts first when
// using pagination.
@@ -345,9 +321,7 @@ func AlertsByImportance(a1, a2 *Alert) bool {
return true
}
// Both alerts are active since the same time so compare their labels
labels1 := labelsForComparison(a1.Labels)
labels2 := labelsForComparison(a2.Labels)
return compareLabels(labels1, labels2)
return promlabels.Compare(a1.Labels, a2.Labels) < 0
}
return importance1 < importance2
@@ -362,9 +336,16 @@ func (s AlertsSorter) Len() int { return len(s.alerts) }
func (s AlertsSorter) Swap(i, j int) { s.alerts[i], s.alerts[j] = s.alerts[j], s.alerts[i] }
func (s AlertsSorter) Less(i, j int) bool { return s.by(&s.alerts[i], &s.alerts[j]) }
// override the labels type with a map for generation.
// The custom marshaling for labels.Labels ends up doing this anyways.
type overrideLabels map[string]string
// LabelsFromMap creates Labels from a map. Note the Labels type requires the
// labels be sorted, so we make sure to do that.
func LabelsFromMap(m map[string]string) promlabels.Labels {
sb := promlabels.NewScratchBuilder(len(m))
for k, v := range m {
sb.Add(k, v)
}
sb.Sort()
return sb.Labels()
}
// swagger:parameters RouteGetGrafanaAlertStatuses
type GetGrafanaAlertStatusesParams struct {
@@ -21,10 +21,11 @@ func makeAlerts(amount int) []Alert {
alerts := make([]Alert, amount)
for i := 0; i < len(alerts); i++ {
alerts[i].Labels = make(map[string]string)
lbls := make(map[string]string)
for label := 0; label < numLabels; label++ {
alerts[i].Labels[fmt.Sprintf("label_%d", label)] = fmt.Sprintf("label_%d_value_%d", label, i%100)
lbls[fmt.Sprintf("label_%d", label)] = fmt.Sprintf("label_%d_value_%d", label, i%100)
}
alerts[i].Labels = LabelsFromMap(lbls)
if i%100 < percentAlerting {
alerts[i].State = "alerting"
@@ -69,32 +69,42 @@ func TestSortAlertsByImportance(t *testing.T) {
}, {
name: "inactive alerts with same importance are ordered by labels",
input: []Alert{
{State: "normal", Labels: map[string]string{"c": "d"}},
{State: "normal", Labels: map[string]string{"a": "b"}},
{State: "normal", Labels: LabelsFromMap(map[string]string{"c": "d"})},
{State: "normal", Labels: LabelsFromMap(map[string]string{"a": "b"})},
},
expected: []Alert{
{State: "normal", Labels: map[string]string{"a": "b"}},
{State: "normal", Labels: map[string]string{"c": "d"}},
{State: "normal", Labels: LabelsFromMap(map[string]string{"a": "b"})},
{State: "normal", Labels: LabelsFromMap(map[string]string{"c": "d"})},
},
}, {
name: "active alerts with same importance and active time are ordered fewest labels first",
name: "active alerts with same importance and active time are ordered by label names",
input: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b", "c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"e": "f"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d", "e": "f"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
},
expected: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"e": "f"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b", "c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d", "e": "f"})},
},
}, {
name: "active alerts with same importance and active time are ordered by labels",
input: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
},
expected: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d"})},
},
}, {
name: "active alerts with same importance and active time are ordered by label values",
input: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "b"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "a"})},
},
expected: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "a"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "b"})},
},
}}
+18 -16
View File
@@ -2,6 +2,7 @@ package model
import (
"strings"
"time"
)
// FilterWhere limits the set of dashboard IDs to the dashboards for
@@ -62,22 +63,23 @@ const (
)
type Hit struct {
ID int64 `json:"id"`
UID string `json:"uid"`
Title string `json:"title"`
URI string `json:"uri"`
URL string `json:"url"`
Slug string `json:"slug"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
FolderID int64 `json:"folderId,omitempty"` // Deprecated: use FolderUID instead
FolderUID string `json:"folderUid,omitempty"`
FolderTitle string `json:"folderTitle,omitempty"`
FolderURL string `json:"folderUrl,omitempty"`
SortMeta int64 `json:"sortMeta"`
SortMetaName string `json:"sortMetaName,omitempty"`
RemainingTrashAtAge string `json:"remainingTrashAtAge,omitempty"`
ID int64 `json:"id"`
UID string `json:"uid"`
Title string `json:"title"`
URI string `json:"uri"`
URL string `json:"url"`
Slug string `json:"slug"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
FolderID int64 `json:"folderId,omitempty"` // Deprecated: use FolderUID instead
FolderUID string `json:"folderUid,omitempty"`
FolderTitle string `json:"folderTitle,omitempty"`
FolderURL string `json:"folderUrl,omitempty"`
SortMeta int64 `json:"sortMeta"`
SortMetaName string `json:"sortMetaName,omitempty"`
IsDeleted bool `json:"isDeleted"`
PermanentlyDeleteDate *time.Time `json:"permanentlyDeleteDate,omitempty"`
}
type HitList []*Hit
+168 -58
View File
@@ -3,18 +3,97 @@ package sqlstash
import (
"context"
"fmt"
"io"
)
type ConnectFunc[T any] func(chan T) error
// Please, when reviewing or working on this file have the following cheat-sheet
// in mind:
// 1. A channel type in Go has one of three directions: send-only (chan<- T),
// receive-only (<-chan T) or bidirctional (chan T). Each of them are a
// different type. A bidirectional type can be converted to any of the other
// two types and is automatic, any other conversion attempt results in a
// panic.
// 2. There are three operations you can do on a channel: send, receive and
// close. Availability of operation for each channel direction:
// | Channel direction
// Operation | Receive-only | Send-only | Bidirectional
// ----------+--------------+------------+--------------
// Receive | Yes | No (panic) | Yes
// Send | No (panic) | Yes | Yes
// Close | No (panic) | Yes | Yes
// 3. A channel of any type also has one of three states: nil (zero value),
// closed, or open (technically called "non-nil, not-closed channel",
// created with the `make` builtin). Nil and closed channels are also
// useful, but you have to know and care for how you use them. Outcome of
// each operation on a channel depending on its state, assuming the
// operation is available to the channel given its direction:
// | Channel state
// Operation | Nil | Closed | Open
// ----------+---------------+---------------+------------------
// Receive | Block forever | Block forever | Receive/Block until receive
// Send | Block forever | Panic | Send/Block until send
// Close | Panic | Panic | Close the channel
// 4. A `select` statement has zero or more `case` branches, each one of them
// containing either a send or a receive channel operation. A `select` with
// no branches blocks forever. At most one branch will be executed, which
// means it behaves similar to a `switch`. If more than one branch can be
// executed then one of them is picked AT RANDOM (i.e. not the one first in
// the list). A `select` statement can also have a (single and optional)
// `default` branch that is executed if all the other branches are
// operations that are blocked at the time the `select` statement is
// reached. This means that having a `default` branch causes the `select`
// statement to never block.
// 5. A receive operation on a closed channel never blocks (as said before),
// but it will always yield a zero value. As it is also valid to send a zero
// value to the channel, you can receive from channels in two forms:
// v := <-c // get a zero value if closed
// v2, ok := <-c // `ok` is set to false iif the channel is closed
// 6. The `make` builtin is used to create open channels (and is the only way
// to get them). It has an optional second parameter to specify the amount
// of items that can buffered. After that, a send operation will block
// waiting for another goroutine to receive from it (which would make room
// for the new item). When the second argument is not passed to `make`, then
// all operations are fully synchronized, meaning that a send will block
// until a receive in another goroutine is performed, and vice versa. Less
// interestingly, `make` can also create send-only or receive-only channel.
//
// The sources are the Go Specs, Effective Go and Go 101, which are already
// linked in the contributing guide for the backend or elsewhere in Grafana, but
// this file exploits so many of these subtleties that it's worth keeping a
// refresher about them at all times. The above is unlikely to change in the
// foreseeable future, so it's zero maintenance as well. We exclude patterns for
// using channels and other concurrency patterns since that's a way longer
// topic for a refresher.
// ConnectFunc is used to initialize the watch implementation. It should do very
// basic work and checks and it has the chance to return an error. After that,
// it should fork to a different goroutine with the provided channel and send to
// it all the new events from the backing database. It is also responsible for
// closing the provided channel under all circumstances, included returning an
// error. The caller of this function will only receive from this channel (i.e.
// it is guaranteed to never send to it or close it), hence providing a safe
// separation of concerns and preventing panics.
//
// FIXME: this signature suffers from inversion of control. It would also be
// much simpler if NewBroadcaster receives a context.Context and a <-chan T
// instead. That would also reduce the scope of the broadcaster to only
// broadcast to subscribers what it receives on the provided <-chan T. The
// context.Context is still needed to provide additional values in case we want
// to add observability into the broadcaster, which we want. The broadcaster
// should still terminate on either the context being done or the provided
// channel being closed.
type ConnectFunc[T any] func(chan<- T) error
type Broadcaster[T any] interface {
Subscribe(context.Context) (<-chan T, error)
Unsubscribe(chan T)
Unsubscribe(<-chan T)
}
func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadcaster[T], error) {
b := &broadcaster[T]{}
err := b.start(ctx, connect)
b := &broadcaster[T]{
started: make(chan struct{}),
}
err := b.init(ctx, connect)
if err != nil {
return nil, err
}
@@ -23,101 +102,132 @@ func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadca
}
type broadcaster[T any] struct {
running bool // FIXME: race condition between `Subscribe`/`Unsubscribe` and `start`
ctx context.Context
subs map[chan T]struct{}
// lifecycle management
started, terminated chan struct{}
shouldTerminate <-chan struct{}
// subscription management
cache Cache[T]
subscribe chan chan T
unsubscribe chan chan T
unsubscribe chan (<-chan T)
subs map[<-chan T]chan T
}
func (b *broadcaster[T]) Subscribe(ctx context.Context) (<-chan T, error) {
if !b.running {
return nil, fmt.Errorf("broadcaster not running")
select {
case <-ctx.Done(): // client canceled
return nil, ctx.Err()
case <-b.started: // wait for broadcaster to start
}
// create the subscription
sub := make(chan T, 100)
b.subscribe <- sub
go func() {
<-ctx.Done()
b.unsubscribe <- sub
}()
return sub, nil
}
func (b *broadcaster[T]) Unsubscribe(sub chan T) {
b.unsubscribe <- sub
}
func (b *broadcaster[T]) start(ctx context.Context, connect ConnectFunc[T]) error {
if b.running {
return fmt.Errorf("broadcaster already running")
select {
case <-ctx.Done(): // client canceled
return nil, ctx.Err()
case <-b.terminated: // no more data
return nil, io.EOF
case b.subscribe <- sub: // success submitting subscription
return sub, nil
}
}
func (b *broadcaster[T]) Unsubscribe(sub <-chan T) {
// wait for broadcaster to start. In practice, the only way to reach
// Unsubscribe is by first having called Subscribe, which means we have
// already started. But a malfunctioning caller may call Unsubscribe freely,
// which would cause us to block forever the goroutine of the caller when
// trying to send to a nil `b.unsubscribe` or receive from a nil
// `b.terminated` if we haven't yet initialized those values. This would
// mean leaking that malfunctioninig caller's goroutine, so we rather make
// Unsubscribe safe in any possible case
if sub == nil {
return
}
<-b.started // wait for broadcaster to start
select {
case b.unsubscribe <- sub: // success submitting unsubscription
case <-b.terminated: // broadcaster terminated, nothing to do
}
}
// init initializes the broadcaster. It should not be run more than once.
func (b *broadcaster[T]) init(ctx context.Context, connect ConnectFunc[T]) error {
// create the stream that will connect us with the watch implementation and
// send it to them so they initialize and start sending data
stream := make(chan T, 100)
err := connect(stream)
if err != nil {
if err := connect(stream); err != nil {
return err
}
b.ctx = ctx
// initialize our internal state
b.shouldTerminate = ctx.Done()
b.cache = NewCache[T](ctx, 100)
b.subscribe = make(chan chan T, 100)
b.unsubscribe = make(chan chan T, 100)
b.subs = make(map[chan T]struct{})
b.unsubscribe = make(chan (<-chan T), 100)
b.subs = make(map[<-chan T]chan T)
b.terminated = make(chan struct{})
// start handling incoming data from the watch implementation. If data came
// in until now, it will be buffered in `stream`
go b.stream(stream)
b.running = true
// unblock any Subscribe/Unsubscribe calls since we are ready to handle them
close(b.started)
return nil
}
func (b *broadcaster[T]) stream(input chan T) {
// stream acts a message broker between the watch implementation that receives a
// raw stream of events and the individual clients watching for those events.
// Thus, we hold the receive side of the watch implementation, and we are
// limited here to receive from it, whereas we are responsible for sending to
// watchers and closing their channels. The responsibility of closing `input`
// (as with any other channel) will always be of the sending side. Hence, the
// watch implementation should do it.
func (b *broadcaster[T]) stream(input <-chan T) {
// make sure we unconditionally cleanup upon return
defer func() {
// prevent new subscriptions and make sure to discard unsubscriptions
close(b.terminated)
// terminate all subscirptions and clean the map
for _, sub := range b.subs {
close(sub)
delete(b.subs, sub)
}
}()
for {
select {
// context cancelled
case <-b.ctx.Done():
close(input)
for sub := range b.subs {
close(sub)
delete(b.subs, sub)
}
b.running = false
case <-b.shouldTerminate: // service context cancelled
return
// new subscriber
case sub := <-b.subscribe:
case sub := <-b.subscribe: // subscribe
// send initial batch of cached items
err := b.cache.ReadInto(sub)
if err != nil {
close(sub)
continue
}
b.subs[sub] = sub
b.subs[sub] = struct{}{}
// unsubscribe
case sub := <-b.unsubscribe:
if _, ok := b.subs[sub]; ok {
case recv := <-b.unsubscribe: // unsubscribe
if sub, ok := b.subs[recv]; ok {
close(sub)
delete(b.subs, sub)
}
// read item from input
case item, ok := <-input:
case item, ok := <-input: // data arrived, send to subscribers
// input closed, drain subscribers and exit
if !ok {
for sub := range b.subs {
close(sub)
delete(b.subs, sub)
}
b.running = false
return
}
b.cache.Add(item)
for sub := range b.subs {
for _, sub := range b.subs {
select {
case sub <- item:
default:
@@ -63,6 +63,9 @@ func ProvideSQLEntityServer(db db.EntityDBInterface, tracer tracing.Tracer /*, c
type SqlEntityServer interface {
entity.EntityStoreServer
// FIXME: accpet a context.Context in the lifecycle methods, and Stop should
// also return an error.
Init() error
Stop()
}
@@ -75,7 +78,6 @@ type sqlEntityServer struct {
broadcaster Broadcaster[*entity.EntityWatchResponse]
ctx context.Context // TODO: remove
cancel context.CancelFunc
stream chan *entity.EntityWatchResponse
tracer trace.Tracer
once sync.Once
@@ -139,9 +141,7 @@ func (s *sqlEntityServer) init() error {
s.dialect = migrator.NewDialect(engine.DriverName())
// set up the broadcaster
s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan *entity.EntityWatchResponse) error {
s.stream = stream
s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan<- *entity.EntityWatchResponse) error {
// start the poller
go s.poller(stream)
@@ -994,13 +994,18 @@ func (s *sqlEntityServer) watchInit(ctx context.Context, r *entity.EntityWatchRe
return lastRv, nil
}
func (s *sqlEntityServer) poller(stream chan *entity.EntityWatchResponse) {
func (s *sqlEntityServer) poller(stream chan<- *entity.EntityWatchResponse) {
var err error
// FIXME: we need a way to state startup of server from a (Group, Resource)
// standpoint, and consider that new (Group, Resource) may be added to
// `kind_version`, so we should probably also poll for changes in there
since := int64(0)
interval := 1 * time.Second
t := time.NewTicker(interval)
defer close(stream)
defer t.Stop()
for {
@@ -1017,7 +1022,7 @@ func (s *sqlEntityServer) poller(stream chan *entity.EntityWatchResponse) {
}
}
func (s *sqlEntityServer) poll(since int64, out chan *entity.EntityWatchResponse) (int64, error) {
func (s *sqlEntityServer) poll(since int64, out chan<- *entity.EntityWatchResponse) (int64, error) {
ctx, span := s.tracer.Start(s.ctx, "storage_server.poll")
defer span.End()
ctxLogger := s.log.FromContext(log.WithContextualAttributes(ctx, []any{"method", "poll"}))
@@ -1182,26 +1187,25 @@ func (s *sqlEntityServer) watch(r *entity.EntityWatchRequest, w entity.EntitySto
if err != nil {
return err
}
defer s.broadcaster.Unsubscribe(evts)
stop := make(chan struct{})
since := r.Since
go func() {
defer close(stop)
for {
r, err := w.Recv()
if errors.Is(err, io.EOF) {
s.log.Debug("watch client closed stream")
stop <- struct{}{}
return
}
if err != nil {
s.log.Error("error receiving message", "err", err)
stop <- struct{}{}
return
}
if r.Action == entity.EntityWatchRequest_STOP {
s.log.Debug("watch stop requested")
stop <- struct{}{}
return
}
// handle any other message types
@@ -1211,7 +1215,6 @@ func (s *sqlEntityServer) watch(r *entity.EntityWatchRequest, w entity.EntitySto
for {
select {
// stop signal
case <-stop:
s.log.Debug("watch stopped")
return nil
+20 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"net"
"testing"
"github.com/apache/arrow/go/v15/arrow/flight"
@@ -21,9 +22,14 @@ type FSQLTestSuite struct {
suite.Suite
db *sql.DB
server flight.Server
addr string
}
func (suite *FSQLTestSuite) SetupTest() {
addr, _ := freeport(suite.T())
suite.addr = addr
db, err := example.CreateDB()
require.NoError(suite.T(), err)
@@ -32,7 +38,7 @@ func (suite *FSQLTestSuite) SetupTest() {
sqliteServer.Alloc = memory.NewCheckedAllocator(memory.DefaultAllocator)
server := flight.NewServerWithMiddleware(nil)
server.RegisterFlightService(flightsql.NewFlightServer(sqliteServer))
err = server.Init("localhost:12345")
err = server.Init(suite.addr)
require.NoError(suite.T(), err)
go func() {
err := server.Serve()
@@ -59,7 +65,7 @@ func (suite *FSQLTestSuite) TestIntegration_QueryData() {
&models.DatasourceInfo{
HTTPClient: nil,
Token: "secret",
URL: "http://localhost:12345",
URL: "http://" + suite.addr,
DbName: "influxdb",
Version: "test",
HTTPMode: "proxy",
@@ -109,3 +115,15 @@ func mustQueryJSON(t *testing.T, refID, sql string) []byte {
}
return b
}
func freeport(t *testing.T) (addr string, err error) {
l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatal(err)
}
defer func() {
err = l.Close()
}()
a := l.Addr().(*net.TCPAddr)
return a.String(), nil
}
+22 -1
View File
@@ -23,7 +23,10 @@ import (
"github.com/grafana/grafana/pkg/tsdb/influxdb/models"
)
const defaultRetentionPolicy = "default"
const (
defaultRetentionPolicy = "default"
metadataPrefix = "x-grafana-meta-add-"
)
var (
ErrInvalidHttpMode = errors.New("'httpMode' should be either 'GET' or 'POST'")
@@ -195,9 +198,27 @@ func execute(ctx context.Context, tracer trace.Tracer, dsInfo *models.Datasource
} else {
resp = buffered.ResponseParse(res.Body, res.StatusCode, query)
}
if resp.Frames != nil && len(resp.Frames) > 0 {
resp.Frames[0].Meta.Custom = readCustomMetadata(res)
}
return *resp, nil
}
func readCustomMetadata(res *http.Response) map[string]any {
var result map[string]any
for k := range res.Header {
if key, found := strings.CutPrefix(strings.ToLower(k), metadataPrefix); found {
if result == nil {
result = make(map[string]any)
}
result[key] = res.Header.Get(k)
}
}
return result
}
// startTrace setups a trace but does not panic if tracer is nil which helps with testing
func startTrace(ctx context.Context, tracer trace.Tracer, name string, attributes ...attribute.KeyValue) (context.Context, func()) {
if tracer == nil {
@@ -3,6 +3,7 @@ package influxql
import (
"context"
"io"
"net/http"
"net/url"
"testing"
@@ -60,3 +61,52 @@ func TestExecutor_createRequest(t *testing.T) {
require.EqualError(t, err, ErrInvalidHttpMode.Error())
})
}
func TestReadCustomMetadata(t *testing.T) {
t.Run("should read nothing if no X-Grafana-Meta-Add-<Thing> header exists", func(t *testing.T) {
header := http.Header{}
header.Add("content-type", "text/html")
header.Add("content-encoding", "gzip")
res := &http.Response{
Header: header,
}
result := readCustomMetadata(res)
require.Nil(t, result)
})
t.Run("should read X-Grafana-Meta-Add-<Thing> header", func(t *testing.T) {
header := http.Header{}
header.Add("content-type", "text/html")
header.Add("content-encoding", "gzip")
header.Add("X-Grafana-Meta-Add-TestThing", "test1234")
res := &http.Response{
Header: header,
}
result := readCustomMetadata(res)
expected := map[string]any{
"testthing": "test1234",
}
require.NotNil(t, result)
require.Equal(t, expected, result)
})
t.Run("should read multiple X-Grafana-Meta-Add-<Thing> header", func(t *testing.T) {
header := http.Header{}
header.Add("content-type", "text/html")
header.Add("content-encoding", "gzip")
header.Add("X-Grafana-Meta-Add-TestThing", "test111")
header.Add("X-Grafana-Meta-Add-TestThing2", "test222")
header.Add("X-Grafana-Meta-Add-Test-Other", "other")
res := &http.Response{
Header: header,
}
result := readCustomMetadata(res)
expected := map[string]any{
"testthing": "test111",
"testthing2": "test222",
"test-other": "other",
}
require.NotNil(t, result)
require.Equal(t, expected, result)
})
}