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/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/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() {
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/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",
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,
+ })
})
}
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
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
);