From e4b79e2fc8db84b735a3e0866c8c0f7445cd6862 Mon Sep 17 00:00:00 2001 From: Daniele Stefano Ferru Date: Mon, 12 Jan 2026 17:52:00 +0100 Subject: [PATCH 01/35] Provisioning: Add Validation and Mutation for `Connection` resource (#115596) * WIP: mutator added, start working on validator * first validator iteration * second validator iteration * wip: working on integration tests * re-working mutation and validation, using Connection interface * fixing some rebase things * fixing integration tests * formatting * fixing unit tests * k8s codegen * linting * moving tests which are available only for enterprise * addressing comments: using repo config for connections, updating tests * addressing comments: adding some more info in the app and installation * fixing app data * addressing comments: updating connection implementation * addressing comments * formatting * fixing tests --- .../apis/provisioning/v0alpha1/connections.go | 2 +- .../v0alpha1/zz_generated.openapi.go | 2 +- ...enerated.openapi_violation_exceptions.list | 1 - .../provisioning/pkg/connection/connection.go | 16 + .../pkg/connection/connection_mock.go | 128 ++++++ .../provisioning/pkg/connection/extra_mock.go | 141 ++++++ apps/provisioning/pkg/connection/factory.go | 75 +++ .../pkg/connection/factory_mock.go | 143 ++++++ .../pkg/connection/factory_test.go | 309 +++++++++++++ .../pkg/connection/github/client.go | 93 ++++ .../pkg/connection/github/client_mock.go | 149 ++++++ .../pkg/connection/github/client_test.go | 297 ++++++++++++ .../pkg/connection/github/connection.go | 192 ++++++++ .../pkg/connection/github/connection_test.go | 434 ++++++++++++++++++ .../pkg/connection/github/extra.go | 36 ++ .../pkg/connection/github/extra_test.go | 126 +++++ .../pkg/connection/github/factory.go | 39 ++ .../pkg/connection/github/factory_mock.go | 86 ++++ apps/provisioning/pkg/connection/mutator.go | 28 -- .../pkg/connection/mutator_test.go | 35 -- apps/provisioning/pkg/connection/validator.go | 104 ----- .../pkg/connection/validator_test.go | 253 ---------- .../provisioning/v0alpha1/connectionsecure.go | 2 +- .../provisioning/v0alpha1/endpoints.gen.ts | 2 +- .../apis/provisioning/extras/register.go | 20 + pkg/registry/apis/provisioning/register.go | 91 +++- .../provisioning/register_validate_test.go | 2 +- pkg/registry/apis/wireset.go | 1 + pkg/server/test_env.go | 62 +-- pkg/server/wire.go | 2 + pkg/server/wire_gen.go | 21 +- pkg/server/wireexts_oss.go | 1 + pkg/tests/apis/helper.go | 5 + .../provisioning.grafana.app-v0alpha1.json | 2 +- .../connection_repositories_test.go | 22 +- .../connection_status_auth_test.go | 11 +- .../apis/provisioning/connection_test.go | 287 ++++++++++-- pkg/tests/apis/provisioning/helper_test.go | 86 +++- .../apis/provisioning/repository_test.go | 18 +- pkg/tests/testinfra/testinfra.go | 7 + 40 files changed, 2798 insertions(+), 533 deletions(-) create mode 100644 apps/provisioning/pkg/connection/connection.go create mode 100644 apps/provisioning/pkg/connection/connection_mock.go create mode 100644 apps/provisioning/pkg/connection/extra_mock.go create mode 100644 apps/provisioning/pkg/connection/factory.go create mode 100644 apps/provisioning/pkg/connection/factory_mock.go create mode 100644 apps/provisioning/pkg/connection/factory_test.go create mode 100644 apps/provisioning/pkg/connection/github/client.go create mode 100644 apps/provisioning/pkg/connection/github/client_mock.go create mode 100644 apps/provisioning/pkg/connection/github/client_test.go create mode 100644 apps/provisioning/pkg/connection/github/connection.go create mode 100644 apps/provisioning/pkg/connection/github/connection_test.go create mode 100644 apps/provisioning/pkg/connection/github/extra.go create mode 100644 apps/provisioning/pkg/connection/github/extra_test.go create mode 100644 apps/provisioning/pkg/connection/github/factory.go create mode 100644 apps/provisioning/pkg/connection/github/factory_mock.go delete mode 100644 apps/provisioning/pkg/connection/mutator.go delete mode 100644 apps/provisioning/pkg/connection/mutator_test.go delete mode 100644 apps/provisioning/pkg/connection/validator.go delete mode 100644 apps/provisioning/pkg/connection/validator_test.go diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go index f9f8dcc8382..2738af49db1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go @@ -32,7 +32,7 @@ type ConnectionSecure struct { // Token is the reference of the token used to act as the Connection. // This value is stored securely and cannot be read back - Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"` + Token common.InlineSecureValue `json:"token,omitzero,omitempty"` } func (v ConnectionSecure) IsZero() bool { diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 11788142e94..4db11489c98 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -320,7 +320,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.Reference Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), }, }, - "webhook": { + "token": { SchemaProps: spec.SchemaProps{ Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", Default: map[string]interface{}{}, diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index 3a54dcf2a5e..72567e04b90 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -22,7 +22,6 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents -API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs diff --git a/apps/provisioning/pkg/connection/connection.go b/apps/provisioning/pkg/connection/connection.go new file mode 100644 index 00000000000..d2043b5af4a --- /dev/null +++ b/apps/provisioning/pkg/connection/connection.go @@ -0,0 +1,16 @@ +package connection + +import ( + "context" +) + +//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter +type Connection interface { + // Validate ensures the resource _looks_ correct. + // It should be called before trying to upsert a resource into the Kubernetes API server. + // This is not an indication that the connection information works, just that they are reasonably configured. + Validate(ctx context.Context) error + + // Mutate performs in place mutation of the underneath resource. + Mutate(context.Context) error +} diff --git a/apps/provisioning/pkg/connection/connection_mock.go b/apps/provisioning/pkg/connection/connection_mock.go new file mode 100644 index 00000000000..3867059d432 --- /dev/null +++ b/apps/provisioning/pkg/connection/connection_mock.go @@ -0,0 +1,128 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockConnection is an autogenerated mock type for the Connection type +type MockConnection struct { + mock.Mock +} + +type MockConnection_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConnection) EXPECT() *MockConnection_Expecter { + return &MockConnection_Expecter{mock: &_m.Mock} +} + +// Mutate provides a mock function with given fields: _a0 +func (_m *MockConnection) Mutate(_a0 context.Context) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Mutate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' +type MockConnection_Mutate_Call struct { + *mock.Call +} + +// Mutate is a helper method to define mock.On call +// - _a0 context.Context +func (_e *MockConnection_Expecter) Mutate(_a0 interface{}) *MockConnection_Mutate_Call { + return &MockConnection_Mutate_Call{Call: _e.mock.On("Mutate", _a0)} +} + +func (_c *MockConnection_Mutate_Call) Run(run func(_a0 context.Context)) *MockConnection_Mutate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Mutate_Call) Return(_a0 error) *MockConnection_Mutate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Mutate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Mutate_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: ctx +func (_m *MockConnection) Validate(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockConnection_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockConnection_Expecter) Validate(ctx interface{}) *MockConnection_Validate_Call { + return &MockConnection_Validate_Call{Call: _e.mock.On("Validate", ctx)} +} + +func (_c *MockConnection_Validate_Call) Run(run func(ctx context.Context)) *MockConnection_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Validate_Call) Return(_a0 error) *MockConnection_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Validate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConnection(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConnection { + mock := &MockConnection{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/extra_mock.go b/apps/provisioning/pkg/connection/extra_mock.go new file mode 100644 index 00000000000..cc2a1f3d5e2 --- /dev/null +++ b/apps/provisioning/pkg/connection/extra_mock.go @@ -0,0 +1,141 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockExtra is an autogenerated mock type for the Extra type +type MockExtra struct { + mock.Mock +} + +type MockExtra_Expecter struct { + mock *mock.Mock +} + +func (_m *MockExtra) EXPECT() *MockExtra_Expecter { + return &MockExtra_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockExtra) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockExtra_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockExtra_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockExtra_Expecter) Build(ctx interface{}, r interface{}) *MockExtra_Build_Call { + return &MockExtra_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockExtra_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockExtra_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockExtra_Build_Call) Return(_a0 Connection, _a1 error) *MockExtra_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockExtra_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockExtra_Build_Call { + _c.Call.Return(run) + return _c +} + +// Type provides a mock function with no fields +func (_m *MockExtra) Type() v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Type") + } + + var r0 v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(v0alpha1.ConnectionType) + } + + return r0 +} + +// MockExtra_Type_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Type' +type MockExtra_Type_Call struct { + *mock.Call +} + +// Type is a helper method to define mock.On call +func (_e *MockExtra_Expecter) Type() *MockExtra_Type_Call { + return &MockExtra_Type_Call{Call: _e.mock.On("Type")} +} + +func (_c *MockExtra_Type_Call) Run(run func()) *MockExtra_Type_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockExtra_Type_Call) Return(_a0 v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockExtra_Type_Call) RunAndReturn(run func() v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(run) + return _c +} + +// NewMockExtra creates a new instance of MockExtra. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockExtra(t interface { + mock.TestingT + Cleanup(func()) +}) *MockExtra { + mock := &MockExtra{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory.go b/apps/provisioning/pkg/connection/factory.go new file mode 100644 index 00000000000..4a0e46d84d2 --- /dev/null +++ b/apps/provisioning/pkg/connection/factory.go @@ -0,0 +1,75 @@ +package connection + +import ( + "context" + "fmt" + "sort" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +//go:generate mockery --name=Extra --structname=MockExtra --inpackage --filename=extra_mock.go --with-expecter +type Extra interface { + Type() provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +//go:generate mockery --name=Factory --structname=MockFactory --inpackage --filename=factory_mock.go --with-expecter +type Factory interface { + Types() []provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +type factory struct { + extras map[provisioning.ConnectionType]Extra + enabled map[provisioning.ConnectionType]struct{} +} + +func ProvideFactory(enabled map[provisioning.ConnectionType]struct{}, extras []Extra) (Factory, error) { + f := &factory{ + enabled: enabled, + extras: make(map[provisioning.ConnectionType]Extra, len(extras)), + } + + for _, e := range extras { + if _, exists := f.extras[e.Type()]; exists { + return nil, fmt.Errorf("connection type %q is already registered", e.Type()) + } + f.extras[e.Type()] = e + } + + return f, nil +} + +func (f *factory) Types() []provisioning.ConnectionType { + var types []provisioning.ConnectionType + for t := range f.enabled { + if _, exists := f.extras[t]; exists { + types = append(types, t) + } + } + + sort.Slice(types, func(i, j int) bool { + return string(types[i]) < string(types[j]) + }) + + return types +} + +func (f *factory) Build(ctx context.Context, c *provisioning.Connection) (Connection, error) { + for _, e := range f.extras { + if e.Type() == c.Spec.Type { + if _, enabled := f.enabled[e.Type()]; !enabled { + return nil, fmt.Errorf("connection type %q is not enabled", e.Type()) + } + + return e.Build(ctx, c) + } + } + + return nil, fmt.Errorf("connection type %q is not supported", c.Spec.Type) +} + +var ( + _ Factory = (*factory)(nil) +) diff --git a/apps/provisioning/pkg/connection/factory_mock.go b/apps/provisioning/pkg/connection/factory_mock.go new file mode 100644 index 00000000000..8fd7023920f --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_mock.go @@ -0,0 +1,143 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockFactory is an autogenerated mock type for the Factory type +type MockFactory struct { + mock.Mock +} + +type MockFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFactory) EXPECT() *MockFactory_Expecter { + return &MockFactory_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockFactory) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockFactory_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockFactory_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockFactory_Expecter) Build(ctx interface{}, r interface{}) *MockFactory_Build_Call { + return &MockFactory_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockFactory_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockFactory_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockFactory_Build_Call) Return(_a0 Connection, _a1 error) *MockFactory_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockFactory_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockFactory_Build_Call { + _c.Call.Return(run) + return _c +} + +// Types provides a mock function with no fields +func (_m *MockFactory) Types() []v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Types") + } + + var r0 []v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() []v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v0alpha1.ConnectionType) + } + } + + return r0 +} + +// MockFactory_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types' +type MockFactory_Types_Call struct { + *mock.Call +} + +// Types is a helper method to define mock.On call +func (_e *MockFactory_Expecter) Types() *MockFactory_Types_Call { + return &MockFactory_Types_Call{Call: _e.mock.On("Types")} +} + +func (_c *MockFactory_Types_Call) Run(run func()) *MockFactory_Types_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockFactory_Types_Call) Return(_a0 []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockFactory_Types_Call) RunAndReturn(run func() []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(run) + return _c +} + +// NewMockFactory creates a new instance of MockFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFactory { + mock := &MockFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory_test.go b/apps/provisioning/pkg/connection/factory_test.go new file mode 100644 index 00000000000..4ce6bc96e0e --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_test.go @@ -0,0 +1,309 @@ +package connection + +import ( + "context" + "errors" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestProvideFactory(t *testing.T) { + t.Run("should create factory with valid extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with empty extras", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with nil enabled map", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + factory, err := ProvideFactory(nil, []Extra{extra1}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should return error when duplicate repository types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.Error(t, err) + assert.Nil(t, factory) + assert.Contains(t, err.Error(), "connection type \"github\" is already registered") + }) +} + +func TestFactory_Types(t *testing.T) { + t.Run("should return only enabled types that have extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.Contains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return sorted list of types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + // github should come before gitlab alphabetically + assert.Equal(t, provisioning.GithubConnectionType, types[0]) + assert.Equal(t, provisioning.GitlabConnectionType, types[1]) + }) + + t.Run("should return empty list when no types are enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) + + t.Run("should not return types that are enabled but have no extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should not return types that have extras but are not enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return empty list when no extras are provided", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) +} + +func TestFactory_Build(t *testing.T) { + t.Run("should successfully build connection when type is enabled and has extra", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) + + t.Run("should return error when type is not enabled", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled") + }) + + t.Run("should return error when type is not supported", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported") + }) + + t.Run("should pass through errors from extra.Build()", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + expectedErr := errors.New("build error") + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(nil, expectedErr) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, expectedErr, err) + }) + + t.Run("should build with multiple extras registered", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/client.go b/apps/provisioning/pkg/connection/github/client.go new file mode 100644 index 00000000000..7ddb9a4665e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v70/github" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// API errors that we need to convey after parsing real GH errors (or faking them). +var ( + //lint:ignore ST1005 this is not punctuation + ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable") +) + +//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter +type Client interface { + // Apps and installations + GetApp(ctx context.Context) (App, error) + GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) +} + +// App represents a Github App. +type App struct { + // ID represents the GH app ID. + ID int64 + // Slug represents the GH app slug. + Slug string + // Owner represents the GH account/org owning the app + Owner string +} + +// AppInstallation represents a Github App Installation. +type AppInstallation struct { + // ID represents the GH installation ID. + ID int64 + // Whether the installation is enabled or not. + Enabled bool +} + +type githubClient struct { + gh *github.Client +} + +func NewClient(client *github.Client) Client { + return &githubClient{client} +} + +// GetApp gets the app by using the given token. +func (r *githubClient) GetApp(ctx context.Context) (App, error) { + app, _, err := r.gh.Apps.Get(ctx, "") + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return App{}, ErrServiceUnavailable + } + return App{}, err + } + + // TODO(ferruvich): do we need any other info? + return App{ + ID: app.GetID(), + Slug: app.GetSlug(), + Owner: app.GetOwner().GetLogin(), + }, nil +} + +// GetAppInstallation gets the installation of the app related to the given token. +func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + id, err := strconv.Atoi(installationID) + if err != nil { + return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID) + } + + installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id)) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return AppInstallation{}, ErrServiceUnavailable + } + return AppInstallation{}, err + } + + // TODO(ferruvich): do we need any other info? + return AppInstallation{ + ID: installation.GetID(), + Enabled: installation.GetSuspendedAt().IsZero(), + }, nil +} diff --git a/apps/provisioning/pkg/connection/github/client_mock.go b/apps/provisioning/pkg/connection/github/client_mock.go new file mode 100644 index 00000000000..c9f009f5021 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_mock.go @@ -0,0 +1,149 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// GetApp provides a mock function with given fields: ctx +func (_m *MockClient) GetApp(ctx context.Context) (App, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetApp") + } + + var r0 App + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (App, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) App); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(App) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApp' +type MockClient_GetApp_Call struct { + *mock.Call +} + +// GetApp is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetApp(ctx interface{}) *MockClient_GetApp_Call { + return &MockClient_GetApp_Call{Call: _e.mock.On("GetApp", ctx)} +} + +func (_c *MockClient_GetApp_Call) Run(run func(ctx context.Context)) *MockClient_GetApp_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetApp_Call) Return(_a0 App, _a1 error) *MockClient_GetApp_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetApp_Call) RunAndReturn(run func(context.Context) (App, error)) *MockClient_GetApp_Call { + _c.Call.Return(run) + return _c +} + +// GetAppInstallation provides a mock function with given fields: ctx, installationID +func (_m *MockClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + ret := _m.Called(ctx, installationID) + + if len(ret) == 0 { + panic("no return value specified for GetAppInstallation") + } + + var r0 AppInstallation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (AppInstallation, error)); ok { + return rf(ctx, installationID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) AppInstallation); ok { + r0 = rf(ctx, installationID) + } else { + r0 = ret.Get(0).(AppInstallation) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, installationID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetAppInstallation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAppInstallation' +type MockClient_GetAppInstallation_Call struct { + *mock.Call +} + +// GetAppInstallation is a helper method to define mock.On call +// - ctx context.Context +// - installationID string +func (_e *MockClient_Expecter) GetAppInstallation(ctx interface{}, installationID interface{}) *MockClient_GetAppInstallation_Call { + return &MockClient_GetAppInstallation_Call{Call: _e.mock.On("GetAppInstallation", ctx, installationID)} +} + +func (_c *MockClient_GetAppInstallation_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_GetAppInstallation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) Return(_a0 AppInstallation, _a1 error) *MockClient_GetAppInstallation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Context, string) (AppInstallation, error)) *MockClient_GetAppInstallation_Call { + _c.Call.Return(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/github/client_test.go b/apps/provisioning/pkg/connection/github/client_test.go new file mode 100644 index 00000000000..bae6d6ac1e9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_test.go @@ -0,0 +1,297 @@ +package github_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v70/github" + conngh "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + mockhub "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGithubClient_GetApp(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + token string + wantApp conngh.App + wantErr error + }{ + { + name: "get app successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + app := &github.App{ + ID: github.Ptr(int64(12345)), + Slug: github.Ptr("my-test-app"), + Owner: &github.User{ + Login: github.Ptr("grafana"), + }, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(app)) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{ + ID: 12345, + Slug: "my-test-app", + Owner: "grafana", + }, + wantErr: nil, + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: conngh.ErrServiceUnavailable, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + }, + }, + { + name: "unauthorized error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + })) + }), + ), + ), + token: "invalid-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + app, err := client.GetApp(context.Background()) + + // Check the error + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantApp, app) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantApp, app) + } + }) + } +} + +func TestGithubClient_GetAppInstallation(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + appToken string + installationID string + wantInstallation conngh.AppInstallation + wantErr bool + errContains string + }{ + { + name: "get disabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: github.Ptr(github.Timestamp{Time: time.Now()}), + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: false, + }, + wantErr: false, + }, + { + name: "get enabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: nil, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: true, + }, + wantErr: false, + }, + { + name: "invalid installation ID", + mockHandler: mockhub.NewMockedHTTPClient(), + appToken: "test-app-token", + installationID: "not-a-number", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + errContains: "invalid installation ID", + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "installation not found", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + }, + Message: "Not Found", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "99999", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + installation, err := client.GetAppInstallation(context.Background(), tt.installationID) + + // Check the error + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + + // Check the result + assert.Equal(t, tt.wantInstallation, installation) + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/connection.go b/apps/provisioning/pkg/connection/github/connection.go new file mode 100644 index 00000000000..6a2da98ac8d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection.go @@ -0,0 +1,192 @@ +package github + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +//go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter +type GithubFactory interface { + New(ctx context.Context, ghToken common.RawSecureValue) Client +} + +type Connection struct { + obj *provisioning.Connection + ghFactory GithubFactory +} + +func NewConnection( + obj *provisioning.Connection, + factory GithubFactory, +) Connection { + return Connection{ + obj: obj, + ghFactory: factory, + } +} + +const ( + //TODO(ferruvich): these probably need to be setup in API configuration. + githubInstallationURL = "https://github.com/settings/installations" + jwtExpirationMinutes = 10 // GitHub Apps JWT tokens expire in 10 minutes maximum +) + +// Mutate performs in place mutation of the underneath resource. +func (c *Connection) Mutate(_ context.Context) error { + // Do nothing in case spec.Github is nil. + // If this field is required, we should fail at validation time. + if c.obj.Spec.GitHub == nil { + return nil + } + + c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID) + + // Generate JWT token if private key is being provided. + // Same as for the spec.Github, if such a field is required, Validation will take care of that. + if !c.obj.Secure.PrivateKey.Create.IsZero() { + token, err := generateToken(c.obj.Spec.GitHub.AppID, c.obj.Secure.PrivateKey.Create) + if err != nil { + return fmt.Errorf("failed to generate JWT token: %w", err) + } + + // Store the generated token + c.obj.Secure.Token = common.InlineSecureValue{Create: token} + } + + return nil +} + +// Token generates and returns the Connection token. +func generateToken(appID string, privateKey common.RawSecureValue) (common.RawSecureValue, error) { + // Decode base64-encoded private key + privateKeyPEM, err := base64.StdEncoding.DecodeString(string(privateKey)) + if err != nil { + return "", fmt.Errorf("failed to decode base64 private key: %w", err) + } + + // Parse the private key + key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %w", err) + } + + // Create the JWT token + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtExpirationMinutes) * time.Minute)), + Issuer: appID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("failed to sign JWT token: %w", err) + } + + return common.RawSecureValue(signedToken), nil +} + +// Validate ensures the resource _looks_ correct. +func (c *Connection) Validate(ctx context.Context) error { + list := field.ErrorList{} + + if c.obj.Spec.Type != provisioning.GithubConnectionType { + list = append(list, field.Invalid(field.NewPath("spec", "type"), c.obj.Spec.Type, "invalid connection type")) + + // Doesn't make much sense to continue validating a connection which is not a Github one. + return toError(c.obj.GetName(), list) + } + + if c.obj.Spec.GitHub == nil { + list = append( + list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), + ) + + // Doesn't make much sense to continue validating a connection with no information. + return toError(c.obj.GetName(), list) + } + + if c.obj.Secure.PrivateKey.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) + } + if c.obj.Secure.Token.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection")) + } + if !c.obj.Secure.ClientSecret.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) + } + + // Validate GitHub configuration fields + if c.obj.Spec.GitHub.AppID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "appID"), "appID must be specified for GitHub connection")) + } + if c.obj.Spec.GitHub.InstallationID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "installationID"), "installationID must be specified for GitHub connection")) + } + + // In case we have any error above, we don't go forward with the validation, and return the errors. + if len(list) > 0 { + return toError(c.obj.GetName(), list) + } + + // Validating app content via GH API + if err := c.validateAppAndInstallation(ctx); err != nil { + list = append(list, err) + } + + return toError(c.obj.GetName(), list) +} + +// validateAppAndInstallation validates the appID and installationID against the given github token. +func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error { + ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create) + + app, err := ghClient.GetApp(ctx) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "token"), "[REDACTED]", "invalid token") + } + + if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID { + return field.Invalid(field.NewPath("spec", "appID"), c.obj.Spec.GitHub.AppID, "appID mismatch") + } + + _, err = ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "installationID"), c.obj.Spec.GitHub.InstallationID, "invalid installation ID") + } + + return nil +} + +// toError converts a field.ErrorList to an error, returning nil if the list is empty +func toError(name string, list field.ErrorList) error { + if len(list) == 0 { + return nil + } + return apierrors.NewInvalid( + provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), + name, + list, + ) +} + +var ( + _ connection.Connection = (*Connection)(nil) +) diff --git a/apps/provisioning/pkg/connection/github/connection_test.go b/apps/provisioning/pkg/connection/github/connection_test.go new file mode 100644 index 00000000000..6a916db730e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection_test.go @@ -0,0 +1,434 @@ +package github + +import ( + "context" + "encoding/base64" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoInVbLY9io2Q/wHvUIXlEHg2Qyvd8eRzBAVEJ92DS6fx9H10 +06V0VRm78S0MXyo6i+n8ZAbZ0/R+GWpP2Ephxm0Gs2zo+iO2mpB19xQFI4o6ZTOw +b2WyjSaa2Vr4oyDkqti6AvfjW4VUAu932e08GkgwmmQSHXj7FX2CMWjgUwTTcuaX +65SHNKLNYLUP0HTumLzoZeqDTdoMMpKNdgH9Avr4/8vkVJ0mD6rqvxnw3JHsseNO +WdQTxf2aApBNHIIKxWZ2i/ZmjLNey7kltgjEquGiBdJvip3fHhH5XHdkrXcjRtnw +OJDnDmi5lQwv5yUBOSkbvbXRv/L/m0YLoD/fbwIDAQABAoIBAFfl//hM8/cnuesV ++R1Con/ZAgTXQOdPqPXbmEyniVrkMqMmCdBUOBTcST4s5yg36+RtkeaGpb/ajyyF +PAB2AYDucwvMpudGpJWOYTiOOp4R8hU1LvZfXVrRd1lo6NgQi4NLtNUpOtACeVQ+ +H4Yv0YemXQ47mnuOoRNMK/u3q5NoIdSahWptXBgUno8KklNpUrH3IYWaUxfBzDN3 +2xsVRTn2SfTSyoDmTDdTgptJONmoK1/sV7UsgWksdFc6XyYhsFAZgOGEJrBABRvF +546dyQ0cWxuPyVXpM7CN3tqC5ssvLjElg3LicK1V6gnjpdRnnvX88d1Eh3Uc/9IM +OZInT2ECgYEA6W8sQXTWinyEwl8SDKKMbB2ApIghAcFgdRxprZE4WFxjsYNCNL70 +dnSB7MRuzmxf5W77cV0N7JhH66N8HvY6Xq9olrpQ5dNttR4w8Pyv3wavDe8x7seL +5L2Xtbu7ihDr8Dk27MjiBSin3IxhBP5CJS910+pR6LrAWtEuU+FzFfECgYEAsA6y +qxHhCMXlTnauXhsnmPd1g61q7chW8kLQFYtHMLlQlgjHTW7irDZ9cPbPYDNjwRLO +7KLorcpv2NKe7rqq2ZyCm6hf1b9WnlQjo3dLpNWMu6fhy/smK8MgbRqcWpX+oTKF +79mK6hbY7o6eBzsQHBl7Z+LBNuwYmp9qOodPa18CgYEArv6ipKdcNhFGzRfMRiCN +OHederp6VACNuP2F05IsNUF9kxOdTEFirnKE++P+VU01TqA2azOhPp6iO+ohIGzi +MR06QNSH1OL9OWvasK4dggpWrRGF00VQgDgJRTnpS4WH+lxJ6pRlrAxgWpv6F24s +VAgSQr1Ejj2B+hMasdMvHWECgYBJ4uE4yhgXBnZlp4kmFV9Y4wF+cZkekaVrpn6N +jBYkbKFVVfnOlWqru3KJpgsB5I9IyAvvY68iwIKQDFSG+/AXw4dMrC0MF3DSoZ0T +TU2Br92QI7SvVod+djV1lGVp3ukt3XY4YqPZ+hywgUnw3uiz4j3YK2HLGup4ec6r +IX5DIQKBgHRLzvT3zqtlR1Oh0vv098clLwt+pGzXOxzJpxioOa5UqK13xIpFXbcg +iWUVh5YXCcuqaICUv4RLIEac5xQitk9Is/9IhP0NJ/81rHniosvdSpCeFXzxTImS +B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb +-----END RSA PRIVATE KEY-----` + +func TestConnection_Mutate(t *testing.T) { + t.Run("should add URL to Github connection", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + }) + + t.Run("should generate JWT token when private key is provided", func(t *testing.T) { + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(privateKeyBase64), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated") + }) + + t.Run("should do nothing when GitHub config is nil", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "clientID", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + }) + + t.Run("should fail when private key is not base64", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("invalid-key"), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to decode base64 private key") + }) + + t.Run("should fail when private key is invalid", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to parse private key") + }) +} + +func TestConnection_Validate(t *testing.T) { + tests := []struct { + name string + connection *provisioning.Connection + setupMock func(*MockGithubFactory) + wantErr bool + errMsgContains []string + }{ + { + name: "invalid type returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: "invalid", + }, + }, + wantErr: true, + errMsgContains: []string{"spec.type"}, + }, + { + name: "github type without github config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github"}, + }, + { + name: "github type without private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.privateKey"}, + }, + { + name: "github type without token returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.token"}, + }, + { + name: "github type with client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + ClientSecret: common.InlineSecureValue{ + Create: common.NewSecretValue("test-client-secret"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.clientSecret"}, + }, + { + name: "github type without appID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.appID"}, + }, + { + name: "github type without installationID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + Token: common.InlineSecureValue{ + Name: "test-token", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.installationID"}, + }, + { + name: "github type with valid config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: false, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil) + }, + }, + { + name: "problem getting app returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.token", "[REDACTED]"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError) + }, + }, + { + name: "mismatched app ID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.appID"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil) + }, + }, + { + name: "problem when getting installation returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.installationID", "456"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockFactory := NewMockGithubFactory(t) + if tt.setupMock != nil { + tt.setupMock(mockFactory) + } + + conn := NewConnection(tt.connection, mockFactory) + err := conn.Validate(context.Background()) + if tt.wantErr { + assert.Error(t, err) + for _, msg := range tt.errMsgContains { + assert.Contains(t, err.Error(), msg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/extra.go b/apps/provisioning/pkg/connection/github/extra.go new file mode 100644 index 00000000000..2c207637c61 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" +) + +type extra struct { + factory GithubFactory +} + +func (e *extra) Type() provisioning.ConnectionType { + return provisioning.GithubConnectionType +} + +func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) { + logger := logging.FromContext(ctx) + if connection == nil || connection.Spec.GitHub == nil { + logger.Error("connection is nil or github info is nil") + + return nil, fmt.Errorf("invalid github connection") + } + + c := NewConnection(connection, e.factory) + return &c, nil +} + +func Extra(factory GithubFactory) connection.Extra { + return &extra{ + factory: factory, + } +} diff --git a/apps/provisioning/pkg/connection/github/extra_test.go b/apps/provisioning/pkg/connection/github/extra_test.go new file mode 100644 index 00000000000..c5bcc8279d9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra_test.go @@ -0,0 +1,126 @@ +package github_test + +import ( + "context" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExtra_Type(t *testing.T) { + t.Run("should return GithubConnectionType", func(t *testing.T) { + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result := e.Type() + assert.Equal(t, provisioning.GithubConnectionType, result) + }) +} + +func TestExtra_Build(t *testing.T) { + t.Run("should successfully build connection", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should handle different connection configurations", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "another-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "789", + InstallationID: "101112", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "existing-private-key", + }, + Token: common.InlineSecureValue{ + Name: "existing-token", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should build connection with background context", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should always pass empty token to factory.New", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("some-token"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/factory.go b/apps/provisioning/pkg/connection/github/factory.go new file mode 100644 index 00000000000..2399f3c9f69 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory.go @@ -0,0 +1,39 @@ +package github + +import ( + "context" + "net/http" + + "github.com/google/go-github/v70/github" + "golang.org/x/oauth2" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// Factory creates new GitHub clients. +// It exists only for the ability to test the code easily. +type Factory struct { + // Client allows overriding the client to use in the GH client returned. It exists primarily for testing. + // FIXME: we should replace in this way. We should add some options pattern for the factory. + Client *http.Client +} + +func ProvideFactory() GithubFactory { + return &Factory{} +} + +func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client { + if r.Client != nil { + return NewClient(github.NewClient(r.Client)) + } + + if !ghToken.IsZero() { + tokenSrc := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: string(ghToken)}, + ) + tokenClient := oauth2.NewClient(ctx, tokenSrc) + return NewClient(github.NewClient(tokenClient)) + } + + return NewClient(github.NewClient(&http.Client{})) +} diff --git a/apps/provisioning/pkg/connection/github/factory_mock.go b/apps/provisioning/pkg/connection/github/factory_mock.go new file mode 100644 index 00000000000..a9e1424b62d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory_mock.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockGithubFactory is an autogenerated mock type for the GithubFactory type +type MockGithubFactory struct { + mock.Mock +} + +type MockGithubFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGithubFactory) EXPECT() *MockGithubFactory_Expecter { + return &MockGithubFactory_Expecter{mock: &_m.Mock} +} + +// New provides a mock function with given fields: ctx, ghToken +func (_m *MockGithubFactory) New(ctx context.Context, ghToken v0alpha1.RawSecureValue) Client { + ret := _m.Called(ctx, ghToken) + + if len(ret) == 0 { + panic("no return value specified for New") + } + + var r0 Client + if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.RawSecureValue) Client); ok { + r0 = rf(ctx, ghToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Client) + } + } + + return r0 +} + +// MockGithubFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New' +type MockGithubFactory_New_Call struct { + *mock.Call +} + +// New is a helper method to define mock.On call +// - ctx context.Context +// - ghToken v0alpha1.RawSecureValue +func (_e *MockGithubFactory_Expecter) New(ctx interface{}, ghToken interface{}) *MockGithubFactory_New_Call { + return &MockGithubFactory_New_Call{Call: _e.mock.On("New", ctx, ghToken)} +} + +func (_c *MockGithubFactory_New_Call) Run(run func(ctx context.Context, ghToken v0alpha1.RawSecureValue)) *MockGithubFactory_New_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(v0alpha1.RawSecureValue)) + }) + return _c +} + +func (_c *MockGithubFactory_New_Call) Return(_a0 Client) *MockGithubFactory_New_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubFactory_New_Call) RunAndReturn(run func(context.Context, v0alpha1.RawSecureValue) Client) *MockGithubFactory_New_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGithubFactory creates a new instance of MockGithubFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGithubFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGithubFactory { + mock := &MockGithubFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/mutator.go b/apps/provisioning/pkg/connection/mutator.go deleted file mode 100644 index 30291669905..00000000000 --- a/apps/provisioning/pkg/connection/mutator.go +++ /dev/null @@ -1,28 +0,0 @@ -package connection - -import ( - "fmt" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" -) - -const ( - githubInstallationURL = "https://github.com/settings/installations" -) - -func MutateConnection(connection *provisioning.Connection) error { - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - // Do nothing in case spec.Github is nil. - // If this field is required, we should fail at validation time. - if connection.Spec.GitHub == nil { - return nil - } - - connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID) - return nil - default: - // TODO: we need to setup the URL for bitbucket and gitlab. - return nil - } -} diff --git a/apps/provisioning/pkg/connection/mutator_test.go b/apps/provisioning/pkg/connection/mutator_test.go deleted file mode 100644 index a25aabd10a1..00000000000 --- a/apps/provisioning/pkg/connection/mutator_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutateConnection(t *testing.T) { - t.Run("should add URL to Github connection", func(t *testing.T) { - c := &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - } - - require.NoError(t, connection.MutateConnection(c)) - assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) - }) -} diff --git a/apps/provisioning/pkg/connection/validator.go b/apps/provisioning/pkg/connection/validator.go deleted file mode 100644 index c2537e3af2f..00000000000 --- a/apps/provisioning/pkg/connection/validator.go +++ /dev/null @@ -1,104 +0,0 @@ -package connection - -import ( - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -func ValidateConnection(connection *provisioning.Connection) error { - list := field.ErrorList{} - - if connection.Spec.Type == "" { - list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified")) - } - - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - list = append(list, validateGithubConnection(connection)...) - case provisioning.BitbucketConnectionType: - list = append(list, validateBitbucketConnection(connection)...) - case provisioning.GitlabConnectionType: - list = append(list, validateGitlabConnection(connection)...) - default: - list = append( - list, field.NotSupported( - field.NewPath("spec", "type"), - connection.Spec.Type, - []provisioning.ConnectionType{ - provisioning.GithubConnectionType, - provisioning.BitbucketConnectionType, - provisioning.GitlabConnectionType, - }), - ) - } - - return toError(connection.GetName(), list) -} - -func validateGithubConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.GitHub == nil { - list = append( - list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), - ) - } - - if connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) - } - if !connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) - } - - return list -} - -func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Bitbucket == nil { - list = append( - list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection")) - } - - return list -} - -func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Gitlab == nil { - list = append( - list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection")) - } - - return list -} - -// toError converts a field.ErrorList to an error, returning nil if the list is empty -func toError(name string, list field.ErrorList) error { - if len(list) == 0 { - return nil - } - return apierrors.NewInvalid( - provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), - name, - list, - ) -} diff --git a/apps/provisioning/pkg/connection/validator_test.go b/apps/provisioning/pkg/connection/validator_test.go deleted file mode 100644 index 23d4b01b800..00000000000 --- a/apps/provisioning/pkg/connection/validator_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestValidateConnection(t *testing.T) { - tests := []struct { - name string - connection *provisioning.Connection - wantErr bool - errMsg string - }{ - { - name: "empty type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{}, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "invalid type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: "invalid", - }, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "github type without github config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.github", - }, - { - name: "github type without private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "github type with client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "github type with github config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - }, - wantErr: false, - }, - { - name: "bitbucket type without bitbucket config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.bitbucket", - }, - { - name: "bitbucket type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "bitbucket type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "bitbucket type with bitbucket config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - { - name: "gitlab type without gitlab config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.gitlab", - }, - { - name: "gitlab type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "gitlab type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "gitlab type with gitlab config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := connection.ValidateConnection(tt.connection) - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go index 8ac26b192c9..f5be635560d 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go @@ -13,7 +13,7 @@ import ( type ConnectionSecureApplyConfiguration struct { PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"` ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"` - Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"` + Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"` } // ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index b6519295c66..40d4299a1a0 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1452,7 +1452,7 @@ export type ConnectionSecure = { /** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */ privateKey?: InlineSecureValue; /** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */ - webhook?: InlineSecureValue; + token?: InlineSecureValue; }; export type BitbucketConnectionConfig = { /** App client ID */ diff --git a/pkg/registry/apis/provisioning/extras/register.go b/pkg/registry/apis/provisioning/extras/register.go index caa3c2a2fa5..43c7064173f 100644 --- a/pkg/registry/apis/provisioning/extras/register.go +++ b/pkg/registry/apis/provisioning/extras/register.go @@ -2,6 +2,8 @@ package extras import ( apisprovisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/repository/git" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" @@ -42,6 +44,15 @@ func ProvideProvisioningOSSRepositoryExtras( } } +func ProvideProvisioningOSSConnectionExtras( + _ *setting.Cfg, + ghFactory ghconnection.GithubFactory, +) []connection.Extra { + return []connection.Extra{ + ghconnection.Extra(ghFactory), + } +} + func ProvideExtraWorkers(pullRequestWorker *pullrequest.PullRequestWorker) []jobs.Worker { return []jobs.Worker{pullRequestWorker} } @@ -54,3 +65,12 @@ func ProvideFactoryFromConfig(cfg *setting.Cfg, extras []repository.Extra) (repo return repository.ProvideFactory(enabledTypes, extras) } + +func ProvideConnectionFactoryFromConfig(cfg *setting.Cfg, extras []connection.Extra) (connection.Factory, error) { + enabledTypes := make(map[apisprovisioning.ConnectionType]struct{}, len(cfg.ProvisioningRepositoryTypes)) + for _, e := range cfg.ProvisioningRepositoryTypes { + enabledTypes[apisprovisioning.ConnectionType(e)] = struct{}{} + } + + return connection.ProvideFactory(enabledTypes, extras) +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index e54a8c2fc28..901bc829c97 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -30,7 +30,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/auth" - connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" @@ -105,20 +105,21 @@ type APIBuilder struct { jobs.Queue jobs.Store } - jobHistoryConfig *JobHistoryConfig - jobHistoryLoki *jobs.LokiJobHistory - resourceLister resources.ResourceLister - dashboardAccess legacy.MigrationDashboardAccessor - unified resource.ResourceClient - repoFactory repository.Factory - client client.ProvisioningV0alpha1Interface - access auth.AccessChecker - accessWithAdmin auth.AccessChecker - accessWithEditor auth.AccessChecker - accessWithViewer auth.AccessChecker - statusPatcher *appcontroller.RepositoryStatusPatcher - healthChecker *controller.HealthChecker - validator repository.RepositoryValidator + jobHistoryConfig *JobHistoryConfig + jobHistoryLoki *jobs.LokiJobHistory + resourceLister resources.ResourceLister + dashboardAccess legacy.MigrationDashboardAccessor + unified resource.ResourceClient + repoFactory repository.Factory + connectionFactory connection.Factory + client client.ProvisioningV0alpha1Interface + access auth.AccessChecker + accessWithAdmin auth.AccessChecker + accessWithEditor auth.AccessChecker + accessWithViewer auth.AccessChecker + statusPatcher *appcontroller.RepositoryStatusPatcher + healthChecker *controller.HealthChecker + repoValidator repository.RepositoryValidator // Extras provides additional functionality to the API. extras []Extra extraWorkers []jobs.Worker @@ -133,6 +134,7 @@ type APIBuilder struct { func NewAPIBuilder( onlyApiServer bool, repoFactory repository.Factory, + connectionFactory connection.Factory, features featuremgmt.FeatureToggles, unified resource.ResourceClient, configProvider apiserver.RestConfigProvider, @@ -176,6 +178,7 @@ func NewAPIBuilder( usageStats: usageStats, features: features, repoFactory: repoFactory, + connectionFactory: connectionFactory, clients: clients, parsers: parsers, repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister), @@ -192,7 +195,7 @@ func NewAPIBuilder( allowedTargets: allowedTargets, allowImageRendering: allowImageRendering, registry: registry, - validator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering), + repoValidator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering), useExclusivelyAccessCheckerForAuthz: useExclusivelyAccessCheckerForAuthz, } @@ -253,6 +256,7 @@ func RegisterAPIService( extraBuilders []ExtraBuilder, extraWorkers []jobs.Worker, repoFactory repository.Factory, + connectionFactory connection.Factory, ) (*APIBuilder, error) { //nolint:staticcheck // not yet migrated to OpenFeature if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { @@ -271,6 +275,7 @@ func RegisterAPIService( builder := NewAPIBuilder( cfg.DisableControllers, repoFactory, + connectionFactory, features, client, configProvider, @@ -641,7 +646,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.ConnectionResourceInfo.StoragePath("repositories")] = NewConnectionRepositoriesConnector() // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place - storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories)) + storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.repoValidator), b.VerifyAgainstExistingRepositories)) storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin) storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{ @@ -682,10 +687,15 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis if ok { return nil } - // TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700 + c, ok := obj.(*provisioning.Connection) if ok { - return connectionvalidation.MutateConnection(c) + conn, err := b.asConnection(ctx, c, nil) + if err != nil { + return err + } + + return conn.Mutate(ctx) } r, ok := obj.(*provisioning.Repository) @@ -736,9 +746,15 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm return nil } - connection, ok := obj.(*provisioning.Connection) + // Validate connections + c, ok := obj.(*provisioning.Connection) if ok { - return connectionvalidation.ValidateConnection(connection) + conn, err := b.asConnection(ctx, c, a.GetOldObject()) + if err != nil { + return err + } + + return conn.Validate(ctx) } // Validate Jobs @@ -758,7 +774,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm // the only time to add configuration checks here is if you need to compare // the incoming change to the current configuration isCreate := a.GetOperation() == admission.Create - list := b.validator.ValidateRepository(repo, isCreate) + list := b.repoValidator.ValidateRepository(repo, isCreate) cfg := repo.Config() if a.GetOperation() == admission.Update { @@ -831,7 +847,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } b.statusPatcher = appcontroller.NewRepositoryStatusPatcher(b.GetClient()) - b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.validator)) + b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.repoValidator)) // if running solely CRUD, skip the rest of the setup if b.onlyApiServer { @@ -1449,6 +1465,35 @@ func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object, old r return b.repoFactory.Build(ctx, r) } +func (b *APIBuilder) asConnection(ctx context.Context, obj runtime.Object, old runtime.Object) (connection.Connection, error) { + if obj == nil { + return nil, fmt.Errorf("missing connection object") + } + + c, ok := obj.(*provisioning.Connection) + if !ok { + return nil, fmt.Errorf("expected connection object") + } + + // Copy previous values if they exist + if old != nil { + o, ok := old.(*provisioning.Connection) + if ok && !o.Secure.IsZero() { + if c.Secure.PrivateKey.IsZero() { + c.Secure.PrivateKey = o.Secure.PrivateKey + } + if c.Secure.Token.IsZero() { + c.Secure.Token = o.Secure.Token + } + if c.Secure.ClientSecret.IsZero() { + c.Secure.ClientSecret = o.Secure.ClientSecret + } + } + } + + return b.connectionFactory.Build(ctx, c) +} + func getJSONResponse(ref string) *spec3.Responses { return &spec3.Responses{ ResponsesProps: spec3.ResponsesProps{ diff --git a/pkg/registry/apis/provisioning/register_validate_test.go b/pkg/registry/apis/provisioning/register_validate_test.go index 18b366e4de8..f612d3a408c 100644 --- a/pkg/registry/apis/provisioning/register_validate_test.go +++ b/pkg/registry/apis/provisioning/register_validate_test.go @@ -28,7 +28,7 @@ func TestAPIBuilderValidate(t *testing.T) { repoFactory: factory, allowedTargets: []v0alpha1.SyncTargetType{v0alpha1.SyncTargetTypeFolder}, allowImageRendering: false, - validator: validator, + repoValidator: validator, } t.Run("min sync interval is less than 10 seconds", func(t *testing.T) { diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index d95f01bef36..296b7d25b59 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -44,6 +44,7 @@ var provisioningExtras = wire.NewSet( pullrequest.ProvidePullRequestWorker, webhooks.ProvideWebhooksWithImages, extras.ProvideFactoryFromConfig, + extras.ProvideConnectionFactoryFromConfig, extras.ProvideProvisioningExtraAPIs, extras.ProvideExtraWorkers, ) diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 76fa96a75c7..57c7e87bbb9 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -3,6 +3,7 @@ package server import ( "github.com/stretchr/testify/mock" + githubconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/apps/secret/pkg/decrypt" "github.com/grafana/grafana/pkg/infra/db" @@ -34,24 +35,26 @@ func ProvideTestEnv( featureMgmt featuremgmt.FeatureToggles, resourceClient resource.ResourceClient, idService auth.IDService, - githubFactory *github.Factory, + githubRepoFactory *github.Factory, + githubConnectionFactory githubconnection.GithubFactory, decryptService decrypt.DecryptService, ) (*TestEnv, error) { return &TestEnv{ - TestingT: testingT, - Server: server, - SQLStore: db, - Cfg: cfg, - NotificationService: ns, - GRPCServer: grpcServer, - PluginRegistry: pluginRegistry, - HTTPClientProvider: httpClientProvider, - OAuthTokenService: oAuthTokenService, - FeatureToggles: featureMgmt, - ResourceClient: resourceClient, - IDService: idService, - GitHubFactory: githubFactory, - DecryptService: decryptService, + TestingT: testingT, + Server: server, + SQLStore: db, + Cfg: cfg, + NotificationService: ns, + GRPCServer: grpcServer, + PluginRegistry: pluginRegistry, + HTTPClientProvider: httpClientProvider, + OAuthTokenService: oAuthTokenService, + FeatureToggles: featureMgmt, + ResourceClient: resourceClient, + IDService: idService, + GithubRepoFactory: githubRepoFactory, + GithubConnectionFactory: githubConnectionFactory, + DecryptService: decryptService, }, nil } @@ -60,18 +63,19 @@ type TestEnv struct { mock.TestingT Cleanup(func()) } - Server *Server - SQLStore db.DB - Cfg *setting.Cfg - NotificationService *notifications.NotificationServiceMock - GRPCServer grpcserver.Provider - PluginRegistry registry.Service - HTTPClientProvider httpclient.Provider - OAuthTokenService *oauthtokentest.Service - RequestMiddleware web.Middleware - FeatureToggles featuremgmt.FeatureToggles - ResourceClient resource.ResourceClient - IDService auth.IDService - GitHubFactory *github.Factory - DecryptService decrypt.DecryptService + Server *Server + SQLStore db.DB + Cfg *setting.Cfg + NotificationService *notifications.NotificationServiceMock + GRPCServer grpcserver.Provider + PluginRegistry registry.Service + HTTPClientProvider httpclient.Provider + OAuthTokenService *oauthtokentest.Service + RequestMiddleware web.Middleware + FeatureToggles featuremgmt.FeatureToggles + ResourceClient resource.ResourceClient + IDService auth.IDService + GithubRepoFactory *github.Factory + GithubConnectionFactory githubconnection.GithubFactory + DecryptService decrypt.DecryptService } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 141079b1513..399794e9787 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -15,6 +15,7 @@ import ( "go.opentelemetry.io/otel/trace" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" @@ -297,6 +298,7 @@ var wireBasicSet = wire.NewSet( notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, + ghconnection.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 65e6ebeb36e..218e9fabc36 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -10,6 +10,7 @@ import ( "github.com/google/wire" httpclient2 "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" + github2 "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" @@ -914,7 +915,13 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + githubFactory := github2.ProvideFactory() + v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory) + connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7) + if err != nil { + return nil, err + } + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory, connectionFactory) if err != nil { return nil, err } @@ -1576,7 +1583,13 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + githubFactory := github2.ProvideFactory() + v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory) + connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7) + if err != nil { + return nil, err + } + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory, connectionFactory) if err != nil { return nil, err } @@ -1610,7 +1623,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, decryptService) + testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, githubFactory, decryptService) if err != nil { return nil, err } @@ -1800,7 +1813,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideDataSourceRetriever, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, github2.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideDataSourceRetriever, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 4d70c3f2f4c..c0534f7fff4 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -72,6 +72,7 @@ import ( var provisioningExtras = wire.NewSet( extras.ProvideProvisioningOSSRepositoryExtras, + extras.ProvideProvisioningOSSConnectionExtras, ) var configProviderExtras = wire.NewSet( diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 379e3ac9734..6087b64687d 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -14,6 +14,7 @@ import ( "testing" "time" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -207,6 +208,10 @@ func (c *K8sTestHelper) GetEnv() server.TestEnv { return c.env } +func (c *K8sTestHelper) SetGithubConnectionFactory(f githubConnection.GithubFactory) { + c.env.GithubConnectionFactory = f +} + func (c *K8sTestHelper) GetListenerAddress() string { return c.listenerAddress } diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 0e2f06946ac..fc8efbaabbb 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -4559,7 +4559,7 @@ } ] }, - "webhook": { + "token": { "description": "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", "default": {}, "allOf": [ diff --git a/pkg/tests/apis/provisioning/connection_repositories_test.go b/pkg/tests/apis/provisioning/connection_repositories_test.go index e6ef823b801..4def16377e3 100644 --- a/pkg/tests/apis/provisioning/connection_repositories_test.go +++ b/pkg/tests/apis/provisioning/connection_repositories_test.go @@ -2,13 +2,13 @@ package provisioning import ( "context" + "encoding/base64" "encoding/json" "net/http" "testing" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" @@ -20,7 +20,7 @@ func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) { helper := runGrafana(t) ctx := context.Background() - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) // Create a connection for testing connection := &unstructured.Unstructured{Object: map[string]any{ @@ -39,13 +39,12 @@ func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) - require.NoError(t, err, "failed to create connection") + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) t.Run("endpoint returns not implemented", func(t *testing.T) { var statusCode int @@ -129,14 +128,14 @@ func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T helper := runGrafana(t) ctx := context.Background() - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) // Create a connection for testing connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", "kind": "Connection", "metadata": map[string]any{ - "name": "connection-repositories-type-test", + "name": "connection-repositories-test", "namespace": "default", }, "spec": map[string]any{ @@ -148,13 +147,12 @@ func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) - require.NoError(t, err, "failed to create connection") + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) t.Run("verify ExternalRepositoryList type exists in API", func(t *testing.T) { // Verify the type is registered and can be instantiated diff --git a/pkg/tests/apis/provisioning/connection_status_auth_test.go b/pkg/tests/apis/provisioning/connection_status_auth_test.go index fbddd85999a..0deaa3eeefe 100644 --- a/pkg/tests/apis/provisioning/connection_status_auth_test.go +++ b/pkg/tests/apis/provisioning/connection_status_auth_test.go @@ -2,12 +2,12 @@ package provisioning import ( "context" + "encoding/base64" "net/http" "testing" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/grafana/grafana/pkg/util/testutil" @@ -18,7 +18,7 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { helper := runGrafana(t) ctx := context.Background() - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) // Create a connection for testing connection := &unstructured.Unstructured{Object: map[string]any{ @@ -37,13 +37,12 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) - require.NoError(t, err, "failed to create connection") + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) t.Run("admin can GET connection status", func(t *testing.T) { var statusCode int diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index 99f32dffa93..98418f7b54e 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -2,11 +2,20 @@ package provisioning import ( "context" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "net/http" "testing" "time" + "github.com/golang-jwt/jwt/v4" + "github.com/google/go-github/v70/github" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/util/testutil" + ghmock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -17,12 +26,55 @@ import ( clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" ) +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEoQIBAAKCAQBn1MuM5hIfH6d3TNStI1ofWv/gcjQ4joi9cFijEwVLuPYkF1nD +KkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6m5T/sHfVSiEWAEUblh3cA34HVCmD +cqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyjpKOXuojJ9wN9a17D2cYU5WkXjoDC +4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7DA9T8yiZwIWPp5YesgsAPyQLCFPgM +s77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQohUID0BIxE7G5quL069RuuCZWZkoF +oPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmXAgMBAAECggEADSs4Bc7ITZo+Kytb +bfol3AQ2n8jcRrANN7mgBE7NRSVYUouDnvUlbnCC2t3QXPwLdxQa11GkygLSQ2bg +GeVDgq1o4GUJTcvxFlFCcpU/hEANI/DQsxNAQ/4wUGoLOlHaO3HPvwBblHA70gGe +Ux/xpG+lMAFAiB0EHEwZ4M0mClBEOQv3NzaFTWuBHtIMS8eid7M1q5qz9+rCgZSL +KBBHo0OvUbajG4CWl8SM6LUYapASGg+U17E+4xA3npwpIdsk+CbtX+vvX324n4kn +0EkrJqCjv8M1KiCKAP+UxwP00ywxOg4PN+x+dHI/I7xBvEKe/x6BltVSdGA+PlUK +02wagQKBgQDF7gdQLFIagPH7X7dBP6qEGxj/Ck9Qdz3S1gotPkVeq+1/UtQijYZ1 +j44up/0yB2B9P4kW091n+iWcyfoU5UwBua9dHvCZP3QH05LR1ZscUHxLGjDPBASt +l2xSq0hqqNWBspb1M0eCY0Yxi65iDkj3xsI2iN35BEb1FlWdR5KGvwKBgQCGS0ce +wASWbZIPU2UoKGOQkIJU6QmLy0KZbfYkpyfE8IxGttYVEQ8puNvDDNZWHNf+LP85 +c8iV6SfnWiLmu1XkG2YmJFBCCAWgJ8Mq2XQD8E+a/xcaW3NqlcC5+I2czX367j3r +69wZSxRbzR+DCfOiIkrekJImwN183ZYy2cBbKQKBgFj86IrSMmO6H5Ft+j06u5ZD +fJyF7Rz3T3NwSgkHWzbyQ4ggHEIgsRg/36P4YSzSBj6phyAdRwkNfUWdxXMJmH+a +FU7frzqnPaqbJAJ1cBRt10QI1XLtkpDdaJVObvONTtjOC3LYiEkGCzQRYeiyFXpZ +AU51gJ8JnkFotjtNR4KPAoGAehVREDlLcl0lnN0ZZspgyPk2Im6/iOA9KTH3xBZZ +ZwWu4FIyiHA7spgk4Ep5R0ttZ9oMI3SIcw/EgONGOy8uw/HMiPwWIhEc3B2JpRiO +CU6bb7JalFFyuQBudiHoyxVcY5PVovWF31CLr3DoJr4TR9+Y5H/U/XnzYCIo+w1N +exECgYBFAGKYTIeGAvhIvD5TphLpbCyeVLBIq5hRyrdRY+6Iwqdr5PGvLPKwin5+ ++4CDhWPW4spq8MYPCRiMrvRSctKt/7FhVGL2vE/0VY3TcLk14qLC+2+0lnPVgnYn +u5/wOyuHp1cIBnjeN41/pluOWFBHI9xLW3ExLtmYMiecJ8VdRA== +-----END RSA PRIVATE KEY-----` + +//nolint:gosec // Test RSA public key (generated for testing purposes only) +const testPublicKeyPem = `-----BEGIN PUBLIC KEY----- +MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQBn1MuM5hIfH6d3TNStI1of +Wv/gcjQ4joi9cFijEwVLuPYkF1nDKkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6 +m5T/sHfVSiEWAEUblh3cA34HVCmDcqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyj +pKOXuojJ9wN9a17D2cYU5WkXjoDC4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7D +A9T8yiZwIWPp5YesgsAPyQLCFPgMs77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQo +hUID0BIxE7G5quL069RuuCZWZkoFoPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmX +AgMBAAE= +-----END PUBLIC KEY-----` + func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) helper := runGrafana(t) - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + decryptService := helper.GetEnv().DecryptService + require.NotNil(t, decryptService, "decrypt service not wired properly") t.Run("should perform CRUDL requests on connection", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ @@ -41,12 +93,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} // CREATE - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + _, err := helper.CreateGithubConnection(t, ctx, connection) require.NoError(t, err, "failed to create resource") // READ @@ -64,6 +116,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { require.Contains(t, output.Object, "secure", "object should contain secure") assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey") + // Verifying token + assert.Contains(t, output.Object["secure"], "token", "token should be created") + secretName, found, err := unstructured.NestedString(output.Object, "secure", "token", "name") + require.NoError(t, err, "error getting secret name") + require.True(t, found, "secret name should exist: %v", output.Object) + decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", output.GetNamespace(), secretName) + require.NoError(t, err, "decryption error") + require.Len(t, decrypted, 1) + + val := decrypted[secretName].Value() + require.NotNil(t, val) + k := val.DangerouslyExposeAndConsumeValue() + valid, err := verifyToken(t, "123456", testPublicKeyPem, k) + require.NoError(t, err, "error verifying token: %s", k) + require.True(t, valid, "token should be valid: %s", k) + // LIST list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err, "failed to list resource") @@ -81,22 +149,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { "spec": map[string]any{ "type": "github", "github": map[string]any{ - "appID": "456789", - "installationID": "454545", + "appID": "123456", + "installationID": "454546", }, }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{}) + res, err := helper.UpdateGithubConnection(t, ctx, updatedConnection) require.NoError(t, err, "failed to update resource") spec = res.Object["spec"].(map[string]any) require.Contains(t, spec, "github") githubInfo = spec["github"].(map[string]any) - assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated") + assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated") // DELETE require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource") @@ -122,7 +190,7 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} @@ -155,9 +223,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { } func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + helper := runGrafana(t) createOptions := metav1.CreateOptions{FieldValidation: "Strict"} ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) t.Run("should fail when type is empty", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ @@ -172,13 +243,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "type must be specified") + assert.Contains(t, err.Error(), "connection type \"\" is not supported") }) t.Run("should fail when type is invalid", func(t *testing.T) { @@ -194,13 +265,57 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"") + assert.Contains(t, err.Error(), "connection type \"some-invalid-type\" is not supported") + }) + + t.Run("should fail when type is 'git'", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "git", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "connection type \"git\" is not supported") + }) + + t.Run("should fail when type is 'local'", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "local", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "connection type \"local\" is not supported") }) t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) { @@ -216,13 +331,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "github info must be specified for GitHub connection") + assert.Contains(t, err.Error(), "invalid github connection") }) t.Run("should fail when type is github but private key is not there", func(t *testing.T) { @@ -246,7 +361,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection") }) - t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) { + t.Run("should fail when type is github but a client Secret is also specified", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", "kind": "Connection", @@ -263,7 +378,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, "clientSecret": map[string]any{ "create": "someSecret", @@ -275,6 +390,100 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection") }) + t.Run("should fail when type is github and github API is unavailable", func(t *testing.T) { + connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatchHandler( + ghmock.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ) + helper.SetGithubConnectionFactory(connectionFactory) + + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.token: Internal error: github is unavailable") + }) + + t.Run("should fail when type is github and returned app ID doesn't match given one", func(t *testing.T) { + var appID int64 = 123455 + appSlug := "appSlug" + connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatch( + ghmock.GetApp, github.App{ + ID: &appID, + Slug: &appSlug, + }, + ), + ) + helper.SetGithubConnectionFactory(connectionFactory) + + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.appID: Invalid value: \"123456\": appID mismatch") + }) +} + +func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !extensions.IsEnterprise { + t.Skip("Skipping integration test when not enterprise") + } + + helper := runGrafana(t) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", @@ -294,7 +503,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection") + assert.Contains(t, err.Error(), "invalid bitbucket connection") }) t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) { @@ -364,7 +573,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection") + assert.Contains(t, err.Error(), "invalid gitlab connection") }) t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) { @@ -428,6 +637,7 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { provisioningClient, err := clientset.NewForConfig(restConfig) require.NoError(t, err) connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace) + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) t.Run("health check gets updated after initial creation", func(t *testing.T) { // Create a connection using unstructured (like other connection tests) @@ -447,12 +657,12 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "test-private-key", + "create": privateKeyBase64, }, }, }} - createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{}) + createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured) require.NoError(t, err) require.NotNil(t, createdUnstructured) @@ -501,12 +711,12 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "test-private-key-2", + "create": privateKeyBase64, }, }, }} - createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{}) + createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured) require.NoError(t, err) require.NotNil(t, createdUnstructured) @@ -538,7 +748,7 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { updatedUnstructured := latestUnstructured.DeepCopy() githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any) githubSpec["appID"] = "99999" - _, err = helper.Connections.Resource.Update(ctx, updatedUnstructured, metav1.UpdateOptions{}) + _, err = helper.UpdateGithubConnection(t, ctx, updatedUnstructured) require.NoError(t, err) // Wait for reconciliation after spec change @@ -566,6 +776,7 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing. helper := runGrafana(t) ctx := context.Background() createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) // Create a connection first connection := &unstructured.Unstructured{Object: map[string]any{ @@ -584,12 +795,12 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing. }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "test-private-key", + "create": privateKeyBase64, }, }, }} - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + _, err := helper.CreateGithubConnection(t, ctx, connection) require.NoError(t, err, "failed to create connection") t.Cleanup(func() { @@ -731,3 +942,27 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing. assert.Contains(t, names, "repo-with-different-connection") }) } + +func verifyToken(t *testing.T, appID, publicKey, token string) (bool, error) { + t.Helper() + + // Parse the private key + key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey)) + if err != nil { + return false, err + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) { + return key, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})) + if err != nil { + return false, err + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok || !parsedToken.Valid { + return false, fmt.Errorf("invalid token") + } + + return claims.VerifyIssuer(appID, true), nil +} diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 791ac4b8a20..34b57afe3c6 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -10,11 +10,14 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "testing" "text/template" "time" + "github.com/google/go-github/v70/github" + "github.com/grafana/grafana/pkg/extensions" ghmock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,6 +33,7 @@ import ( dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -699,13 +703,18 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper // (instance is needed for export jobs, folder for most operations) ProvisioningAllowedTargets: []string{"folder", "instance"}, } + + if extensions.IsEnterprise { + opts.ProvisioningRepositoryTypes = []string{"local", "github", "gitlab", "bitbucket"} + } + for _, o := range options { o(&opts) } helper := apis.NewK8sTestHelper(t, opts) - // FIXME: keeping this line here to keep the dependency around until we have tests which use this again. - helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient() + // FIXME: keeping these lines here to keep the dependency around until we have tests which use this again. + helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient() repositories := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, @@ -973,6 +982,79 @@ func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) { }, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests") } +func (h *provisioningTestHelper) CreateGithubConnection( + t *testing.T, + ctx context.Context, + connection *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + t.Helper() + + err := h.setGithubClient(t, connection) + if err != nil { + return nil, err + } + + return h.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{FieldValidation: "Strict"}) +} + +func (h *provisioningTestHelper) UpdateGithubConnection( + t *testing.T, + ctx context.Context, + connection *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + t.Helper() + + err := h.setGithubClient(t, connection) + if err != nil { + return nil, err + } + + return h.Connections.Resource.Update(ctx, connection, metav1.UpdateOptions{FieldValidation: "Strict"}) +} + +func (h *provisioningTestHelper) setGithubClient(t *testing.T, connection *unstructured.Unstructured) error { + t.Helper() + + objectSpec := connection.Object["spec"].(map[string]interface{}) + githubObj := objectSpec["github"].(map[string]interface{}) + appID := githubObj["appID"].(string) + id, err := strconv.ParseInt(appID, 10, 64) + if err != nil { + return err + } + + appSlug := "someSlug" + connectionFactory := h.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatchHandler( + ghmock.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + app := github.App{ + ID: &id, + Slug: &appSlug, + } + _, _ = w.Write(ghmock.MustMarshal(app)) + }), + ), + ghmock.WithRequestMatchHandler( + ghmock.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("installation_id") + idInt, _ := strconv.ParseInt(id, 10, 64) + w.WriteHeader(http.StatusOK) + installation := github.Installation{ + ID: &idInt, + } + _, _ = w.Write(ghmock.MustMarshal(installation)) + }), + ), + ) + h.SetGithubConnectionFactory(connectionFactory) + + return nil +} + func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) { return requestHelper(t, helper, http.MethodPost, path, body, user) } diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index f2447e71d23..0f3509602b7 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/extensions" provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -149,10 +150,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { } } - assert.ElementsMatch(collect, []provisioning.RepositoryType{ - provisioning.LocalRepositoryType, - provisioning.GitHubRepositoryType, - }, settings.AvailableRepositoryTypes) + if extensions.IsEnterprise { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + provisioning.BitbucketRepositoryType, + provisioning.GitLabRepositoryType, + }, settings.AvailableRepositoryTypes) + } else { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + }, settings.AvailableRepositoryTypes) + } }, time.Second*10, time.Millisecond*100, "Expected settings to match") }) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 88f65223675..81e243f42b5 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -622,6 +622,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|")) require.NoError(t, err) } + if len(opts.ProvisioningRepositoryTypes) > 0 { + provisioningSect, err := getOrCreateSection("provisioning") + require.NoError(t, err) + _, err = provisioningSect.NewKey("repository_types", strings.Join(opts.ProvisioningRepositoryTypes, "|")) + require.NoError(t, err) + } if opts.EnableSCIM { scimSection, err := getOrCreateSection("auth.scim") require.NoError(t, err) @@ -731,6 +737,7 @@ type GrafanaOpts struct { UnifiedStorageMaxPageSizeBytes int PermittedProvisioningPaths string ProvisioningAllowedTargets []string + ProvisioningRepositoryTypes []string GrafanaComSSOAPIToken string LicensePath string EnableRecordingRules bool From 1263a3d364010e848a9cf4b4cda993f31c9072b6 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:17:41 -0500 Subject: [PATCH 02/35] unified-storage: HappyPath and notifier tests + couple of bugfixes (#116087) * unified-storage: couple of bugfixes and enable HappyPath and notifier sqlkv tests --- pkg/storage/unified/resource/notifier.go | 8 +-- pkg/storage/unified/resource/notifier_test.go | 22 +++----- .../unified/resource/storage_backend.go | 3 +- .../storage_backend_sql_compatibility.go | 54 +++++++++++++------ .../unified/testing/storage_backend_test.go | 32 +++++------ 5 files changed, 67 insertions(+), 52 deletions(-) diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go index 5dd6a17ad29..3d3b2024d7e 100644 --- a/pkg/storage/unified/resource/notifier.go +++ b/pkg/storage/unified/resource/notifier.go @@ -78,13 +78,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { cache := gocache.New(cacheTTL, cacheCleanupInterval) events := make(chan Event, opts.BufferSize) - initialRV, err := n.lastEventResourceVersion(ctx) + lastRV, err := n.lastEventResourceVersion(ctx) if errors.Is(err, ErrNotFound) { - initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning + lastRV = 0 // No events yet, start from the beginning } else if err != nil { n.log.Error("Failed to get last event resource version", "error", err) } - lastRV := initialRV + 1 // We want to start watching from the next event + lastRV = lastRV + 1 // We want to start watching from the next event go func() { defer close(events) @@ -110,7 +110,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { } // Skip old events lower than the requested resource version - if evt.ResourceVersion <= initialRV { + if evt.ResourceVersion < lastRV { continue } diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index 060f8eecfbe..f78629ebeb7 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -25,7 +25,6 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { return notifier, eventStore } -// nolint:unused func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) @@ -60,8 +59,7 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin func TestNotifier_lastEventResourceVersion(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) } func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -112,8 +110,7 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not func TestNotifier_cachekey(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) } func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -167,8 +164,7 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, func TestNotifier_Watch_NoEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) } func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -209,8 +205,7 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti func TestNotifier_Watch_WithExistingEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) } func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -284,8 +279,7 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti func TestNotifier_Watch_EventDeduplication(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) } func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -351,8 +345,7 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti func TestNotifier_Watch_ContextCancellation(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) } func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -398,8 +391,7 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not func TestNotifier_Watch_MultipleEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) } func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 55843905c72..4db6da89d9a 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -346,7 +346,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in return 0, fmt.Errorf("failed to write data: %w", err) } - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(rv) + rv = rvmanager.SnowflakeFromRv(rv) + dataKey.ResourceVersion = rv } else { err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) if err != nil { diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go index 6584992f3cd..9066a39221c 100644 --- a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/bwmarrin/snowflake" "github.com/stretchr/testify/require" claims "github.com/grafana/authlib/types" @@ -187,13 +186,30 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri // verifyKeyPath is a helper function to verify key_path generation func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { + // For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format + // but key_path stores snowflake RV, so convert to snowflake + // For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format + isSqlBackend := strings.Contains(key.Namespace, "-sql") + + var keyPathRV int64 + if isSqlBackend { + // Convert microsecond RV to snowflake for key_path construction + keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion) + } else { + // KV backend already provides snowflake RV + keyPathRV = resourceVersion + } + + // Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder + expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder) + var query string if db.DriverName() == "postgres" { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3" + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1" } else { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?" + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?" } - rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion) + rows, err := db.QueryContext(ctx, query, expectedKeyPath) require.NoError(t, err) require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table") @@ -220,10 +236,6 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource // Verify action suffix require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) - // Verify snowflake calculation - expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000) - require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), "actual RV: %d", actualRV) - // Verify folder if specified if expectedFolder != "" { require.Equal(t, expectedFolder, actualFolder) @@ -492,10 +504,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp } // Validate previous_resource_version - // For KV backend operations, resource versions are stored as snowflake format - // but expectedPrevRV is in microsecond format, so we need to use IsRvEqual for comparison + // For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend) + // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison if strings.Contains(record.Namespace, "-kv") { - require.True(t, rvmanager.IsRvEqual(record.PreviousResourceVersion, expectedPrevRV), + require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion), "Previous resource version should match (KV backend snowflake format)") } else { require.Equal(t, expectedPrevRV, record.PreviousResourceVersion) @@ -505,9 +517,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp require.Equal(t, expectedGeneration, record.Generation) // Validate resource_version - // For KV backend operations, resource versions are stored as snowflake format + // For KV backend operations, expectedRV is now in snowflake format (returned by KV backend) + // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison if strings.Contains(record.Namespace, "-kv") { - require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV), + require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion), "Resource version should match (KV backend snowflake format)") } else { require.Equal(t, expectedRV, record.ResourceVersion) @@ -574,7 +587,7 @@ func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources // Resource version should match the expected version for test-resource-3 (updated version) expectedRV := resourceVersions[2][1] // test-resource-3's update version if strings.Contains(namespace, "-kv") { - require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV), + require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion), "Resource version should match (KV backend snowflake format)") } else { require.Equal(t, expectedRV, record.ResourceVersion) @@ -625,9 +638,16 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res // The resource_version table should contain the latest RV for the group+resource // It might be slightly higher due to RV manager operations, so check it's at least our max - require.GreaterOrEqual(t, record.ResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked") - // But it shouldn't be too much higher (within a reasonable range) - require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected") + // For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format + // Use IsRvEqual for proper comparison between different RV formats + isKvBackend := strings.Contains(namespace, "-kv") + recordResourceVersion := record.ResourceVersion + if isKvBackend { + recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion) + } + + require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable") + require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked") } // runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight) diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 092cd476b52..3046967adee 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -38,7 +38,6 @@ func TestBadgerKVStorageBackend(t *testing.T) { func TestSQLKVStorageBackend(t *testing.T) { skipTests := map[string]bool{ - TestHappyPath: true, TestWatchWriteEvents: true, TestList: true, TestBlobSupport: true, @@ -51,21 +50,24 @@ func TestSQLKVStorageBackend(t *testing.T) { TestGetResourceLastImportTime: true, TestOptimisticLocking: true, } - // without RvManager - RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { - backend, _ := NewTestSqlKvBackend(t, ctx, false) - return backend - }, &TestOptions{ - NSPrefix: "sqlkvstorage-test", - SkipTests: skipTests, + + t.Run("Without RvManager", func(t *testing.T) { + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, false) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-test", + SkipTests: skipTests, + }) }) - // with RvManager - RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { - backend, _ := NewTestSqlKvBackend(t, ctx, true) - return backend - }, &TestOptions{ - NSPrefix: "sqlkvstorage-withrvmanager-test", - SkipTests: skipTests, + t.Run("With RvManager", func(t *testing.T) { + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, true) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-withrvmanager-test", + SkipTests: skipTests, + }) }) } From 69bf3068b3423d7db4d5fc00279534bbb944c92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Mon, 12 Jan 2026 18:52:23 +0100 Subject: [PATCH 03/35] Dashboards: Never show scopes variables (#116132) --- .../scene/VariableControls.test.tsx | 84 +++++++++++++++++++ .../scene/VariableControls.tsx | 3 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 public/app/features/dashboard-scene/scene/VariableControls.test.tsx diff --git a/public/app/features/dashboard-scene/scene/VariableControls.test.tsx b/public/app/features/dashboard-scene/scene/VariableControls.test.tsx new file mode 100644 index 00000000000..c65639edfa6 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VariableControls.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; + +import { VariableHide } from '@grafana/data'; +import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes'; + +import { DashboardScene } from './DashboardScene'; +import { VariableControls } from './VariableControls'; +import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; + +jest.mock('@grafana/runtime', () => { + const runtime = jest.requireActual('@grafana/runtime'); + return { + ...runtime, + config: { + ...runtime.config, + featureToggles: { + dashboardNewLayouts: true, + }, + }, + }; +}); + +describe('VariableControls', () => { + it('should not render scopes variable', () => { + const variables = [new ScopesVariable({})]; + const dashboard = buildScene(variables); + dashboard.activate(); + + render(); + + expect(screen.queryByText('__scopes')).not.toBeInTheDocument(); + }); + + it('should not render regular hidden variables', () => { + const hiddenVariable = new TextBoxVariable({ + name: 'HiddenVar', + hide: VariableHide.hideVariable, + }); + const variables = [hiddenVariable]; + const dashboard = buildScene(variables); + dashboard.activate(); + + render(); + + expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument(); + }); + + it('should render regular hidden variables in edit mode', async () => { + const hiddenVariable = new TextBoxVariable({ + name: 'HiddenVar', + hide: VariableHide.hideVariable, + }); + const variables = [hiddenVariable]; + const dashboard = buildScene(variables); + dashboard.activate(); + + dashboard.setState({ isEditing: true }); + render(); + + expect(await screen.findByText('HiddenVar')).toBeInTheDocument(); + }); + + it('should not render variables hidden in controls menu in edit mode', async () => { + const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]); + dashboard.activate(); + + dashboard.setState({ isEditing: true }); + render(); + + expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument(); + }); +}); + +function buildScene(variables: SceneVariable[] = []) { + const dashboard = new DashboardScene({ + $variables: new SceneVariableSet({ variables }), + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [], + }), + }), + }); + return dashboard; +} diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index a2e6f3daf88..4cd92d34614 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -39,8 +39,9 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { ? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu) : variables.filter( (v) => + // used for scopes variables, should always be hidden // if we're editing in dynamic dashboards, still shows hidden variable but greyed out - (isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) || + (!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) || v.state.hide !== VariableHide.inControlsMenu ); From 53aa5e8f7f19c19518876b0356d00d0f7ec42c85 Mon Sep 17 00:00:00 2001 From: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:52:40 -0500 Subject: [PATCH 04/35] MetricsDrilldown: Remove `exploreMetricsRelatedLogs` feature toggle (#116090) chore: remove unused exploreMetricsRelatedLogs feature toggle --- packages/grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/services/featuremgmt/registry.go | 8 -------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.json | 3 ++- 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 31359af52cf..8bc45524b37 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -695,10 +695,6 @@ export interface FeatureToggles { */ passwordlessMagicLinkAuthentication?: boolean; /** - * Display Related Logs in Grafana Metrics Drilldown - */ - exploreMetricsRelatedLogs?: boolean; - /** * Adds support for quotes and special characters in label values for Prometheus queries */ prometheusSpecialCharsInLabelValues?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index f6d9e2da560..3e108d1ed0c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1148,14 +1148,6 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, - { - Name: "exploreMetricsRelatedLogs", - Description: "Display Related Logs in Grafana Metrics Drilldown", - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityMetricsSquad, - FrontendOnly: true, - HideFromDocs: false, - }, { Name: "prometheusSpecialCharsInLabelValues", Description: "Adds support for quotes and special characters in label values for Prometheus queries", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index fdb265c7980..20ff391364e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -159,7 +159,6 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false -exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false enableSCIM,preview,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 5747b9bdd20..50383070847 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1408,7 +1408,8 @@ "metadata": { "name": "exploreMetricsRelatedLogs", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-11-05T16:28:43Z" + "creationTimestamp": "2024-11-05T16:28:43Z", + "deletionTimestamp": "2026-01-09T22:14:53Z" }, "spec": { "description": "Display Related Logs in Grafana Metrics Drilldown", From 69ccfd6bfc9541c4c912a41aafc61025fb45f234 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:33:34 -0500 Subject: [PATCH 05/35] unified-storage: fix sharedwithme search not returning folders (#116089) * unified-storage: fix dashboard sharedwithme search not returning folders shared with the user --- pkg/registry/apis/dashboard/search.go | 25 ++++++++++++------ pkg/registry/apis/dashboard/search_test.go | 30 +++++++++++++++++++--- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 8572dae8295..bbc032e7382 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -552,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use // gets dashboards that the user was granted read access to permissions := user.GetPermissions() dashboardPermissions := permissions[dashboards.ActionDashboardsRead] + folderPermissions := permissions[dashboards.ActionFoldersRead] dashboardUids := make([]string, 0) sharedDashboards := make([]string, 0) @@ -562,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } } } + for _, folderPermission := range folderPermissions { + if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID { + dashboardUids = append(dashboardUids, folderUid) + } + } + } if len(dashboardUids) == 0 { return sharedDashboards, nil @@ -572,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use return sharedDashboards, err } + folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE) + if err != nil { + return sharedDashboards, err + } + dashboardSearchRequest := &resourcepb.ResourceSearchRequest{ - Fields: []string{"folder"}, - Limit: int64(len(dashboardUids)), + Federated: []*resourcepb.ResourceKey{folderKey}, + Fields: []string{"folder"}, + Limit: int64(len(dashboardUids)), Options: &resourcepb.ListOptions{ Key: key, Fields: []*resourcepb.Requirement{{ @@ -610,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } } - // only folders the user has access to will be returned here - folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE) - if err != nil { - return sharedDashboards, err - } - folderSearchRequest := &resourcepb.ResourceSearchRequest{ Fields: []string{"folder"}, Limit: int64(len(allFolders)), @@ -628,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use }}, }, } + // only folders the user has access to will be returned here foldersResult, err := s.client.Search(ctx, folderSearchRequest) if err != nil { return sharedDashboards, err diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 3b9935f8247..c7defba3fea 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { []byte("publicfolder"), // folder uid }, }, + { + Key: &resourcepb.ResourceKey{ + Name: "sharedfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, }, }, } @@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { []byte("privatefolder"), // folder uid }, }, + { + Key: &resourcepb.ResourceKey{ + Name: "sharedfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, }, }, } @@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { allPermissions := make(map[int64]map[string][]string) permissions := make(map[string][]string) permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"} + permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"} allPermissions[1] = permissions // "Permissions" is where we store the uid of dashboards shared with the user req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions})) @@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { // first call gets all dashboards user has permission for firstCall := mockClient.MockCalls[0] - assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"}) + assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"}) + // verify federated field is set to include folders + assert.NotNil(t, firstCall.Federated) + assert.Equal(t, 1, len(firstCall.Federated)) + assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group) + assert.Equal(t, "folders", firstCall.Federated[0].Resource) // second call gets folders associated with the previous dashboards secondCall := mockClient.MockCalls[1] assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"}) - // lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have + // lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have // permission to read thirdCall := mockClient.MockCalls[2] - assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"}) + assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"}) resp := rr.Result() defer func() { From 8c8efd2494967a7dfbf2443f64eea4ec362e95c2 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:31:29 -0500 Subject: [PATCH 06/35] unified-storage: skip sqlkv/sqlbackend compatibility tests in sqlite (#116164) --- .../testing/storage_backend_sql_compatibility.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go index 9066a39221c..5e1423fa1ec 100644 --- a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -81,6 +81,12 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac kvbackend, db := newKvBackend(t.Context()) sqlbackend, _ := newSqlBackend(t.Context()) + + // Skip on SQLite due to concurrency limitations + if db.DriverName() == "sqlite3" { + t.Skip("Skipping concurrent operations stress test on SQLite") + } + tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db) }) } @@ -686,11 +692,6 @@ func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource // runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { - // Skip on SQLite due to concurrency limitations - if db.DriverName() == "sqlite3" { - t.Skip("Skipping concurrent operations stress test on SQLite") - } - ctx := testutil.NewDefaultTestContext(t) // Create storage servers from both backends From ce9ab6a89ad66d18ab2b6aa8e220a6db1e83a006 Mon Sep 17 00:00:00 2001 From: Denis Vodopianov Date: Mon, 12 Jan 2026 22:53:23 +0100 Subject: [PATCH 07/35] Add non-boolean feature flags support to the StaticProvider (#115085) * initial commit * add support of integerts * finialise the static provider * minor refactoring * the rest * revert: the rest * add new thiongs * more tests added * add ff parsing tests to check if types are handled correctly * update tests according to recent changes * address golint issues * Update pkg/setting/setting_feature_toggles.go Co-authored-by: Dave Henderson * fix rebase issues * addressing review comments * add test cases for enterprise * handle enterprise cases * minor refactoring to make api a bit easier to debug * make test names a bit more precise * fix linter * add openfeature sdk to goleak ignore in testutil * Remove only boolean check in ff gen tests * add non-boolean types top the doc in default.ini and doc string in FeatureFlag type * apply remarks, add docs to sample.ini * reflect changes in feature flags in the public grafana configuration doc * fix doc formatting * apply suggestions to the doc file --------- Co-authored-by: Dave Henderson --- conf/defaults.ini | 8 +- conf/sample.ini | 11 +- .../setup-grafana/configure-grafana/_index.md | 6 +- pkg/services/featuremgmt/models.go | 6 +- pkg/services/featuremgmt/openfeature.go | 7 +- pkg/services/featuremgmt/service.go | 3 +- pkg/services/featuremgmt/static_evaluator.go | 2 +- pkg/services/featuremgmt/static_provider.go | 47 +++--- .../featuremgmt/static_provider_test.go | 142 ++++++++++++++++++ pkg/services/featuremgmt/toggles_gen_test.go | 3 - pkg/services/updatemanager/plugins_test.go | 7 +- pkg/setting/setting_feature_toggles.go | 80 +++++++++- pkg/setting/setting_feature_toggles_test.go | 77 +++++++--- pkg/tests/apis/features/features_test.go | 2 +- pkg/util/testutil/context_test.go | 5 +- 15 files changed, 330 insertions(+), 76 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 8e0a113a0ec..080d4e62fe0 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -336,7 +336,7 @@ rudderstack_data_plane_url = rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -rudderstack_v3_sdk_url = +rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config rudderstack_config_url = @@ -2079,8 +2079,14 @@ enable = # To enable features by default, set `Expression: "true"` in: # https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. +# # feature1 = true # feature2 = false +# feature3 = "foobar" +# feature4 = 1.5 +# feature5 = { "foo": "bar" } [feature_toggles.openfeature] # This is EXPERIMENTAL. Please, do not use this section diff --git a/conf/sample.ini b/conf/sample.ini index 5a579d0e74e..b4bc6027abf 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -323,7 +323,7 @@ ;rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -;rudderstack_v3_sdk_url = +;rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = @@ -1913,7 +1913,7 @@ default_datasource_uid = # client_queue_max_size is the maximum size in bytes of the client queue # for Live connections. Defaults to 4MB. -;client_queue_max_size = +;client_queue_max_size = #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] @@ -1996,9 +1996,14 @@ default_datasource_uid = ;enable = feature1,feature2 +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. + ;feature1 = true ;feature2 = false - +;feature3 = "foobar" +;feature4 = 1.5 +;feature5 = { "foo": "bar" } [date_formats] # For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/ diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 67c361b2bdc..d9125991c12 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2836,9 +2836,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../ Keys of features to enable, separated by space. -#### `FEATURE_TOGGLE_NAME = false` +#### `FEATURE_NAME = ` -Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`. +Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details. + +For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index d59dff63c37..72f9d5ffc5b 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -133,7 +133,11 @@ type FeatureFlag struct { Stage FeatureFlagStage `json:"stage,omitempty"` Owner codeowner `json:"-"` // Owner person or team that owns this feature flag - // CEL-GO expression. Using the value "true" will mean this is on by default + // Expression defined by the feature_toggles configuration. + // Supports multiple types including boolean, string, integer, float, + // and structured values following the OpenFeature specification. + // Using the value "true" means the feature flag is enabled by default, + // Using the value "1.0" means the default value of the feature flag is 1.0 Expression string `json:"expression,omitempty"` // Special behavior properties diff --git a/pkg/services/featuremgmt/openfeature.go b/pkg/services/featuremgmt/openfeature.go index cd3b77322fb..22017b8de03 100644 --- a/pkg/services/featuremgmt/openfeature.go +++ b/pkg/services/featuremgmt/openfeature.go @@ -8,6 +8,7 @@ import ( clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature/memprovider" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/open-feature/go-sdk/openfeature" @@ -26,7 +27,7 @@ type OpenFeatureConfig struct { // HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers) HTTPClient *http.Client // StaticFlags are the feature flags to use with static provider - StaticFlags map[string]bool + StaticFlags map[string]memprovider.InMemoryFlag // TargetingKey is used for evaluation context TargetingKey string // ContextAttrs are additional attributes for evaluation context @@ -100,7 +101,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { func createProvider( providerType string, u *url.URL, - staticFlags map[string]bool, + staticFlags map[string]memprovider.InMemoryFlag, httpClient *http.Client, ) (openfeature.FeatureProvider, error) { if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType { @@ -117,7 +118,7 @@ func createProvider( } } - return newStaticProvider(staticFlags) + return newStaticProvider(staticFlags, standardFeatureFlags) } func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) { diff --git a/pkg/services/featuremgmt/service.go b/pkg/services/featuremgmt/service.go index 2769a75d788..2c97666d9e9 100644 --- a/pkg/services/featuremgmt/service.go +++ b/pkg/services/featuremgmt/service.go @@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) { } mgmt.warnings[key] = "unknown flag in config" } - mgmt.startup[key] = val + + mgmt.startup[key] = val.Variants[val.DefaultVariant] == true } // update the values diff --git a/pkg/services/featuremgmt/static_evaluator.go b/pkg/services/featuremgmt/static_evaluator.go index c3d46837d28..fdeef7a5858 100644 --- a/pkg/services/featuremgmt/static_evaluator.go +++ b/pkg/services/featuremgmt/static_evaluator.go @@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) { return nil, fmt.Errorf("failed to read feature flags from config: %w", err) } - staticProvider, err := newStaticProvider(staticFlags) + staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags) if err != nil { return nil, fmt.Errorf("failed to create static provider: %w", err) } diff --git a/pkg/services/featuremgmt/static_provider.go b/pkg/services/featuremgmt/static_provider.go index f384bd00de1..f6fe14d7de9 100644 --- a/pkg/services/featuremgmt/static_provider.go +++ b/pkg/services/featuremgmt/static_provider.go @@ -1,8 +1,13 @@ package featuremgmt import ( + "fmt" + "maps" + "github.com/open-feature/go-sdk/openfeature" "github.com/open-feature/go-sdk/openfeature/memprovider" + + "github.com/grafana/grafana/pkg/setting" ) // inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that @@ -28,37 +33,21 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) { return keys, nil } -func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) { - flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)) +func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) { + flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags)) + + // Parse and add standard flags + for _, flag := range standardFlags { + inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression) + if err != nil { + return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err) + } + + flags[flag.Name] = inMemFlag + } // Add flags from config.ini file - for name, value := range confFlags { - flags[name] = createInMemoryFlag(name, value) - } - - // Add standard flags - for _, flag := range standardFeatureFlags { - if _, exists := flags[flag.Name]; !exists { - enabled := flag.Expression == "true" - flags[flag.Name] = createInMemoryFlag(flag.Name, enabled) - } - } + maps.Copy(flags, confFlags) return newInMemoryBulkProvider(flags), nil } - -func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { - variant := "disabled" - if enabled { - variant = "enabled" - } - - return memprovider.InMemoryFlag{ - Key: name, - DefaultVariant: variant, - Variants: map[string]interface{}{ - "enabled": true, - "disabled": false, - }, - } -} diff --git a/pkg/services/featuremgmt/static_provider_test.go b/pkg/services/featuremgmt/static_provider_test.go index 29610baa8c4..c3245f7dd97 100644 --- a/pkg/services/featuremgmt/static_provider_test.go +++ b/pkg/services/featuremgmt/static_provider_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" @@ -93,3 +94,144 @@ ABCD = true enabledFeatureManager := mgr.GetEnabled(ctx) assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager) } + +func Test_StaticProvider_TypedFlags(t *testing.T) { + tests := []struct { + flags FeatureFlag + defaultValue any + expectedValue any + }{ + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "true", + }, + defaultValue: false, + expectedValue: true, + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "1.0", + }, + defaultValue: 0.0, + expectedValue: 1.0, + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "blue", + }, + defaultValue: "red", + expectedValue: "blue", + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "1", + }, + defaultValue: int64(0), + expectedValue: int64(1), + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: `{ "foo": "bar" }`, + }, + expectedValue: map[string]any{"foo": "bar"}, + }, + } + + for _, tt := range tests { + provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags}) + assert.NoError(t, err) + + var result any + switch tt.expectedValue.(type) { + case bool: + result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value + case float64: + result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value + case string: + result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value + case int64: + result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value + case map[string]any: + result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value + } + + assert.Equal(t, tt.expectedValue, result) + } +} +func Test_StaticProvider_ConfigOverride(t *testing.T) { + tests := []struct { + name string + originalValue string + configValue any + }{ + { + name: "bool", + originalValue: "false", + configValue: true, + }, + { + name: "int", + originalValue: "0", + configValue: int64(1), + }, + { + name: "float", + originalValue: "0.0", + configValue: 1.0, + }, + { + name: "string", + originalValue: "foo", + configValue: "bar", + }, + { + name: "structure", + originalValue: "{}", + configValue: make(map[string]any), + }, + } + + for _, tt := range tests { + configFlags, standardFlags := makeFlags(tt) + provider, err := newStaticProvider(configFlags, standardFlags) + assert.NoError(t, err) + + var result any + switch tt.configValue.(type) { + case bool: + result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value + case float64: + result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value + case string: + result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value + case int64: + result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value + case map[string]any: + result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value + } + + assert.Equal(t, tt.configValue, result) + } +} + +func makeFlags(tt struct { + name string + originalValue string + configValue any +}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) { + orig := FeatureFlag{ + Name: tt.name, + Expression: tt.originalValue, + } + + config := map[string]memprovider.InMemoryFlag{ + tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue), + } + + return config, []FeatureFlag{orig} +} diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index 57e308de4ec..dbfa4af0c1c 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -190,9 +190,6 @@ func verifyFlagsConfiguration(t *testing.T) { if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" { t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name) } - if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" { - t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name) - } // Check camel case names if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] { invalidNames = append(invalidNames, flag.Name) diff --git a/pkg/services/updatemanager/plugins_test.go b/pkg/services/updatemanager/plugins_test.go index 75834c44c7d..93b3d54b321 100644 --- a/pkg/services/updatemanager/plugins_test.go +++ b/pkg/services/updatemanager/plugins_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/open-feature/go-sdk/openfeature" + "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -378,8 +379,10 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) { err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{ ProviderType: setting.StaticProviderType, - StaticFlags: map[string]bool{ - featuremgmt.FlagPluginsAutoUpdate: flagValue, + StaticFlags: map[string]memprovider.InMemoryFlag{ + featuremgmt.FlagPluginsAutoUpdate: { + Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue}, + }, }, }) require.NoError(t, err) diff --git a/pkg/setting/setting_feature_toggles.go b/pkg/setting/setting_feature_toggles.go index e09b45e9edb..38bfd0269e1 100644 --- a/pkg/setting/setting_feature_toggles.go +++ b/pkg/setting/setting_feature_toggles.go @@ -1,13 +1,20 @@ package setting import ( + "encoding/json" + "math" "strconv" "gopkg.in/ini.v1" + "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/grafana/grafana/pkg/util" ) +// DefaultVariantName a placeholder name for config-based Feature Flags +const DefaultVariantName = "default" + // Deprecated: should use `featuremgmt.FeatureToggles` func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { section := iniFile.Section("feature_toggles") @@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { if err != nil { return err } + // TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely // nolint:staticcheck - cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] } + cfg.IsFeatureToggleEnabled = func(key string) bool { + toggle, ok := toggles[key] + if !ok { + return false + } + + value, ok := toggle.Variants[toggle.DefaultVariant].(bool) + return value && ok + } return nil } -func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) { - featureToggles := make(map[string]bool, 10) +func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) { + featureToggles := make(map[string]memprovider.InMemoryFlag, 10) // parse the comma separated list in `enable`. featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "") for _, feature := range util.SplitString(featuresTogglesStr) { - featureToggles[feature] = true + featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}} } // read all other settings under [feature_toggles]. If a toggle is @@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str continue } - b, err := strconv.ParseBool(v.Value()) + b, err := ParseFlag(v.Name(), v.Value()) if err != nil { return featureToggles, err } @@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str } return featureToggles, nil } + +func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) { + var structure map[string]any + + if integer, err := strconv.Atoi(value); err == nil { + return NewInMemoryFlag(name, integer), nil + } + if float, err := strconv.ParseFloat(value, 64); err == nil { + return NewInMemoryFlag(name, float), nil + } + if err := json.Unmarshal([]byte(value), &structure); err == nil { + return NewInMemoryFlag(name, structure), nil + } + if boolean, err := strconv.ParseBool(value); err == nil { + return NewInMemoryFlag(name, boolean), nil + } + + return NewInMemoryFlag(name, value), nil +} + +func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag { + return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}} +} + +func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string { + var res = map[string]string{} + for k, v := range m { + res[k] = serializeFlagValue(v) + } + return res +} + +func serializeFlagValue(flag memprovider.InMemoryFlag) string { + value := flag.Variants[flag.DefaultVariant] + + switch castedValue := value.(type) { + case bool: + return strconv.FormatBool(castedValue) + case int64: + return strconv.FormatInt(castedValue, 10) + case float64: + // handle cases with a single or no zeros after the decimal point + if math.Trunc(castedValue) == castedValue { + return strconv.FormatFloat(castedValue, 'f', 1, 64) + } + + return strconv.FormatFloat(castedValue, 'g', -1, 64) + case string: + return castedValue + default: + val, _ := json.Marshal(value) + return string(val) + } +} diff --git a/pkg/setting/setting_feature_toggles_test.go b/pkg/setting/setting_feature_toggles_test.go index b0c3730bcad..040a9ef7427 100644 --- a/pkg/setting/setting_feature_toggles_test.go +++ b/pkg/setting/setting_feature_toggles_test.go @@ -1,9 +1,11 @@ package setting import ( - "strconv" "testing" + "github.com/google/go-cmp/cmp" + "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" ) @@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) { testCases := []struct { name string conf map[string]string - err error - expectedToggles map[string]bool + expectedToggles map[string]memprovider.InMemoryFlag }{ { name: "can parse feature toggles passed in the `enable` array", conf: map[string]string{ "enable": "feature1,feature2", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": true, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", true), }, }, { @@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) { "enable": "feature1,feature2", "feature3": "true", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": true, - "feature3": true, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", true), + "feature3": NewInMemoryFlag("feature3", true), }, }, { @@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) { "enable": "feature1,feature2", "feature2": "false", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": false, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", false), }, }, { - name: "invalid boolean value should return syntax error", + name: "feature flags of different types are handled correctly", conf: map[string]string{ - "enable": "feature1,feature2", - "feature2": "invalid", + "feature1": "1", "feature2": "1.0", + "feature3": `{"foo":"bar"}`, "feature4": "bar", + "feature5": "t", "feature6": "T", + }, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", 1), + "feature2": NewInMemoryFlag("feature2", 1.0), + "feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}), + "feature4": NewInMemoryFlag("feature4", "bar"), + "feature5": NewInMemoryFlag("feature5", true), + "feature6": NewInMemoryFlag("feature6", true), }, - expectedToggles: map[string]bool{}, - err: strconv.ErrSyntax, }, } @@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) { } featureToggles, err := ReadFeatureTogglesFromInitFile(toggles) - require.ErrorIs(t, err, tc.err) + require.NoError(t, err) - if err == nil { - for k, v := range featureToggles { - require.Equal(t, tc.expectedToggles[k], v, tc.name) - } + for k, v := range featureToggles { + toggle := tc.expectedToggles[k] + require.Equal(t, toggle, v, tc.name) + } + } +} + +func TestFlagValueSerialization(t *testing.T) { + testCases := []memprovider.InMemoryFlag{ + NewInMemoryFlag("int", 1), + NewInMemoryFlag("1.0f", 1.0), + NewInMemoryFlag("1.01f", 1.01), + NewInMemoryFlag("1.10f", 1.10), + NewInMemoryFlag("struct", map[string]any{"foo": "bar"}), + NewInMemoryFlag("string", "bar"), + NewInMemoryFlag("true", true), + NewInMemoryFlag("false", false), + } + + for _, tt := range testCases { + asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt}) + + deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key]) + assert.NoError(t, err) + + if diff := cmp.Diff(tt, deserialized); diff != "" { + t.Errorf("(-want, +got) = %v", diff) } } } diff --git a/pkg/tests/apis/features/features_test.go b/pkg/tests/apis/features/features_test.go index ab7bfea64d4..0a8c9564492 100644 --- a/pkg/tests/apis/features/features_test.go +++ b/pkg/tests/apis/features/features_test.go @@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) { "value": true, "key":"`+flag+`", "reason":"static provider evaluation result", - "variant":"enabled"}`, string(rsp.Body)) + "variant":"default"}`, string(rsp.Body)) }) } diff --git a/pkg/util/testutil/context_test.go b/pkg/util/testutil/context_test.go index 4d7ecf670f6..ca5c5abee4f 100644 --- a/pkg/util/testutil/context_test.go +++ b/pkg/util/testutil/context_test.go @@ -15,7 +15,10 @@ import ( func TestMain(m *testing.M) { // make sure we don't leak goroutines after tests in this package have // finished, which means we haven't leaked contexts either - goleak.VerifyTestMain(m) + // (Except for goroutines running specific functions. If possible we should fix this.) + goleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + ) } func TestTestContextFunc(t *testing.T) { From d0217588a3a6be271e04c7194a9be0f452449bdf Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:43:01 -0600 Subject: [PATCH 08/35] LogsDrilldown: Remove exploreLogsLimitedTimeRange flag (#116177) chore: remove flag --- packages/grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.json | 3 ++- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8bc45524b37..f1b1ce4154d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -622,10 +622,6 @@ export interface FeatureToggles { */ exploreLogsAggregatedMetrics?: boolean; /** - * Used in Logs Drilldown to limit the time range - */ - exploreLogsLimitedTimeRange?: boolean; - /** * Enables the gRPC client to authenticate with the App Platform by using ID & access tokens */ appPlatformGrpcClientAuth?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3e108d1ed0c..c9f2ca03184 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1031,13 +1031,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, - { - Name: "exploreLogsLimitedTimeRange", - Description: "Used in Logs Drilldown to limit the time range", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - }, { Name: "appPlatformGrpcClientAuth", Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 20ff391364e..7557323e43b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -142,7 +142,6 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true -exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 50383070847..49cdbd86374 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1382,7 +1382,8 @@ "metadata": { "name": "exploreLogsLimitedTimeRange", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-08-29T13:55:59Z" + "creationTimestamp": "2024-08-29T13:55:59Z", + "deletionTimestamp": "2026-01-12T22:18:14Z" }, "spec": { "description": "Used in Logs Drilldown to limit the time range", From b57ed324843443c90d09552993bb23d3ceb0c8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 13 Jan 2026 06:23:21 +0100 Subject: [PATCH 09/35] chore: remove app/core/config barrel files (#116068) --- eslint-suppressions.json | 5 ----- .../SplitPaneWrapper/SplitPaneWrapper.tsx | 2 +- public/app/core/config.ts | 1 - public/app/core/internationalization/dates.ts | 2 +- public/app/core/services/theme.ts | 3 +-- public/app/features/alerting/routes.tsx | 2 +- .../alerting/state/ThresholdMapper.ts | 2 +- .../unified/components/rule-editor/util.ts | 2 +- .../annotations/standardAnnotationSupport.ts | 2 +- .../auth-config/AuthProvidersListPage.tsx | 3 +-- public/app/features/canvas/element.ts | 2 +- public/app/features/canvas/elements/cloud.tsx | 2 +- .../app/features/canvas/elements/ellipse.tsx | 2 +- .../canvas/elements/parallelogram.tsx | 2 +- .../app/features/canvas/elements/triangle.tsx | 2 +- .../app/features/canvas/runtime/element.tsx | 2 +- public/app/features/canvas/runtime/scene.tsx | 3 +-- .../canvas/runtime/sceneAbleManagement.ts | 2 +- .../sharing/ShareButton/ShareMenu.test.tsx | 2 +- .../components/ShareModal/ShareModal.tsx | 2 +- .../dashgrid/PanelLoadTimeMonitor.test.tsx | 2 +- .../dashgrid/PanelLoadTimeMonitor.tsx | 2 +- .../dashgrid/panelOptionsLogger.test.ts | 2 +- .../dashboard/dashgrid/panelOptionsLogger.ts | 2 +- public/app/features/dashboard/routes.ts | 2 +- .../features/dashboard/services/TimeSrv.ts | 3 +-- .../dashboard/state/DashboardMigrator.test.ts | 2 +- .../dashboard/utils/loadSnapshotData.ts | 2 +- .../editors/ResourcePickerPopover.tsx | 3 +-- .../RawPrometheus/RawPrometheusContainer.tsx | 3 +-- .../features/explore/Table/TableContainer.tsx | 3 +-- .../TracePageHeader/TracePageHeader.test.tsx | 22 +++++-------------- .../TracePageHeader/TracePageHeader.tsx | 9 ++++++-- public/app/features/expressions/types.ts | 2 +- .../app/features/inspector/InspectDataTab.tsx | 3 +-- public/app/features/inspector/styles.ts | 2 +- public/app/features/panel/state/util.ts | 2 +- .../suggestions/getAllSuggestions.test.ts | 2 +- .../features/plugins/admin/state/actions.ts | 3 +-- public/app/features/profile/routes.tsx | 2 +- .../features/users/UsersActionBar.test.tsx | 2 +- .../datasource/alertmanager/ConfigEditor.tsx | 2 +- .../panel/alertlist/UnifiedAlertList.tsx | 3 +-- .../plugins/panel/bargauge/BarGaugePanel.tsx | 2 +- .../panel/candlestick/CandlestickPanel.tsx | 3 +-- .../canvas/components/CanvasContextMenu.tsx | 2 +- .../components/connections/ConnectionSVG.tsx | 2 +- .../components/connections/ConnectionSVG2.tsx | 2 +- public/app/plugins/panel/canvas/utils.ts | 3 ++- public/app/plugins/panel/gauge/GaugePanel.tsx | 2 +- .../panel/geomap/components/DebugOverlay.tsx | 2 +- .../panel/geomap/components/MarkersLegend.tsx | 2 +- .../geomap/components/MeasureOverlay.tsx | 2 +- .../plugins/panel/geomap/layers/registry.ts | 3 ++- .../plugins/panel/live/LiveChannelEditor.tsx | 2 +- .../panel/radialbar/RadialBarPanel.tsx | 3 +-- public/app/plugins/panel/table/suggestions.ts | 2 +- public/app/plugins/panel/text/module.tsx | 2 +- .../panel/timeseries/TimeSeriesPanel.tsx | 3 +-- 59 files changed, 69 insertions(+), 93 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 250dbd20348..f633ed5b4eb 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1156,11 +1156,6 @@ "count": 2 } }, - "public/app/core/config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "public/app/core/navigation/types.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 9ff66c518ee..a6a9041f6c1 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import SplitPane, { Split } from 'react-split-pane'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { getDragStyles } from '@grafana/ui'; -import { config } from 'app/core/config'; interface Props { splitOrientation?: Split; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 6c757c9436a..f725aab5472 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,6 +1,5 @@ import { PluginState } from '@grafana/data'; import { config, GrafanaBootConfig } from '@grafana/runtime'; -export { config, type GrafanaBootConfig as Settings }; let grafanaConfig: GrafanaBootConfig = config; diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts index 9ef7bbbdb82..c151e505487 100644 --- a/public/app/core/internationalization/dates.ts +++ b/public/app/core/internationalization/dates.ts @@ -2,7 +2,7 @@ import deepEqual from 'fast-deep-equal'; import memoize from 'micro-memoize'; import { getLanguage } from '@grafana/i18n/internal'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual }); diff --git a/public/app/core/services/theme.ts b/public/app/core/services/theme.ts index 542aa1ab743..82bd949904f 100644 --- a/public/app/core/services/theme.ts +++ b/public/app/core/services/theme.ts @@ -1,8 +1,7 @@ import { getThemeById } from '@grafana/data/internal'; -import { ThemeChangedEvent } from '@grafana/runtime'; +import { config, ThemeChangedEvent } from '@grafana/runtime'; import { appEvents } from '../app_events'; -import { config } from '../config'; import { contextSrv } from '../services/context_srv'; import { PreferencesService } from './PreferencesService'; diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 34459b9581e..418327face5 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -1,7 +1,7 @@ import { Navigate } from 'react-router-dom-v5-compat'; +import { config } from '@grafana/runtime'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; -import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; import { AccessControlAction } from 'app/types/accessControl'; diff --git a/public/app/features/alerting/state/ThresholdMapper.ts b/public/app/features/alerting/state/ThresholdMapper.ts index 48b9b7c656a..140330b9d06 100644 --- a/public/app/features/alerting/state/ThresholdMapper.ts +++ b/public/app/features/alerting/state/ThresholdMapper.ts @@ -1,4 +1,4 @@ -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs']; diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts index b8e20cba6b4..852a37bdf25 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.ts @@ -8,8 +8,8 @@ import { ThresholdsMode, isTimeSeriesFrames, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { GraphThresholdsStyleMode } from '@grafana/schema'; -import { config } from 'app/core/config'; import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { isExpressionQuery } from 'app/features/expressions/guards'; import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types'; diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts index 90d28715c7f..cd258e5b0ee 100644 --- a/public/app/features/annotations/standardAnnotationSupport.ts +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -18,7 +18,7 @@ import { standardTransformers, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; export const standardAnnotationSupport: AnnotationSupport = { /** diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index af05c498ccf..413d78c75a4 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -3,10 +3,9 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaEdition } from '@grafana/data/internal'; import { Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Grid, TextLink, ToolbarButton } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { config } from 'app/core/config'; import { StoreState } from 'app/types/store'; import { isOpenSourceBuildOrUnlicenced } from '../admin/EnterpriseAuthFeaturesCard'; diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index 7e78ad784e7..97de7a5fd5b 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -2,8 +2,8 @@ import { ComponentType } from 'react'; import { DataLink, RegistryItem, Action } from '@grafana/data'; import { PanelOptionsSupplier } from '@grafana/data/internal'; +import { config } from '@grafana/runtime'; import { ColorDimensionConfig, ScaleDimensionConfig, DirectionDimensionConfig } from '@grafana/schema'; -import { config } from 'app/core/config'; import { BackgroundConfig, Constraint, LineConfig, Placement } from 'app/plugins/panel/canvas/panelcfg.gen'; import { LineStyleConfig } from '../../plugins/panel/canvas/editor/LineStyleEditor'; diff --git a/public/app/features/canvas/elements/cloud.tsx b/public/app/features/canvas/elements/cloud.tsx index f215b4dfd9e..1c11f05818e 100644 --- a/public/app/features/canvas/elements/cloud.tsx +++ b/public/app/features/canvas/elements/cloud.tsx @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; diff --git a/public/app/features/canvas/elements/ellipse.tsx b/public/app/features/canvas/elements/ellipse.tsx index d4d7f3fb23f..6659263cb09 100644 --- a/public/app/features/canvas/elements/ellipse.tsx +++ b/public/app/features/canvas/elements/ellipse.tsx @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; diff --git a/public/app/features/canvas/elements/parallelogram.tsx b/public/app/features/canvas/elements/parallelogram.tsx index 2dc0df79799..dced8408bd4 100644 --- a/public/app/features/canvas/elements/parallelogram.tsx +++ b/public/app/features/canvas/elements/parallelogram.tsx @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; diff --git a/public/app/features/canvas/elements/triangle.tsx b/public/app/features/canvas/elements/triangle.tsx index 35b00d05761..e335092a785 100644 --- a/public/app/features/canvas/elements/triangle.tsx +++ b/public/app/features/canvas/elements/triangle.tsx @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index f53008f9cb7..4a7c5ec8a7b 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -14,10 +14,10 @@ import { ActionType, } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; import { ConfirmModal, VariablesInputModal } from '@grafana/ui'; import { LayerElement } from 'app/core/components/Layers/types'; -import { config } from 'app/core/config'; import { notFoundItem } from 'app/features/canvas/elements/notFound'; import { DimensionContext } from 'app/features/dimensions/context'; import { diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index ad217e45337..4b58709934f 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'; import Selecto from 'selecto'; import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { ColorDimensionConfig, ResourceDimensionConfig, @@ -17,7 +17,6 @@ import { DirectionDimensionConfig, } from '@grafana/schema'; import { Portal } from '@grafana/ui'; -import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { getColorDimensionFromData, diff --git a/public/app/features/canvas/runtime/sceneAbleManagement.ts b/public/app/features/canvas/runtime/sceneAbleManagement.ts index 9af3e1a74e0..e8e3cb7f3ac 100644 --- a/public/app/features/canvas/runtime/sceneAbleManagement.ts +++ b/public/app/features/canvas/runtime/sceneAbleManagement.ts @@ -2,7 +2,7 @@ import InfiniteViewer from 'infinite-viewer'; import Moveable from 'moveable'; import Selecto from 'selecto'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; import { CONNECTION_VERTEX_ID, diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx index c049579fb38..5c618b18b16 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx @@ -1,11 +1,11 @@ import { render, screen } from '@testing-library/react'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { SceneTimeRange, VizPanel } from '@grafana/scenes'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; -import { config } from '../../../../core/config'; import { grantUserPermissions } from '../../../alerting/unified/mocks'; import { DashboardScene, DashboardSceneState } from '../../scene/DashboardScene'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 4c701cbda15..55df40308cf 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { contextSrv } from 'app/core/services/context_srv'; import { SharePublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard'; import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; diff --git a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx index 48358aa563c..550869201f1 100644 --- a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx @@ -4,7 +4,7 @@ const mockPushMeasurement = jest.fn(); import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor'; -jest.mock('app/core/config', () => ({ +jest.mock('@grafana/runtime', () => ({ config: { grafanaJavascriptAgent: { enabled: true, diff --git a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx index 230add25c6a..2cda7cce69d 100644 --- a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { faro } from '@grafana/faro-web-sdk'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { PanelLogEvents } from 'app/core/log_events'; interface Props { diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts index 95a43c75cad..7c55a8842a2 100644 --- a/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts @@ -12,7 +12,7 @@ jest.mock('@grafana/faro-web-sdk', () => ({ }, })); -jest.mock('app/core/config', () => ({ +jest.mock('@grafana/runtime', () => ({ config: { grafanaJavascriptAgent: { enabled: true, diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts index 0e75d7fdad1..6c994f924cf 100644 --- a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts @@ -1,6 +1,6 @@ import { FieldConfigSource } from '@grafana/data'; import { faro } from '@grafana/faro-web-sdk'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events'; interface PanelLogInfo { diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts index 57702d1a35d..8cd75418deb 100644 --- a/public/app/features/dashboard/routes.ts +++ b/public/app/features/dashboard/routes.ts @@ -1,7 +1,7 @@ +import { config } from '@grafana/runtime'; import { DashboardRoutes } from 'app/types/dashboard'; import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport'; -import { config } from '../../core/config'; import { RouteDescriptor } from '../../core/navigation/types'; export const getPublicDashboardRoutes = (): RouteDescriptor[] => { diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index fb8f346a267..2c819f9018f 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -13,10 +13,9 @@ import { dateTimeForTimeZone, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; import { appEvents } from 'app/core/app_events'; -import { config } from 'app/core/config'; import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv'; import { getCopiedTimeRange, diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index acdc306fc87..6c4551f9158 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2,8 +2,8 @@ import { each, map } from 'lodash'; import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; +import { config } from '@grafana/runtime'; import { FieldConfigSource } from '@grafana/schema'; -import { config } from 'app/core/config'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; diff --git a/public/app/features/dashboard/utils/loadSnapshotData.ts b/public/app/features/dashboard/utils/loadSnapshotData.ts index 2be1206ec12..d44e70e3ffb 100644 --- a/public/app/features/dashboard/utils/loadSnapshotData.ts +++ b/public/app/features/dashboard/utils/loadSnapshotData.ts @@ -6,7 +6,7 @@ import { LoadingState, PanelData, } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker'; import { getTimeSrv } from '../services/TimeSrv'; diff --git a/public/app/features/dimensions/editors/ResourcePickerPopover.tsx b/public/app/features/dimensions/editors/ResourcePickerPopover.tsx index bfb35f9e717..6187974620f 100644 --- a/public/app/features/dimensions/editors/ResourcePickerPopover.tsx +++ b/public/app/features/dimensions/editors/ResourcePickerPopover.tsx @@ -6,9 +6,8 @@ import { useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { getBackendSrv } from '@grafana/runtime'; +import { config, getBackendSrv } from '@grafana/runtime'; import { Button, useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MediaType, PickerTabType, ResourceFolderName } from '../types'; diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index ebd005f595b..6a00d567a41 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -3,10 +3,9 @@ import { memo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data'; -import { getTemplateSrv, reportInteraction } from '@grafana/runtime'; +import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; -import { config } from 'app/core/config'; import { PANEL_BORDER } from 'app/core/constants'; import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; import { StoreState } from 'app/types/store'; diff --git a/public/app/features/explore/Table/TableContainer.tsx b/public/app/features/explore/Table/TableContainer.tsx index 2c1614e539d..7283711e4ce 100644 --- a/public/app/features/explore/Table/TableContainer.tsx +++ b/public/app/features/explore/Table/TableContainer.tsx @@ -13,10 +13,9 @@ import { EventBusSrv, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { getTemplateSrv, PanelRenderer } from '@grafana/runtime'; +import { config, getTemplateSrv, PanelRenderer } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; import { AdHocFilterItem, PanelChrome, withTheme2, Themeable2, PanelContextProvider } from '@grafana/ui'; -import { config } from 'app/core/config'; import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames, diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx index fb735991a3d..037a4a3c0c2 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx @@ -22,7 +22,7 @@ import { PluginExtensionPoints, PluginExtensionTypes, } from '@grafana/data'; -import { usePluginLinks, usePluginComponents } from '@grafana/runtime'; +import { usePluginLinks, usePluginComponents, config } from '@grafana/runtime'; import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants'; import { TraceViewPluginExtensionContext } from '../types/trace'; @@ -47,13 +47,6 @@ jest.mock('app/core/copy/appNotification', () => ({ })), })); -// Mock config -jest.mock('../../../../../core/config', () => ({ - config: { - feedbackLinksEnabled: false, // Default to false to avoid interference with tests - }, -})); - // Mock navigator.clipboard Object.assign(navigator, { clipboard: { @@ -127,6 +120,7 @@ describe('TracePageHeader test', () => { beforeEach(() => { jest.clearAllMocks(); mockWindowOpen.mockClear(); + config.feedbackLinksEnabled = false; // Default to false to avoid interference with tests }); it('should render the new trace header', () => { @@ -438,9 +432,7 @@ describe('TracePageHeader test', () => { }); it('should render feedback button when feedbackLinksEnabled is true', () => { - // Mock config with feedbackLinksEnabled = true - const mockConfig = require('../../../../../core/config'); - mockConfig.config.feedbackLinksEnabled = true; + config.feedbackLinksEnabled = true; setup(); @@ -453,9 +445,7 @@ describe('TracePageHeader test', () => { it('should display tooltip for feedback button', async () => { const user = userEvent.setup(); - // Mock config with feedbackLinksEnabled = true - const mockConfig = require('../../../../../core/config'); - mockConfig.config.feedbackLinksEnabled = true; + config.feedbackLinksEnabled = true; setup(); @@ -469,9 +459,7 @@ describe('TracePageHeader test', () => { }); it('should render feedback button with correct styling and icon', () => { - // Mock config with feedbackLinksEnabled = true - const mockConfig = require('../../../../../core/config'); - mockConfig.config.feedbackLinksEnabled = true; + config.feedbackLinksEnabled = true; setup(); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index f0038ff1593..1e0fedade74 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -26,7 +26,13 @@ import { PluginExtensionPoints, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime'; +import { + reportInteraction, + renderLimitedComponents, + usePluginComponents, + usePluginLinks, + config, +} from '@grafana/runtime'; import { AdHocFiltersComboboxRenderer } from '@grafana/scenes'; import { TimeZone } from '@grafana/schema'; import { @@ -46,7 +52,6 @@ import { } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { config } from '../../../../../core/config'; import { downloadTraceAsJson } from '../../../../inspector/utils/download'; import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types'; import { getHeaderTags, getTraceName } from '../model/trace-viewer'; diff --git a/public/app/features/expressions/types.ts b/public/app/features/expressions/types.ts index 3a4bd936424..ec83f0e50e2 100644 --- a/public/app/features/expressions/types.ts +++ b/public/app/features/expressions/types.ts @@ -1,5 +1,5 @@ import { DataQuery, ReducerID, SelectableValue } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { EvalFunction } from '../alerting/state/alertDef'; diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index 8aba53622cc..4e979740c9a 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -15,9 +15,8 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { getTemplateSrv, reportInteraction } from '@grafana/runtime'; +import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime'; import { Button, Spinner, Table } from '@grafana/ui'; -import { config } from 'app/core/config'; import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner'; import { dataFrameToLogsModel } from '../logs/logsModel'; diff --git a/public/app/features/inspector/styles.ts b/public/app/features/inspector/styles.ts index 86646950382..65a50dd3816 100644 --- a/public/app/features/inspector/styles.ts +++ b/public/app/features/inspector/styles.ts @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { stylesFactory } from '@grafana/ui'; -import { config } from 'app/core/config'; /** @deprecated */ export const getPanelInspectorStyles = stylesFactory(() => { diff --git a/public/app/features/panel/state/util.ts b/public/app/features/panel/state/util.ts index 87f674479a8..4c785e67fe8 100644 --- a/public/app/features/panel/state/util.ts +++ b/public/app/features/panel/state/util.ts @@ -1,5 +1,5 @@ import { PanelPluginMeta, PluginState, unEscapeStringFromRegex } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; export function getAllPanelPluginMeta(): PanelPluginMeta[] { const allPanels = config.panels; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index 8657824cc27..7631cbf95f9 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -11,6 +11,7 @@ import { toDataFrame, VisualizationSuggestionScore, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { BarGaugeDisplayMode, BigValueColorMode, @@ -20,7 +21,6 @@ import { VizOrientation, } from '@grafana/schema'; import { appEvents } from 'app/core/app_events'; -import { config } from 'app/core/config'; import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin'; import { pluginImporter } from 'app/features/plugins/importer/pluginImporter'; diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index e9cf2d9d40d..34ffc227354 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -3,7 +3,6 @@ import { from, forkJoin, timeout, lastValueFrom, catchError, of } from 'rxjs'; import { PanelPlugin, PluginError } from '@grafana/data'; import { config, getBackendSrv, isFetchError } from '@grafana/runtime'; -import { Settings } from 'app/core/config'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { StoreState, ThunkResult } from 'app/types/store'; @@ -301,7 +300,7 @@ export const loadPanelPlugin = (id: string): ThunkResult> = function updatePanels() { return getBackendSrv() .get('/api/frontend/settings') - .then((settings: Settings) => { + .then((settings) => { config.panels = settings.panels; }); } diff --git a/public/app/features/profile/routes.tsx b/public/app/features/profile/routes.tsx index a473b05050c..46ba36dc658 100644 --- a/public/app/features/profile/routes.tsx +++ b/public/app/features/profile/routes.tsx @@ -1,7 +1,7 @@ import { uniq } from 'lodash'; +import { config } from '@grafana/runtime'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; -import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; const profileRoutes: RouteDescriptor[] = [ diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx index ec58494b21e..83681aac2a0 100644 --- a/public/app/features/users/UsersActionBar.test.tsx +++ b/public/app/features/users/UsersActionBar.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { Props, UsersActionBarUnconnected } from './UsersActionBar'; import { searchQueryChanged } from './state/reducers'; diff --git a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx index 24bc89e9b90..be7cc9a197a 100644 --- a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx +++ b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx @@ -4,8 +4,8 @@ import { Link } from 'react-router-dom-v5-compat'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Box, DataSourceHttpSettings, InlineField, InlineSwitch, Select, Text } from '@grafana/ui'; -import { config } from 'app/core/config'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types'; diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index 7fe6b348846..8d585476cc9 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -5,7 +5,7 @@ import { useEffectOnce, useToggle } from 'react-use'; import { GrafanaTheme2, PanelProps } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { TimeRangeUpdatedEvent } from '@grafana/runtime'; +import { config, TimeRangeUpdatedEvent } from '@grafana/runtime'; import { Alert, BigValue, @@ -17,7 +17,6 @@ import { ScrollContainer, useStyles2, } from '@grafana/ui'; -import { config } from 'app/core/config'; import alertDef from 'app/features/alerting/state/alertDef'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index d8714fd8f4a..2a1ff46fe12 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -12,10 +12,10 @@ import { PanelProps, VizOrientation, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { BarGaugeSizing } from '@grafana/schema'; import { BarGauge, DataLinksContextMenu, VizLayout, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { BarGaugeLegend } from './BarGaugeLegend'; import { defaultOptions, Options } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 4f5f7a46b23..1772f8b2d77 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react'; import uPlot from 'uplot'; import { Field, getDisplayProcessor, PanelProps, useDataLinksContext } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema'; import { EventBusPlugin, @@ -18,7 +18,6 @@ import { } from '@grafana/ui'; import { AxisProps, ScaleProps, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; -import { config } from 'app/core/config'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; diff --git a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx index be667771d56..8dfd1c48a68 100644 --- a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx @@ -5,8 +5,8 @@ import { first } from 'rxjs/operators'; import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { ContextMenu, MenuItem, MenuItemProps } from '@grafana/ui'; -import { config } from 'app/core/config'; import { ElementState } from 'app/features/canvas/runtime/element'; import { FrameState } from 'app/features/canvas/runtime/frame'; import { Scene } from 'app/features/canvas/runtime/scene'; diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index 3dfcad29220..93713b261a2 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { DirectionDimensionConfig, DirectionDimensionMode, ConnectionDirection } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionCoordinates } from '../../panelcfg.gen'; diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx index 6e6093250df..8d0219f09b1 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { DirectionDimensionConfig, DirectionDimensionMode, ConnectionDirection } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionCoordinates } from '../../panelcfg.gen'; diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 59953e9085a..14f42ad9b7e 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -1,9 +1,10 @@ import { isNumber, isString } from 'lodash'; import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { ConnectionDirection } from '@grafana/schema'; import { appEvents } from 'app/core/app_events'; -import { hasAlphaPanels, config } from 'app/core/config'; +import { hasAlphaPanels } from 'app/core/config'; import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element'; import { notFoundItem } from 'app/features/canvas/elements/notFound'; import { advancedElementItems, canvasElementRegistry, defaultElementItems } from 'app/features/canvas/registry'; diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index 3ae1988e30a..0dd53f2505b 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -1,10 +1,10 @@ import { PureComponent, type JSX } from 'react'; import { FieldDisplay, getDisplayProcessor, getFieldDisplayValues, PanelProps } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { BarGaugeSizing, VizOrientation } from '@grafana/schema'; import { DataLinksContextMenu, Gauge, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { clearNameForSingleSeries } from '../bargauge/BarGaugePanel'; diff --git a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx index d08d2042f05..9ce3b8f09db 100644 --- a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx +++ b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx @@ -8,7 +8,7 @@ import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; interface Props { map: Map; diff --git a/public/app/plugins/panel/geomap/components/MarkersLegend.tsx b/public/app/plugins/panel/geomap/components/MarkersLegend.tsx index 75d9cfe154b..331ceafe3c3 100644 --- a/public/app/plugins/panel/geomap/components/MarkersLegend.tsx +++ b/public/app/plugins/panel/geomap/components/MarkersLegend.tsx @@ -12,11 +12,11 @@ import { GrafanaTheme2, } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { useStyles2, VizLegendItem } from '@grafana/ui'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { getThresholdItems } from 'app/core/components/TimelineChart/utils'; -import { config } from 'app/core/config'; import { DimensionSupplier } from 'app/features/dimensions/types'; import { StyleConfigState } from '../style/types'; diff --git a/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx b/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx index 90b29f160e9..e6c4b2968eb 100644 --- a/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx +++ b/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx @@ -5,8 +5,8 @@ import { useMemo, useRef, useState } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Button, IconButton, RadioButtonGroup, Select } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MapMeasure, MapMeasureOptions, measures } from '../utils/measure'; diff --git a/public/app/plugins/panel/geomap/layers/registry.ts b/public/app/plugins/panel/geomap/layers/registry.ts index 15e6fbb7861..960da8d7aac 100644 --- a/public/app/plugins/panel/geomap/layers/registry.ts +++ b/public/app/plugins/panel/geomap/layers/registry.ts @@ -9,7 +9,8 @@ import { SelectableValue, PluginState, } from '@grafana/data'; -import { config, hasAlphaPanels } from 'app/core/config'; +import { config } from '@grafana/runtime'; +import { hasAlphaPanels } from 'app/core/config'; import { basemapLayers } from './basemaps'; import { carto } from './basemaps/carto'; diff --git a/public/app/plugins/panel/live/LiveChannelEditor.tsx b/public/app/plugins/panel/live/LiveChannelEditor.tsx index 06af567317e..82f2444ca56 100644 --- a/public/app/plugins/panel/live/LiveChannelEditor.tsx +++ b/public/app/plugins/panel/live/LiveChannelEditor.tsx @@ -10,8 +10,8 @@ import { parseLiveChannelAddress, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Select, Alert, Label, stylesFactory, Combobox } from '@grafana/ui'; -import { config } from 'app/core/config'; import { discoveryResources, getAPIGroupDiscoveryList, GroupDiscoveryResource } from 'app/features/apiserver/discovery'; import { getManagedChannelInfo } from 'app/features/live/info'; diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 42c70511977..8b32e651f84 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -7,10 +7,9 @@ import { getFieldDisplayValues, PanelProps, } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { DataLinksContextMenu, Stack, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi, RadialGauge } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { Options } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 260e73b43eb..bd172f26ad8 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,5 +1,5 @@ import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import { Options, FieldConfig } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/text/module.tsx b/public/app/plugins/panel/text/module.tsx index dff26482092..ded19ac3185 100644 --- a/public/app/plugins/panel/text/module.tsx +++ b/public/app/plugins/panel/text/module.tsx @@ -1,6 +1,6 @@ import { PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { TextPanel } from './TextPanel'; import { TextPanelEditor } from './TextPanelEditor'; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index eb3b226a248..a85ef58cdc7 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -10,7 +10,7 @@ import { useDataLinksContext, FieldType, } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode, VizOrientation } from '@grafana/schema'; import { EventBusPlugin, @@ -21,7 +21,6 @@ import { } from '@grafana/ui'; import { FILTER_OUT_OPERATOR, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; -import { config } from 'app/core/config'; import { TimeSeriesTooltip } from './TimeSeriesTooltip'; import { Options } from './panelcfg.gen'; From 250ca7985ff8567ce314f34220ea331448ef0da9 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Tue, 13 Jan 2026 08:25:40 +0200 Subject: [PATCH 10/35] Provisioning: Add Connections page (#116060) * Provisioning: Add connections page * Provisioning: Add connections form * Provisioning: Add connections form * Update fields * Fix generated name * Update connection name * Add edit page * error handling * Form validation * Add Connections button * Cleanup * Extract ConnectionFormData type * Add list test and separate empty states * Add form test * Update tests * i18n * Cleanup * Use SecretTextArea from grafana-ui * Fix breadcrumbs * tweaks * Add missing URL * Switch to ShowConfirmModalEvent * i18n * redirect to list on success * add timeout * Fix tags invalidation --- .../SecretTextArea/SecretTextArea.tsx | 6 +- .../clients/provisioning/v0alpha1/index.ts | 125 ++++++-- .../provisioning/Config/ConfigForm.tsx | 19 +- .../Connection/ConnectionForm.test.tsx | 277 ++++++++++++++++++ .../Connection/ConnectionForm.tsx | 199 +++++++++++++ .../Connection/ConnectionFormPage.tsx | 59 ++++ .../Connection/ConnectionList.test.tsx | 165 +++++++++++ .../Connection/ConnectionList.tsx | 51 ++++ .../Connection/ConnectionListItem.tsx | 49 ++++ .../Connection/ConnectionStatusBadge.tsx | 42 +++ .../Connection/ConnectionsPage.tsx | 55 ++++ .../Connection/DeleteConnectionButton.tsx | 53 ++++ .../Repository/DeleteRepositoryButton.tsx | 182 ++++++------ .../Repository/RepositoryActions.tsx | 5 +- public/app/features/provisioning/constants.ts | 1 + .../provisioning/hooks/useConnectionList.ts | 17 ++ .../hooks/useCreateOrUpdateConnection.ts | 40 +++ public/app/features/provisioning/types.ts | 11 + .../provisioning/utils/getFormErrors.ts | 19 +- .../app/features/provisioning/utils/routes.ts | 22 +- public/locales/en-US/grafana.json | 47 +++ 21 files changed, 1301 insertions(+), 143 deletions(-) create mode 100644 public/app/features/provisioning/Connection/ConnectionForm.test.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionForm.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionFormPage.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionList.test.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionList.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionListItem.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx create mode 100644 public/app/features/provisioning/Connection/ConnectionsPage.tsx create mode 100644 public/app/features/provisioning/Connection/DeleteConnectionButton.tsx create mode 100644 public/app/features/provisioning/hooks/useConnectionList.ts create mode 100644 public/app/features/provisioning/hooks/useCreateOrUpdateConnection.ts diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx index a10ad157120..5b919c05ec2 100644 --- a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx @@ -14,6 +14,8 @@ export type Props = React.ComponentProps & { isConfigured: boolean; /** Called when the user clicks on the "Reset" button in order to clear the secret */ onReset: () => void; + /** If true, the text area will grow to fill available width. */ + grow?: boolean; }; export const CONFIGURED_TEXT = 'configured'; @@ -35,11 +37,11 @@ const getStyles = (theme: GrafanaTheme2) => { * * https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs */ -export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => { +export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => { const styles = useStyles2(getStyles); return ( - + {!isConfigured &&