Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44856d9c06 |
@@ -2,9 +2,6 @@ package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter
|
||||
@@ -16,9 +13,4 @@ type Connection interface {
|
||||
|
||||
// Mutate performs in place mutation of the underneath resource.
|
||||
Mutate(context.Context) error
|
||||
|
||||
// GenerateRepositoryToken generates a repository-scoped access token.
|
||||
// For GitHub connections, this creates an installation token using the GitHub App credentials.
|
||||
// The repo parameter specifies the repository name the token should be scoped to.
|
||||
GenerateRepositoryToken(ctx context.Context, repo *provisioning.Repository) (common.RawSecureValue, error)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ package connection
|
||||
import (
|
||||
context "context"
|
||||
|
||||
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// MockConnection is an autogenerated mock type for the Connection type
|
||||
@@ -25,63 +21,6 @@ func (_m *MockConnection) EXPECT() *MockConnection_Expecter {
|
||||
return &MockConnection_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// GenerateRepositoryToken provides a mock function with given fields: ctx, repo
|
||||
func (_m *MockConnection) GenerateRepositoryToken(ctx context.Context, repo *v0alpha1.Repository) (commonv0alpha1.RawSecureValue, error) {
|
||||
ret := _m.Called(ctx, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GenerateRepositoryToken")
|
||||
}
|
||||
|
||||
var r0 commonv0alpha1.RawSecureValue
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) (commonv0alpha1.RawSecureValue, error)); ok {
|
||||
return rf(ctx, repo)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) commonv0alpha1.RawSecureValue); ok {
|
||||
r0 = rf(ctx, repo)
|
||||
} else {
|
||||
r0 = ret.Get(0).(commonv0alpha1.RawSecureValue)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Repository) error); ok {
|
||||
r1 = rf(ctx, repo)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockConnection_GenerateRepositoryToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateRepositoryToken'
|
||||
type MockConnection_GenerateRepositoryToken_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GenerateRepositoryToken is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - repo *v0alpha1.Repository
|
||||
func (_e *MockConnection_Expecter) GenerateRepositoryToken(ctx interface{}, repo interface{}) *MockConnection_GenerateRepositoryToken_Call {
|
||||
return &MockConnection_GenerateRepositoryToken_Call{Call: _e.mock.On("GenerateRepositoryToken", ctx, repo)}
|
||||
}
|
||||
|
||||
func (_c *MockConnection_GenerateRepositoryToken_Call) Run(run func(ctx context.Context, repo *v0alpha1.Repository)) *MockConnection_GenerateRepositoryToken_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(*v0alpha1.Repository))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockConnection_GenerateRepositoryToken_Call) Return(_a0 commonv0alpha1.RawSecureValue, _a1 error) *MockConnection_GenerateRepositoryToken_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockConnection_GenerateRepositoryToken_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository) (commonv0alpha1.RawSecureValue, error)) *MockConnection_GenerateRepositoryToken_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Mutate provides a mock function with given fields: _a0
|
||||
func (_m *MockConnection) Mutate(_a0 context.Context) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package connection_test
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,319 +6,304 @@ import (
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestProvideFactory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupExtras func(t *testing.T) []connection.Extra
|
||||
enabled map[provisioning.ConnectionType]struct{}
|
||||
wantErr bool
|
||||
validateError func(t *testing.T, err error)
|
||||
}{
|
||||
{
|
||||
name: "should create factory with valid extras",
|
||||
setupExtras: func(t *testing.T) []connection.Extra {
|
||||
extra1 := connection.NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
t.Run("should create factory with valid extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := connection.NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
return []connection.Extra{extra1, extra2}
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should return error when duplicate connection types",
|
||||
setupExtras: func(t *testing.T) []connection.Extra {
|
||||
extra1 := connection.NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
extra2 := connection.NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
return []connection.Extra{extra1, extra2}
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
assert.Contains(t, err.Error(), "connection type \"github\" is already registered")
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Run("should create factory with empty extras", func(t *testing.T) {
|
||||
enabled := map[provisioning.ConnectionType]struct{}{}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
extras := tt.setupExtras(t)
|
||||
factory, err := ProvideFactory(enabled, []Extra{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
factory, err := connection.ProvideFactory(tt.enabled, extras)
|
||||
t.Run("should create factory with nil enabled map", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, factory)
|
||||
if tt.validateError != nil {
|
||||
tt.validateError(t, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
}
|
||||
})
|
||||
}
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extraTypes []provisioning.ConnectionType
|
||||
enabled map[provisioning.ConnectionType]struct{}
|
||||
expectedLen int
|
||||
expectedList []provisioning.ConnectionType
|
||||
checkSorted bool
|
||||
}{
|
||||
{
|
||||
name: "should return only enabled types that have extras",
|
||||
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
},
|
||||
expectedLen: 2,
|
||||
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
|
||||
},
|
||||
{
|
||||
name: "should return sorted list of types",
|
||||
extraTypes: []provisioning.ConnectionType{provisioning.GitlabConnectionType, provisioning.GithubConnectionType},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
},
|
||||
expectedLen: 2,
|
||||
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
|
||||
checkSorted: true,
|
||||
},
|
||||
{
|
||||
name: "should return empty list when no types are enabled",
|
||||
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{},
|
||||
expectedLen: 0,
|
||||
expectedList: []provisioning.ConnectionType{},
|
||||
},
|
||||
{
|
||||
name: "should not return types that are enabled but have no extras",
|
||||
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
},
|
||||
expectedLen: 1,
|
||||
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType},
|
||||
},
|
||||
{
|
||||
name: "should not return types that have extras but are not enabled",
|
||||
extraTypes: []provisioning.ConnectionType{provisioning.GithubConnectionType, provisioning.GitlabConnectionType},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
},
|
||||
expectedLen: 1,
|
||||
expectedList: []provisioning.ConnectionType{provisioning.GithubConnectionType},
|
||||
},
|
||||
{
|
||||
name: "should return empty list when no extras are provided",
|
||||
extraTypes: []provisioning.ConnectionType{},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
},
|
||||
expectedLen: 0,
|
||||
expectedList: []provisioning.ConnectionType{},
|
||||
},
|
||||
}
|
||||
t.Run("should return only enabled types that have extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup extras based on the types specified
|
||||
extras := make([]connection.Extra, 0, len(tt.extraTypes))
|
||||
for _, connType := range tt.extraTypes {
|
||||
extra := connection.NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(connType)
|
||||
extras = append(extras, extra)
|
||||
}
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
factory, err := connection.ProvideFactory(tt.enabled, extras)
|
||||
require.NoError(t, err)
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
types := factory.Types()
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, types, tt.expectedLen)
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 2)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.Contains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
if tt.checkSorted {
|
||||
// Verify exact order: github should come before gitlab alphabetically
|
||||
assert.Equal(t, tt.expectedList, types)
|
||||
} else {
|
||||
// Just verify the types are present
|
||||
for _, expectedType := range tt.expectedList {
|
||||
assert.Contains(t, types, expectedType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connectionType provisioning.ConnectionType
|
||||
setupExtras func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error)
|
||||
enabled map[provisioning.ConnectionType]struct{}
|
||||
wantErr bool
|
||||
validateError func(t *testing.T, err error)
|
||||
}{
|
||||
{
|
||||
name: "should successfully build connection when type is enabled and has extra",
|
||||
connectionType: provisioning.GithubConnectionType,
|
||||
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
|
||||
mockConnection := connection.NewMockConnection(t)
|
||||
extra := connection.NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
}).Return(mockConnection, nil)
|
||||
|
||||
return []connection.Extra{extra}, mockConnection, nil
|
||||
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,
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should return error when type is not enabled",
|
||||
connectionType: provisioning.GitlabConnectionType,
|
||||
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
|
||||
extra := connection.NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
}
|
||||
|
||||
return []connection.Extra{extra}, nil, nil
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return error when type is not supported",
|
||||
connectionType: provisioning.GitlabConnectionType,
|
||||
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
|
||||
extra := connection.NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
mockConnection := NewMockConnection(t)
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil)
|
||||
|
||||
return []connection.Extra{extra}, nil, 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,
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported")
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should pass through errors from extra.Build()",
|
||||
connectionType: provisioning.GithubConnectionType,
|
||||
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
|
||||
buildErr := errors.New("build error")
|
||||
extra := connection.NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
}).Return(nil, buildErr)
|
||||
}
|
||||
|
||||
return []connection.Extra{extra}, nil, buildErr
|
||||
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,
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
assert.Equal(t, "build error", err.Error())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should build with multiple extras registered",
|
||||
connectionType: provisioning.GitlabConnectionType,
|
||||
setupExtras: func(t *testing.T, ctx context.Context) ([]connection.Extra, connection.Connection, error) {
|
||||
mockConnection := connection.NewMockConnection(t)
|
||||
}
|
||||
|
||||
extra1 := connection.NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
mockConnection := NewMockConnection(t)
|
||||
|
||||
extra2 := connection.NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
extra2.EXPECT().Build(ctx, &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}).Return(mockConnection, nil)
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
return []connection.Extra{extra1, extra2}, mockConnection, nil
|
||||
},
|
||||
enabled: map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
extras, expectedConnection, _ := tt.setupExtras(t, ctx)
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
factory, err := connection.ProvideFactory(tt.enabled, extras)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: tt.connectionType,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
if tt.validateError != nil {
|
||||
tt.validateError(t, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedConnection, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mockConnection, result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ type Client interface {
|
||||
// Apps and installations
|
||||
GetApp(ctx context.Context) (App, error)
|
||||
GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error)
|
||||
CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error)
|
||||
}
|
||||
|
||||
// App represents a Github App.
|
||||
@@ -43,14 +42,6 @@ type AppInstallation struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// InstallationToken represents a Github App Installation Access Token.
|
||||
type InstallationToken struct {
|
||||
// Token is the access token value.
|
||||
Token string
|
||||
// ExpiresAt is the expiration time of the token.
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type githubClient struct {
|
||||
gh *github.Client
|
||||
}
|
||||
@@ -70,6 +61,7 @@ func (r *githubClient) GetApp(ctx context.Context) (App, error) {
|
||||
return App{}, err
|
||||
}
|
||||
|
||||
// TODO(ferruvich): do we need any other info?
|
||||
return App{
|
||||
ID: app.GetID(),
|
||||
Slug: app.GetSlug(),
|
||||
@@ -93,34 +85,9 @@ func (r *githubClient) GetAppInstallation(ctx context.Context, installationID st
|
||||
return AppInstallation{}, err
|
||||
}
|
||||
|
||||
// TODO(ferruvich): do we need any other info?
|
||||
return AppInstallation{
|
||||
ID: installation.GetID(),
|
||||
Enabled: installation.GetSuspendedAt().IsZero(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateInstallationAccessToken creates an installation access token scoped to a specific repository.
|
||||
func (r *githubClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
|
||||
id, err := strconv.Atoi(installationID)
|
||||
if err != nil {
|
||||
return InstallationToken{}, fmt.Errorf("invalid installation ID: %s", installationID)
|
||||
}
|
||||
|
||||
opts := &github.InstallationTokenOptions{
|
||||
Repositories: []string{repo},
|
||||
}
|
||||
|
||||
token, _, err := r.gh.Apps.CreateInstallationToken(ctx, int64(id), opts)
|
||||
if err != nil {
|
||||
var ghErr *github.ErrorResponse
|
||||
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
|
||||
return InstallationToken{}, ErrServiceUnavailable
|
||||
}
|
||||
return InstallationToken{}, err
|
||||
}
|
||||
|
||||
return InstallationToken{
|
||||
Token: token.GetToken(),
|
||||
ExpiresAt: token.GetExpiresAt().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -21,64 +21,6 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter {
|
||||
return &MockClient_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// CreateInstallationAccessToken provides a mock function with given fields: ctx, installationID, repo
|
||||
func (_m *MockClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
|
||||
ret := _m.Called(ctx, installationID, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateInstallationAccessToken")
|
||||
}
|
||||
|
||||
var r0 InstallationToken
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) (InstallationToken, error)); ok {
|
||||
return rf(ctx, installationID, repo)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) InstallationToken); ok {
|
||||
r0 = rf(ctx, installationID, repo)
|
||||
} else {
|
||||
r0 = ret.Get(0).(InstallationToken)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = rf(ctx, installationID, repo)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockClient_CreateInstallationAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateInstallationAccessToken'
|
||||
type MockClient_CreateInstallationAccessToken_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateInstallationAccessToken is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - installationID string
|
||||
// - repo string
|
||||
func (_e *MockClient_Expecter) CreateInstallationAccessToken(ctx interface{}, installationID interface{}, repo interface{}) *MockClient_CreateInstallationAccessToken_Call {
|
||||
return &MockClient_CreateInstallationAccessToken_Call{Call: _e.mock.On("CreateInstallationAccessToken", ctx, installationID, repo)}
|
||||
}
|
||||
|
||||
func (_c *MockClient_CreateInstallationAccessToken_Call) Run(run func(ctx context.Context, installationID string, repo string)) *MockClient_CreateInstallationAccessToken_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_CreateInstallationAccessToken_Call) Return(_a0 InstallationToken, _a1 error) *MockClient_CreateInstallationAccessToken_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_CreateInstallationAccessToken_Call) RunAndReturn(run func(context.Context, string, string) (InstallationToken, error)) *MockClient_CreateInstallationAccessToken_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetApp provides a mock function with given fields: ctx
|
||||
func (_m *MockClient) GetApp(ctx context.Context) (App, error) {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
@@ -295,175 +295,3 @@ func TestGithubClient_GetAppInstallation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubClient_CreateInstallationAccessToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockHandler *http.Client
|
||||
installationID string
|
||||
repo string
|
||||
wantToken conngh.InstallationToken
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "create installation token successfully",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.PostAppInstallationsAccessTokensByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
expiresAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
token := &github.InstallationToken{
|
||||
Token: github.Ptr("ghs_test_token_123456789"),
|
||||
ExpiresAt: &github.Timestamp{Time: expiresAt},
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(token))
|
||||
}),
|
||||
),
|
||||
),
|
||||
installationID: "12345",
|
||||
repo: "test-repo",
|
||||
wantToken: conngh.InstallationToken{
|
||||
Token: "ghs_test_token_123456789",
|
||||
ExpiresAt: "2024-01-01 00:00:00 +0000 UTC",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid installation ID",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(),
|
||||
installationID: "not-a-number",
|
||||
repo: "test-repo",
|
||||
wantToken: conngh.InstallationToken{},
|
||||
wantErr: true,
|
||||
errContains: "invalid installation ID",
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.PostAppInstallationsAccessTokensByInstallationId,
|
||||
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",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
installationID: "12345",
|
||||
repo: "test-repo",
|
||||
wantToken: conngh.InstallationToken{},
|
||||
wantErr: true,
|
||||
errContains: conngh.ErrServiceUnavailable.Error(),
|
||||
},
|
||||
{
|
||||
name: "installation not found",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.PostAppInstallationsAccessTokensByInstallationId,
|
||||
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",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
installationID: "99999",
|
||||
repo: "test-repo",
|
||||
wantToken: conngh.InstallationToken{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unauthorized error",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.PostAppInstallationsAccessTokensByInstallationId,
|
||||
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",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
installationID: "12345",
|
||||
repo: "test-repo",
|
||||
wantToken: conngh.InstallationToken{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "forbidden - no permissions for repository",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.PostAppInstallationsAccessTokensByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
},
|
||||
Message: "Resource not accessible by integration",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
installationID: "12345",
|
||||
repo: "private-repo",
|
||||
wantToken: conngh.InstallationToken{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "internal server error",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.PostAppInstallationsAccessTokensByInstallationId,
|
||||
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",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
installationID: "12345",
|
||||
repo: "test-repo",
|
||||
wantToken: conngh.InstallationToken{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ghClient := github.NewClient(tt.mockHandler)
|
||||
client := conngh.NewClient(ghClient)
|
||||
|
||||
token, err := client.CreateInstallationAccessToken(context.Background(), tt.installationID, tt.repo)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantToken, token)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"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"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
@@ -21,26 +20,18 @@ type GithubFactory interface {
|
||||
New(ctx context.Context, ghToken common.RawSecureValue) Client
|
||||
}
|
||||
|
||||
type ConnectionSecrets struct {
|
||||
PrivateKey common.RawSecureValue
|
||||
Token common.RawSecureValue
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
obj *provisioning.Connection
|
||||
ghFactory GithubFactory
|
||||
secrets ConnectionSecrets
|
||||
}
|
||||
|
||||
func NewConnection(
|
||||
obj *provisioning.Connection,
|
||||
factory GithubFactory,
|
||||
secrets ConnectionSecrets,
|
||||
) Connection {
|
||||
return Connection{
|
||||
obj: obj,
|
||||
ghFactory: factory,
|
||||
secrets: secrets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +51,14 @@ func (c *Connection) Mutate(_ context.Context) error {
|
||||
|
||||
c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID)
|
||||
|
||||
// Generate JWT token if a new private key is being provided.
|
||||
// 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.secrets.PrivateKey)
|
||||
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}
|
||||
}
|
||||
@@ -125,10 +117,10 @@ func (c *Connection) Validate(ctx context.Context) error {
|
||||
return toError(c.obj.GetName(), list)
|
||||
}
|
||||
|
||||
if c.secrets.PrivateKey.IsZero() {
|
||||
if c.obj.Secure.PrivateKey.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
|
||||
}
|
||||
if c.secrets.Token.IsZero() {
|
||||
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() {
|
||||
@@ -158,7 +150,7 @@ func (c *Connection) Validate(ctx context.Context) error {
|
||||
|
||||
// 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.secrets.Token)
|
||||
ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create)
|
||||
|
||||
app, err := ghClient.GetApp(ctx)
|
||||
if err != nil {
|
||||
@@ -195,35 +187,6 @@ func toError(name string, list field.ErrorList) error {
|
||||
)
|
||||
}
|
||||
|
||||
// GenerateRepositoryToken generates a repository-scoped access token.
|
||||
func (c *Connection) GenerateRepositoryToken(ctx context.Context, repo *provisioning.Repository) (common.RawSecureValue, error) {
|
||||
if repo == nil {
|
||||
return "", errors.New("a repository is required to generate a token")
|
||||
}
|
||||
if c.obj.Spec.GitHub == nil {
|
||||
return "", errors.New("connection is not a GitHub connection")
|
||||
}
|
||||
if repo.Spec.GitHub == nil {
|
||||
return "", errors.New("repository is not a GitHub repo")
|
||||
}
|
||||
|
||||
_, repoName, err := github.ParseOwnerRepoGithub(repo.Spec.GitHub.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse repo URL: %w", err)
|
||||
}
|
||||
|
||||
// Create the GitHub client with the JWT token
|
||||
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
|
||||
|
||||
// Create an installation access token scoped to this repository
|
||||
installationToken, err := ghClient.CreateInstallationAccessToken(ctx, c.obj.Spec.GitHub.InstallationID, repoName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create installation access token: %w", err)
|
||||
}
|
||||
|
||||
return common.RawSecureValue(installationToken.Token), nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ connection.Connection = (*Connection)(nil)
|
||||
)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package github_test
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"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/mock"
|
||||
@@ -45,386 +43,132 @@ B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
func TestConnection_Mutate(t *testing.T) {
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
secrets github.ConnectionSecrets
|
||||
wantErr bool
|
||||
validateError func(t *testing.T, err error)
|
||||
validateResult func(t *testing.T, connection *provisioning.Connection)
|
||||
}{
|
||||
{
|
||||
name: "should add URL to Github connection",
|
||||
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(privateKeyBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: github.ConnectionSecrets{
|
||||
PrivateKey: common.NewSecretValue(privateKeyBase64),
|
||||
},
|
||||
wantErr: false,
|
||||
validateResult: func(t *testing.T, connection *provisioning.Connection) {
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should generate JWT token when private key is provided",
|
||||
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(privateKeyBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: github.ConnectionSecrets{
|
||||
PrivateKey: common.NewSecretValue(privateKeyBase64),
|
||||
},
|
||||
wantErr: false,
|
||||
validateResult: func(t *testing.T, connection *provisioning.Connection) {
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
|
||||
assert.False(t, connection.Secure.Token.Create.IsZero(), "JWT token should be generated")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should not generate JWT token when no new private key is provided",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection", Generation: 1},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
// The private key is already in the stoere
|
||||
Name: "somePrivateKey",
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("someToken"),
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: github.ConnectionSecrets{
|
||||
PrivateKey: common.NewSecretValue(privateKeyBase64),
|
||||
Token: common.NewSecretValue("someToken"),
|
||||
},
|
||||
wantErr: false,
|
||||
validateResult: func(t *testing.T, connection *provisioning.Connection) {
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
|
||||
assert.False(t, connection.Secure.Token.Create.IsZero(), "JWT token should be generated")
|
||||
assert.Equal(t, "someToken", connection.Secure.Token.Create.DangerouslyExposeAndConsumeValue())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should do nothing when GitHub config is nil",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "clientID",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: github.ConnectionSecrets{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should fail when private key is not base64",
|
||||
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("invalid-key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: github.ConnectionSecrets{
|
||||
PrivateKey: "invalid-key",
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
assert.Contains(t, err.Error(), "failed to generate JWT token")
|
||||
assert.Contains(t, err.Error(), "failed to decode base64 private key")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should fail when private key is invalid",
|
||||
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(base64.StdEncoding.EncodeToString([]byte("invalid-key"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: github.ConnectionSecrets{},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
assert.Contains(t, err.Error(), "failed to generate JWT token")
|
||||
assert.Contains(t, err.Error(), "failed to parse private key")
|
||||
},
|
||||
},
|
||||
}
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
conn := github.NewConnection(tt.connection, mockFactory, tt.secrets)
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
|
||||
})
|
||||
|
||||
err := conn.Mutate(context.Background())
|
||||
t.Run("should generate JWT token when private key is provided", func(t *testing.T) {
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.validateError != nil {
|
||||
tt.validateError(t, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if tt.validateResult != nil {
|
||||
tt.validateResult(t, tt.connection)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestConnection_GenerateRepositoryToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
repo *provisioning.Repository
|
||||
setupMock func(*github.MockGithubFactory)
|
||||
expectedToken common.RawSecureValue
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
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{
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.RawSecureValue("jwt-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test-owner/test-repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupMock: func(mockFactory *github.MockGithubFactory) {
|
||||
mockClient := github.NewMockClient(t)
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("jwt-token")).Return(mockClient)
|
||||
mockClient.EXPECT().CreateInstallationAccessToken(mock.Anything, "456", "test-repo").
|
||||
Return(github.InstallationToken{Token: "ghs_repository_token_123", ExpiresAt: "2024-01-01T00:00:00Z"}, nil)
|
||||
},
|
||||
expectedToken: common.RawSecureValue("ghs_repository_token_123"),
|
||||
},
|
||||
{
|
||||
name: "nil repository returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: nil,
|
||||
expectedError: "a repository is required to generate a token",
|
||||
},
|
||||
{
|
||||
name: "connection without GitHub config returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "clientID",
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test-owner/test-repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "connection is not a GitHub connection",
|
||||
},
|
||||
{
|
||||
name: "repository without GitHub config 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{
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.RawSecureValue("jwt-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: nil,
|
||||
},
|
||||
},
|
||||
expectedError: "repository is not a GitHub repo",
|
||||
},
|
||||
{
|
||||
name: "invalid repository URL 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{
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.RawSecureValue("jwt-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "invalid-url",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "failed to parse repo URL",
|
||||
},
|
||||
{
|
||||
name: "GitHub API 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{
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.RawSecureValue("jwt-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test-owner/test-repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupMock: func(mockFactory *github.MockGithubFactory) {
|
||||
mockClient := github.NewMockClient(t)
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("jwt-token")).Return(mockClient)
|
||||
mockClient.EXPECT().CreateInstallationAccessToken(mock.Anything, "456", "test-repo").
|
||||
Return(github.InstallationToken{}, errors.New("API rate limit exceeded"))
|
||||
},
|
||||
expectedError: "failed to create installation access token",
|
||||
},
|
||||
}
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
if tt.setupMock != nil {
|
||||
tt.setupMock(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")
|
||||
})
|
||||
|
||||
conn := github.NewConnection(tt.connection, mockFactory, github.ConnectionSecrets{
|
||||
Token: tt.connection.Secure.Token.Create,
|
||||
PrivateKey: tt.connection.Secure.PrivateKey.Create,
|
||||
})
|
||||
token, err := conn.GenerateRepositoryToken(context.Background(), tt.repo)
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedToken, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
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(*github.MockGithubFactory)
|
||||
setupMock func(*MockGithubFactory)
|
||||
wantErr bool
|
||||
errMsgContains []string
|
||||
}{
|
||||
@@ -570,12 +314,12 @@ func TestConnection_Validate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
setupMock: func(mockFactory *github.MockGithubFactory) {
|
||||
mockClient := github.NewMockClient(t)
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(github.AppInstallation{ID: 456}, nil)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -600,11 +344,11 @@ func TestConnection_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.token", "[REDACTED]"},
|
||||
setupMock: func(mockFactory *github.MockGithubFactory) {
|
||||
mockClient := github.NewMockClient(t)
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{}, assert.AnError)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -629,11 +373,11 @@ func TestConnection_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.appID"},
|
||||
setupMock: func(mockFactory *github.MockGithubFactory) {
|
||||
mockClient := github.NewMockClient(t)
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 444, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -658,27 +402,24 @@ func TestConnection_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.installationID", "456"},
|
||||
setupMock: func(mockFactory *github.MockGithubFactory) {
|
||||
mockClient := github.NewMockClient(t)
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(github.AppInstallation{}, assert.AnError)
|
||||
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 := github.NewMockGithubFactory(t)
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
if tt.setupMock != nil {
|
||||
tt.setupMock(mockFactory)
|
||||
}
|
||||
|
||||
conn := github.NewConnection(tt.connection, mockFactory, github.ConnectionSecrets{
|
||||
PrivateKey: tt.connection.Secure.PrivateKey.Create,
|
||||
Token: tt.connection.Secure.Token.Create,
|
||||
})
|
||||
conn := NewConnection(tt.connection, mockFactory)
|
||||
err := conn.Validate(context.Background())
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -10,51 +10,27 @@ import (
|
||||
)
|
||||
|
||||
type extra struct {
|
||||
factory GithubFactory
|
||||
decrypter connection.Decrypter
|
||||
factory GithubFactory
|
||||
}
|
||||
|
||||
func (e *extra) Type() provisioning.ConnectionType {
|
||||
return provisioning.GithubConnectionType
|
||||
}
|
||||
|
||||
func (e *extra) Build(ctx context.Context, conn *provisioning.Connection) (connection.Connection, error) {
|
||||
func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) {
|
||||
logger := logging.FromContext(ctx)
|
||||
if conn == nil || conn.Spec.GitHub == nil {
|
||||
if connection == nil || connection.Spec.GitHub == nil {
|
||||
logger.Error("connection is nil or github info is nil")
|
||||
|
||||
return nil, fmt.Errorf("invalid github connection")
|
||||
}
|
||||
|
||||
// Decrypt secure values
|
||||
secure := e.decrypter(conn)
|
||||
|
||||
// Decrypt private key
|
||||
pKey, err := secure.PrivateKey(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Failed to decrypt private key", "error", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
t, err := secure.Token(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Failed to decrypt token", "error", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := NewConnection(conn, e.factory, ConnectionSecrets{
|
||||
PrivateKey: pKey,
|
||||
Token: t,
|
||||
})
|
||||
c := NewConnection(connection, e.factory)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func Extra(decrypter connection.Decrypter, factory GithubFactory) connection.Extra {
|
||||
func Extra(factory GithubFactory) connection.Extra {
|
||||
return &extra{
|
||||
decrypter: decrypter,
|
||||
factory: factory,
|
||||
factory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ package github_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -14,218 +12,115 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type mockSecureValues struct {
|
||||
privateKey common.RawSecureValue
|
||||
privateKeyErr error
|
||||
clientSecret common.RawSecureValue
|
||||
clientSecErr error
|
||||
token common.RawSecureValue
|
||||
tokenErr error
|
||||
}
|
||||
|
||||
func (m *mockSecureValues) PrivateKey(_ context.Context) (common.RawSecureValue, error) {
|
||||
return m.privateKey, m.privateKeyErr
|
||||
}
|
||||
|
||||
func (m *mockSecureValues) ClientSecret(_ context.Context) (common.RawSecureValue, error) {
|
||||
return m.clientSecret, m.clientSecErr
|
||||
}
|
||||
|
||||
func (m *mockSecureValues) Token(_ context.Context) (common.RawSecureValue, error) {
|
||||
return m.token, m.tokenErr
|
||||
}
|
||||
|
||||
func TestExtra_Type(t *testing.T) {
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
decrypter := func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{}
|
||||
}
|
||||
|
||||
e := github.Extra(decrypter, mockFactory)
|
||||
|
||||
result := e.Type()
|
||||
|
||||
assert.Equal(t, provisioning.GithubConnectionType, result)
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
conn *provisioning.Connection
|
||||
setupDecrypter func() connection.Decrypter
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "success with valid connection",
|
||||
conn: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123456",
|
||||
InstallationID: "789012",
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{
|
||||
privateKey: common.RawSecureValue("test-private-key"),
|
||||
token: common.RawSecureValue("test-token"),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil connection",
|
||||
conn: nil,
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{}
|
||||
}
|
||||
},
|
||||
expectedError: "invalid github connection",
|
||||
},
|
||||
{
|
||||
name: "connection without github config",
|
||||
conn: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: nil,
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{}
|
||||
}
|
||||
},
|
||||
expectedError: "invalid github connection",
|
||||
},
|
||||
{
|
||||
name: "error decrypting private key",
|
||||
conn: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123456",
|
||||
InstallationID: "789012",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{
|
||||
privateKeyErr: errors.New("failed to decrypt private key"),
|
||||
}
|
||||
}
|
||||
},
|
||||
expectedError: "failed to decrypt private key",
|
||||
},
|
||||
{
|
||||
name: "error decrypting token",
|
||||
conn: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123456",
|
||||
InstallationID: "789012",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{
|
||||
privateKey: common.RawSecureValue("test-private-key"),
|
||||
tokenErr: errors.New("failed to decrypt token"),
|
||||
}
|
||||
}
|
||||
},
|
||||
expectedError: "failed to decrypt token",
|
||||
},
|
||||
{
|
||||
name: "success with empty secure values",
|
||||
conn: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123456",
|
||||
InstallationID: "789012",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{
|
||||
privateKey: common.RawSecureValue(""),
|
||||
token: common.RawSecureValue(""),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success with different app and installation IDs",
|
||||
conn: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "another-connection",
|
||||
Namespace: "prod",
|
||||
},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "999888",
|
||||
InstallationID: "777666",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() connection.Decrypter {
|
||||
return func(c *provisioning.Connection) connection.SecureValues {
|
||||
return &mockSecureValues{
|
||||
privateKey: common.RawSecureValue("another-private-key"),
|
||||
token: common.RawSecureValue("another-token"),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
decrypter := tt.setupDecrypter()
|
||||
e := github.Extra(mockFactory)
|
||||
|
||||
e := github.Extra(decrypter, mockFactory)
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
result, err := e.Build(ctx, tt.conn)
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/secret/pkg/decrypt"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
type Decrypter = func(c *provisioning.Connection) SecureValues
|
||||
|
||||
type SecureValues interface {
|
||||
PrivateKey(ctx context.Context) (common.RawSecureValue, error)
|
||||
ClientSecret(ctx context.Context) (common.RawSecureValue, error)
|
||||
Token(ctx context.Context) (common.RawSecureValue, error)
|
||||
}
|
||||
|
||||
type secureValues struct {
|
||||
svc decrypt.DecryptService
|
||||
names provisioning.ConnectionSecure
|
||||
namespace string
|
||||
}
|
||||
|
||||
func (s *secureValues) get(ctx context.Context, sv common.InlineSecureValue) (common.RawSecureValue, error) {
|
||||
if !sv.Create.IsZero() {
|
||||
return sv.Create, nil // If this was called before the value is actually saved
|
||||
}
|
||||
if sv.Name == "" {
|
||||
return "", nil
|
||||
}
|
||||
results, err := s.svc.Decrypt(ctx, provisioning.GROUP, s.namespace, sv.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to call decrypt service: %w", err)
|
||||
}
|
||||
|
||||
v, found := results[sv.Name]
|
||||
if !found {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
if v.Error() != nil {
|
||||
return "", v.Error()
|
||||
}
|
||||
return common.RawSecureValue(*v.Value()), nil
|
||||
}
|
||||
|
||||
func (s *secureValues) PrivateKey(ctx context.Context) (common.RawSecureValue, error) {
|
||||
return s.get(ctx, s.names.PrivateKey)
|
||||
}
|
||||
|
||||
func (s *secureValues) ClientSecret(ctx context.Context) (common.RawSecureValue, error) {
|
||||
return s.get(ctx, s.names.ClientSecret)
|
||||
}
|
||||
|
||||
func (s *secureValues) Token(ctx context.Context) (common.RawSecureValue, error) {
|
||||
return s.get(ctx, s.names.Token)
|
||||
}
|
||||
|
||||
func ProvideDecrypter(svc decrypt.DecryptService) Decrypter {
|
||||
return func(c *provisioning.Connection) SecureValues {
|
||||
return &secureValues{svc: svc, names: c.Secure, namespace: c.Namespace}
|
||||
}
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
package connection_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
|
||||
"github.com/grafana/grafana/apps/secret/pkg/decrypt"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
// mockDecryptService implements decrypt.DecryptService for testing
|
||||
type mockDecryptService struct {
|
||||
results map[string]decrypt.DecryptResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDecryptService) Decrypt(ctx context.Context, group, namespace string, names ...string) (map[string]decrypt.DecryptResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
|
||||
results := make(map[string]decrypt.DecryptResult)
|
||||
for _, name := range names {
|
||||
if result, ok := m.results[name]; ok {
|
||||
results[name] = result
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func newDecryptResult(value string) decrypt.DecryptResult {
|
||||
v := secretv1beta1.ExposedSecureValue(value)
|
||||
return decrypt.NewDecryptResultValue(&v)
|
||||
}
|
||||
|
||||
func newDecryptResultWithError(err error) decrypt.DecryptResult {
|
||||
return decrypt.NewDecryptResultErr(err)
|
||||
}
|
||||
|
||||
func TestProvideDecrypter(t *testing.T) {
|
||||
t.Run("should return a decrypter function", func(t *testing.T) {
|
||||
mockSvc := &mockDecryptService{}
|
||||
decrypter := connection.ProvideDecrypter(mockSvc)
|
||||
|
||||
require.NotNil(t, decrypter)
|
||||
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
}
|
||||
|
||||
result := decrypter(conn)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecureValues_PrivateKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
mockResults map[string]decrypt.DecryptResult
|
||||
mockErr error
|
||||
expectedValue common.RawSecureValue
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "returns Create value when present",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("create-private-key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedValue: common.RawSecureValue("create-private-key"),
|
||||
},
|
||||
{
|
||||
name: "returns empty when Name is empty",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{},
|
||||
},
|
||||
},
|
||||
expectedValue: "",
|
||||
},
|
||||
{
|
||||
name: "decrypts from service when Name is provided",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "private-key-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{
|
||||
"private-key-ref": newDecryptResult("decrypted-private-key"),
|
||||
},
|
||||
expectedValue: common.RawSecureValue("decrypted-private-key"),
|
||||
},
|
||||
{
|
||||
name: "returns error when decrypt service fails",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "private-key-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockErr: errors.New("decrypt service error"),
|
||||
expectedError: "failed to call decrypt service",
|
||||
},
|
||||
{
|
||||
name: "returns error when value not found",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "missing-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{},
|
||||
expectedError: "not found",
|
||||
},
|
||||
{
|
||||
name: "returns error when decrypt result has error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "private-key-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{
|
||||
"private-key-ref": newDecryptResultWithError(errors.New("decryption failed")),
|
||||
},
|
||||
expectedError: "decryption failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockSvc := &mockDecryptService{
|
||||
results: tt.mockResults,
|
||||
err: tt.mockErr,
|
||||
}
|
||||
|
||||
decrypter := connection.ProvideDecrypter(mockSvc)
|
||||
secureVals := decrypter(tt.connection)
|
||||
|
||||
value, err := secureVals.PrivateKey(context.Background())
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureValues_ClientSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
mockResults map[string]decrypt.DecryptResult
|
||||
mockErr error
|
||||
expectedValue common.RawSecureValue
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "returns Create value when present",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("create-client-secret"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedValue: common.RawSecureValue("create-client-secret"),
|
||||
},
|
||||
{
|
||||
name: "returns empty when Name is empty",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{},
|
||||
},
|
||||
},
|
||||
expectedValue: "",
|
||||
},
|
||||
{
|
||||
name: "decrypts from service when Name is provided",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "client-secret-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{
|
||||
"client-secret-ref": newDecryptResult("decrypted-client-secret"),
|
||||
},
|
||||
expectedValue: common.RawSecureValue("decrypted-client-secret"),
|
||||
},
|
||||
{
|
||||
name: "returns error when decrypt service fails",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "client-secret-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockErr: errors.New("decrypt service error"),
|
||||
expectedError: "failed to call decrypt service",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockSvc := &mockDecryptService{
|
||||
results: tt.mockResults,
|
||||
err: tt.mockErr,
|
||||
}
|
||||
|
||||
decrypter := connection.ProvideDecrypter(mockSvc)
|
||||
secureVals := decrypter(tt.connection)
|
||||
|
||||
value, err := secureVals.ClientSecret(context.Background())
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureValues_Token(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
mockResults map[string]decrypt.DecryptResult
|
||||
mockErr error
|
||||
expectedValue common.RawSecureValue
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "returns Create value when present",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("create-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedValue: common.RawSecureValue("create-token"),
|
||||
},
|
||||
{
|
||||
name: "returns empty when Name is empty",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{},
|
||||
},
|
||||
},
|
||||
expectedValue: "",
|
||||
},
|
||||
{
|
||||
name: "decrypts from service when Name is provided",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "token-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{
|
||||
"token-ref": newDecryptResult("decrypted-token"),
|
||||
},
|
||||
expectedValue: common.RawSecureValue("decrypted-token"),
|
||||
},
|
||||
{
|
||||
name: "returns error when decrypt service fails",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "token-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockErr: errors.New("decrypt service error"),
|
||||
expectedError: "failed to call decrypt service",
|
||||
},
|
||||
{
|
||||
name: "returns error when value not found",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "missing-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{},
|
||||
expectedError: "not found",
|
||||
},
|
||||
{
|
||||
name: "returns error when decrypt result has error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "token-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockResults: map[string]decrypt.DecryptResult{
|
||||
"token-ref": newDecryptResultWithError(errors.New("decryption failed")),
|
||||
},
|
||||
expectedError: "decryption failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockSvc := &mockDecryptService{
|
||||
results: tt.mockResults,
|
||||
err: tt.mockErr,
|
||||
}
|
||||
|
||||
decrypter := connection.ProvideDecrypter(mockSvc)
|
||||
secureVals := decrypter(tt.connection)
|
||||
|
||||
value, err := secureVals.Token(context.Background())
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureValues_MultipleFields(t *testing.T) {
|
||||
t.Run("should decrypt all fields independently", func(t *testing.T) {
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "private-key-ref",
|
||||
},
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "client-secret-ref",
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "token-ref",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc := &mockDecryptService{
|
||||
results: map[string]decrypt.DecryptResult{
|
||||
"private-key-ref": newDecryptResult("decrypted-private-key"),
|
||||
"client-secret-ref": newDecryptResult("decrypted-client-secret"),
|
||||
"token-ref": newDecryptResult("decrypted-token"),
|
||||
},
|
||||
}
|
||||
|
||||
decrypter := connection.ProvideDecrypter(mockSvc)
|
||||
secureVals := decrypter(conn)
|
||||
|
||||
privateKey, err := secureVals.PrivateKey(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RawSecureValue("decrypted-private-key"), privateKey)
|
||||
|
||||
clientSecret, err := secureVals.ClientSecret(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RawSecureValue("decrypted-client-secret"), clientSecret)
|
||||
|
||||
token, err := secureVals.Token(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RawSecureValue("decrypted-token"), token)
|
||||
})
|
||||
|
||||
t.Run("should handle mix of Create and Name references", func(t *testing.T) {
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-connection",
|
||||
Namespace: "default",
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("inline-private-key"),
|
||||
},
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "client-secret-ref",
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("inline-token"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc := &mockDecryptService{
|
||||
results: map[string]decrypt.DecryptResult{
|
||||
"client-secret-ref": newDecryptResult("decrypted-client-secret"),
|
||||
},
|
||||
}
|
||||
|
||||
decrypter := connection.ProvideDecrypter(mockSvc)
|
||||
secureVals := decrypter(conn)
|
||||
|
||||
// PrivateKey should return Create value without calling decrypt
|
||||
privateKey, err := secureVals.PrivateKey(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RawSecureValue("inline-private-key"), privateKey)
|
||||
|
||||
// ClientSecret should decrypt
|
||||
clientSecret, err := secureVals.ClientSecret(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RawSecureValue("decrypted-client-secret"), clientSecret)
|
||||
|
||||
// Token should return Create value without calling decrypt
|
||||
token, err := secureVals.Token(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RawSecureValue("inline-token"), token)
|
||||
})
|
||||
}
|
||||
@@ -101,8 +101,7 @@ func (r *gitRepository) Validate() (list field.ErrorList) {
|
||||
}
|
||||
|
||||
// Readonly repositories may not need a token (if public)
|
||||
// Also, in case a connection is provided, the token will be created by the controller.
|
||||
if len(r.config.Spec.Workflows) > 0 && r.config.Spec.Connection == nil {
|
||||
if len(r.config.Spec.Workflows) > 0 {
|
||||
if cfg.Token == "" && r.config.Secure.Token.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "token"), "a git access token is required"))
|
||||
}
|
||||
|
||||
@@ -169,22 +169,6 @@ func TestGitRepository_Validate(t *testing.T) {
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "missing token for R/W repository with connection",
|
||||
config: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: "test_type",
|
||||
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
|
||||
Connection: &provisioning.ConnectionInfo{Name: "my-connection"},
|
||||
},
|
||||
},
|
||||
gitConfig: RepositoryConfig{
|
||||
URL: "https://git.example.com/repo.git",
|
||||
Branch: "main",
|
||||
Token: "", // Empty token - should be OK because connection is provided
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "unsafe path",
|
||||
config: &provisioning.Repository{
|
||||
|
||||
@@ -5,15 +5,13 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/util"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
//go:generate mockery --name WebhookURLBuilder --structname MockWebhookURLBuilder --inpackage --filename webhook_builder_mock.go --with-expecter
|
||||
type WebhookURLBuilder interface {
|
||||
WebhookURL(ctx context.Context, r *provisioning.Repository) string
|
||||
}
|
||||
@@ -37,22 +35,24 @@ func (e *extra) Type() provisioning.RepositoryType {
|
||||
}
|
||||
|
||||
func (e *extra) Build(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
|
||||
if r == nil || r.Spec.GitHub == nil {
|
||||
return nil, fmt.Errorf("github configuration is required")
|
||||
}
|
||||
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
|
||||
logger.Info("Instantiating Github repository")
|
||||
|
||||
secure := e.decrypter(r)
|
||||
cfg := r.Spec.GitHub
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("github configuration is required")
|
||||
}
|
||||
|
||||
token, err := secure.Token(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
gitRepo, err := git.NewRepository(ctx, r, git.RepositoryConfig{
|
||||
URL: r.Spec.GitHub.URL,
|
||||
Branch: r.Spec.GitHub.Branch,
|
||||
Path: r.Spec.GitHub.Path,
|
||||
URL: cfg.URL,
|
||||
Branch: cfg.Branch,
|
||||
Path: cfg.Path,
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
package github_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
|
||||
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"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
type mockSecureValues struct {
|
||||
token common.RawSecureValue
|
||||
tokenErr error
|
||||
webhookSecret common.RawSecureValue
|
||||
webhookErr error
|
||||
}
|
||||
|
||||
func (m *mockSecureValues) Token(_ context.Context) (common.RawSecureValue, error) {
|
||||
return m.token, m.tokenErr
|
||||
}
|
||||
|
||||
func (m *mockSecureValues) WebhookSecret(_ context.Context) (common.RawSecureValue, error) {
|
||||
return m.webhookSecret, m.webhookErr
|
||||
}
|
||||
|
||||
func TestExtra_Type(t *testing.T) {
|
||||
e := github.Extra(nil, nil, nil)
|
||||
assert.Equal(t, provisioning.GitHubRepositoryType, e.Type())
|
||||
}
|
||||
|
||||
func TestExtra_Build(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo *provisioning.Repository
|
||||
setupDecrypter func() repository.Decrypter
|
||||
setupWebhook func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder
|
||||
expectedError string
|
||||
validateResult func(t *testing.T, repo repository.Repository)
|
||||
}{
|
||||
{
|
||||
name: "missing github config",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: nil,
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder { return nil },
|
||||
expectedError: "github configuration is required",
|
||||
},
|
||||
{
|
||||
name: "nil repository",
|
||||
repo: nil,
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder { return nil },
|
||||
expectedError: "github configuration is required",
|
||||
},
|
||||
{
|
||||
name: "error decrypting token",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
Branch: "main",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{
|
||||
tokenErr: errors.New("decryption failed"),
|
||||
}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder { return nil },
|
||||
expectedError: "unable to decrypt token",
|
||||
},
|
||||
{
|
||||
name: "success without webhooks",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
Branch: "main",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{
|
||||
token: common.RawSecureValue("test-token"),
|
||||
}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
|
||||
return nil
|
||||
},
|
||||
validateResult: func(t *testing.T, repo repository.Repository) {
|
||||
assert.NotNil(t, repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success with webhooks",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
Branch: "main",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{
|
||||
token: common.RawSecureValue("test-token"),
|
||||
webhookSecret: common.RawSecureValue("webhook-secret"),
|
||||
}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
|
||||
mockWebhook := github.NewMockWebhookURLBuilder(t)
|
||||
mockWebhook.EXPECT().WebhookURL(mock.Anything, repo).Return("https://example.com/webhook")
|
||||
return mockWebhook
|
||||
},
|
||||
validateResult: func(t *testing.T, repo repository.Repository) {
|
||||
assert.NotNil(t, repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip webhook setup when URL is empty",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
Branch: "main",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{
|
||||
token: common.RawSecureValue("test-token"),
|
||||
}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
|
||||
mockWebhook := github.NewMockWebhookURLBuilder(t)
|
||||
mockWebhook.EXPECT().WebhookURL(mock.Anything, repo).Return("")
|
||||
return mockWebhook
|
||||
},
|
||||
validateResult: func(t *testing.T, repo repository.Repository) {
|
||||
assert.NotNil(t, repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error decrypting webhook secret",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
Branch: "main",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{
|
||||
token: common.RawSecureValue("test-token"),
|
||||
webhookErr: errors.New("webhook decryption failed"),
|
||||
}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
|
||||
mockWebhook := github.NewMockWebhookURLBuilder(t)
|
||||
mockWebhook.EXPECT().WebhookURL(mock.Anything, repo).Return("https://example.com/webhook")
|
||||
return mockWebhook
|
||||
},
|
||||
expectedError: "decrypt webhookSecret",
|
||||
},
|
||||
{
|
||||
name: "success with custom path",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
Branch: "main",
|
||||
Path: "custom/path",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupDecrypter: func() repository.Decrypter {
|
||||
return func(r *provisioning.Repository) repository.SecureValues {
|
||||
return &mockSecureValues{
|
||||
token: common.RawSecureValue("test-token"),
|
||||
}
|
||||
}
|
||||
},
|
||||
setupWebhook: func(t *testing.T, repo *provisioning.Repository) github.WebhookURLBuilder {
|
||||
return nil
|
||||
},
|
||||
validateResult: func(t *testing.T, repo repository.Repository) {
|
||||
assert.NotNil(t, repo)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
decrypter := tt.setupDecrypter()
|
||||
webhookBuilder := tt.setupWebhook(t, tt.repo)
|
||||
factory := github.ProvideFactory()
|
||||
|
||||
e := github.Extra(decrypter, factory, webhookBuilder)
|
||||
|
||||
result, err := e.Build(ctx, tt.repo)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if tt.validateResult != nil {
|
||||
tt.validateResult(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtra_Mutate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
obj runtime.Object
|
||||
expectedError bool
|
||||
validateObj func(t *testing.T, obj runtime.Object)
|
||||
}{
|
||||
{
|
||||
name: "mutates repository with github config",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo.git/",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: false,
|
||||
validateObj: func(t *testing.T, obj runtime.Object) {
|
||||
repo := obj.(*provisioning.Repository)
|
||||
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles repository without github config",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: nil,
|
||||
},
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "handles non-repository object",
|
||||
obj: &runtime.Unknown{},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "trims only trailing slash",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo/",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: false,
|
||||
validateObj: func(t *testing.T, obj runtime.Object) {
|
||||
repo := obj.(*provisioning.Repository)
|
||||
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trims only .git suffix",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo.git",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: false,
|
||||
validateObj: func(t *testing.T, obj runtime.Object) {
|
||||
repo := obj.(*provisioning.Repository)
|
||||
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no changes when URL is clean",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitHubRepositoryType,
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
URL: "https://github.com/test/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: false,
|
||||
validateObj: func(t *testing.T, obj runtime.Object) {
|
||||
repo := obj.(*provisioning.Repository)
|
||||
assert.Equal(t, "https://github.com/test/repo", repo.Spec.GitHub.URL)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
e := github.Extra(nil, nil, nil)
|
||||
|
||||
err := e.Mutate(ctx, tt.obj)
|
||||
|
||||
if tt.expectedError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if tt.validateObj != nil {
|
||||
tt.validateObj(t, tt.obj)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package github
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockWebhookURLBuilder is an autogenerated mock type for the WebhookURLBuilder type
|
||||
type MockWebhookURLBuilder struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockWebhookURLBuilder_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockWebhookURLBuilder) EXPECT() *MockWebhookURLBuilder_Expecter {
|
||||
return &MockWebhookURLBuilder_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// WebhookURL provides a mock function with given fields: ctx, r
|
||||
func (_m *MockWebhookURLBuilder) WebhookURL(ctx context.Context, r *v0alpha1.Repository) string {
|
||||
ret := _m.Called(ctx, r)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for WebhookURL")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) string); ok {
|
||||
r0 = rf(ctx, r)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWebhookURLBuilder_WebhookURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WebhookURL'
|
||||
type MockWebhookURLBuilder_WebhookURL_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// WebhookURL is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - r *v0alpha1.Repository
|
||||
func (_e *MockWebhookURLBuilder_Expecter) WebhookURL(ctx interface{}, r interface{}) *MockWebhookURLBuilder_WebhookURL_Call {
|
||||
return &MockWebhookURLBuilder_WebhookURL_Call{Call: _e.mock.On("WebhookURL", ctx, r)}
|
||||
}
|
||||
|
||||
func (_c *MockWebhookURLBuilder_WebhookURL_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository)) *MockWebhookURLBuilder_WebhookURL_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(*v0alpha1.Repository))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWebhookURLBuilder_WebhookURL_Call) Return(_a0 string) *MockWebhookURLBuilder_WebhookURL_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWebhookURLBuilder_WebhookURL_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository) string) *MockWebhookURLBuilder_WebhookURL_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockWebhookURLBuilder creates a new instance of MockWebhookURLBuilder. 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 NewMockWebhookURLBuilder(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockWebhookURLBuilder {
|
||||
mock := &MockWebhookURLBuilder{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -14,8 +14,6 @@ export type Props = React.ComponentProps<typeof TextArea> & {
|
||||
isConfigured: boolean;
|
||||
/** Called when the user clicks on the "Reset" button in order to clear the secret */
|
||||
onReset: () => void;
|
||||
/** If true, the text area will grow to fill available width. */
|
||||
grow?: boolean;
|
||||
};
|
||||
|
||||
export const CONFIGURED_TEXT = 'configured';
|
||||
@@ -37,11 +35,11 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
*
|
||||
* https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs
|
||||
*/
|
||||
export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => {
|
||||
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<Stack>
|
||||
<Box grow={grow ? 1 : undefined}>
|
||||
<Box>
|
||||
{!isConfigured && <TextArea {...props} />}
|
||||
{isConfigured && (
|
||||
<TextArea
|
||||
|
||||
@@ -46,12 +46,10 @@ func ProvideProvisioningOSSRepositoryExtras(
|
||||
|
||||
func ProvideProvisioningOSSConnectionExtras(
|
||||
_ *setting.Cfg,
|
||||
decryptSvc decrypt.DecryptService,
|
||||
ghFactory ghconnection.GithubFactory,
|
||||
) []connection.Extra {
|
||||
decrypter := connection.ProvideDecrypter(decryptSvc)
|
||||
return []connection.Extra{
|
||||
ghconnection.Extra(decrypter, ghFactory),
|
||||
ghconnection.Extra(ghFactory),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,7 @@ type APIBuilder struct {
|
||||
usageStats usagestats.Service
|
||||
|
||||
tracer tracing.Tracer
|
||||
repoStore grafanarest.Storage
|
||||
connectionStore grafanarest.Storage
|
||||
store grafanarest.Storage
|
||||
parsers resources.ParserFactory
|
||||
repositoryResources resources.RepositoryResourcesFactory
|
||||
clients resources.ClientFactory
|
||||
@@ -605,7 +604,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
}
|
||||
|
||||
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
|
||||
b.repoStore = repositoryStorage
|
||||
b.store = repositoryStorage
|
||||
|
||||
jobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.JobResourceInfo, opts.OptsGetter)
|
||||
if err != nil {
|
||||
@@ -637,7 +636,6 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
return fmt.Errorf("failed to create connection storage: %w", err)
|
||||
}
|
||||
connectionStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, connectionsStore)
|
||||
b.connectionStore = connectionsStore
|
||||
|
||||
storage[provisioning.JobResourceInfo.StoragePath()] = jobStore
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
|
||||
@@ -819,7 +817,7 @@ func invalidRepositoryError(name string, list field.ErrorList) error {
|
||||
}
|
||||
|
||||
func (b *APIBuilder) VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error {
|
||||
return VerifyAgainstExistingRepositories(ctx, b.repoStore, cfg)
|
||||
return VerifyAgainstExistingRepositories(ctx, b.store, cfg)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) {
|
||||
@@ -867,7 +865,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
|
||||
|
||||
// Create the repository resources factory
|
||||
repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) {
|
||||
return GetRepositoriesInNamespace(ctx, b.repoStore)
|
||||
return GetRepositoriesInNamespace(ctx, b.store)
|
||||
}
|
||||
usageMetricCollector := usage.MetricCollector(b.tracer, repositoryListerWrapper, b.unified)
|
||||
b.usageStats.RegisterMetricsFunc(usageMetricCollector)
|
||||
@@ -1413,21 +1411,13 @@ spec:
|
||||
// TODO: where should the helpers live?
|
||||
|
||||
func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository.Repository, error) {
|
||||
obj, err := b.repoStore.Get(ctx, name, &metav1.GetOptions{})
|
||||
obj, err := b.store.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.asRepository(ctx, obj, nil)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetConnection(ctx context.Context, name string) (connection.Connection, error) {
|
||||
obj, err := b.connectionStore.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.asConnection(ctx, obj, nil)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetRepoFactory() repository.Factory {
|
||||
return b.repoFactory
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// TODO: check if lister could list too many repositories or resources
|
||||
all, err := GetRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()), b.repoStore)
|
||||
all, err := GetRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()), b.store)
|
||||
if err != nil {
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
return
|
||||
|
||||
@@ -30,29 +30,23 @@ type HealthCheckerProvider interface {
|
||||
|
||||
type ConnectorDependencies interface {
|
||||
RepoGetter
|
||||
ConnectionGetter
|
||||
HealthCheckerProvider
|
||||
GetRepoFactory() repository.Factory
|
||||
}
|
||||
|
||||
type testConnector struct {
|
||||
repoGetter RepoGetter
|
||||
repoFactory repository.Factory
|
||||
connectionGetter ConnectionGetter
|
||||
healthProvider HealthCheckerProvider
|
||||
tester repository.RepositoryTesterWithExistingChecker
|
||||
getter RepoGetter
|
||||
factory repository.Factory
|
||||
healthProvider HealthCheckerProvider
|
||||
tester repository.RepositoryTesterWithExistingChecker
|
||||
}
|
||||
|
||||
func NewTestConnector(
|
||||
deps ConnectorDependencies,
|
||||
tester repository.RepositoryTesterWithExistingChecker,
|
||||
) *testConnector {
|
||||
func NewTestConnector(deps ConnectorDependencies, tester repository.RepositoryTesterWithExistingChecker) *testConnector {
|
||||
return &testConnector{
|
||||
repoFactory: deps.GetRepoFactory(),
|
||||
repoGetter: deps,
|
||||
connectionGetter: deps,
|
||||
healthProvider: deps,
|
||||
tester: tester,
|
||||
factory: deps.GetRepoFactory(),
|
||||
getter: deps,
|
||||
healthProvider: deps,
|
||||
tester: tester,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +72,7 @@ func (*testConnector) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
ns, ok := request.NamespaceFrom(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing namespace")
|
||||
@@ -110,7 +104,7 @@ func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Obje
|
||||
name = "hack-on-hack-for-new"
|
||||
} else {
|
||||
// Copy previous secure values if they exist
|
||||
old, _ := s.repoGetter.GetRepository(ctx, name)
|
||||
old, _ := s.getter.GetRepository(ctx, name)
|
||||
if old != nil && !old.Config().Secure.IsZero() {
|
||||
secure := old.Config().Secure
|
||||
if cfg.Secure.Token.IsZero() {
|
||||
@@ -127,27 +121,8 @@ func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Obje
|
||||
cfg.SetNamespace(ns)
|
||||
}
|
||||
|
||||
// The new repository should be connected to a Connection resource,
|
||||
// i.e. we should be generating the token based on it.
|
||||
if cfg.Secure.Token.IsZero() && cfg.Spec.Connection != nil && cfg.Spec.Connection.Name != "" {
|
||||
// A connection must be there
|
||||
c, err := s.connectionGetter.GetConnection(ctx, cfg.Spec.Connection.Name)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := c.GenerateRepositoryToken(ctx, &cfg)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Secure.Token.Create = token
|
||||
}
|
||||
|
||||
// Create a temporary repository
|
||||
tmp, err := s.repoFactory.Build(ctx, &cfg)
|
||||
tmp, err := s.factory.Build(ctx, &cfg)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
@@ -173,7 +148,7 @@ func (s *testConnector) Connect(ctx context.Context, name string, _ runtime.Obje
|
||||
}
|
||||
|
||||
// Testing existing repository - get it and update health
|
||||
repo, err = s.repoGetter.GetRepository(ctx, name)
|
||||
repo, err = s.getter.GetRepository(ctx, name)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,6 @@ package provisioning
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
)
|
||||
@@ -16,10 +15,6 @@ type RepoGetter interface {
|
||||
GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error)
|
||||
}
|
||||
|
||||
type ConnectionGetter interface {
|
||||
GetConnection(ctx context.Context, name string) (connection.Connection, error)
|
||||
}
|
||||
|
||||
type ClientGetter interface {
|
||||
GetClient() client.ProvisioningV0alpha1Interface
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -916,7 +916,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
return nil, err
|
||||
}
|
||||
githubFactory := github2.ProvideFactory()
|
||||
v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, decryptService, githubFactory)
|
||||
v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory)
|
||||
connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1584,7 +1584,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
return nil, err
|
||||
}
|
||||
githubFactory := github2.ProvideFactory()
|
||||
v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, decryptService, githubFactory)
|
||||
v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory)
|
||||
connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,12 +11,15 @@ import (
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/team"
|
||||
@@ -50,25 +53,39 @@ func (a *api) getResourcePermissionsFromK8s(ctx context.Context, namespace strin
|
||||
}
|
||||
|
||||
resourcePermName := a.buildResourcePermissionName(resourceID)
|
||||
|
||||
resourcePermResource := dynamicClient.Resource(iamv0.ResourcePermissionInfo.GroupVersionResource()).Namespace(namespace)
|
||||
unstructuredObj, err := resourcePermResource.Get(ctx, resourcePermName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
|
||||
dto := make(getResourcePermissionsResponse, 0)
|
||||
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, fmt.Errorf("failed to get resource permission from k8s: %w", err)
|
||||
}
|
||||
|
||||
var resourcePerm iamv0.ResourcePermission
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &resourcePerm); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to typed resource permission: %w", err)
|
||||
if unstructuredObj != nil {
|
||||
var resourcePerm iamv0.ResourcePermission
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &resourcePerm); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to typed resource permission: %w", err)
|
||||
}
|
||||
|
||||
directDTO, err := a.convertK8sResourcePermissionToDTO(&resourcePerm, namespace, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto = append(dto, directDTO...)
|
||||
}
|
||||
|
||||
return a.convertK8sResourcePermissionToDTO(&resourcePerm, namespace)
|
||||
inheritedDTO, err := a.getInheritedPermissions(ctx, namespace, resourceID, dynamicClient)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get inherited permissions from k8s API", "error", err, "resourceID", resourceID, "resource", a.service.options.Resource)
|
||||
} else {
|
||||
dto = append(dto, inheritedDTO...)
|
||||
}
|
||||
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePermission, namespace string) (getResourcePermissionsResponse, error) {
|
||||
func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePermission, namespace string, isInherited bool) (getResourcePermissionsResponse, error) {
|
||||
permissions := resourcePerm.Spec.Permissions
|
||||
if len(permissions) == 0 {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
@@ -107,7 +124,7 @@ func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePerm
|
||||
Permission: permission,
|
||||
Actions: actions,
|
||||
IsManaged: true,
|
||||
IsInherited: false,
|
||||
IsInherited: isInherited,
|
||||
}
|
||||
|
||||
switch kind {
|
||||
@@ -162,6 +179,115 @@ func getMapKeys(m map[string][]string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func (a *api) getInheritedPermissions(ctx context.Context, namespace string, resourceID string, dynamicClient dynamic.Interface) (getResourcePermissionsResponse, error) {
|
||||
switch a.service.options.Resource {
|
||||
case "folders":
|
||||
return a.getFolderHierarchyPermissions(ctx, namespace, resourceID, dynamicClient)
|
||||
|
||||
case "dashboards":
|
||||
return a.getDashboardInheritedPermissions(ctx, namespace, resourceID, dynamicClient)
|
||||
|
||||
default:
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) getDashboardInheritedPermissions(ctx context.Context, namespace string, dashboardUID string, dynamicClient dynamic.Interface) (getResourcePermissionsResponse, error) {
|
||||
dashboardsGVR := schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
|
||||
dashboardResource := dynamicClient.Resource(dashboardsGVR).Namespace(namespace)
|
||||
unstructuredDash, err := dashboardResource.Get(ctx, dashboardUID, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get dashboard from k8s: %w", err)
|
||||
}
|
||||
|
||||
annotations := unstructuredDash.GetAnnotations()
|
||||
parentFolderUID := annotations[utils.AnnoKeyFolder]
|
||||
|
||||
if parentFolderUID == "" {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
|
||||
return a.getFolderHierarchyPermissions(ctx, namespace, parentFolderUID, dynamicClient)
|
||||
}
|
||||
|
||||
// getFolderHierarchyPermissions gets permissions from a folder and all its parents
|
||||
func (a *api) getFolderHierarchyPermissions(ctx context.Context, namespace string, folderUID string, dynamicClient dynamic.Interface) (getResourcePermissionsResponse, error) {
|
||||
foldersGVR := schema.GroupVersionResource{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
}
|
||||
|
||||
// GET /apis/folder.grafana.app/v1beta1/namespaces/{namespace}/folders/{folderUID}/parents
|
||||
parentsResource := dynamicClient.Resource(foldersGVR).Namespace(namespace)
|
||||
unstructuredResult, err := parentsResource.Get(ctx, folderUID, metav1.GetOptions{}, "parents")
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
// Folder not found or no parents
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get folder parents from k8s: %w", err)
|
||||
}
|
||||
|
||||
var folderInfoList folderv1.FolderInfoList
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredResult.Object, &folderInfoList); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert folder parents response: %w", err)
|
||||
}
|
||||
|
||||
if len(folderInfoList.Items) == 0 {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
|
||||
allInheritedPermissions := make(getResourcePermissionsResponse, 0)
|
||||
resourcePermResource := dynamicClient.Resource(iamv0.ResourcePermissionInfo.GroupVersionResource()).Namespace(namespace)
|
||||
|
||||
for _, parentFolder := range folderInfoList.Items {
|
||||
if parentFolder.Detached {
|
||||
a.logger.Debug("Skipping detached parent folder", "folderName", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if parentFolder.Name == folderUID {
|
||||
continue
|
||||
}
|
||||
|
||||
parentPermName := a.buildResourcePermissionName(parentFolder.Name)
|
||||
|
||||
unstructuredObj, err := resourcePermResource.Get(ctx, parentPermName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
a.logger.Warn("Failed to get parent folder permission from k8s", "error", err, "parentFolder", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
var parentResourcePerm iamv0.ResourcePermission
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &parentResourcePerm); err != nil {
|
||||
a.logger.Warn("Failed to convert parent folder permission", "error", err, "parentFolder", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
inheritedDTO, err := a.convertK8sResourcePermissionToDTO(&parentResourcePerm, namespace, true)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to convert parent folder permissions to DTO", "error", err, "parentFolder", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
allInheritedPermissions = append(allInheritedPermissions, inheritedDTO...)
|
||||
}
|
||||
|
||||
return allInheritedPermissions, nil
|
||||
}
|
||||
|
||||
func (a *api) buildResourcePermissionName(resourceID string) string {
|
||||
return fmt.Sprintf("%s-%s-%s", a.getAPIGroup(), a.service.options.Resource, resourceID)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { t } from '@grafana/i18n';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
|
||||
import { getState } from 'app/store/store';
|
||||
import { ThunkDispatch } from 'app/types/store';
|
||||
|
||||
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
|
||||
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||
@@ -20,26 +19,6 @@ import { refetchChildren } from '../../../../features/browse-dashboards/state/ac
|
||||
import { handleError } from '../../../utils';
|
||||
import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded';
|
||||
|
||||
const handleProvisioningFormError = (e: unknown, dispatch: ThunkDispatch, title: string) => {
|
||||
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
|
||||
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
|
||||
const statusError: Status = e.error.data;
|
||||
dispatch(notifyApp(createErrorNotification(title, new Error(statusError.message || 'Unknown error'))));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
|
||||
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
|
||||
if (nonFieldErrors.length > 0) {
|
||||
dispatch(notifyApp(createErrorNotification(title)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleError(e, dispatch, title);
|
||||
};
|
||||
|
||||
export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
|
||||
endpoints: {
|
||||
listJob: {
|
||||
@@ -58,17 +37,6 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
|
||||
}),
|
||||
onCacheEntryAdded: createOnCacheEntryAdded<RepositorySpec, RepositoryStatus>('repositories'),
|
||||
},
|
||||
listConnection: {
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [
|
||||
{ type: 'Connection', id: 'LIST' },
|
||||
...result.items
|
||||
.map((connection) => ({ type: 'Connection' as const, id: connection.metadata?.name }))
|
||||
.filter(Boolean),
|
||||
]
|
||||
: [{ type: 'Connection', id: 'LIST' }],
|
||||
},
|
||||
deleteRepository: {
|
||||
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
|
||||
try {
|
||||
@@ -136,7 +104,34 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
|
||||
try {
|
||||
await queryFulfilled;
|
||||
} catch (e) {
|
||||
handleProvisioningFormError(e, dispatch, 'Error validating repository');
|
||||
// Handle special cases first
|
||||
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
|
||||
// Handle Status error responses (Kubernetes style)
|
||||
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
|
||||
const statusError: Status = e.error.data;
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createErrorNotification(
|
||||
'Error validating repository',
|
||||
new Error(statusError.message || 'Unknown error')
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Handle TestResults error responses with field errors
|
||||
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
|
||||
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
|
||||
// Only show notification if there are errors that don't have a field, field errors are handled by the form
|
||||
if (nonFieldErrors.length > 0) {
|
||||
dispatch(notifyApp(createErrorNotification('Error validating repository')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For all other cases, use handleError
|
||||
handleError(e, dispatch, 'Error validating repository');
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -245,70 +240,6 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
|
||||
}
|
||||
},
|
||||
},
|
||||
createConnection: {
|
||||
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
|
||||
try {
|
||||
await queryFulfilled;
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createSuccessNotification(t('provisioning.connection-form.alert-connection-saved', 'Connection saved'))
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
handleProvisioningFormError(
|
||||
e,
|
||||
dispatch,
|
||||
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
replaceConnection: {
|
||||
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
|
||||
try {
|
||||
await queryFulfilled;
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createSuccessNotification(
|
||||
t('provisioning.connection-form.alert-connection-updated', 'Connection updated')
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
handleProvisioningFormError(
|
||||
e,
|
||||
dispatch,
|
||||
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
deleteConnection: {
|
||||
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Connection', id: 'LIST' }]),
|
||||
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
|
||||
try {
|
||||
await queryFulfilled;
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createSuccessNotification(
|
||||
t('provisioning.connection-form.alert-connection-deleted', 'Connection deleted')
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createErrorNotification(
|
||||
t('provisioning.connection-form.error-delete-connection', 'Failed to delete connection'),
|
||||
e
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
NewObjectAddedToCanvasEvent,
|
||||
ObjectRemovedFromCanvasEvent,
|
||||
ObjectsReorderedOnCanvasEvent,
|
||||
RepeatsUpdatedEvent,
|
||||
} from './shared';
|
||||
|
||||
export interface DashboardEditPaneState extends SceneObjectState {
|
||||
@@ -88,12 +87,6 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
})
|
||||
);
|
||||
|
||||
this._subs.add(
|
||||
dashboard.subscribeToEvent(RepeatsUpdatedEvent, () => {
|
||||
this.forceRender();
|
||||
})
|
||||
);
|
||||
|
||||
if (this.panelEditAction) {
|
||||
this.performPanelEditAction(this.panelEditAction);
|
||||
this.panelEditAction = undefined;
|
||||
|
||||
@@ -57,10 +57,12 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
|
||||
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
|
||||
const outlineRename = useOutlineRename(editableElement, isEditing);
|
||||
const isContainer = editableElement.getOutlineChildren ? true : false;
|
||||
const outlineChildren = editableElement.getOutlineChildren?.(isEditing) ?? [];
|
||||
const visibleChildren = isEditing
|
||||
? outlineChildren
|
||||
: outlineChildren.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
|
||||
const visibleChildren = useMemo(() => {
|
||||
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
|
||||
return isEditing
|
||||
? children
|
||||
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
|
||||
}, [editableElement, isEditing]);
|
||||
|
||||
const onNodeClicked = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -256,6 +258,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
}),
|
||||
nodeButtonClone: css({
|
||||
color: theme.colors.text.secondary,
|
||||
cursor: 'not-allowed',
|
||||
}),
|
||||
outlineInput: css({
|
||||
border: `1px solid ${theme.components.input.borderColor}`,
|
||||
|
||||
@@ -84,10 +84,6 @@ export class ConditionalRenderingChangedEvent extends BusEventWithPayload<SceneO
|
||||
static type = 'conditional-rendering-changed';
|
||||
}
|
||||
|
||||
export class RepeatsUpdatedEvent extends BusEventWithPayload<SceneObject> {
|
||||
static type = 'repeats-updated';
|
||||
}
|
||||
|
||||
export interface DashboardEditActionEventPayload {
|
||||
removedObject?: SceneObject;
|
||||
addedObject?: SceneObject;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
||||
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
|
||||
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
|
||||
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
|
||||
@@ -147,7 +147,6 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
|
||||
|
||||
this.setState({ repeatedPanels, repeatedConditionalRendering });
|
||||
this._prevRepeatValues = values;
|
||||
this.publishEvent(new RepeatsUpdatedEvent(this), true);
|
||||
}
|
||||
|
||||
public getPanelCount() {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
|
||||
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
|
||||
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
|
||||
@@ -219,7 +219,6 @@ export class DashboardGridItem
|
||||
}
|
||||
|
||||
this._prevRepeatValues = values;
|
||||
this.publishEvent(new RepeatsUpdatedEvent(this), true);
|
||||
}
|
||||
|
||||
public handleVariableName() {
|
||||
|
||||
@@ -57,7 +57,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
const repositoryName = data?.metadata?.name;
|
||||
const settings = useGetFrontendSettingsQuery();
|
||||
const [submitData, request] = useCreateOrUpdateRepository(repositoryName);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -78,6 +77,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
const isEdit = Boolean(repositoryName);
|
||||
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [type, readOnly] = watch(['type', 'readOnly']);
|
||||
const targetOptions = useMemo(() => getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]);
|
||||
const isGitBased = isGitProvider(type);
|
||||
@@ -104,6 +104,17 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
const localFields = type === 'local' ? getLocalProviderFields(type) : null;
|
||||
const hasTokenInstructions = getHasTokenInstructions(type);
|
||||
|
||||
// TODO: this should be removed after 12.2 is released
|
||||
useEffect(() => {
|
||||
if (isGitBased && !data?.secure?.token) {
|
||||
setTokenConfigured(false);
|
||||
setError('token', {
|
||||
type: 'manual',
|
||||
message: `Enter your ${gitFields?.tokenConfig.label ?? 'access token'}`,
|
||||
});
|
||||
}
|
||||
}, [data, gitFields, setTokenConfigured, setError, isGitBased]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request.isSuccess) {
|
||||
const formData = getValues();
|
||||
@@ -115,9 +126,11 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
});
|
||||
|
||||
reset(formData);
|
||||
setTimeout(() => navigate(PROVISIONING_URL), 300);
|
||||
setTimeout(() => {
|
||||
navigate('/admin/provisioning');
|
||||
}, 300);
|
||||
}
|
||||
}, [request.isSuccess, reset, getValues, repositoryName, navigate]);
|
||||
}, [request.isSuccess, reset, getValues, navigate, repositoryName]);
|
||||
|
||||
const onSubmit = async (form: RepositoryFormData) => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { QueryStatus } from '@reduxjs/toolkit/query';
|
||||
import { render, screen, waitFor } from 'test/test-utils';
|
||||
|
||||
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
|
||||
|
||||
import { ConnectionForm } from './ConnectionForm';
|
||||
|
||||
jest.mock('../hooks/useCreateOrUpdateConnection', () => ({
|
||||
useCreateOrUpdateConnection: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSubmitData = jest.fn();
|
||||
const mockUseCreateOrUpdateConnection = useCreateOrUpdateConnection as jest.MockedFunction<
|
||||
typeof useCreateOrUpdateConnection
|
||||
>;
|
||||
|
||||
type MockRequestState = {
|
||||
status: QueryStatus;
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
error?: unknown;
|
||||
reset: jest.Mock;
|
||||
};
|
||||
|
||||
const createMockRequestState = (overrides: Partial<MockRequestState> = {}): MockRequestState => ({
|
||||
status: QueryStatus.uninitialized,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
reset: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
metadata: { name: 'test-connection' },
|
||||
spec: {
|
||||
type: 'github',
|
||||
url: 'https://github.com/settings/installations/12345678',
|
||||
github: {
|
||||
appID: '123456',
|
||||
installationID: '12345678',
|
||||
},
|
||||
},
|
||||
secure: {
|
||||
privateKey: { name: 'configured' },
|
||||
},
|
||||
status: {
|
||||
state: 'connected',
|
||||
health: { healthy: true },
|
||||
observedGeneration: 1,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
interface SetupOptions {
|
||||
data?: Connection;
|
||||
requestState?: Partial<MockRequestState>;
|
||||
}
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const { data, requestState = {} } = options;
|
||||
|
||||
mockUseCreateOrUpdateConnection.mockReturnValue([
|
||||
mockSubmitData,
|
||||
createMockRequestState(requestState) as unknown as ReturnType<typeof useCreateOrUpdateConnection>[1],
|
||||
]);
|
||||
|
||||
return {
|
||||
mockSubmitData,
|
||||
...render(<ConnectionForm data={data} />),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConnectionForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSubmitData.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('Rendering - Create Mode', () => {
|
||||
it('should render all form fields', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^GitHub App ID/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^GitHub Installation ID/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Save button', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Delete button in create mode', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have Provider field disabled', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByLabelText(/^Provider/)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering - Edit Mode', () => {
|
||||
it('should populate form fields with existing connection data', () => {
|
||||
setup({ data: createMockConnection() });
|
||||
|
||||
expect(screen.getByLabelText(/^GitHub App ID/)).toHaveValue('123456');
|
||||
expect(screen.getByLabelText(/^GitHub Installation ID/)).toHaveValue('12345678');
|
||||
});
|
||||
|
||||
it('should render Delete button in edit mode', () => {
|
||||
setup({ data: createMockConnection() });
|
||||
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show configured state for private key', () => {
|
||||
setup({ data: createMockConnection() });
|
||||
|
||||
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toHaveValue('configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should show required error and not submit when fields are empty', async () => {
|
||||
const { user, mockSubmitData } = setup();
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save$/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('This field is required')).toHaveLength(3);
|
||||
});
|
||||
|
||||
expect(mockSubmitData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission - Create', () => {
|
||||
it('should call submitData with correct data on valid submission', async () => {
|
||||
const { user, mockSubmitData } = setup();
|
||||
|
||||
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
|
||||
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
|
||||
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save$/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSubmitData).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'github',
|
||||
github: {
|
||||
appID: '123456',
|
||||
installationID: '12345678',
|
||||
},
|
||||
},
|
||||
'-----BEGIN RSA PRIVATE KEY-----'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission - Edit', () => {
|
||||
it('should allow submission without changing private key', async () => {
|
||||
const { user, mockSubmitData } = setup({ data: createMockConnection() });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save$/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSubmitData).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'github',
|
||||
github: {
|
||||
appID: '123456',
|
||||
installationID: '12345678',
|
||||
},
|
||||
},
|
||||
'configured'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should disable Save button while loading', () => {
|
||||
setup({ requestState: { isLoading: true } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /saving/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show "Saving..." text while loading', () => {
|
||||
setup({ requestState: { isLoading: true } });
|
||||
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should map API error for appID to form field', async () => {
|
||||
const { user, mockSubmitData } = setup();
|
||||
|
||||
mockSubmitData.mockRejectedValue({
|
||||
status: 400,
|
||||
data: { errors: [{ field: 'appID', detail: 'Invalid App ID' }] },
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
|
||||
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
|
||||
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save$/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid App ID')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should map API error for installationID to form field', async () => {
|
||||
const { user, mockSubmitData } = setup();
|
||||
|
||||
mockSubmitData.mockRejectedValue({
|
||||
status: 400,
|
||||
data: { errors: [{ field: 'installationID', detail: 'Invalid Installation ID' }] },
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
|
||||
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
|
||||
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save$/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid Installation ID')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should map API error for privateKey to form field', async () => {
|
||||
const { user, mockSubmitData } = setup();
|
||||
|
||||
mockSubmitData.mockRejectedValue({
|
||||
status: 400,
|
||||
data: { errors: [{ field: 'secure.privateKey', detail: 'Invalid Private Key format' }] },
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
|
||||
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
|
||||
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), 'invalid-key');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save$/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid Private Key format')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { isFetchError, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Combobox, Field, Input, SecretTextArea, Stack } from '@grafana/ui';
|
||||
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
|
||||
|
||||
import { CONNECTIONS_URL } from '../constants';
|
||||
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
|
||||
import { ConnectionFormData } from '../types';
|
||||
import { getConnectionFormErrors } from '../utils/getFormErrors';
|
||||
|
||||
import { DeleteConnectionButton } from './DeleteConnectionButton';
|
||||
|
||||
interface ConnectionFormProps {
|
||||
data?: Connection;
|
||||
}
|
||||
|
||||
const providerOptions = [{ value: 'github', label: 'GitHub' }];
|
||||
|
||||
export function ConnectionForm({ data }: ConnectionFormProps) {
|
||||
const connectionName = data?.metadata?.name;
|
||||
const isEdit = Boolean(connectionName);
|
||||
const privateKey = data?.secure?.privateKey;
|
||||
const [privateKeyConfigured, setPrivateKeyConfigured] = useState(Boolean(privateKey));
|
||||
const [submitData, request] = useCreateOrUpdateConnection(connectionName);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors, isDirty },
|
||||
setValue,
|
||||
getValues,
|
||||
setError,
|
||||
} = useForm<ConnectionFormData>({
|
||||
defaultValues: {
|
||||
type: data?.spec?.type || 'github',
|
||||
appID: data?.spec?.github?.appID || '',
|
||||
installationID: data?.spec?.github?.installationID || '',
|
||||
privateKey: privateKey?.name || '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (request.isSuccess) {
|
||||
const formData = getValues();
|
||||
|
||||
reportInteraction('grafana_provisioning_connection_saved', {
|
||||
connectionName: connectionName ?? 'unknown',
|
||||
connectionType: formData.type,
|
||||
});
|
||||
|
||||
reset(formData);
|
||||
// use timeout to ensure the form resets before navigating
|
||||
setTimeout(() => navigate(CONNECTIONS_URL), 300);
|
||||
}
|
||||
}, [request.isSuccess, reset, getValues, connectionName, navigate]);
|
||||
|
||||
const onSubmit = async (form: ConnectionFormData) => {
|
||||
try {
|
||||
const spec = {
|
||||
type: form.type,
|
||||
github: {
|
||||
appID: form.appID,
|
||||
installationID: form.installationID,
|
||||
},
|
||||
};
|
||||
|
||||
await submitData(spec, form.privateKey);
|
||||
} catch (err) {
|
||||
if (isFetchError(err)) {
|
||||
const [field, errorMessage] = getConnectionFormErrors(err.data?.errors);
|
||||
|
||||
if (field && errorMessage) {
|
||||
setError(field, errorMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 700 }}>
|
||||
<FormPrompt onDiscard={reset} confirmRedirect={isDirty} />
|
||||
<Stack direction="column" gap={2}>
|
||||
<Field
|
||||
noMargin
|
||||
htmlFor="type"
|
||||
label={t('provisioning.connection-form.label-provider', 'Provider')}
|
||||
description={t('provisioning.connection-form.description-provider', 'Select the provider type')}
|
||||
>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Combobox
|
||||
id="type"
|
||||
disabled // TODO enable when other providers are supported
|
||||
options={providerOptions}
|
||||
onChange={(option) => onChange(option?.value)}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('provisioning.connection-form.label-app-id', 'GitHub App ID')}
|
||||
description={t('provisioning.connection-form.description-app-id', 'The ID of your GitHub App')}
|
||||
invalid={!!errors.appID}
|
||||
error={errors?.appID?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="appID"
|
||||
{...register('appID', {
|
||||
required: t('provisioning.connection-form.error-required', 'This field is required'),
|
||||
})}
|
||||
placeholder={t('provisioning.connection-form.placeholder-app-id', '123456')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('provisioning.connection-form.label-installation-id', 'GitHub Installation ID')}
|
||||
description={t(
|
||||
'provisioning.connection-form.description-installation-id',
|
||||
'The installation ID of your GitHub App'
|
||||
)}
|
||||
invalid={!!errors.installationID}
|
||||
error={errors?.installationID?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="installationID"
|
||||
{...register('installationID', {
|
||||
required: t('provisioning.connection-form.error-required', 'This field is required'),
|
||||
})}
|
||||
placeholder={t('provisioning.connection-form.placeholder-installation-id', '12345678')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
htmlFor="privateKey"
|
||||
label={t('provisioning.connection-form.label-private-key', 'Private Key (PEM)')}
|
||||
description={t(
|
||||
'provisioning.connection-form.description-private-key',
|
||||
'The private key for your GitHub App in PEM format'
|
||||
)}
|
||||
invalid={!!errors.privateKey}
|
||||
error={errors?.privateKey?.message}
|
||||
required={!isEdit}
|
||||
>
|
||||
<Controller
|
||||
name="privateKey"
|
||||
control={control}
|
||||
rules={{
|
||||
required: isEdit ? false : t('provisioning.connection-form.error-required', 'This field is required'),
|
||||
}}
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<SecretTextArea
|
||||
{...field}
|
||||
id="privateKey"
|
||||
placeholder={t(
|
||||
'provisioning.connection-form.placeholder-private-key',
|
||||
'-----BEGIN RSA PRIVATE KEY-----...'
|
||||
)}
|
||||
isConfigured={privateKeyConfigured}
|
||||
onReset={() => {
|
||||
setValue('privateKey', '');
|
||||
setPrivateKeyConfigured(false);
|
||||
}}
|
||||
rows={8}
|
||||
grow
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button type="submit" disabled={request.isLoading}>
|
||||
{request.isLoading
|
||||
? t('provisioning.connection-form.button-saving', 'Saving...')
|
||||
: t('provisioning.connection-form.button-save', 'Save')}
|
||||
</Button>
|
||||
{connectionName && data && <DeleteConnectionButton name={connectionName} connection={data} />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { EmptyState, Text, TextLink } from '@grafana/ui';
|
||||
import { useGetConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { CONNECTIONS_URL } from '../constants';
|
||||
|
||||
import { ConnectionForm } from './ConnectionForm';
|
||||
|
||||
export default function ConnectionFormPage() {
|
||||
const { name = '' } = useParams();
|
||||
const isCreate = !name;
|
||||
|
||||
const query = useGetConnectionQuery(isCreate ? skipToken : { name });
|
||||
|
||||
//@ts-expect-error TODO add error types
|
||||
const notFound = !isCreate && query.isError && query.error?.status === 404;
|
||||
|
||||
const pageTitle = isCreate
|
||||
? t('provisioning.connection-form.page-title-create', 'Create connection')
|
||||
: t('provisioning.connection-form.page-title-edit', 'Edit connection');
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{
|
||||
text: pageTitle,
|
||||
subTitle: t(
|
||||
'provisioning.connection-form.page-subtitle',
|
||||
'Configure a connection to authenticate with external providers'
|
||||
),
|
||||
parentItem: {
|
||||
text: t('provisioning.connections.page-title', 'Connections'),
|
||||
url: CONNECTIONS_URL,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Page.Contents isLoading={!isCreate && query.isLoading}>
|
||||
{notFound ? (
|
||||
<EmptyState message={t('provisioning.connection-form.not-found', 'Connection not found')} variant="not-found">
|
||||
<Text element="p">
|
||||
<Trans i18nKey="provisioning.connection-form.not-found-description">
|
||||
The connection you are looking for does not exist.
|
||||
</Trans>
|
||||
</Text>
|
||||
<TextLink href={CONNECTIONS_URL}>
|
||||
<Trans i18nKey="provisioning.connection-form.back-to-connections">Back to connections</Trans>
|
||||
</TextLink>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ConnectionForm data={isCreate ? undefined : query.data} />
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { ConnectionList } from './ConnectionList';
|
||||
|
||||
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
metadata: { name: 'test-connection' },
|
||||
spec: {
|
||||
type: 'github',
|
||||
url: 'https://github.com/settings/installations/12345678',
|
||||
github: {
|
||||
appID: '123456',
|
||||
installationID: '12345678',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
state: 'connected',
|
||||
health: { healthy: true },
|
||||
observedGeneration: 1,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockConnections: Connection[] = [
|
||||
createMockConnection({
|
||||
metadata: { name: 'github-conn-1' },
|
||||
spec: {
|
||||
type: 'github',
|
||||
url: 'https://github.com/settings/installations/103343308',
|
||||
github: {
|
||||
appID: '123456',
|
||||
installationID: '103343308',
|
||||
},
|
||||
},
|
||||
}),
|
||||
createMockConnection({
|
||||
metadata: { name: 'gitlab-conn-2' },
|
||||
spec: { type: 'gitlab', url: 'https://gitlab.com/org2/repo2' },
|
||||
}),
|
||||
createMockConnection({
|
||||
metadata: { name: 'another-github' },
|
||||
spec: {
|
||||
type: 'github',
|
||||
url: 'https://github.com/settings/installations/987654321',
|
||||
github: {
|
||||
appID: '654321',
|
||||
installationID: '987654321',
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
function setup(items: Connection[] = mockConnections) {
|
||||
return render(<ConnectionList items={items} />, { renderWithRouter: true });
|
||||
}
|
||||
|
||||
describe('ConnectionList', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render search input with correct placeholder', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByPlaceholderText('Search connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all connection items when no filter is applied', () => {
|
||||
setup();
|
||||
|
||||
// Verify all 3 connections are displayed by checking for their URL links
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render EmptyState when items array is empty', () => {
|
||||
setup([]);
|
||||
|
||||
expect(screen.getByText('No connections configured')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('should filter connections by name', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search connections');
|
||||
await user.type(searchInput, 'gitlab');
|
||||
|
||||
// Should show only gitlab connection
|
||||
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'https://github.com/settings/installations/987654321' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter connections by provider type', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search connections');
|
||||
await user.type(searchInput, 'github');
|
||||
|
||||
// Should show only github connections
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'https://gitlab.com/org2/repo2' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be case-insensitive', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search connections');
|
||||
await user.type(searchInput, 'GITLAB');
|
||||
|
||||
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show EmptyState when filter matches nothing', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search connections');
|
||||
await user.type(searchInput, 'nonexistent');
|
||||
|
||||
expect(screen.getByText('No results matching your query')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clear filter and show all items', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search connections');
|
||||
await user.type(searchInput, 'gitlab');
|
||||
|
||||
// Filter applied
|
||||
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Clear the filter
|
||||
await user.clear(searchInput);
|
||||
|
||||
// All items should be visible again
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { EmptyState, FilterInput, Stack } from '@grafana/ui';
|
||||
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { ConnectionListItem } from './ConnectionListItem';
|
||||
|
||||
interface Props {
|
||||
items: Connection[];
|
||||
}
|
||||
|
||||
export function ConnectionList({ items }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const name = item.metadata?.name?.toLowerCase() ?? '';
|
||||
const providerType = item.spec?.type?.toLowerCase() ?? '';
|
||||
return name.includes(lowerQuery) || providerType.includes(lowerQuery);
|
||||
});
|
||||
|
||||
const isEmpty = items.length === 0;
|
||||
|
||||
return (
|
||||
<Stack direction={'column'} gap={3}>
|
||||
<FilterInput
|
||||
placeholder={t('provisioning.connections.search-placeholder', 'Search connections')}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
/>
|
||||
<Stack direction={'column'} gap={2}>
|
||||
{filteredItems.length ? (
|
||||
filteredItems.map((item) => <ConnectionListItem key={item.metadata?.name} connection={item} />)
|
||||
) : (
|
||||
<EmptyState
|
||||
variant={isEmpty ? 'completed' : 'not-found'}
|
||||
message={
|
||||
isEmpty
|
||||
? t('provisioning.connections.no-connections', 'No connections configured')
|
||||
: t('provisioning.connections.no-results', 'No results matching your query')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui';
|
||||
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { RepoIcon } from '../Shared/RepoIcon';
|
||||
import { RepoType } from '../Wizard/types';
|
||||
import { CONNECTIONS_URL } from '../constants';
|
||||
import { getRepositoryTypeConfigs } from '../utils/repositoryTypes';
|
||||
|
||||
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
|
||||
|
||||
interface Props {
|
||||
connection: Connection;
|
||||
}
|
||||
|
||||
export function ConnectionListItem({ connection }: Props) {
|
||||
const { metadata, spec, status } = connection;
|
||||
const name = metadata?.name ?? '';
|
||||
const url = spec?.url;
|
||||
const providerType: RepoType = spec?.type ?? 'github';
|
||||
const repoConfig = getRepositoryTypeConfigs().find((config) => config.type === providerType);
|
||||
return (
|
||||
<Card noMargin key={name}>
|
||||
<Card.Figure>
|
||||
<RepoIcon type={providerType} />
|
||||
</Card.Figure>
|
||||
<Card.Heading>
|
||||
<Stack gap={2} direction="row" alignItems="center">
|
||||
{repoConfig && <Text variant="h3">{`${repoConfig.label} app connection`}</Text>}
|
||||
{status?.state && <ConnectionStatusBadge status={status} />}
|
||||
</Stack>
|
||||
</Card.Heading>
|
||||
|
||||
{url && (
|
||||
<Card.Meta>
|
||||
<TextLink external href={url}>
|
||||
{url}
|
||||
</TextLink>
|
||||
</Card.Meta>
|
||||
)}
|
||||
|
||||
<Card.Actions>
|
||||
<LinkButton icon="eye" href={`${CONNECTIONS_URL}/${name}/edit`} variant="primary" size="md">
|
||||
<Trans i18nKey="provisioning.connections.view">View</Trans>
|
||||
</LinkButton>
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Badge, IconName } from '@grafana/ui';
|
||||
import { ConnectionStatus } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
}
|
||||
|
||||
interface BadgeConfig {
|
||||
color: 'green' | 'red' | 'darkgrey';
|
||||
text: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
function getBadgeConfig(status: ConnectionStatus): BadgeConfig {
|
||||
switch (status.state) {
|
||||
case 'connected':
|
||||
return {
|
||||
color: 'green',
|
||||
text: t('provisioning.connections.status-connected', 'Connected'),
|
||||
icon: 'check',
|
||||
};
|
||||
case 'disconnected':
|
||||
return {
|
||||
color: 'red',
|
||||
text: t('provisioning.connections.status-disconnected', 'Disconnected'),
|
||||
icon: 'times-circle',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'darkgrey',
|
||||
text: t('provisioning.connections.status-unknown', 'Unknown'),
|
||||
icon: 'question-circle',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectionStatusBadge({ status }: Props) {
|
||||
const config = getBadgeConfig(status);
|
||||
|
||||
return <Badge color={config.color} text={config.text} icon={config.icon} />;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { Alert, EmptyState, LinkButton, Stack, Text } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { CONNECTIONS_URL } from '../constants';
|
||||
import { useConnectionList } from '../hooks/useConnectionList';
|
||||
import { getErrorMessage } from '../utils/httpUtils';
|
||||
|
||||
import { ConnectionList } from './ConnectionList';
|
||||
|
||||
export default function ConnectionsPage() {
|
||||
const [items, isLoading, error] = useConnectionList();
|
||||
const hasNoConnections = !isLoading && !error && items?.length === 0;
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{
|
||||
text: t('provisioning.connections.page-title', 'Connections'),
|
||||
subTitle: t('provisioning.connections.page-subtitle', 'View and manage your app connections'),
|
||||
}}
|
||||
actions={
|
||||
<LinkButton variant="primary" href={`${CONNECTIONS_URL}/new`}>
|
||||
<Trans i18nKey="provisioning.connections.add-connection">Add connection</Trans>
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<Stack direction={'column'} gap={3}>
|
||||
{!!error && (
|
||||
<Alert severity="error" title={t('provisioning.connections.error-loading', 'Failed to load connections')}>
|
||||
{getErrorMessage(error)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasNoConnections && (
|
||||
<EmptyState
|
||||
variant="call-to-action"
|
||||
message={t('provisioning.connections.no-connections', 'No connections configured')}
|
||||
>
|
||||
<Text element="p">
|
||||
{t(
|
||||
'provisioning.connections.no-connections-message',
|
||||
'Add a connection to authenticate with external providers'
|
||||
)}
|
||||
</Text>
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{!!items?.length && <ConnectionList items={items} />}
|
||||
</Stack>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { Connection, useDeleteConnectionMutation } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
import { CONNECTIONS_URL } from '../constants';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
connection: Connection;
|
||||
}
|
||||
|
||||
export function DeleteConnectionButton({ name, connection }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [deleteConnection, deleteRequest] = useDeleteConnectionMutation();
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
reportInteraction('grafana_provisioning_connection_deleted', {
|
||||
connectionName: name,
|
||||
connectionType: connection?.spec?.type ?? 'unknown',
|
||||
});
|
||||
|
||||
await deleteConnection({ name });
|
||||
navigate(CONNECTIONS_URL);
|
||||
}, [deleteConnection, name, connection, navigate]);
|
||||
|
||||
const showDeleteModal = useCallback(() => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t('provisioning.connections.delete-title', 'Delete connection'),
|
||||
text: t(
|
||||
'provisioning.connections.delete-confirm',
|
||||
'Are you sure you want to delete this connection? This action cannot be undone.'
|
||||
),
|
||||
yesText: t('provisioning.connections.delete', 'Delete'),
|
||||
noText: t('provisioning.connections.cancel', 'Cancel'),
|
||||
yesButtonVariant: 'destructive',
|
||||
onConfirm: onDelete,
|
||||
})
|
||||
);
|
||||
}, [onDelete]);
|
||||
|
||||
return (
|
||||
<Button variant="destructive" size="md" disabled={deleteRequest.isLoading} onClick={showDeleteModal}>
|
||||
<Trans i18nKey="provisioning.connections.delete">Delete</Trans>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
|
||||
import {
|
||||
Repository,
|
||||
useDeleteRepositoryMutation,
|
||||
useReplaceRepositoryMutation,
|
||||
} from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
type DeleteAction = 'remove-resources' | 'keep-resources';
|
||||
|
||||
@@ -23,102 +21,110 @@ interface Props {
|
||||
export function DeleteRepositoryButton({ name, repository, redirectTo }: Props) {
|
||||
const [deleteRepository, deleteRequest] = useDeleteRepositoryMutation();
|
||||
const [replaceRepository, replaceRequest] = useReplaceRepositoryMutation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedAction, setSelectedAction] = useState<DeleteAction>('remove-resources');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const performDelete = useCallback(
|
||||
async (deleteAction: DeleteAction) => {
|
||||
if (deleteAction === 'keep-resources' && repository) {
|
||||
const updatedRepository = {
|
||||
...repository,
|
||||
metadata: {
|
||||
...repository.metadata,
|
||||
finalizers: ['cleanup', 'release-orphan-resources'],
|
||||
},
|
||||
};
|
||||
await replaceRepository({ name, repository: updatedRepository });
|
||||
}
|
||||
|
||||
reportInteraction('grafana_provisioning_repository_deleted', {
|
||||
repositoryName: name,
|
||||
repositoryType: repository?.spec?.type ?? 'unknown',
|
||||
deleteAction,
|
||||
target: repository?.spec?.sync?.target ?? 'unknown',
|
||||
workflows: repository?.spec?.workflows ?? [],
|
||||
});
|
||||
|
||||
await deleteRepository({ name });
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteRequest.isSuccess) {
|
||||
setShowModal(false);
|
||||
if (redirectTo) {
|
||||
navigate(redirectTo);
|
||||
}
|
||||
},
|
||||
[deleteRepository, replaceRepository, name, repository, redirectTo, navigate]
|
||||
);
|
||||
}
|
||||
}, [deleteRequest.isSuccess, redirectTo, navigate]);
|
||||
|
||||
const showDeleteWithResourcesModal = useCallback(() => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t(
|
||||
'provisioning.delete-repository-button.title-delete-repository-and-resources',
|
||||
'Delete repository configuration and resources'
|
||||
),
|
||||
text: t(
|
||||
'provisioning.delete-repository-button.confirm-delete-with-resources',
|
||||
'Are you sure you want to delete the repository configuration and all its resources?'
|
||||
),
|
||||
yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'),
|
||||
noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'),
|
||||
yesButtonVariant: 'destructive',
|
||||
onConfirm: () => performDelete('remove-resources'),
|
||||
})
|
||||
);
|
||||
}, [performDelete]);
|
||||
const onConfirm = useCallback(async () => {
|
||||
if (selectedAction === 'keep-resources' && repository) {
|
||||
const updatedRepository = {
|
||||
...repository,
|
||||
metadata: {
|
||||
...repository.metadata,
|
||||
finalizers: ['cleanup', 'release-orphan-resources'],
|
||||
},
|
||||
};
|
||||
await replaceRepository({ name, repository: updatedRepository });
|
||||
}
|
||||
|
||||
const showDeleteKeepResourcesModal = useCallback(() => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t(
|
||||
'provisioning.delete-repository-button.title-delete-repository-only',
|
||||
'Delete repository configuration only'
|
||||
),
|
||||
text: t(
|
||||
'provisioning.delete-repository-button.confirm-delete-keep-resources',
|
||||
'Are you sure you want to delete the repository configuration but keep its resources?'
|
||||
),
|
||||
yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'),
|
||||
noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'),
|
||||
yesButtonVariant: 'destructive',
|
||||
onConfirm: () => performDelete('keep-resources'),
|
||||
})
|
||||
reportInteraction('grafana_provisioning_repository_deleted', {
|
||||
repositoryName: name,
|
||||
repositoryType: repository?.spec?.type ?? 'unknown',
|
||||
deleteAction: selectedAction,
|
||||
target: repository?.spec?.sync?.target ?? 'unknown',
|
||||
workflows: repository?.spec?.workflows ?? [],
|
||||
});
|
||||
|
||||
deleteRepository({ name });
|
||||
}, [deleteRepository, replaceRepository, name, selectedAction, repository]);
|
||||
|
||||
const getConfirmationMessage = () => {
|
||||
if (selectedAction === 'remove-resources') {
|
||||
return t(
|
||||
'provisioning.delete-repository-button.confirm-delete-with-resources',
|
||||
'Are you sure you want to delete the repository configuration and all its resources?'
|
||||
);
|
||||
}
|
||||
return t(
|
||||
'provisioning.delete-repository-button.confirm-delete-keep-resources',
|
||||
'Are you sure you want to delete the repository configuration but keep its resources?'
|
||||
);
|
||||
}, [performDelete]);
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (selectedAction === 'remove-resources') {
|
||||
return t(
|
||||
'provisioning.delete-repository-button.title-delete-repository-and-resources',
|
||||
'Delete repository configuration and resources'
|
||||
);
|
||||
}
|
||||
return t(
|
||||
'provisioning.delete-repository-button.title-delete-repository-only',
|
||||
'Delete repository configuration only'
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = deleteRequest.isLoading || replaceRequest.isLoading;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
label={t(
|
||||
'provisioning.delete-repository-button.delete-and-remove-resources',
|
||||
'Delete and remove resources (default)'
|
||||
)}
|
||||
onClick={showDeleteWithResourcesModal}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={t('provisioning.delete-repository-button.delete-and-keep-resources', 'Delete and keep resources')}
|
||||
onClick={showDeleteKeepResourcesModal}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="destructive" disabled={isLoading}>
|
||||
<Stack alignItems="center">
|
||||
<Trans i18nKey="provisioning.delete-repository-button.delete">Delete</Trans>
|
||||
<Icon name={'angle-down'} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
label={t(
|
||||
'provisioning.delete-repository-button.delete-and-remove-resources',
|
||||
'Delete and remove resources (default)'
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedAction('remove-resources');
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={t('provisioning.delete-repository-button.delete-and-keep-resources', 'Delete and keep resources')}
|
||||
onClick={() => {
|
||||
setSelectedAction('keep-resources');
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="destructive" disabled={isLoading}>
|
||||
<Stack alignItems="center">
|
||||
<Trans i18nKey="provisioning.delete-repository-button.delete">Delete</Trans>
|
||||
<Icon name={'angle-down'} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ConfirmModal
|
||||
isOpen={showModal}
|
||||
title={getModalTitle()}
|
||||
body={getConfirmationMessage()}
|
||||
confirmText={t('provisioning.delete-repository-button.button-delete', 'Delete')}
|
||||
onConfirm={onConfirm}
|
||||
onDismiss={() => setShowModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge, Button, LinkButton, Stack } from '@grafana/ui';
|
||||
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { StatusBadge } from '../Shared/StatusBadge';
|
||||
import { CONNECTIONS_URL, PROVISIONING_URL } from '../constants';
|
||||
import { PROVISIONING_URL } from '../constants';
|
||||
import { getRepoHrefForProvider } from '../utils/git';
|
||||
import { getIsReadOnlyWorkflows } from '../utils/repository';
|
||||
import { getRepositoryTypeConfig } from '../utils/repositoryTypes';
|
||||
@@ -34,9 +34,6 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) {
|
||||
</Button>
|
||||
)}
|
||||
<SyncRepository repository={repository} />
|
||||
<LinkButton variant="secondary" icon="link" href={CONNECTIONS_URL}>
|
||||
<Trans i18nKey="provisioning.repository-actions.connections">Connections</Trans>
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
icon="cog"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const PROVISIONING_URL = '/admin/provisioning';
|
||||
export const CONNECTIONS_URL = `${PROVISIONING_URL}/connections`;
|
||||
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
|
||||
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
|
||||
export const UPGRADE_URL = 'https://grafana.com/profile/org/subscription';
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { ListConnectionApiArg, useListConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
// Sort connections alphabetically by name
|
||||
export function useConnectionList(options: ListConnectionApiArg | typeof skipToken = {}) {
|
||||
const query = useListConnectionQuery(options);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true });
|
||||
|
||||
const sortedItems = query.data?.items?.slice().sort((a, b) => {
|
||||
const nameA = a.metadata?.name ?? '';
|
||||
const nameB = b.metadata?.name ?? '';
|
||||
return collator.compare(nameA, nameB);
|
||||
});
|
||||
|
||||
return [sortedItems, query.isLoading, query.error] as const;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
Connection,
|
||||
ConnectionSpec,
|
||||
ConnectionSecure,
|
||||
useCreateConnectionMutation,
|
||||
useReplaceConnectionMutation,
|
||||
} from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
export function useCreateOrUpdateConnection(name?: string) {
|
||||
const [create, createRequest] = useCreateConnectionMutation();
|
||||
const [update, updateRequest] = useReplaceConnectionMutation();
|
||||
|
||||
const updateOrCreate = useCallback(
|
||||
async (data: ConnectionSpec, privateKey?: string) => {
|
||||
const secure: ConnectionSecure | undefined = privateKey?.length
|
||||
? { privateKey: { create: privateKey } }
|
||||
: undefined;
|
||||
|
||||
const connection: Connection = {
|
||||
metadata: name ? { name } : { generateName: 'c' },
|
||||
spec: data,
|
||||
secure,
|
||||
};
|
||||
|
||||
if (name) {
|
||||
return update({
|
||||
name,
|
||||
connection,
|
||||
});
|
||||
}
|
||||
|
||||
return create({ connection });
|
||||
},
|
||||
[create, name, update]
|
||||
);
|
||||
|
||||
return [updateOrCreate, name ? updateRequest : createRequest] as const;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import {
|
||||
BitbucketRepositoryConfig,
|
||||
ConnectionSpec,
|
||||
GitHubRepositoryConfig,
|
||||
GitLabRepositoryConfig,
|
||||
GitRepositoryConfig,
|
||||
@@ -52,16 +51,6 @@ export type RepositoryFormData = Omit<RepositorySpec, 'workflows' | RepositorySp
|
||||
|
||||
export type RepositorySettingsField = Path<RepositoryFormData>;
|
||||
|
||||
// Connection type definition - extracted from API client
|
||||
export type ConnectionType = ConnectionSpec['type'];
|
||||
|
||||
export type ConnectionFormData = {
|
||||
type: ConnectionSpec['type'];
|
||||
appID: string;
|
||||
installationID: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
|
||||
// Section configuration
|
||||
export interface RepositorySection {
|
||||
name: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Path } from 'react-hook-form';
|
||||
import { ErrorDetails } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { WizardFormData } from '../Wizard/types';
|
||||
import { ConnectionFormData, RepositoryFormData } from '../types';
|
||||
import { RepositoryFormData } from '../types';
|
||||
|
||||
export type RepositoryField = keyof WizardFormData['repository'];
|
||||
export type RepositoryFormPath = `repository.${RepositoryField}` | 'repository.sync.intervalSeconds';
|
||||
@@ -89,20 +89,3 @@ export const getConfigFormErrors = (errors?: ErrorDetails[]): ConfigFormErrorTup
|
||||
|
||||
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
|
||||
};
|
||||
|
||||
// Connection form errors
|
||||
export type ConnectionFormPath = Path<ConnectionFormData>;
|
||||
export type ConnectionFormErrorTuple = GenericFormErrorTuple<ConnectionFormPath>;
|
||||
|
||||
export const getConnectionFormErrors = (errors?: ErrorDetails[]): ConnectionFormErrorTuple => {
|
||||
const fieldMap: Record<string, ConnectionFormPath> = {
|
||||
appID: 'appID',
|
||||
installationID: 'installationID',
|
||||
'github.appID': 'appID',
|
||||
'github.installationID': 'installationID',
|
||||
'secure.privateKey': 'privateKey',
|
||||
privateKey: 'privateKey',
|
||||
};
|
||||
|
||||
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RouteDescriptor } from 'app/core/navigation/types';
|
||||
import { DashboardRoutes } from 'app/types/dashboard';
|
||||
|
||||
import { checkRequiredFeatures } from '../GettingStarted/features';
|
||||
import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
|
||||
import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
|
||||
|
||||
export function getProvisioningRoutes(): RouteDescriptor[] {
|
||||
if (!checkRequiredFeatures()) {
|
||||
@@ -36,26 +36,6 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: CONNECTIONS_URL,
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "ConnectionsPage"*/ 'app/features/provisioning/Connection/ConnectionsPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${CONNECTIONS_URL}/:name/edit`,
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${CONNECTIONS_URL}/new`,
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${CONNECT_URL}/:type`,
|
||||
component: SafeDynamicImport(
|
||||
|
||||
@@ -11806,53 +11806,7 @@
|
||||
"free-tier-limit-tooltip": "Free-tier accounts are restricted to one connection",
|
||||
"instance-fully-managed-tooltip": "Configuration is disabled because this instance is fully managed"
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "Connection deleted",
|
||||
"alert-connection-saved": "Connection saved",
|
||||
"alert-connection-updated": "Connection updated",
|
||||
"back-to-connections": "Back to connections",
|
||||
"button-save": "Save",
|
||||
"button-saving": "Saving...",
|
||||
"description-app-id": "The ID of your GitHub App",
|
||||
"description-installation-id": "The installation ID of your GitHub App",
|
||||
"description-private-key": "The private key for your GitHub App in PEM format",
|
||||
"description-provider": "Select the provider type",
|
||||
"error-delete-connection": "Failed to delete connection",
|
||||
"error-required": "This field is required",
|
||||
"error-save-connection": "Failed to save connection",
|
||||
"label-app-id": "GitHub App ID",
|
||||
"label-installation-id": "GitHub Installation ID",
|
||||
"label-private-key": "Private Key (PEM)",
|
||||
"label-provider": "Provider",
|
||||
"not-found": "Connection not found",
|
||||
"not-found-description": "The connection you are looking for does not exist.",
|
||||
"page-subtitle": "Configure a connection to authenticate with external providers",
|
||||
"page-title-create": "Create connection",
|
||||
"page-title-edit": "Edit connection",
|
||||
"placeholder-app-id": "123456",
|
||||
"placeholder-installation-id": "12345678",
|
||||
"placeholder-private-key": "-----BEGIN RSA PRIVATE KEY-----..."
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "Add connection",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"delete-confirm": "Are you sure you want to delete this connection? This action cannot be undone.",
|
||||
"delete-title": "Delete connection",
|
||||
"error-loading": "Failed to load connections",
|
||||
"no-connections": "No connections configured",
|
||||
"no-connections-message": "Add a connection to authenticate with external providers",
|
||||
"no-results": "No results matching your query",
|
||||
"page-subtitle": "View and manage your app connections",
|
||||
"page-title": "Connections",
|
||||
"search-placeholder": "Search connections",
|
||||
"status-connected": "Connected",
|
||||
"status-disconnected": "Disconnected",
|
||||
"status-unknown": "Unknown",
|
||||
"view": "View"
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "Cancel",
|
||||
"button-delete": "Delete",
|
||||
"confirm-delete-keep-resources": "Are you sure you want to delete the repository configuration but keep its resources?",
|
||||
"confirm-delete-with-resources": "Are you sure you want to delete the repository configuration and all its resources?",
|
||||
@@ -12116,7 +12070,6 @@
|
||||
"jobs": "Jobs"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "Connections",
|
||||
"settings": "Settings",
|
||||
"source-code": "Source code"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user