Compare commits

..

1 Commits

Author SHA1 Message Date
mohammad-hamid 44856d9c06 support inherited permissions 2026-01-13 01:18:20 -05:00
50 changed files with 826 additions and 3767 deletions
@@ -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)
+263 -278
View File
@@ -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),
}
}
+5 -15
View File
@@ -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
}
+1 -1
View File
@@ -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
+13 -38
View File
@@ -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
-5
View File
@@ -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
}
+2 -2
View File
@@ -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;
}
-11
View File
@@ -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(
-47
View File
@@ -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"
},