Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs

This commit is contained in:
Ryan McKinley
2025-07-10 09:51:51 -07:00
196 changed files with 5867 additions and 14047 deletions

View File

@@ -36,8 +36,22 @@ const (
serviceNameForProvisioning = "provisioning"
)
func newInternalIdentity(name string, namespace string, orgID int64) Requester {
return &StaticRequester{
type IdentityOpts func(*StaticRequester)
// WithServiceIdentityName sets the `StaticRequester.AccessTokenClaims.Rest.ServiceIdentity` field to the provided name.
// This is so far only used by Secrets Manager to identify and gate the service decrypting a secret.
func WithServiceIdentityName(name string) IdentityOpts {
return func(r *StaticRequester) {
r.AccessTokenClaims.Rest.ServiceIdentity = name
}
}
func newInternalIdentity(name string, namespace string, orgID int64, opts ...IdentityOpts) Requester {
// Create a copy of the ServiceIdentityClaims to avoid modifying the global one.
// Some of the options might mutate it.
claimsCopy := *ServiceIdentityClaims
staticRequester := &StaticRequester{
Type: types.TypeAccessPolicy,
Name: name,
UserUID: name,
@@ -50,37 +64,43 @@ func newInternalIdentity(name string, namespace string, orgID int64) Requester {
Permissions: map[int64]map[string][]string{
orgID: serviceIdentityPermissions,
},
AccessTokenClaims: ServiceIdentityClaims,
AccessTokenClaims: &claimsCopy,
}
for _, opt := range opts {
opt(staticRequester)
}
return staticRequester
}
// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context.
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
// static permissions so it can be used in legacy code paths.
func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) {
r := newInternalIdentity(serviceName, "*", orgID)
func WithServiceIdentity(ctx context.Context, orgID int64, opts ...IdentityOpts) (context.Context, Requester) {
r := newInternalIdentity(serviceName, "*", orgID, opts...)
return WithRequester(ctx, r), r
}
func WithProvisioningIdentity(ctx context.Context, namespace string) (context.Context, Requester, error) {
func WithProvisioningIdentity(ctx context.Context, namespace string, opts ...IdentityOpts) (context.Context, Requester, error) {
ns, err := types.ParseNamespace(namespace)
if err != nil {
return nil, nil, err
}
r := newInternalIdentity(serviceNameForProvisioning, ns.Value, ns.OrgID)
r := newInternalIdentity(serviceNameForProvisioning, ns.Value, ns.OrgID, opts...)
return WithRequester(ctx, r), r, nil
}
// WithServiceIdentityContext sets an identity representing the service itself in context.
func WithServiceIdentityContext(ctx context.Context, orgID int64) context.Context {
ctx, _ = WithServiceIdentity(ctx, orgID)
func WithServiceIdentityContext(ctx context.Context, orgID int64, opts ...IdentityOpts) context.Context {
ctx, _ = WithServiceIdentity(ctx, orgID, opts...)
return ctx
}
// WithServiceIdentityFN calls provided closure with an context contaning the identity of the service.
func WithServiceIdentityFn[T any](ctx context.Context, orgID int64, fn func(ctx context.Context) (T, error)) (T, error) {
return fn(WithServiceIdentityContext(ctx, orgID))
func WithServiceIdentityFn[T any](ctx context.Context, orgID int64, fn func(ctx context.Context) (T, error), opts ...IdentityOpts) (T, error) {
return fn(WithServiceIdentityContext(ctx, orgID, opts...))
}
func getWildcardPermissions(actions ...string) map[string][]string {
@@ -91,14 +111,6 @@ func getWildcardPermissions(actions ...string) map[string][]string {
return permissions
}
func getTokenPermissions(groups ...string) []string {
out := make([]string, 0, len(groups))
for _, group := range groups {
out = append(out, group+":*")
}
return out
}
// serviceIdentityPermissions is a list of wildcard permissions for provided actions.
// We should add every action required "internally" here.
var serviceIdentityPermissions = getWildcardPermissions(
@@ -121,13 +133,16 @@ var serviceIdentityPermissions = getWildcardPermissions(
"serviceaccounts:read", // serviceaccounts.ActionRead,
)
var serviceIdentityTokenPermissions = getTokenPermissions(
"folder.grafana.app",
"dashboard.grafana.app",
"secret.grafana.app",
"query.grafana.app",
"iam.grafana.app",
)
var serviceIdentityTokenPermissions = []string{
"folder.grafana.app:*",
"dashboard.grafana.app:*",
"secret.grafana.app:*",
"query.grafana.app:*",
"iam.grafana.app:*",
// Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions.
"secret.grafana.app/securevalues:decrypt",
}
var ServiceIdentityClaims = &authn.Claims[authn.AccessTokenClaims]{
Rest: authn.AccessTokenClaims{

View File

@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/grafana/authlib/authn"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -24,3 +25,48 @@ func TestRequesterFromContext(t *testing.T) {
require.Equal(t, expected.GetUID(), actual.GetUID())
})
}
func TestWithServiceIdentity(t *testing.T) {
t.Run("with a custom service identity name", func(t *testing.T) {
customName := "custom-service"
orgID := int64(1)
ctx, requester := identity.WithServiceIdentity(context.Background(), orgID, identity.WithServiceIdentityName(customName))
require.NotNil(t, requester)
require.Equal(t, orgID, requester.GetOrgID())
require.Equal(t, customName, requester.GetExtra()[string(authn.ServiceIdentityKey)][0])
require.Contains(t, requester.GetTokenPermissions(), "secret.grafana.app/securevalues:decrypt")
fromCtx, err := identity.GetRequester(ctx)
require.NoError(t, err)
require.Equal(t, customName, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)][0])
// Reuse the context but create another identity on top with a different name and org ID
anotherCustomName := "another-custom-service"
anotherOrgID := int64(2)
ctx2 := identity.WithServiceIdentityContext(ctx, anotherOrgID, identity.WithServiceIdentityName(anotherCustomName))
fromCtx, err = identity.GetRequester(ctx2)
require.NoError(t, err)
require.Equal(t, anotherOrgID, fromCtx.GetOrgID())
require.Equal(t, anotherCustomName, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)][0])
// Reuse the context but create another identity without a custom name
ctx3, requester := identity.WithServiceIdentity(ctx2, 1)
require.NotNil(t, requester)
require.Empty(t, requester.GetExtra()[string(authn.ServiceIdentityKey)])
fromCtx, err = identity.GetRequester(ctx3)
require.NoError(t, err)
require.Empty(t, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)])
})
t.Run("without a custom service identity name", func(t *testing.T) {
ctx, requester := identity.WithServiceIdentity(context.Background(), 1)
require.NotNil(t, requester)
require.Empty(t, requester.GetExtra()[string(authn.ServiceIdentityKey)])
fromCtx, err := identity.GetRequester(ctx)
require.NoError(t, err)
require.Empty(t, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)])
})
}

View File

@@ -128,12 +128,6 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
return
}
// TODO: Implement folder delete
if r.Method == http.MethodDelete && isDir {
responder.Error(apierrors.NewBadRequest("folder navigation not yet supported"))
return
}
var obj *provisioning.ResourceWrapper
code := http.StatusOK
switch r.Method {

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package export

View File

@@ -1,87 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package export
import (
context "context"
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
mock "github.com/stretchr/testify/mock"
)
// MockWrapWithCloneFn is an autogenerated mock type for the WrapWithCloneFn type
type MockWrapWithCloneFn struct {
mock.Mock
}
type MockWrapWithCloneFn_Expecter struct {
mock *mock.Mock
}
func (_m *MockWrapWithCloneFn) EXPECT() *MockWrapWithCloneFn_Expecter {
return &MockWrapWithCloneFn_Expecter{mock: &_m.Mock}
}
// Execute provides a mock function with given fields: ctx, repo, cloneOptions, pushOptions, fn
func (_m *MockWrapWithCloneFn) Execute(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error) error {
ret := _m.Called(ctx, repo, cloneOptions, pushOptions, fn)
if len(ret) == 0 {
panic("no return value specified for Execute")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error); ok {
r0 = rf(ctx, repo, cloneOptions, pushOptions, fn)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockWrapWithCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
type MockWrapWithCloneFn_Execute_Call struct {
*mock.Call
}
// Execute is a helper method to define mock.On call
// - ctx context.Context
// - repo repository.Repository
// - cloneOptions repository.CloneOptions
// - pushOptions repository.PushOptions
// - fn func(repository.Repository , bool) error
func (_e *MockWrapWithCloneFn_Expecter) Execute(ctx interface{}, repo interface{}, cloneOptions interface{}, pushOptions interface{}, fn interface{}) *MockWrapWithCloneFn_Execute_Call {
return &MockWrapWithCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, cloneOptions, pushOptions, fn)}
}
func (_c *MockWrapWithCloneFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error)) *MockWrapWithCloneFn_Execute_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.CloneOptions), args[3].(repository.PushOptions), args[4].(func(repository.Repository, bool) error))
})
return _c
}
func (_c *MockWrapWithCloneFn_Execute_Call) Return(_a0 error) *MockWrapWithCloneFn_Execute_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockWrapWithCloneFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error) *MockWrapWithCloneFn_Execute_Call {
_c.Call.Return(run)
return _c
}
// NewMockWrapWithCloneFn creates a new instance of MockWrapWithCloneFn. 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 NewMockWrapWithCloneFn(t interface {
mock.TestingT
Cleanup(func())
}) *MockWrapWithCloneFn {
mock := &MockWrapWithCloneFn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,86 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package export
import (
context "context"
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
mock "github.com/stretchr/testify/mock"
)
// MockWrapWithStageFn is an autogenerated mock type for the WrapWithStageFn type
type MockWrapWithStageFn struct {
mock.Mock
}
type MockWrapWithStageFn_Expecter struct {
mock *mock.Mock
}
func (_m *MockWrapWithStageFn) EXPECT() *MockWrapWithStageFn_Expecter {
return &MockWrapWithStageFn_Expecter{mock: &_m.Mock}
}
// Execute provides a mock function with given fields: ctx, repo, stageOptions, fn
func (_m *MockWrapWithStageFn) Execute(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
ret := _m.Called(ctx, repo, stageOptions, fn)
if len(ret) == 0 {
panic("no return value specified for Execute")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.StageOptions, func(repository.Repository, bool) error) error); ok {
r0 = rf(ctx, repo, stageOptions, fn)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockWrapWithStageFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
type MockWrapWithStageFn_Execute_Call struct {
*mock.Call
}
// Execute is a helper method to define mock.On call
// - ctx context.Context
// - repo repository.Repository
// - stageOptions repository.StageOptions
// - fn func(repository.Repository , bool) error
func (_e *MockWrapWithStageFn_Expecter) Execute(ctx interface{}, repo interface{}, stageOptions interface{}, fn interface{}) *MockWrapWithStageFn_Execute_Call {
return &MockWrapWithStageFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, stageOptions, fn)}
}
func (_c *MockWrapWithStageFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error)) *MockWrapWithStageFn_Execute_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.StageOptions), args[3].(func(repository.Repository, bool) error))
})
return _c
}
func (_c *MockWrapWithStageFn_Execute_Call) Return(_a0 error) *MockWrapWithStageFn_Execute_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockWrapWithStageFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.StageOptions, func(repository.Repository, bool) error) error) *MockWrapWithStageFn_Execute_Call {
_c.Call.Return(run)
return _c
}
// NewMockWrapWithStageFn creates a new instance of MockWrapWithStageFn. 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 NewMockWrapWithStageFn(t interface {
mock.TestingT
Cleanup(func())
}) *MockWrapWithStageFn {
mock := &MockWrapWithStageFn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -67,7 +67,7 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
}
}
if err := exportResource(ctx, options, client, shim, repositoryResources, progress); err != nil {
if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil {
return fmt.Errorf("export %s: %w", kind.Resource, err)
}
}
@@ -76,6 +76,7 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
}
func exportResource(ctx context.Context,
resource string,
options provisioning.ExportJobOptions,
client dynamic.ResourceInterface,
shim conversionShim,
@@ -88,7 +89,7 @@ func exportResource(ctx context.Context,
gvk := item.GroupVersionKind()
result := jobs.JobResourceResult{
Name: item.GetName(),
Resource: gvk.Kind,
Resource: resource,
Group: gvk.Group,
Action: repository.FileActionCreated,
}

View File

@@ -9,34 +9,33 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
//go:generate mockery --name ExportFn --structname MockExportFn --inpackage --filename mock_export_fn.go --with-expecter
type ExportFn func(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error
//go:generate mockery --name WrapWithCloneFn --structname MockWrapWithCloneFn --inpackage --filename mock_wrap_with_clone_fn.go --with-expecter
type WrapWithCloneFn func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repo repository.Repository, cloned bool) error) error
//go:generate mockery --name WrapWithStageFn --structname MockWrapWithStageFn --inpackage --filename mock_wrap_with_stage_fn.go --with-expecter
type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
type ExportWorker struct {
clientFactory resources.ClientFactory
repositoryResources resources.RepositoryResourcesFactory
exportFn ExportFn
wrapWithCloneFn WrapWithCloneFn
wrapWithStageFn WrapWithStageFn
}
func NewExportWorker(
clientFactory resources.ClientFactory,
repositoryResources resources.RepositoryResourcesFactory,
exportFn ExportFn,
wrapWithCloneFn WrapWithCloneFn,
wrapWithStageFn WrapWithStageFn,
) *ExportWorker {
return &ExportWorker{
clientFactory: clientFactory,
repositoryResources: repositoryResources,
exportFn: exportFn,
wrapWithCloneFn: wrapWithCloneFn,
wrapWithStageFn: wrapWithStageFn,
}
}
@@ -57,32 +56,9 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return err
}
writer := gogit.Progress(func(line string) {
progress.SetMessage(ctx, line)
}, "finished")
cloneOptions := repository.CloneOptions{
cloneOptions := repository.StageOptions{
Timeout: 10 * time.Minute,
PushOnWrites: false,
Progress: writer,
BeforeFn: func() error {
progress.SetMessage(ctx, "clone target")
// :( the branch is now baked into the repo
if options.Branch != "" {
return fmt.Errorf("branch is not supported for clonable repositories")
}
return nil
},
}
pushOptions := repository.PushOptions{
Timeout: 10 * time.Minute,
Progress: writer,
BeforeFn: func() error {
progress.SetMessage(ctx, "push changes")
return nil
},
}
fn := func(repo repository.Repository, _ bool) error {
@@ -104,5 +80,5 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
}
return r.wrapWithCloneFn(ctx, repo, cloneOptions, pushOptions, fn)
return r.wrapWithStageFn(ctx, repo, cloneOptions, fn)
}

View File

@@ -7,7 +7,6 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -143,12 +142,12 @@ func TestExportWorker_ProcessFailedToCreateClients(t *testing.T) {
mockClients := resources.NewMockClientFactory(t)
mockClients.On("Clients", context.Background(), "test-namespace").Return(nil, errors.New("failed to create clients"))
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, nil, nil, mockCloneFn.Execute)
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute)
mockProgress := jobs.NewMockJobProgressRecorder(t)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
@@ -179,12 +178,12 @@ func TestExportWorker_ProcessNotReaderWriter(t *testing.T) {
mockClients.On("Clients", context.Background(), "test-namespace").Return(resourceClients, nil)
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, nil, nil, mockCloneFn.Execute)
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export job submitted targeting repository that is not a ReaderWriter")
}
@@ -216,16 +215,16 @@ func TestExportWorker_ProcessRepositoryResourcesError(t *testing.T) {
mockRepoResources.On("Client", context.Background(), mockRepo).Return(nil, fmt.Errorf("failed to create repository resources client"))
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, nil, mockCloneFn.Execute)
r := NewExportWorker(mockClients, mockRepoResources, nil, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "create repository resource client: failed to create repository resources client")
}
func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
func TestExportWorker_ProcessStageOptions(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
@@ -245,9 +244,7 @@ func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
// Verify progress messages are set
mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
mockProgress.On("SetMessage", mock.Anything, "push changes").Return()
// No progress messages expected in current implementation
mockClients := resources.NewMockClientFactory(t)
mockResourceClients := resources.NewMockResourceClients(t)
@@ -260,21 +257,15 @@ func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockCloneFn := NewMockWrapWithCloneFn(t)
mockStageFn := NewMockWrapWithStageFn(t)
// Verify clone and push options
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.CloneOptions) bool {
return opts.Timeout == 10*time.Minute && !opts.PushOnWrites && opts.BeforeFn != nil
}), mock.MatchedBy(func(opts repository.PushOptions) bool {
return opts.Timeout == 10*time.Minute && opts.Progress != nil && opts.BeforeFn != nil
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
// Execute both BeforeFn functions to verify progress messages
assert.NoError(t, cloneOpts.BeforeFn())
assert.NoError(t, pushOpts.BeforeFn())
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
return opts.Timeout == 10*time.Minute && !opts.PushOnWrites
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -310,17 +301,17 @@ func TestExportWorker_ProcessExportFnError(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("export failed"))
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
func TestExportWorker_ProcessWrapWithCloneFnError(t *testing.T) {
func TestExportWorker_ProcessWrapWithStageFnError(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
@@ -340,15 +331,15 @@ func TestExportWorker_ProcessWrapWithCloneFnError(t *testing.T) {
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("clone failed"))
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(errors.New("stage failed"))
r := NewExportWorker(nil, nil, nil, mockCloneFn.Execute)
r := NewExportWorker(nil, nil, nil, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "clone failed")
require.EqualError(t, err, "stage failed")
}
func TestExportWorker_ProcessBranchNotAllowedForClonableRepositories(t *testing.T) {
func TestExportWorker_ProcessBranchNotAllowedForStageableRepositories(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
@@ -362,24 +353,16 @@ func TestExportWorker_ProcessBranchNotAllowedForClonableRepositories(t *testing.
mockRepo.On("Config").Return(&v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
Type: v0alpha1.GitHubRepositoryType,
Workflows: []v0alpha1.Workflow{v0alpha1.BranchWorkflow},
Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow}, // Only write workflow, not branch
},
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
if cloneOpts.BeforeFn != nil {
return cloneOpts.BeforeFn()
}
// No progress messages expected in current implementation
return fn(repo, true)
})
r := NewExportWorker(nil, nil, nil, mockCloneFn.Execute)
r := NewExportWorker(nil, nil, nil, nil)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "branch is not supported for clonable repositories")
require.EqualError(t, err, "this repository does not support the branch workflow")
}
func TestExportWorker_ProcessGitRepository(t *testing.T) {
@@ -407,9 +390,7 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
// Verify progress messages are set
mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
mockProgress.On("SetMessage", mock.Anything, "push changes").Return()
// No progress messages expected in current implementation
mockClients := resources.NewMockClientFactory(t)
mockResourceClients := resources.NewMockResourceClients(t)
@@ -422,21 +403,15 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockCloneFn := NewMockWrapWithCloneFn(t)
mockStageFn := NewMockWrapWithStageFn(t)
// Verify clone and push options
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.CloneOptions) bool {
return opts.Timeout == 10*time.Minute && !opts.PushOnWrites && opts.BeforeFn != nil
}), mock.MatchedBy(func(opts repository.PushOptions) bool {
return opts.Timeout == 10*time.Minute && opts.Progress != nil && opts.BeforeFn != nil
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
// Execute both BeforeFn functions to verify progress messages
assert.NoError(t, cloneOpts.BeforeFn())
assert.NoError(t, pushOpts.BeforeFn())
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
return opts.Timeout == 10*time.Minute && !opts.PushOnWrites
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -477,12 +452,12 @@ func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("export failed"))
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}

View File

@@ -10,57 +10,38 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
)
type LegacyMigrator struct {
legacyMigrator LegacyResourcesMigrator
storageSwapper StorageSwapper
syncWorker jobs.Worker
wrapWithCloneFn WrapWithCloneFn
wrapWithStageFn WrapWithStageFn
}
func NewLegacyMigrator(
legacyMigrator LegacyResourcesMigrator,
storageSwapper StorageSwapper,
syncWorker jobs.Worker,
wrapWithCloneFn WrapWithCloneFn,
wrapWithStageFn WrapWithStageFn,
) *LegacyMigrator {
return &LegacyMigrator{
legacyMigrator: legacyMigrator,
storageSwapper: storageSwapper,
syncWorker: syncWorker,
wrapWithCloneFn: wrapWithCloneFn,
wrapWithStageFn: wrapWithStageFn,
}
}
func (m *LegacyMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
namespace := rw.Config().Namespace
writer := gogit.Progress(func(line string) {
progress.SetMessage(ctx, line)
}, "finished")
cloneOptions := repository.CloneOptions{
stageOptions := repository.StageOptions{
PushOnWrites: options.History,
// TODO: make this configurable
Timeout: 10 * time.Minute,
Progress: writer,
BeforeFn: func() error {
progress.SetMessage(ctx, "clone repository")
return nil
},
}
pushOptions := repository.PushOptions{
// TODO: make this configurable
Timeout: 10 * time.Minute,
Progress: writer,
BeforeFn: func() error {
progress.SetMessage(ctx, "push changes")
return nil
},
Timeout: 10 * time.Minute,
}
if err := m.wrapWithCloneFn(ctx, rw, cloneOptions, pushOptions, func(repo repository.Repository, cloned bool) error {
if err := m.wrapWithStageFn(ctx, rw, stageOptions, func(repo repository.Repository, staged bool) error {
rw, ok := repo.(repository.ReaderWriter)
if !ok {
return errors.New("migration job submitted targeting repository that is not a ReaderWriter")

View File

@@ -3,7 +3,6 @@ package migrate
import (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -16,12 +15,12 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
func TestWrapWithCloneFn(t *testing.T) {
func TestWrapWithStageFn(t *testing.T) {
t.Run("should return error when repository is not a ReaderWriter", func(t *testing.T) {
// Setup
ctx := context.Background()
// Create the wrapper function that matches WrapWithCloneFn signature
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
// pass a reader to function call
repo := repository.NewMockReader(t)
return fn(repo, true)
@@ -56,7 +55,7 @@ func TestWrapWithCloneFn_Error(t *testing.T) {
expectedErr := errors.New("clone failed")
// Create the wrapper function that returns an error
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return expectedErr
}
@@ -98,7 +97,7 @@ func TestLegacyMigrator_MigrateFails(t *testing.T) {
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -147,7 +146,7 @@ func TestLegacyMigrator_ResetUnifiedStorageFails(t *testing.T) {
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -202,7 +201,7 @@ func TestLegacyMigrator_SyncFails(t *testing.T) {
}), mock.Anything).Return(expectedErr)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -257,7 +256,7 @@ func TestLegacyMigrator_SyncFails(t *testing.T) {
}), mock.Anything).Return(syncErr)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -310,7 +309,7 @@ func TestLegacyMigrator_Success(t *testing.T) {
}), mock.Anything).Return(nil)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -352,19 +351,7 @@ func TestLegacyMigrator_BeforeFnExecution(t *testing.T) {
mockStorageSwapper := NewMockStorageSwapper(t)
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
if clone.BeforeFn != nil {
if err := clone.BeforeFn(); err != nil {
return err
}
}
if push.BeforeFn != nil {
if err := push.BeforeFn(); err != nil {
return err
}
}
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return errors.New("abort test here")
}
@@ -376,8 +363,7 @@ func TestLegacyMigrator_BeforeFnExecution(t *testing.T) {
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, "clone repository").Return()
progress.On("SetMessage", mock.Anything, "push changes").Return()
// No progress messages expected in current staging implementation
// Execute
repo := repository.NewMockRepository(t)
@@ -399,19 +385,7 @@ func TestLegacyMigrator_ProgressScanner(t *testing.T) {
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
if clone.Progress != nil {
if _, err := clone.Progress.Write([]byte("clone repository\n")); err != nil {
return fmt.Errorf("failed to write to clone progress in tests: %w", err)
}
}
if push.Progress != nil {
if _, err := push.Progress.Write([]byte("push changes\n")); err != nil {
return fmt.Errorf("failed to write to push progress in tests: %w", err)
}
}
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return errors.New("abort test here")
}
@@ -423,8 +397,7 @@ func TestLegacyMigrator_ProgressScanner(t *testing.T) {
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, "clone repository").Return()
progress.On("SetMessage", mock.Anything, "push changes").Return()
// No progress messages expected in current staging implementation
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
@@ -437,10 +410,7 @@ func TestLegacyMigrator_ProgressScanner(t *testing.T) {
require.EqualError(t, err, "migrate from SQL: abort test here")
require.Eventually(t, func() bool {
if len(progress.Calls) != 2 {
return false
}
// No progress message calls expected in current staging implementation
return progress.AssertExpectations(t)
}, time.Second, 10*time.Millisecond)
})

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate

View File

@@ -1,87 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
import (
context "context"
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
mock "github.com/stretchr/testify/mock"
)
// MockWrapWithCloneFn is an autogenerated mock type for the WrapWithCloneFn type
type MockWrapWithCloneFn struct {
mock.Mock
}
type MockWrapWithCloneFn_Expecter struct {
mock *mock.Mock
}
func (_m *MockWrapWithCloneFn) EXPECT() *MockWrapWithCloneFn_Expecter {
return &MockWrapWithCloneFn_Expecter{mock: &_m.Mock}
}
// Execute provides a mock function with given fields: ctx, repo, cloneOptions, pushOptions, fn
func (_m *MockWrapWithCloneFn) Execute(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error) error {
ret := _m.Called(ctx, repo, cloneOptions, pushOptions, fn)
if len(ret) == 0 {
panic("no return value specified for Execute")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error); ok {
r0 = rf(ctx, repo, cloneOptions, pushOptions, fn)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockWrapWithCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
type MockWrapWithCloneFn_Execute_Call struct {
*mock.Call
}
// Execute is a helper method to define mock.On call
// - ctx context.Context
// - repo repository.Repository
// - cloneOptions repository.CloneOptions
// - pushOptions repository.PushOptions
// - fn func(repository.Repository , bool) error
func (_e *MockWrapWithCloneFn_Expecter) Execute(ctx interface{}, repo interface{}, cloneOptions interface{}, pushOptions interface{}, fn interface{}) *MockWrapWithCloneFn_Execute_Call {
return &MockWrapWithCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, cloneOptions, pushOptions, fn)}
}
func (_c *MockWrapWithCloneFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error)) *MockWrapWithCloneFn_Execute_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.CloneOptions), args[3].(repository.PushOptions), args[4].(func(repository.Repository, bool) error))
})
return _c
}
func (_c *MockWrapWithCloneFn_Execute_Call) Return(_a0 error) *MockWrapWithCloneFn_Execute_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockWrapWithCloneFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error) *MockWrapWithCloneFn_Execute_Call {
_c.Call.Return(run)
return _c
}
// NewMockWrapWithCloneFn creates a new instance of MockWrapWithCloneFn. 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 NewMockWrapWithCloneFn(t interface {
mock.TestingT
Cleanup(func())
}) *MockWrapWithCloneFn {
mock := &MockWrapWithCloneFn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -9,8 +9,8 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
//go:generate mockery --name WrapWithCloneFn --structname MockWrapWithCloneFn --inpackage --filename mock_wrap_with_clone_fn.go --with-expecter
type WrapWithCloneFn func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repo repository.Repository, cloned bool) error) error
//go:generate mockery --name WrapWithStageFn --structname MockWrapWithStageFn --inpackage --filename mock_wrap_with_stage_fn.go --with-expecter
type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
type UnifiedStorageMigrator struct {
namespaceCleaner NamespaceCleaner

View File

@@ -12,6 +12,7 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/local"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
@@ -88,7 +89,7 @@ func TestMigrationWorker_WithHistory(t *testing.T) {
progressRecorder.On("SetTotal", mock.Anything, 10).Return()
progressRecorder.On("Strict").Return()
repo := repository.NewLocal(&provisioning.Repository{}, nil)
repo := local.NewLocal(&provisioning.Repository{}, nil)
err := worker.Process(context.Background(), repo, job, progressRecorder)
require.EqualError(t, err, "history is only supported for github repositories")
})

View File

@@ -3,7 +3,6 @@ package sync
import (
"context"
"fmt"
"sort"
"strings"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
@@ -143,17 +142,7 @@ func Changes(source []repository.FileTreeEntry, target *provisioning.ResourceLis
}
// Deepest first (stable sort order)
sort.Slice(changes, func(i, j int) bool {
if safepath.Depth(changes[i].Path) > safepath.Depth(changes[j].Path) {
return true
}
if safepath.Depth(changes[i].Path) < safepath.Depth(changes[j].Path) {
return false
}
return changes[i].Path < changes[j].Path
})
safepath.SortByDepth(changes, func(c ResourceFileChange) string { return c.Path }, false)
return changes, nil
}

View File

@@ -45,9 +45,9 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/nanogit"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/local"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
@@ -76,7 +76,7 @@ type APIBuilder struct {
features featuremgmt.FeatureToggles
getter rest.Getter
localFileResolver *repository.LocalFolderResolver
localFileResolver *local.LocalFolderResolver
parsers resources.ParserFactory
repositoryResources resources.RepositoryResourcesFactory
clients resources.ClientFactory
@@ -105,7 +105,7 @@ type APIBuilder struct {
// It avoids anything that is core to Grafana, such that it can be used in a multi-tenant service down the line.
// This means there are no hidden dependencies, and no use of e.g. *settings.Cfg.
func NewAPIBuilder(
local *repository.LocalFolderResolver,
local *local.LocalFolderResolver,
features featuremgmt.FeatureToggles,
unified resource.ResourceClient,
clonedir string, // where repo clones are managed
@@ -168,14 +168,7 @@ func RegisterAPIService(
return nil, nil
}
logger := logging.DefaultLogger.With("logger", "provisioning startup")
if features.IsEnabledGlobally(featuremgmt.FlagNanoGit) {
logger.Info("Using nanogit for repositories")
} else {
logger.Debug("Using go-git and Github API for repositories")
}
folderResolver := &repository.LocalFolderResolver{
folderResolver := &local.LocalFolderResolver{
PermittedPrefixes: cfg.PermittedProvisioningPaths,
HomePath: safepath.Clean(cfg.HomePath),
}
@@ -606,11 +599,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.repositoryLister = repoInformer.Lister()
stageIfPossible := repository.WrapWithStageAndPushIfPossible
exportWorker := export.NewExportWorker(
b.clients,
b.repositoryResources,
export.ExportAll,
repository.WrapWithCloneAndPushIfPossible,
stageIfPossible,
)
b.statusPatcher = controller.NewRepositoryStatusPatcher(b.GetClient())
@@ -636,7 +630,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
legacyResources,
storageSwapper,
syncWorker,
repository.WrapWithCloneAndPushIfPossible,
stageIfPossible,
)
cleaner := migrate.NewNamespaceCleaner(b.clients)
@@ -1170,52 +1164,63 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
switch r.Spec.Type {
case provisioning.LocalRepositoryType:
return repository.NewLocal(r, b.localFileResolver), nil
return local.NewLocal(r, b.localFileResolver), nil
case provisioning.GitRepositoryType:
return nanogit.NewGitRepository(ctx, b.secrets, r, nanogit.RepositoryConfig{
// Decrypt token if needed
token := r.Spec.Git.Token
if token == "" {
decrypted, err := b.secrets.Decrypt(ctx, r.Spec.Git.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("decrypt git token: %w", err)
}
token = string(decrypted)
}
return git.NewGitRepository(ctx, r, git.RepositoryConfig{
URL: r.Spec.Git.URL,
Branch: r.Spec.Git.Branch,
Path: r.Spec.Git.Path,
Token: r.Spec.Git.Token,
Token: token,
EncryptedToken: r.Spec.Git.EncryptedToken,
})
case provisioning.GitHubRepositoryType:
cloneFn := func(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets)
}
apiRepo, err := repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, cloneFn)
if err != nil {
return nil, fmt.Errorf("create github API repository: %w", err)
}
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
if !b.features.IsEnabledGlobally(featuremgmt.FlagNanoGit) {
logger.Debug("Instantiating Github repository with go-git and Github API")
return apiRepo, nil
}
logger.Info("Instantiating Github repository with nanogit")
logger.Info("Instantiating Github repository")
ghCfg := r.Spec.GitHub
if ghCfg == nil {
return nil, fmt.Errorf("github configuration is required for nano git")
}
gitCfg := nanogit.RepositoryConfig{
// Decrypt GitHub token if needed
ghToken := ghCfg.Token
if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
decrypted, err := b.secrets.Decrypt(ctx, ghCfg.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}
ghToken = string(decrypted)
}
gitCfg := git.RepositoryConfig{
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
Token: ghCfg.Token,
Token: ghToken,
EncryptedToken: ghCfg.EncryptedToken,
}
nanogitRepo, err := nanogit.NewGitRepository(ctx, b.secrets, r, gitCfg)
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
if err != nil {
return nil, fmt.Errorf("error creating nanogit repository: %w", err)
return nil, fmt.Errorf("error creating git repository: %w", err)
}
return nanogit.NewGithubRepository(apiRepo, nanogitRepo), nil
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}
return ghRepo, nil
default:
return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type)
}

View File

@@ -1,95 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package repository
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockClonableRepository is an autogenerated mock type for the ClonableRepository type
type MockClonableRepository struct {
mock.Mock
}
type MockClonableRepository_Expecter struct {
mock *mock.Mock
}
func (_m *MockClonableRepository) EXPECT() *MockClonableRepository_Expecter {
return &MockClonableRepository_Expecter{mock: &_m.Mock}
}
// Clone provides a mock function with given fields: ctx, opts
func (_m *MockClonableRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Clone")
}
var r0 ClonedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(ClonedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClonableRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone'
type MockClonableRepository_Clone_Call struct {
*mock.Call
}
// Clone is a helper method to define mock.On call
// - ctx context.Context
// - opts CloneOptions
func (_e *MockClonableRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockClonableRepository_Clone_Call {
return &MockClonableRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)}
}
func (_c *MockClonableRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockClonableRepository_Clone_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(CloneOptions))
})
return _c
}
func (_c *MockClonableRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockClonableRepository_Clone_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClonableRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockClonableRepository_Clone_Call {
_c.Call.Return(run)
return _c
}
// NewMockClonableRepository creates a new instance of MockClonableRepository. 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 NewMockClonableRepository(t interface {
mock.TestingT
Cleanup(func())
}) *MockClonableRepository {
mock := &MockClonableRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1,44 +0,0 @@
package repository
import (
context "context"
"fmt"
"github.com/grafana/grafana-app-sdk/logging"
)
// WrapWithCloneAndPushIfPossible clones a repository if possible, executes operations on the clone,
// and automatically pushes changes when the function completes. For repositories that support cloning,
// all operations are transparently executed on the clone, and the clone is automatically cleaned up
// afterward. If cloning is not supported, the original repository instance is used directly.
func WrapWithCloneAndPushIfPossible(
ctx context.Context,
repo Repository,
cloneOptions CloneOptions,
pushOptions PushOptions,
fn func(repo Repository, cloned bool) error,
) error {
clonable, ok := repo.(ClonableRepository)
if !ok {
return fn(repo, false)
}
clone, err := clonable.Clone(ctx, cloneOptions)
if err != nil {
return fmt.Errorf("clone repository: %w", err)
}
// We don't, we simply log it
// FIXME: should we handle this differently?
defer func() {
if err := clone.Remove(ctx); err != nil {
logging.FromContext(ctx).Error("failed to remove cloned repository after export", "err", err)
}
}()
if err := fn(clone, true); err != nil {
return err
}
return clone.Push(ctx, pushOptions)
}

View File

@@ -1,95 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package repository
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockCloneFn is an autogenerated mock type for the CloneFn type
type MockCloneFn struct {
mock.Mock
}
type MockCloneFn_Expecter struct {
mock *mock.Mock
}
func (_m *MockCloneFn) EXPECT() *MockCloneFn_Expecter {
return &MockCloneFn_Expecter{mock: &_m.Mock}
}
// Execute provides a mock function with given fields: ctx, opts
func (_m *MockCloneFn) Execute(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Execute")
}
var r0 ClonedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(ClonedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
type MockCloneFn_Execute_Call struct {
*mock.Call
}
// Execute is a helper method to define mock.On call
// - ctx context.Context
// - opts CloneOptions
func (_e *MockCloneFn_Expecter) Execute(ctx interface{}, opts interface{}) *MockCloneFn_Execute_Call {
return &MockCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, opts)}
}
func (_c *MockCloneFn_Execute_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockCloneFn_Execute_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(CloneOptions))
})
return _c
}
func (_c *MockCloneFn_Execute_Call) Return(_a0 ClonedRepository, _a1 error) *MockCloneFn_Execute_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCloneFn_Execute_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockCloneFn_Execute_Call {
_c.Call.Return(run)
return _c
}
// NewMockCloneFn creates a new instance of MockCloneFn. 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 NewMockCloneFn(t interface {
mock.TestingT
Cleanup(func())
}) *MockCloneFn {
mock := &MockCloneFn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1,144 +0,0 @@
package repository
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type mockClonableRepo struct {
*MockClonableRepository
*MockClonedRepository
}
func Test_WrapWithCloneAndPushIfPossible_NonClonableRepository(t *testing.T) {
nonClonable := NewMockRepository(t)
var called bool
fn := func(repo Repository, cloned bool) error {
called = true
return errors.New("operation failed")
}
err := WrapWithCloneAndPushIfPossible(context.Background(), nonClonable, CloneOptions{}, PushOptions{}, fn)
require.EqualError(t, err, "operation failed")
require.True(t, called)
}
func TestWrapWithCloneAndPushIfPossible(t *testing.T) {
tests := []struct {
name string
setupMocks func(t *testing.T) *mockClonableRepo
operation func(repo Repository, cloned bool) error
expectedError string
}{
{
name: "successful clone, operation, and push",
setupMocks: func(t *testing.T) *mockClonableRepo {
mockRepo := NewMockClonableRepository(t)
mockCloned := NewMockClonedRepository(t)
mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
mockCloned.EXPECT().Push(mock.Anything, PushOptions{}).Return(nil)
mockCloned.EXPECT().Remove(mock.Anything).Return(nil)
return &mockClonableRepo{
MockClonableRepository: mockRepo,
MockClonedRepository: mockCloned,
}
},
operation: func(repo Repository, cloned bool) error {
require.True(t, cloned)
return nil
},
},
{
name: "clone failure",
setupMocks: func(t *testing.T) *mockClonableRepo {
mockRepo := NewMockClonableRepository(t)
mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(nil, errors.New("clone failed"))
return &mockClonableRepo{
MockClonableRepository: mockRepo,
}
},
operation: func(repo Repository, cloned bool) error {
return nil
},
expectedError: "clone repository: clone failed",
},
{
name: "operation failure",
setupMocks: func(t *testing.T) *mockClonableRepo {
mockRepo := NewMockClonableRepository(t)
mockCloned := NewMockClonedRepository(t)
mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
mockCloned.EXPECT().Remove(mock.Anything).Return(nil)
return &mockClonableRepo{
MockClonableRepository: mockRepo,
MockClonedRepository: mockCloned,
}
},
operation: func(repo Repository, cloned bool) error {
return errors.New("operation failed")
},
expectedError: "operation failed",
},
{
name: "push failure",
setupMocks: func(t *testing.T) *mockClonableRepo {
mockRepo := NewMockClonableRepository(t)
mockCloned := NewMockClonedRepository(t)
mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
mockCloned.EXPECT().Push(mock.Anything, PushOptions{}).Return(errors.New("push failed"))
mockCloned.EXPECT().Remove(mock.Anything).Return(nil)
return &mockClonableRepo{
MockClonableRepository: mockRepo,
MockClonedRepository: mockCloned,
}
},
operation: func(repo Repository, cloned bool) error {
return nil
},
expectedError: "push failed",
},
{
name: "remove failure should only log",
setupMocks: func(t *testing.T) *mockClonableRepo {
mockRepo := NewMockClonableRepository(t)
mockCloned := NewMockClonedRepository(t)
mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
mockCloned.EXPECT().Push(mock.Anything, PushOptions{}).Return(nil)
mockCloned.EXPECT().Remove(mock.Anything).Return(errors.New("remove failed"))
return &mockClonableRepo{
MockClonableRepository: mockRepo,
MockClonedRepository: mockCloned,
}
},
operation: func(repo Repository, cloned bool) error {
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := tt.setupMocks(t)
err := WrapWithCloneAndPushIfPossible(context.Background(), repo, CloneOptions{}, PushOptions{}, tt.operation)
if tt.expectedError != "" {
require.EqualError(t, err, tt.expectedError)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository

View File

@@ -0,0 +1,33 @@
package git
import (
"regexp"
"strings"
)
// basicGitBranchNameRegex is a regular expression to validate a git branch name
// it does not cover all cases as positive lookaheads are not supported in Go's regexp
var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
// IsValidGitBranchName checks if a branch name is valid.
// It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
// 1. The branch name must have at least one character and must not be empty.
// 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
// 3. The branch name cannot contain consecutive slashes (`//`).
// 4. The branch name cannot contain consecutive dots (`..`).
// 5. The branch name cannot contain `@{`.
// 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
func IsValidGitBranchName(branch string) bool {
if !basicGitBranchNameRegex.MatchString(branch) {
return false
}
// Additional checks for invalid patterns
if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
return false
}
return true
}

View File

@@ -0,0 +1,47 @@
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsValidGitBranchName(t *testing.T) {
tests := []struct {
name string
branch string
expected bool
}{
{"Valid branch name", "feature/add-tests", true},
{"Valid branch name with numbers", "feature/123-add-tests", true},
{"Valid branch name with dots", "feature.add.tests", true},
{"Valid branch name with hyphens", "feature-add-tests", true},
{"Valid branch name with underscores", "feature_add_tests", true},
{"Valid branch name with mixed characters", "feature/add_tests-123", true},
{"Starts with /", "/feature", false},
{"Ends with /", "feature/", false},
{"Ends with .", "feature.", false},
{"Ends with space", "feature ", false},
{"Contains consecutive slashes", "feature//branch", false},
{"Contains consecutive dots", "feature..branch", false},
{"Contains @{", "feature@{branch", false},
{"Contains invalid character ~", "feature~branch", false},
{"Contains invalid character ^", "feature^branch", false},
{"Contains invalid character :", "feature:branch", false},
{"Contains invalid character ?", "feature?branch", false},
{"Contains invalid character *", "feature*branch", false},
{"Contains invalid character [", "feature[branch", false},
{"Contains invalid character ]", "feature]branch", false},
{"Contains invalid character \\", "feature\\branch", false},
{"Empty branch name", "", false},
{"Only whitespace", " ", false},
{"Single valid character", "a", true},
{"Ends with .lock", "feature.lock", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, IsValidGitBranchName(tt.branch))
})
}
}

View File

@@ -1,15 +1,17 @@
package repository
package git
import "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
// GitRepository is an interface that combines all repository capabilities
// needed for Git repositories.
//
//go:generate mockery --name GitRepository --structname MockGitRepository --inpackage --filename git_repository_mock.go --with-expecter
type GitRepository interface {
Repository
Versioned
Writer
Reader
ClonableRepository
repository.Repository
repository.Versioned
repository.Writer
repository.Reader
repository.StageableRepository
URL() string
Branch() string
}

View File

@@ -1,6 +1,6 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
package git
import (
context "context"
@@ -8,6 +8,8 @@ import (
mock "github.com/stretchr/testify/mock"
field "k8s.io/apimachinery/pkg/util/validation/field"
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
@@ -69,83 +71,24 @@ func (_c *MockGitRepository_Branch_Call) RunAndReturn(run func() string) *MockGi
return _c
}
// Clone provides a mock function with given fields: ctx, opts
func (_m *MockGitRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Clone")
}
var r0 ClonedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(ClonedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGitRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone'
type MockGitRepository_Clone_Call struct {
*mock.Call
}
// Clone is a helper method to define mock.On call
// - ctx context.Context
// - opts CloneOptions
func (_e *MockGitRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockGitRepository_Clone_Call {
return &MockGitRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)}
}
func (_c *MockGitRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockGitRepository_Clone_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(CloneOptions))
})
return _c
}
func (_c *MockGitRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockGitRepository_Clone_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockGitRepository_Clone_Call {
_c.Call.Return(run)
return _c
}
// CompareFiles provides a mock function with given fields: ctx, base, ref
func (_m *MockGitRepository) CompareFiles(ctx context.Context, base string, ref string) ([]VersionedFileChange, error) {
func (_m *MockGitRepository) CompareFiles(ctx context.Context, base string, ref string) ([]repository.VersionedFileChange, error) {
ret := _m.Called(ctx, base, ref)
if len(ret) == 0 {
panic("no return value specified for CompareFiles")
}
var r0 []VersionedFileChange
var r0 []repository.VersionedFileChange
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]VersionedFileChange, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]repository.VersionedFileChange, error)); ok {
return rf(ctx, base, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) []VersionedFileChange); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) []repository.VersionedFileChange); ok {
r0 = rf(ctx, base, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]VersionedFileChange)
r0 = ret.Get(0).([]repository.VersionedFileChange)
}
}
@@ -178,12 +121,12 @@ func (_c *MockGitRepository_CompareFiles_Call) Run(run func(ctx context.Context,
return _c
}
func (_c *MockGitRepository_CompareFiles_Call) Return(_a0 []VersionedFileChange, _a1 error) *MockGitRepository_CompareFiles_Call {
func (_c *MockGitRepository_CompareFiles_Call) Return(_a0 []repository.VersionedFileChange, _a1 error) *MockGitRepository_CompareFiles_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]VersionedFileChange, error)) *MockGitRepository_CompareFiles_Call {
func (_c *MockGitRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]repository.VersionedFileChange, error)) *MockGitRepository_CompareFiles_Call {
_c.Call.Return(run)
return _c
}
@@ -451,23 +394,23 @@ func (_c *MockGitRepository_LatestRef_Call) RunAndReturn(run func(context.Contex
}
// Read provides a mock function with given fields: ctx, path, ref
func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
ret := _m.Called(ctx, path, ref)
if len(ret) == 0 {
panic("no return value specified for Read")
}
var r0 *FileInfo
var r0 *repository.FileInfo
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*repository.FileInfo, error)); ok {
return rf(ctx, path, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) *repository.FileInfo); ok {
r0 = rf(ctx, path, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*FileInfo)
r0 = ret.Get(0).(*repository.FileInfo)
}
}
@@ -500,34 +443,34 @@ func (_c *MockGitRepository_Read_Call) Run(run func(ctx context.Context, path st
return _c
}
func (_c *MockGitRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockGitRepository_Read_Call {
func (_c *MockGitRepository_Read_Call) Return(_a0 *repository.FileInfo, _a1 error) *MockGitRepository_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockGitRepository_Read_Call {
func (_c *MockGitRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*repository.FileInfo, error)) *MockGitRepository_Read_Call {
_c.Call.Return(run)
return _c
}
// ReadTree provides a mock function with given fields: ctx, ref
func (_m *MockGitRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
func (_m *MockGitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
ret := _m.Called(ctx, ref)
if len(ret) == 0 {
panic("no return value specified for ReadTree")
}
var r0 []FileTreeEntry
var r0 []repository.FileTreeEntry
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, string) ([]repository.FileTreeEntry, error)); ok {
return rf(ctx, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok {
if rf, ok := ret.Get(0).(func(context.Context, string) []repository.FileTreeEntry); ok {
r0 = rf(ctx, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]FileTreeEntry)
r0 = ret.Get(0).([]repository.FileTreeEntry)
}
}
@@ -559,12 +502,71 @@ func (_c *MockGitRepository_ReadTree_Call) Run(run func(ctx context.Context, ref
return _c
}
func (_c *MockGitRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockGitRepository_ReadTree_Call {
func (_c *MockGitRepository_ReadTree_Call) Return(_a0 []repository.FileTreeEntry, _a1 error) *MockGitRepository_ReadTree_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockGitRepository_ReadTree_Call {
func (_c *MockGitRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]repository.FileTreeEntry, error)) *MockGitRepository_ReadTree_Call {
_c.Call.Return(run)
return _c
}
// Stage provides a mock function with given fields: ctx, opts
func (_m *MockGitRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Stage")
}
var r0 repository.StagedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) (repository.StagedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) repository.StagedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(repository.StagedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, repository.StageOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGitRepository_Stage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stage'
type MockGitRepository_Stage_Call struct {
*mock.Call
}
// Stage is a helper method to define mock.On call
// - ctx context.Context
// - opts repository.StageOptions
func (_e *MockGitRepository_Expecter) Stage(ctx interface{}, opts interface{}) *MockGitRepository_Stage_Call {
return &MockGitRepository_Stage_Call{Call: _e.mock.On("Stage", ctx, opts)}
}
func (_c *MockGitRepository_Stage_Call) Run(run func(ctx context.Context, opts repository.StageOptions)) *MockGitRepository_Stage_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.StageOptions))
})
return _c
}
func (_c *MockGitRepository_Stage_Call) Return(_a0 repository.StagedRepository, _a1 error) *MockGitRepository_Stage_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_Stage_Call) RunAndReturn(run func(context.Context, repository.StageOptions) (repository.StagedRepository, error)) *MockGitRepository_Stage_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -1,12 +1,14 @@
package nanogit
package git
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -17,7 +19,6 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/log"
"github.com/grafana/nanogit/options"
@@ -38,28 +39,19 @@ type gitRepository struct {
config *provisioning.Repository
gitConfig RepositoryConfig
client nanogit.Client
secrets secrets.Service
}
func NewGitRepository(
ctx context.Context,
secrets secrets.Service,
config *provisioning.Repository,
gitConfig RepositoryConfig,
) (repository.GitRepository, error) {
if gitConfig.Token == "" {
decrypted, err := secrets.Decrypt(ctx, gitConfig.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("decrypt token: %w", err)
}
gitConfig.Token = string(decrypted)
) (GitRepository, error) {
var opts []options.Option
if len(gitConfig.Token) > 0 {
opts = append(opts, options.WithBasicAuth("git", gitConfig.Token))
}
// Create nanogit client with authentication
client, err := nanogit.NewHTTPClient(
gitConfig.URL,
options.WithBasicAuth("git", gitConfig.Token),
)
client, err := nanogit.NewHTTPClient(gitConfig.URL, opts...)
if err != nil {
return nil, fmt.Errorf("create nanogit client: %w", err)
}
@@ -68,7 +60,6 @@ func NewGitRepository(
config: config,
gitConfig: gitConfig,
client: client,
secrets: secrets,
}, nil
}
@@ -98,12 +89,15 @@ func (r *gitRepository) Validate() (list field.ErrorList) {
}
if cfg.Branch == "" {
list = append(list, field.Required(field.NewPath("spec", t, "branch"), "a git branch is required"))
} else if !repository.IsValidGitBranchName(cfg.Branch) {
} else if !IsValidGitBranchName(cfg.Branch) {
list = append(list, field.Invalid(field.NewPath("spec", t, "branch"), cfg.Branch, "invalid branch name"))
}
if cfg.Token == "" && len(cfg.EncryptedToken) == 0 {
list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required"))
// If the repository has workflows, we require a token or encrypted token
if len(r.config.Spec.Workflows) > 0 {
if cfg.Token == "" && len(cfg.EncryptedToken) == 0 {
list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required"))
}
}
if err := safepath.IsSafe(cfg.Path); err != nil {
@@ -412,11 +406,15 @@ func (r *gitRepository) Write(ctx context.Context, path string, ref string, data
}
ctx, _ = r.logger(ctx, ref)
_, err := r.Read(ctx, path, ref)
info, err := r.Read(ctx, path, ref)
if err != nil && !(errors.Is(err, repository.ErrFileNotFound)) {
return fmt.Errorf("check if file exists before writing: %w", err)
}
if err == nil {
// If the value already exists and is the same, we don't need to do anything
if bytes.Equal(info.Data, data) {
return nil
}
return r.Update(ctx, path, ref, data, message)
}
@@ -450,7 +448,8 @@ func (r *gitRepository) delete(ctx context.Context, path string, writer nanogit.
finalPath := safepath.Join(r.gitConfig.Path, path)
// Check if it's a directory - use DeleteTree for directories, DeleteBlob for files
if safepath.IsDir(path) {
if _, err := writer.DeleteTree(ctx, finalPath); err != nil {
trimmed := strings.TrimSuffix(finalPath, "/")
if _, err := writer.DeleteTree(ctx, trimmed); err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return repository.ErrFileNotFound
}
@@ -582,7 +581,7 @@ func (r *gitRepository) CompareFiles(ctx context.Context, base, ref string) ([]r
return changes, nil
}
func (r *gitRepository) Clone(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
func (r *gitRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
return NewStagedGitRepository(ctx, r, opts)
}
@@ -590,7 +589,7 @@ func (r *gitRepository) Clone(ctx context.Context, opts repository.CloneOptions)
func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.Hash, error) {
// Use default branch if ref is empty
if ref == "" {
ref = fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch)
ref = r.gitConfig.Branch
}
// Try to parse ref as a hash first
@@ -600,6 +599,9 @@ func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.
return refHash, nil
}
// Prefix ref with refs/heads/
ref = fmt.Sprintf("refs/heads/%s", ref)
// Not a valid hash, try to resolve as a branch reference
branchRef, err := r.client.GetRef(ctx, ref)
if err != nil {
@@ -615,7 +617,7 @@ func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.
// ensureBranchExists checks if a branch exists and creates it if it doesn't,
// returning the branch reference to avoid duplicate GetRef calls
func (r *gitRepository) ensureBranchExists(ctx context.Context, branchName string) (nanogit.Ref, error) {
if !repository.IsValidGitBranchName(branchName) {
if !IsValidGitBranchName(branchName) {
return nanogit.Ref{}, &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusBadRequest,

View File

@@ -1,4 +1,4 @@
package nanogit
package git
import (
"context"
@@ -7,17 +7,17 @@ import (
"testing"
"time"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/mocks"
"github.com/grafana/nanogit/protocol"
"github.com/grafana/nanogit/protocol/hash"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/mocks"
"github.com/grafana/nanogit/protocol"
"github.com/grafana/nanogit/protocol/hash"
)
func TestGitRepository_Validate(t *testing.T) {
@@ -138,10 +138,11 @@ func TestGitRepository_Validate(t *testing.T) {
},
},
{
name: "missing token",
name: "missing token for R/W repository",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: "test_type",
Type: "test_type",
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
},
},
gitConfig: RepositoryConfig{
@@ -153,6 +154,21 @@ func TestGitRepository_Validate(t *testing.T) {
field.Required(field.NewPath("spec", "test_type", "token"), "a git access token is required"),
},
},
{
name: "missing token for read-only repository",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: "test_type",
Workflows: nil, // read-only
},
},
gitConfig: RepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
Token: "", // Empty token
},
want: nil,
},
{
name: "unsafe path",
config: &provisioning.Repository{
@@ -258,20 +274,8 @@ func TestIsValidGitURL(t *testing.T) {
}
}
// Mock secrets service for testing
type mockSecretsService struct{}
func (m *mockSecretsService) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
return []byte("decrypted-token"), nil
}
func (m *mockSecretsService) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
return []byte("encrypted-token"), nil
}
func TestNewGit(t *testing.T) {
ctx := context.Background()
mockSecrets := &mockSecretsService{}
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -288,7 +292,7 @@ func TestNewGit(t *testing.T) {
// This should succeed in creating the client but won't be able to connect
// We just test that the basic structure is created correctly
gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig)
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
require.NoError(t, err)
require.NotNil(t, gitRepo)
require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL())
@@ -1073,27 +1077,27 @@ func TestGitRepository_Update(t *testing.T) {
func TestGitRepository_Delete(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeClient)
gitConfig RepositoryConfig
path string
ref string
comment string
wantError bool
errorType error
name string
setupMock func(*mocks.FakeClient, *mocks.FakeStagedWriter)
assertions func(*testing.T, *mocks.FakeClient, *mocks.FakeStagedWriter)
gitConfig RepositoryConfig
path string
ref string
comment string
wantError bool
errorType error
}{
{
name: "success - delete file",
setupMock: func(mockClient *mocks.FakeClient) {
setupMock: func(mockClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/main",
Hash: hash.Hash{},
}, nil)
mockWriter := &mocks.FakeStagedWriter{}
mockWriter.DeleteBlobReturns(hash.Hash{}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
gitConfig: RepositoryConfig{
Branch: "main",
@@ -1106,16 +1110,19 @@ func TestGitRepository_Delete(t *testing.T) {
},
{
name: "success - delete directory",
setupMock: func(mockClient *mocks.FakeClient) {
setupMock: func(mockClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/main",
Hash: hash.Hash{},
}, nil)
mockWriter := &mocks.FakeStagedWriter{}
mockWriter.DeleteTreeReturns(hash.Hash{}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
assertions: func(t *testing.T, fakeClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
require.Equal(t, 1, mockWriter.DeleteTreeCallCount(), "DeleteTree should be called once")
_, p := mockWriter.DeleteTreeArgsForCall(0)
require.Equal(t, "configs/testdir", p, "DeleteTree should be called with correct path")
},
gitConfig: RepositoryConfig{
Branch: "main",
@@ -1128,14 +1135,12 @@ func TestGitRepository_Delete(t *testing.T) {
},
{
name: "failure - file not found",
setupMock: func(mockClient *mocks.FakeClient) {
setupMock: func(mockClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/main",
Hash: hash.Hash{},
}, nil)
mockWriter := &mocks.FakeStagedWriter{}
mockWriter.DeleteBlobReturns(hash.Hash{}, nanogit.ErrObjectNotFound)
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
gitConfig: RepositoryConfig{
Branch: "main",
@@ -1152,7 +1157,10 @@ func TestGitRepository_Delete(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mocks.FakeClient{}
tt.setupMock(mockClient)
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
tt.setupMock(mockClient, mockWriter)
gitRepo := &gitRepository{
client: mockClient,
@@ -1174,6 +1182,10 @@ func TestGitRepository_Delete(t *testing.T) {
} else {
require.NoError(t, err)
}
if tt.assertions != nil {
tt.assertions(t, mockClient, mockWriter)
}
})
}
}
@@ -1729,57 +1741,21 @@ func TestGitRepository_createSignature(t *testing.T) {
func TestNewGitRepository(t *testing.T) {
tests := []struct {
name string
setupMock func(*mockSecretsService)
gitConfig RepositoryConfig
wantError bool
expectURL string
expectToken string
name string
gitConfig RepositoryConfig
wantError bool
expectURL string
}{
{
name: "success - with token",
setupMock: func(mockSecrets *mockSecretsService) {
// No setup needed for token case
},
gitConfig: RepositoryConfig{
URL: "https://git.example.com/owner/repo.git",
Branch: "main",
Token: "plain-token",
Path: "configs",
},
wantError: false,
expectURL: "https://git.example.com/owner/repo.git",
expectToken: "plain-token",
},
{
name: "success - with encrypted token",
setupMock: func(mockSecrets *mockSecretsService) {
// Mock will return decrypted token
},
gitConfig: RepositoryConfig{
URL: "https://git.example.com/owner/repo.git",
Branch: "main",
Token: "", // Empty token, will use encrypted
EncryptedToken: []byte("encrypted-token"),
Path: "configs",
},
wantError: false,
expectURL: "https://git.example.com/owner/repo.git",
expectToken: "decrypted-token", // From mock
},
{
name: "failure - decryption error",
setupMock: func(mockSecrets *mockSecretsService) {
// This test will use the separate mockSecretsServiceWithError
},
gitConfig: RepositoryConfig{
URL: "https://git.example.com/owner/repo.git",
Branch: "main",
Token: "",
EncryptedToken: []byte("bad-encrypted-token"),
Path: "configs",
},
wantError: true,
wantError: false,
expectURL: "https://git.example.com/owner/repo.git",
},
}
@@ -1787,20 +1763,13 @@ func TestNewGitRepository(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
var mockSecrets secrets.Service
if tt.name == "failure - decryption error" {
mockSecrets = &mockSecretsServiceWithError{shouldError: true}
} else {
mockSecrets = &mockSecretsService{}
}
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
},
}
gitRepo, err := NewGitRepository(ctx, mockSecrets, config, tt.gitConfig)
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig)
if tt.wantError {
require.Error(t, err)
@@ -2168,7 +2137,7 @@ func TestGitRepository_logger(t *testing.T) {
})
}
func TestGitRepository_Clone(t *testing.T) {
func TestGitRepository_Stage(t *testing.T) {
gitRepo := &gitRepository{
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -2184,9 +2153,8 @@ func TestGitRepository_Clone(t *testing.T) {
t.Run("calls NewStagedGitRepository", func(t *testing.T) {
ctx := context.Background()
opts := repository.CloneOptions{
CreateIfNotExists: true,
PushOnWrites: true,
opts := repository.StageOptions{
PushOnWrites: true,
}
// Since NewStagedGitRepository is not mocked and may panic, we expect this to fail
@@ -2198,11 +2166,11 @@ func TestGitRepository_Clone(t *testing.T) {
}
}()
cloned, err := gitRepo.Clone(ctx, opts)
staged, err := gitRepo.Stage(ctx, opts)
// This will likely error/panic since we don't have a real implementation
// but we're testing that the method exists and forwards to NewStagedGitRepository
_ = cloned
_ = staged
_ = err
})
}
@@ -2271,50 +2239,6 @@ func TestGitRepository_EdgeCases(t *testing.T) {
})
}
// Enhanced secrets service mock with error handling
type mockSecretsServiceWithError struct {
shouldError bool
}
func (m *mockSecretsServiceWithError) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
if m.shouldError {
return nil, errors.New("decryption failed")
}
return []byte("decrypted-token"), nil
}
func (m *mockSecretsServiceWithError) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
if m.shouldError {
return nil, errors.New("encryption failed")
}
return []byte("encrypted-token"), nil
}
func TestNewGitRepository_DecryptionError(t *testing.T) {
ctx := context.Background()
mockSecrets := &mockSecretsServiceWithError{shouldError: true}
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
},
}
gitConfig := RepositoryConfig{
URL: "https://git.example.com/owner/repo.git",
Branch: "main",
Token: "",
EncryptedToken: []byte("encrypted-token"),
Path: "configs",
}
gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig)
require.Error(t, err)
require.Nil(t, gitRepo)
require.Contains(t, err.Error(), "decrypt token")
}
func TestGitRepository_ValidateBranchNames(t *testing.T) {
tests := []struct {
name string
@@ -2782,7 +2706,6 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
// This test would require mocking nanogit.NewHTTPClient which is difficult
// We test the path where the client creation would fail by using invalid URL
ctx := context.Background()
mockSecrets := &mockSecretsService{}
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -2797,7 +2720,7 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
Path: "configs",
}
gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig)
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
// We expect this to fail during client creation
require.Error(t, err)

View File

@@ -1,4 +1,4 @@
package nanogit
package git
import (
"context"
@@ -15,17 +15,11 @@ import (
// once that happens we could do more magic here.
type stagedGitRepository struct {
*gitRepository
opts repository.CloneOptions
opts repository.StageOptions
writer nanogit.StagedWriter
}
func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repository.CloneOptions) (repository.ClonedRepository, error) {
if opts.BeforeFn != nil {
if err := opts.BeforeFn(); err != nil {
return nil, err
}
}
func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repository.StageOptions) (repository.StagedRepository, error) {
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
@@ -89,28 +83,35 @@ func (r *stagedGitRepository) Create(ctx context.Context, path, ref string, data
}
if r.opts.PushOnWrites {
return r.Push(ctx, repository.PushOptions{})
return r.Push(ctx)
}
return nil
}
func (r *stagedGitRepository) blobExists(ctx context.Context, path string) (bool, error) {
if r.gitConfig.Path != "" {
path = safepath.Join(r.gitConfig.Path, path)
}
return r.writer.BlobExists(ctx, path)
}
func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data []byte, message string) error {
if ref != "" && ref != r.gitConfig.Branch {
return errors.New("ref is not supported for staged repository")
}
ok, err := r.writer.BlobExists(ctx, path)
exists, err := r.blobExists(ctx, path)
if err != nil {
return fmt.Errorf("check if file exists: %w", err)
}
if !ok {
if err := r.create(ctx, path, data, r.writer); err != nil {
if exists {
if err := r.update(ctx, path, data, r.writer); err != nil {
return err
}
} else {
if err := r.update(ctx, path, data, r.writer); err != nil {
if err := r.create(ctx, path, data, r.writer); err != nil {
return err
}
}
@@ -120,7 +121,7 @@ func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data
}
if r.opts.PushOnWrites {
return r.Push(ctx, repository.PushOptions{})
return r.Push(ctx)
}
return nil
@@ -144,7 +145,7 @@ func (r *stagedGitRepository) Update(ctx context.Context, path, ref string, data
}
if r.opts.PushOnWrites {
return r.Push(ctx, repository.PushOptions{})
return r.Push(ctx)
}
return nil
@@ -164,22 +165,16 @@ func (r *stagedGitRepository) Delete(ctx context.Context, path, ref, message str
}
if r.opts.PushOnWrites {
return r.Push(ctx, repository.PushOptions{})
return r.Push(ctx)
}
return nil
}
func (r *stagedGitRepository) Push(ctx context.Context, opts repository.PushOptions) error {
if opts.BeforeFn != nil {
if err := opts.BeforeFn(); err != nil {
return err
}
}
if opts.Timeout > 0 {
func (r *stagedGitRepository) Push(ctx context.Context) error {
if r.opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
ctx, cancel = context.WithTimeout(ctx, r.opts.Timeout)
defer cancel()
}

View File

@@ -1,4 +1,4 @@
package nanogit
package git
import (
"context"
@@ -18,7 +18,7 @@ func TestNewStagedGitRepository(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeClient)
opts repository.CloneOptions
opts repository.StageOptions
wantError error
}{
{
@@ -31,9 +31,8 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
opts: repository.CloneOptions{
CreateIfNotExists: false,
PushOnWrites: false,
opts: repository.StageOptions{
PushOnWrites: false,
},
wantError: nil,
},
@@ -47,12 +46,8 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
opts: repository.CloneOptions{
CreateIfNotExists: false,
PushOnWrites: false,
BeforeFn: func() error {
return nil
},
opts: repository.StageOptions{
PushOnWrites: false,
},
wantError: nil,
},
@@ -66,33 +61,19 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
opts: repository.CloneOptions{
CreateIfNotExists: false,
PushOnWrites: false,
Timeout: time.Second * 5,
opts: repository.StageOptions{
PushOnWrites: false,
Timeout: time.Second * 5,
},
wantError: nil,
},
{
name: "fails with BeforeFn error",
setupMock: func(mockClient *mocks.FakeClient) {
// No setup needed as BeforeFn fails first
},
opts: repository.CloneOptions{
BeforeFn: func() error {
return errors.New("before function failed")
},
},
wantError: errors.New("before function failed"),
},
{
name: "fails with GetRef error",
setupMock: func(mockClient *mocks.FakeClient) {
mockClient.GetRefReturns(nanogit.Ref{}, errors.New("ref not found"))
},
opts: repository.CloneOptions{
CreateIfNotExists: false,
PushOnWrites: false,
opts: repository.StageOptions{
PushOnWrites: false,
},
wantError: errors.New("ref not found"),
},
@@ -105,9 +86,8 @@ func TestNewStagedGitRepository(t *testing.T) {
}, nil)
mockClient.NewStagedWriterReturns(nil, errors.New("failed to create writer"))
},
opts: repository.CloneOptions{
CreateIfNotExists: false,
PushOnWrites: false,
opts: repository.StageOptions{
PushOnWrites: false,
},
wantError: errors.New("build staged writer: failed to create writer"),
},
@@ -141,13 +121,8 @@ func TestNewStagedGitRepository(t *testing.T) {
// Compare opts fields individually since function pointers can't be compared directly
actualOpts := stagedRepo.(*stagedGitRepository).opts
require.Equal(t, tt.opts.CreateIfNotExists, actualOpts.CreateIfNotExists)
require.Equal(t, tt.opts.PushOnWrites, actualOpts.PushOnWrites)
require.Equal(t, tt.opts.MaxSize, actualOpts.MaxSize)
require.Equal(t, tt.opts.Timeout, actualOpts.Timeout)
require.Equal(t, tt.opts.Progress, actualOpts.Progress)
// BeforeFn is a function pointer, so we just check if both are nil or both are not nil
require.Equal(t, tt.opts.BeforeFn == nil, actualOpts.BeforeFn == nil)
}
})
}
@@ -287,7 +262,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
opts repository.CloneOptions
opts repository.StageOptions
path string
ref string
data []byte
@@ -301,7 +276,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -318,7 +293,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -333,7 +308,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -347,7 +322,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create blob failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -362,7 +337,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -378,7 +353,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(errors.New("push failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -419,7 +394,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
opts repository.CloneOptions
opts repository.StageOptions
path string
ref string
data []byte
@@ -435,7 +410,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -454,7 +429,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -470,7 +445,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -484,7 +459,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.BlobExistsReturns(false, errors.New("blob exists check failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -499,7 +474,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.BlobExistsReturns(false, nil)
mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -514,7 +489,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.BlobExistsReturns(true, nil)
mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -530,7 +505,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -570,7 +545,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
opts repository.CloneOptions
opts repository.StageOptions
path string
ref string
data []byte
@@ -584,7 +559,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -601,7 +576,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -616,7 +591,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -630,7 +605,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "directory/",
@@ -644,7 +619,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update blob failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -659,7 +634,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -699,7 +674,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
opts repository.CloneOptions
opts repository.StageOptions
path string
ref string
message string
@@ -712,7 +687,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
mockWriter.DeleteBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -728,7 +703,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: true,
},
path: "testdir/",
@@ -742,7 +717,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -755,7 +730,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.DeleteBlobReturns(hash.Hash{}, errors.New("delete blob failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -769,7 +744,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
mockWriter.DeleteBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
opts: repository.CloneOptions{
opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -808,7 +783,6 @@ func TestStagedGitRepository_Push(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
opts repository.PushOptions
wantError error
expectCalls int
}{
@@ -817,7 +791,6 @@ func TestStagedGitRepository_Push(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(nil)
},
opts: repository.PushOptions{},
wantError: nil,
expectCalls: 1,
},
@@ -826,11 +799,6 @@ func TestStagedGitRepository_Push(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(nil)
},
opts: repository.PushOptions{
BeforeFn: func() error {
return nil
},
},
wantError: nil,
expectCalls: 1,
},
@@ -839,31 +807,14 @@ func TestStagedGitRepository_Push(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(nil)
},
opts: repository.PushOptions{
Timeout: time.Second * 5,
},
wantError: nil,
expectCalls: 1,
},
{
name: "fails with before fn error",
setupMock: func(_ *mocks.FakeStagedWriter) {
// No setup needed as BeforeFn fails first
},
opts: repository.PushOptions{
BeforeFn: func() error {
return errors.New("before function failed")
},
},
wantError: errors.New("before function failed"),
expectCalls: 0,
},
{
name: "fails with push error",
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(errors.New("push failed"))
},
opts: repository.PushOptions{},
wantError: errors.New("push failed"),
expectCalls: 1,
},
@@ -874,9 +825,9 @@ func TestStagedGitRepository_Push(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
tt.setupMock(mockWriter)
stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{})
stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.StageOptions{})
err := stagedRepo.Push(context.Background(), tt.opts)
err := stagedRepo.Push(context.Background())
if tt.wantError != nil {
require.EqualError(t, err, tt.wantError.Error())
@@ -892,7 +843,7 @@ func TestStagedGitRepository_Push(t *testing.T) {
func TestStagedGitRepository_Remove(t *testing.T) {
t.Run("succeeds with remove", func(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{})
stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.StageOptions{})
err := stagedRepo.Remove(context.Background())
require.NoError(t, err)
@@ -904,10 +855,10 @@ func TestStagedGitRepository_Remove(t *testing.T) {
func createTestStagedRepository(mockClient *mocks.FakeClient) *stagedGitRepository {
mockWriter := &mocks.FakeStagedWriter{}
return createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{}, mockClient)
return createTestStagedRepositoryWithWriter(mockWriter, repository.StageOptions{}, mockClient)
}
func createTestStagedRepositoryWithWriter(mockWriter *mocks.FakeStagedWriter, opts repository.CloneOptions, mockClient ...*mocks.FakeClient) *stagedGitRepository {
func createTestStagedRepositoryWithWriter(mockWriter *mocks.FakeStagedWriter, opts repository.StageOptions, mockClient ...*mocks.FakeClient) *stagedGitRepository {
var client nanogit.Client
if len(mockClient) > 0 {
client = mockClient[0]

View File

@@ -1,683 +0,0 @@
package repository
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
type githubRepository struct {
config *provisioning.Repository
gh pgh.Client // assumes github.com base URL
secrets secrets.Service
owner string
repo string
cloneFn CloneFn
}
// GithubRepository is an interface that combines all repository capabilities
// needed for GitHub repositories.
//go:generate mockery --name GithubRepository --structname MockGithubRepository --inpackage --filename github_repository_mock.go --with-expecter
type GithubRepository interface {
Repository
Versioned
Writer
Reader
RepositoryWithURLs
ClonableRepository
Owner() string
Repo() string
Client() pgh.Client
}
func NewGitHub(
ctx context.Context,
config *provisioning.Repository,
factory *pgh.Factory,
secrets secrets.Service,
cloneFn CloneFn,
) (GithubRepository, error) {
owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
if err != nil {
return nil, fmt.Errorf("parse owner and repo: %w", err)
}
token := config.Spec.GitHub.Token
if token == "" {
decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("decrypt token: %w", err)
}
token = string(decrypted)
}
return &githubRepository{
config: config,
gh: factory.New(ctx, token), // TODO, baseURL from config
secrets: secrets,
owner: owner,
repo: repo,
cloneFn: cloneFn,
}, nil
}
func (r *githubRepository) Config() *provisioning.Repository {
return r.config
}
func (r *githubRepository) Owner() string {
return r.owner
}
func (r *githubRepository) Repo() string {
return r.repo
}
func (r *githubRepository) Client() pgh.Client {
return r.gh
}
// Validate implements provisioning.Repository.
func (r *githubRepository) Validate() (list field.ErrorList) {
gh := r.config.Spec.GitHub
if gh == nil {
list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
return list
}
if gh.URL == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
} else {
_, _, err := ParseOwnerRepoGithub(gh.URL)
if err != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
} else if !strings.HasPrefix(gh.URL, "https://github.com/") {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
}
}
if gh.Branch == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required"))
} else if !IsValidGitBranchName(gh.Branch) {
list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name"))
}
// TODO: Use two fields for token
if gh.Token == "" && len(gh.EncryptedToken) == 0 {
list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required"))
}
if err := safepath.IsSafe(gh.Path); err != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github", "prefix"), gh.Path, err.Error()))
}
if safepath.IsAbs(gh.Path) {
list = append(list, field.Invalid(field.NewPath("spec", "github", "prefix"), gh.Path, "path must be relative"))
}
return list
}
func ParseOwnerRepoGithub(giturl string) (owner string, repo string, err error) {
parsed, e := url.Parse(strings.TrimSuffix(giturl, ".git"))
if e != nil {
err = e
return
}
parts := strings.Split(parsed.Path, "/")
if len(parts) < 3 {
err = fmt.Errorf("unable to parse repo+owner from url")
return
}
return parts[1], parts[2], nil
}
// Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
if err := r.gh.IsAuthenticated(ctx); err != nil {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "token").String(),
Detail: err.Error(),
}}}, nil
}
url := r.config.Spec.GitHub.URL
owner, repo, err := ParseOwnerRepoGithub(url)
if err != nil {
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
// FIXME: check token permissions
ok, err := r.gh.RepoExists(ctx, owner, repo)
if err != nil {
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
if !ok {
return fromFieldError(field.NotFound(
field.NewPath("spec", "github", "url"), url)), nil
}
branch := r.config.Spec.GitHub.Branch
ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, branch)
if err != nil {
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "branch"), branch, err.Error())), nil
}
if !ok {
return fromFieldError(field.NotFound(
field.NewPath("spec", "github", "branch"), branch)), nil
}
return &provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil
}
// ReadResource implements provisioning.Repository.
func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*FileInfo, error) {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
finalPath := safepath.Join(r.config.Spec.GitHub.Path, filePath)
content, dirContent, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return nil, ErrFileNotFound
}
return nil, fmt.Errorf("get contents: %w", err)
}
if dirContent != nil {
return &FileInfo{
Path: filePath,
Ref: ref,
}, nil
}
data, err := content.GetFileContent()
if err != nil {
return nil, fmt.Errorf("get content: %w", err)
}
return &FileInfo{
Path: filePath,
Ref: ref,
Data: []byte(data),
Hash: content.GetSHA(),
}, nil
}
func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
ctx, _ = r.logger(ctx, ref)
tree, truncated, err := r.gh.GetTree(ctx, r.owner, r.repo, r.config.Spec.GitHub.Path, ref, true)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: fmt.Sprintf("tree not found; ref=%s", ref),
Code: http.StatusNotFound,
},
}
}
return nil, fmt.Errorf("get tree: %w", err)
}
if truncated {
return nil, fmt.Errorf("tree truncated")
}
entries := make([]FileTreeEntry, 0, len(tree))
for _, entry := range tree {
isBlob := !entry.IsDirectory()
// FIXME: this we could potentially do somewhere else on in a different way
filePath := entry.GetPath()
if !isBlob && !safepath.IsDir(filePath) {
filePath = filePath + "/"
}
converted := FileTreeEntry{
Path: filePath,
Size: entry.GetSize(),
Hash: entry.GetSHA(),
Blob: !entry.IsDirectory(),
}
entries = append(entries, converted)
}
return entries, nil
}
func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
ctx, _ = r.logger(ctx, ref)
if err := r.ensureBranchExists(ctx, ref); err != nil {
return err
}
finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
// Create .keep file if it is a directory
if safepath.IsDir(finalPath) {
if data != nil {
return apierrors.NewBadRequest("data cannot be provided for a directory")
}
finalPath = safepath.Join(finalPath, ".keep")
data = []byte{}
}
err := r.gh.CreateFile(ctx, r.owner, r.repo, finalPath, ref, comment, data)
if errors.Is(err, pgh.ErrResourceAlreadyExists) {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "file already exists",
Code: http.StatusConflict,
},
}
}
return err
}
func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
ctx, _ = r.logger(ctx, ref)
if err := r.ensureBranchExists(ctx, ref); err != nil {
return err
}
finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
file, _, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "file not found",
Code: http.StatusNotFound,
},
}
}
return fmt.Errorf("get content before file update: %w", err)
}
if file.IsDirectory() {
return apierrors.NewBadRequest("cannot update a directory")
}
if err := r.gh.UpdateFile(ctx, r.owner, r.repo, finalPath, ref, comment, file.GetSHA(), data); err != nil {
return fmt.Errorf("update file: %w", err)
}
return nil
}
func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
ctx, _ = r.logger(ctx, ref)
_, err := r.Read(ctx, path, ref)
if err != nil && !(errors.Is(err, ErrFileNotFound)) {
return fmt.Errorf("check if file exists before writing: %w", err)
}
if err == nil {
return r.Update(ctx, path, ref, data, message)
}
return r.Create(ctx, path, ref, data, message)
}
func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
ctx, _ = r.logger(ctx, ref)
if err := r.ensureBranchExists(ctx, ref); err != nil {
return err
}
// TODO: should add some protection against deleting the root directory?
// Inside deleteRecursively, all paths are relative to the root of the repository
// so we need to prepend the prefix there but only here.
finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
return r.deleteRecursively(ctx, finalPath, ref, comment)
}
func (r *githubRepository) deleteRecursively(ctx context.Context, path, ref, comment string) error {
file, contents, err := r.gh.GetContents(ctx, r.owner, r.repo, path, ref)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return ErrFileNotFound
}
return fmt.Errorf("find file to delete: %w", err)
}
if file != nil && !file.IsDirectory() {
return r.gh.DeleteFile(ctx, r.owner, r.repo, path, ref, comment, file.GetSHA())
}
for _, c := range contents {
p := c.GetPath()
if c.IsDirectory() {
if err := r.deleteRecursively(ctx, p, ref, comment); err != nil {
return fmt.Errorf("delete directory recursively: %w", err)
}
continue
}
if err := r.gh.DeleteFile(ctx, r.owner, r.repo, p, ref, comment, c.GetSHA()); err != nil {
return fmt.Errorf("delete file: %w", err)
}
}
return nil
}
func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
ctx, _ = r.logger(ctx, ref)
finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return nil, ErrFileNotFound
}
return nil, fmt.Errorf("get commits: %w", err)
}
ret := make([]provisioning.HistoryItem, 0, len(commits))
for _, commit := range commits {
authors := make([]provisioning.Author, 0)
if commit.Author != nil {
authors = append(authors, provisioning.Author{
Name: commit.Author.Name,
Username: commit.Author.Username,
AvatarURL: commit.Author.AvatarURL,
})
}
if commit.Committer != nil && commit.Author != nil && commit.Author.Name != commit.Committer.Name {
authors = append(authors, provisioning.Author{
Name: commit.Committer.Name,
Username: commit.Committer.Username,
AvatarURL: commit.Committer.AvatarURL,
})
}
ret = append(ret, provisioning.HistoryItem{
Ref: commit.Ref,
Message: commit.Message,
Authors: authors,
CreatedAt: commit.CreatedAt.UnixMilli(),
})
}
return ret, nil
}
// basicGitBranchNameRegex is a regular expression to validate a git branch name
// it does not cover all cases as positive lookaheads are not supported in Go's regexp
var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
// IsValidGitBranchName checks if a branch name is valid.
// It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
// 1. The branch name must have at least one character and must not be empty.
// 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
// 3. The branch name cannot contain consecutive slashes (`//`).
// 4. The branch name cannot contain consecutive dots (`..`).
// 5. The branch name cannot contain `@{`.
// 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
func IsValidGitBranchName(branch string) bool {
if !basicGitBranchNameRegex.MatchString(branch) {
return false
}
// Additional checks for invalid patterns
if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
return false
}
return true
}
func (r *githubRepository) ensureBranchExists(ctx context.Context, branchName string) error {
if !IsValidGitBranchName(branchName) {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusBadRequest,
Message: "invalid branch name",
},
}
}
ok, err := r.gh.BranchExists(ctx, r.owner, r.repo, branchName)
if err != nil {
return fmt.Errorf("check branch exists: %w", err)
}
if ok {
logging.FromContext(ctx).Info("branch already exists", "branch", branchName)
return nil
}
srcBranch := r.config.Spec.GitHub.Branch
if err := r.gh.CreateBranch(ctx, r.owner, r.repo, srcBranch, branchName); err != nil {
if errors.Is(err, pgh.ErrResourceAlreadyExists) {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusConflict,
Message: "branch already exists",
},
}
}
return fmt.Errorf("create branch: %w", err)
}
return nil
}
func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
ctx, _ = r.logger(ctx, "")
branch, err := r.gh.GetBranch(ctx, r.owner, r.repo, r.Config().Spec.GitHub.Branch)
if err != nil {
return "", fmt.Errorf("get branch: %w", err)
}
return branch.Sha, nil
}
func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]VersionedFileChange, error) {
if ref == "" {
var err error
ref, err = r.LatestRef(ctx)
if err != nil {
return nil, fmt.Errorf("get latest ref: %w", err)
}
}
ctx, logger := r.logger(ctx, ref)
files, err := r.gh.CompareCommits(ctx, r.owner, r.repo, base, ref)
if err != nil {
return nil, fmt.Errorf("compare commits: %w", err)
}
changes := make([]VersionedFileChange, 0)
for _, f := range files {
// reference: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
switch f.GetStatus() {
case "added", "copied":
currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: FileActionCreated,
})
case "modified", "changed":
currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: FileActionUpdated,
})
case "renamed":
previousPath, previousErr := safepath.RelativeTo(f.GetPreviousFilename(), r.config.Spec.GitHub.Path)
currentPath, currentErr := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
// Handle all possible combinations of path validation results:
// 1. Both paths outside configured path, do nothing
// 2. Both paths inside configured path, rename
// 3. Moving out of configured path, delete previous file
// 4. Moving into configured path, create new file
switch {
case previousErr != nil && currentErr != nil:
// do nothing as it's outside of configured path
case previousErr == nil && currentErr == nil:
changes = append(changes, VersionedFileChange{
Path: currentPath,
PreviousPath: previousPath,
Ref: ref,
PreviousRef: base,
Action: FileActionRenamed,
})
case previousErr == nil && currentErr != nil:
changes = append(changes, VersionedFileChange{
Path: previousPath,
Ref: base,
Action: FileActionDeleted,
})
case previousErr != nil && currentErr == nil:
changes = append(changes, VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: FileActionCreated,
})
}
case "removed":
currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, VersionedFileChange{
Ref: ref,
PreviousRef: base,
Path: currentPath,
PreviousPath: currentPath,
Action: FileActionDeleted,
})
case "unchanged":
// do nothing
default:
logger.Error("ignore unhandled file", "file", f.GetFilename(), "status", f.GetStatus())
}
}
return changes, nil
}
// ResourceURLs implements RepositoryWithURLs.
func (r *githubRepository) ResourceURLs(ctx context.Context, file *FileInfo) (*provisioning.ResourceURLs, error) {
cfg := r.config.Spec.GitHub
if file.Path == "" || cfg == nil {
return nil, nil
}
ref := file.Ref
if ref == "" {
ref = cfg.Branch
}
urls := &provisioning.ResourceURLs{
RepositoryURL: cfg.URL,
SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path),
}
if ref != cfg.Branch {
urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
// Create a new pull request
urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
}
return urls, nil
}
func (r *githubRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
return r.cloneFn(ctx, opts)
}
func (r *githubRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) {
logger := logging.FromContext(ctx)
type containsGh int
var containsGhKey containsGh
if ctx.Value(containsGhKey) != nil {
return ctx, logging.FromContext(ctx)
}
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
logger = logger.With(slog.Group("github_repository", "owner", r.owner, "name", r.repo, "ref", ref))
ctx = logging.Context(ctx, logger)
// We want to ensure we don't add multiple github_repository keys. With doesn't deduplicate the keys...
ctx = context.WithValue(ctx, containsGhKey, true)
return ctx, logger
}

View File

@@ -7,127 +7,34 @@ import (
"errors"
"time"
"github.com/google/go-github/v70/github"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
// API errors that we need to convey after parsing real GH errors (or faking them).
var (
ErrResourceAlreadyExists = errors.New("the resource already exists")
ErrResourceNotFound = errors.New("the resource does not exist")
ErrMismatchedHash = errors.New("the update cannot be applied because the expected and actual hashes are unequal")
ErrNoSecret = errors.New("new webhooks must have a secret")
ErrResourceNotFound = errors.New("the resource does not exist")
//lint:ignore ST1005 this is not punctuation
ErrPathTraversalDisallowed = errors.New("the path contained ..") //nolint:staticcheck
ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
ErrFileTooLarge = errors.New("file exceeds maximum allowed size")
ErrTooManyItems = errors.New("maximum number of items exceeded")
ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
ErrTooManyItems = errors.New("maximum number of items exceeded")
)
// MaxFileSize maximum file size limit (10MB)
const MaxFileSize = 10 * 1024 * 1024 // 10MB in bytes
type ErrRateLimited = github.RateLimitError
//go:generate mockery --name Client --structname MockClient --inpackage --filename mock_client.go --with-expecter
type Client interface {
// IsAuthenticated checks if the client is authenticated.
IsAuthenticated(ctx context.Context) error
// GetContents returns the metadata and content of a file or directory.
// When a file is checked, the first returned value will have a value. For a directory, the second will. The other value is always nil.
// If an error occurs, the returned values may or may not be nil.
//
// If ".." appears in the "path", this method will return an error.
GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error)
// GetTree returns the Git tree in the repository.
// When recursive is given, subtrees are mapped into the returned array.
// When basePath is given, only trees under it are given. The results do not include this path in their names.
//
// The truncated bool will be set to true if the tree is larger than 7 MB or 100 000 entries.
// When truncated is true, you may wish to read each subtree manually instead.
GetTree(ctx context.Context, owner, repository, basePath, ref string, recursive bool) (entries []RepositoryContent, truncated bool, err error)
// CreateFile creates a new file in the repository under the given path.
// The file is created on the branch given.
// The message given is the commit message. If none is given, an appropriate default is used.
// The content is what the file should contain. An empty slice is valid, though often not very useful.
//
// If ".." appears in the "path", this method will return an error.
CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error
// UpdateFile updates a file in the repository under the given path.
// The file is updated on the branch given.
// The message given is the commit message. If none is given, an appropriate default is used.
// The content is what the file should contain. An empty slice is valid, though often not very useful.
// If the path does not exist, an error is returned.
// The hash given must be the SHA hash of the file contents. Calling GetContents in advance is an easy way of handling this.
//
// If ".." appears in the "path", this method will return an error.
UpdateFile(ctx context.Context, owner, repository, path, branch, message, hash string, content []byte) error
// DeleteFile deletes a file in the repository under the given path.
// The file is deleted from the branch given.
// The message given is the commit message. If none is given, an appropriate default is used.
// If the path does not exist, an error is returned.
// The hash given must be the SHA hash of the file contents. Calling GetContents in advance is an easy way of handling this.
//
// If ".." appears in the "path", this method will return an error.
DeleteFile(ctx context.Context, owner, repository, path, branch, message, hash string) error
// Commits returns the commits for the given path
// Commits
Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error)
// CompareCommits returns the changes between two commits.
CompareCommits(ctx context.Context, owner, repository, base, head string) ([]CommitFile, error)
// RepoExists checks if a repository exists.
RepoExists(ctx context.Context, owner, repository string) (bool, error)
// CreateBranch creates a new branch in the repository.
CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error
// BranchExists checks if a branch exists in the repository.
BranchExists(ctx context.Context, owner, repository, branchName string) (bool, error)
// GetBranch returns the branch of the repository.
GetBranch(ctx context.Context, owner, repository, branchName string) (Branch, error)
// Webhooks
ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error)
CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) (WebhookConfig, error)
GetWebhook(ctx context.Context, owner, repository string, webhookID int64) (WebhookConfig, error)
DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error
EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error
// Pull requests
ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error)
CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error
}
//go:generate mockery --name RepositoryContent --structname MockRepositoryContent --inpackage --filename mock_repository_content.go --with-expecter
type RepositoryContent interface {
// Returns true if this is a directory, false if it is a file.
IsDirectory() bool
// Returns the contents of the file. Decoding happens if necessary.
// Returns an error if the content represents a directory.
GetFileContent() (string, error)
// Returns true if this is a symlink.
// If true, GetPath returns the path where this symlink leads.
IsSymlink() bool
// Returns the full path from the root of the repository.
// This has no leading or trailing slashes.
// The path only uses '/' for directories. You can use the 'path' package to interact with these.
GetPath() string
// Get the SHA hash. This is usually a SHA-256, but may also be SHA-512.
// Directories have SHA hashes, too (TODO: how is this calculated?).
GetSHA() string
// The size of the file. Not necessarily non-zero, even if the file is supposed to be non-zero.
GetSize() int64
}
type Branch struct {
Name string
Sha string
}
type CommitAuthor struct {
Name string
Username string
@@ -150,20 +57,6 @@ type CommitFile interface {
GetStatus() string
}
type FileComment struct {
Content string
Path string
Position int
Ref string
}
type CreateFileOptions struct {
// The message of the commit. May be empty, in which case a default value is entered.
Message string
// The content of the file to write, unencoded.
Content []byte
}
type WebhookConfig struct {
// The ID of the webhook.
// Can be 0 on creation.

View File

@@ -27,6 +27,11 @@ func (r *Factory) New(ctx context.Context, ghToken string) Client {
tokenSrc := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: ghToken},
)
tokenClient := oauth2.NewClient(ctx, tokenSrc)
return NewClient(github.NewClient(tokenClient))
if len(ghToken) == 0 {
tokenClient := oauth2.NewClient(ctx, tokenSrc)
return NewClient(github.NewClient(tokenClient))
}
return NewClient(github.NewClient(&http.Client{}))
}

View File

@@ -1,14 +1,14 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
package github
import (
context "context"
github "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
mock "github.com/stretchr/testify/mock"
field "k8s.io/apimachinery/pkg/util/validation/field"
mock "github.com/stretchr/testify/mock"
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
@@ -27,19 +27,19 @@ func (_m *MockGithubRepository) EXPECT() *MockGithubRepository_Expecter {
}
// Client provides a mock function with no fields
func (_m *MockGithubRepository) Client() github.Client {
func (_m *MockGithubRepository) Client() Client {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Client")
}
var r0 github.Client
if rf, ok := ret.Get(0).(func() github.Client); ok {
var r0 Client
if rf, ok := ret.Get(0).(func() Client); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(github.Client)
r0 = ret.Get(0).(Client)
}
}
@@ -63,93 +63,34 @@ func (_c *MockGithubRepository_Client_Call) Run(run func()) *MockGithubRepositor
return _c
}
func (_c *MockGithubRepository_Client_Call) Return(_a0 github.Client) *MockGithubRepository_Client_Call {
func (_c *MockGithubRepository_Client_Call) Return(_a0 Client) *MockGithubRepository_Client_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGithubRepository_Client_Call) RunAndReturn(run func() github.Client) *MockGithubRepository_Client_Call {
_c.Call.Return(run)
return _c
}
// Clone provides a mock function with given fields: ctx, opts
func (_m *MockGithubRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Clone")
}
var r0 ClonedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(ClonedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone'
type MockGithubRepository_Clone_Call struct {
*mock.Call
}
// Clone is a helper method to define mock.On call
// - ctx context.Context
// - opts CloneOptions
func (_e *MockGithubRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockGithubRepository_Clone_Call {
return &MockGithubRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)}
}
func (_c *MockGithubRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockGithubRepository_Clone_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(CloneOptions))
})
return _c
}
func (_c *MockGithubRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockGithubRepository_Clone_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockGithubRepository_Clone_Call {
func (_c *MockGithubRepository_Client_Call) RunAndReturn(run func() Client) *MockGithubRepository_Client_Call {
_c.Call.Return(run)
return _c
}
// CompareFiles provides a mock function with given fields: ctx, base, ref
func (_m *MockGithubRepository) CompareFiles(ctx context.Context, base string, ref string) ([]VersionedFileChange, error) {
func (_m *MockGithubRepository) CompareFiles(ctx context.Context, base string, ref string) ([]repository.VersionedFileChange, error) {
ret := _m.Called(ctx, base, ref)
if len(ret) == 0 {
panic("no return value specified for CompareFiles")
}
var r0 []VersionedFileChange
var r0 []repository.VersionedFileChange
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]VersionedFileChange, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]repository.VersionedFileChange, error)); ok {
return rf(ctx, base, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) []VersionedFileChange); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) []repository.VersionedFileChange); ok {
r0 = rf(ctx, base, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]VersionedFileChange)
r0 = ret.Get(0).([]repository.VersionedFileChange)
}
}
@@ -182,12 +123,12 @@ func (_c *MockGithubRepository_CompareFiles_Call) Run(run func(ctx context.Conte
return _c
}
func (_c *MockGithubRepository_CompareFiles_Call) Return(_a0 []VersionedFileChange, _a1 error) *MockGithubRepository_CompareFiles_Call {
func (_c *MockGithubRepository_CompareFiles_Call) Return(_a0 []repository.VersionedFileChange, _a1 error) *MockGithubRepository_CompareFiles_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]VersionedFileChange, error)) *MockGithubRepository_CompareFiles_Call {
func (_c *MockGithubRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]repository.VersionedFileChange, error)) *MockGithubRepository_CompareFiles_Call {
_c.Call.Return(run)
return _c
}
@@ -500,23 +441,23 @@ func (_c *MockGithubRepository_Owner_Call) RunAndReturn(run func() string) *Mock
}
// Read provides a mock function with given fields: ctx, path, ref
func (_m *MockGithubRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
func (_m *MockGithubRepository) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
ret := _m.Called(ctx, path, ref)
if len(ret) == 0 {
panic("no return value specified for Read")
}
var r0 *FileInfo
var r0 *repository.FileInfo
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*repository.FileInfo, error)); ok {
return rf(ctx, path, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok {
if rf, ok := ret.Get(0).(func(context.Context, string, string) *repository.FileInfo); ok {
r0 = rf(ctx, path, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*FileInfo)
r0 = ret.Get(0).(*repository.FileInfo)
}
}
@@ -549,34 +490,34 @@ func (_c *MockGithubRepository_Read_Call) Run(run func(ctx context.Context, path
return _c
}
func (_c *MockGithubRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockGithubRepository_Read_Call {
func (_c *MockGithubRepository_Read_Call) Return(_a0 *repository.FileInfo, _a1 error) *MockGithubRepository_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockGithubRepository_Read_Call {
func (_c *MockGithubRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*repository.FileInfo, error)) *MockGithubRepository_Read_Call {
_c.Call.Return(run)
return _c
}
// ReadTree provides a mock function with given fields: ctx, ref
func (_m *MockGithubRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
func (_m *MockGithubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
ret := _m.Called(ctx, ref)
if len(ret) == 0 {
panic("no return value specified for ReadTree")
}
var r0 []FileTreeEntry
var r0 []repository.FileTreeEntry
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, string) ([]repository.FileTreeEntry, error)); ok {
return rf(ctx, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok {
if rf, ok := ret.Get(0).(func(context.Context, string) []repository.FileTreeEntry); ok {
r0 = rf(ctx, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]FileTreeEntry)
r0 = ret.Get(0).([]repository.FileTreeEntry)
}
}
@@ -608,12 +549,12 @@ func (_c *MockGithubRepository_ReadTree_Call) Run(run func(ctx context.Context,
return _c
}
func (_c *MockGithubRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockGithubRepository_ReadTree_Call {
func (_c *MockGithubRepository_ReadTree_Call) Return(_a0 []repository.FileTreeEntry, _a1 error) *MockGithubRepository_ReadTree_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockGithubRepository_ReadTree_Call {
func (_c *MockGithubRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]repository.FileTreeEntry, error)) *MockGithubRepository_ReadTree_Call {
_c.Call.Return(run)
return _c
}
@@ -664,7 +605,7 @@ func (_c *MockGithubRepository_Repo_Call) RunAndReturn(run func() string) *MockG
}
// ResourceURLs provides a mock function with given fields: ctx, file
func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo) (*v0alpha1.ResourceURLs, error) {
func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*v0alpha1.ResourceURLs, error) {
ret := _m.Called(ctx, file)
if len(ret) == 0 {
@@ -673,10 +614,10 @@ func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo
var r0 *v0alpha1.ResourceURLs
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) (*v0alpha1.ResourceURLs, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) (*v0alpha1.ResourceURLs, error)); ok {
return rf(ctx, file)
}
if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) *v0alpha1.ResourceURLs); ok {
if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) *v0alpha1.ResourceURLs); ok {
r0 = rf(ctx, file)
} else {
if ret.Get(0) != nil {
@@ -684,7 +625,7 @@ func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo
}
}
if rf, ok := ret.Get(1).(func(context.Context, *FileInfo) error); ok {
if rf, ok := ret.Get(1).(func(context.Context, *repository.FileInfo) error); ok {
r1 = rf(ctx, file)
} else {
r1 = ret.Error(1)
@@ -700,14 +641,14 @@ type MockGithubRepository_ResourceURLs_Call struct {
// ResourceURLs is a helper method to define mock.On call
// - ctx context.Context
// - file *FileInfo
// - file *repository.FileInfo
func (_e *MockGithubRepository_Expecter) ResourceURLs(ctx interface{}, file interface{}) *MockGithubRepository_ResourceURLs_Call {
return &MockGithubRepository_ResourceURLs_Call{Call: _e.mock.On("ResourceURLs", ctx, file)}
}
func (_c *MockGithubRepository_ResourceURLs_Call) Run(run func(ctx context.Context, file *FileInfo)) *MockGithubRepository_ResourceURLs_Call {
func (_c *MockGithubRepository_ResourceURLs_Call) Run(run func(ctx context.Context, file *repository.FileInfo)) *MockGithubRepository_ResourceURLs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*FileInfo))
run(args[0].(context.Context), args[1].(*repository.FileInfo))
})
return _c
}
@@ -717,7 +658,66 @@ func (_c *MockGithubRepository_ResourceURLs_Call) Return(_a0 *v0alpha1.ResourceU
return _c
}
func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *FileInfo) (*v0alpha1.ResourceURLs, error)) *MockGithubRepository_ResourceURLs_Call {
func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *repository.FileInfo) (*v0alpha1.ResourceURLs, error)) *MockGithubRepository_ResourceURLs_Call {
_c.Call.Return(run)
return _c
}
// Stage provides a mock function with given fields: ctx, opts
func (_m *MockGithubRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Stage")
}
var r0 repository.StagedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) (repository.StagedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) repository.StagedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(repository.StagedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, repository.StageOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_Stage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stage'
type MockGithubRepository_Stage_Call struct {
*mock.Call
}
// Stage is a helper method to define mock.On call
// - ctx context.Context
// - opts repository.StageOptions
func (_e *MockGithubRepository_Expecter) Stage(ctx interface{}, opts interface{}) *MockGithubRepository_Stage_Call {
return &MockGithubRepository_Stage_Call{Call: _e.mock.On("Stage", ctx, opts)}
}
func (_c *MockGithubRepository_Stage_Call) Run(run func(ctx context.Context, opts repository.StageOptions)) *MockGithubRepository_Stage_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.StageOptions))
})
return _c
}
func (_c *MockGithubRepository_Stage_Call) Return(_a0 repository.StagedRepository, _a1 error) *MockGithubRepository_Stage_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_Stage_Call) RunAndReturn(run func(context.Context, repository.StageOptions) (repository.StagedRepository, error)) *MockGithubRepository_Stage_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -8,10 +8,6 @@ import (
"time"
"github.com/google/go-github/v70/github"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
type githubClient struct {
@@ -22,268 +18,12 @@ func NewClient(client *github.Client) Client {
return &githubClient{client}
}
func (r *githubClient) IsAuthenticated(ctx context.Context) error {
if _, _, err := r.gh.Users.Get(ctx, ""); err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
switch ghErr.Response.StatusCode {
case http.StatusUnauthorized:
return apierrors.NewUnauthorized("token is invalid or expired")
case http.StatusForbidden:
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusUnauthorized,
Reason: metav1.StatusReasonUnauthorized,
Message: "token is revoked or has insufficient permissions",
},
}
case http.StatusServiceUnavailable:
return ErrServiceUnavailable
}
}
return err
}
return nil
}
func (r *githubClient) RepoExists(ctx context.Context, owner, repository string) (bool, error) {
_, resp, err := r.gh.Repositories.Get(ctx, owner, repository)
if err == nil {
return true, nil
}
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
return false, err
}
const (
maxDirectoryItems = 1000 // Maximum number of items allowed in a directory
maxTreeItems = 10000 // Maximum number of items allowed in a tree
maxCommits = 1000 // Maximum number of commits to fetch
maxCompareFiles = 1000 // Maximum number of files to compare between commits
maxWebhooks = 100 // Maximum number of webhooks allowed per repository
maxPRFiles = 1000 // Maximum number of files allowed in a pull request
maxPullRequestsFileComments = 1000 // Maximum number of comments allowed in a pull request
maxFileSize = 10 * 1024 * 1024 // 10MB in bytes
maxCommits = 1000 // Maximum number of commits to fetch
maxWebhooks = 100 // Maximum number of webhooks allowed per repository
maxPRFiles = 1000 // Maximum number of files allowed in a pull request
)
func (r *githubClient) GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error) {
// First try to get repository contents
opts := &github.RepositoryContentGetOptions{
Ref: ref,
}
fc, dc, _, err := r.gh.Repositories.GetContents(ctx, owner, repository, path, opts)
if err != nil {
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return nil, nil, err
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, nil, ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return nil, nil, ErrResourceNotFound
}
return nil, nil, err
}
if fc != nil {
// Check file size before returning content
if fc.GetSize() > maxFileSize {
return nil, nil, ErrFileTooLarge
}
return realRepositoryContent{fc}, nil, nil
}
// For directories, check size limits
if len(dc) > maxDirectoryItems {
return nil, nil, fmt.Errorf("directory contains too many items (more than %d)", maxDirectoryItems)
}
// Convert directory contents
allContents := make([]RepositoryContent, 0, len(dc))
for _, original := range dc {
allContents = append(allContents, realRepositoryContent{original})
}
return nil, allContents, nil
}
func (r *githubClient) GetTree(ctx context.Context, owner, repository, basePath, ref string, recursive bool) ([]RepositoryContent, bool, error) {
var tree *github.Tree
var err error
subPaths := safepath.Split(basePath)
currentRef := ref
for {
// If subPaths is empty, we can read recursively, as we're reading the tree from the "base" of the repository. Otherwise, always read only the direct children.
recursive := recursive && len(subPaths) == 0
tree, _, err = r.gh.Git.GetTree(ctx, owner, repository, currentRef, recursive)
if err != nil {
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return nil, false, err
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, false, ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
if currentRef != ref {
// We're operating with a subpath which doesn't exist yet.
// Pretend as if there is simply no files.
// FIXME: why should we pretend this?
return nil, false, nil
}
// currentRef == ref
// This indicates the repository or commitish reference doesn't exist. This should always return an error.
return nil, false, ErrResourceNotFound
}
return nil, false, err
}
// Check if we've exceeded the maximum allowed items
if len(tree.Entries) > maxTreeItems {
return nil, false, fmt.Errorf("tree contains too many items (more than %d)", maxTreeItems)
}
// Prep for next iteration.
if len(subPaths) == 0 {
// We're done: we've discovered the tree we want.
break
}
// the ref must be equal the SHA of the entry corresponding to subPaths[0]
currentRef = ""
for _, e := range tree.Entries {
if e.GetPath() == subPaths[0] {
currentRef = e.GetSHA()
break
}
}
subPaths = subPaths[1:]
if currentRef == "" {
// We couldn't find the folder in the tree...
return nil, false, nil
}
}
// If the tree is truncated and we're in recursive mode, return an error
if tree.GetTruncated() && recursive {
return nil, true, fmt.Errorf("tree is too large to fetch recursively (more than %d items)", maxTreeItems)
}
entries := make([]RepositoryContent, 0, len(tree.Entries))
for _, te := range tree.Entries {
rrc := &realRepositoryContent{
real: &github.RepositoryContent{
Path: te.Path,
Size: te.Size,
SHA: te.SHA,
},
}
if te.GetType() == "tree" {
rrc.real.Type = github.Ptr("dir")
} else {
rrc.real.Type = te.Type
}
entries = append(entries, rrc)
}
return entries, tree.GetTruncated(), nil
}
func (r *githubClient) CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error {
if message == "" {
message = fmt.Sprintf("Create %s", path)
}
_, _, err := r.gh.Repositories.CreateFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
Branch: &branch,
Message: &message,
Content: content,
})
if err == nil {
return nil
}
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return err
}
if ghErr.Response.StatusCode == http.StatusUnprocessableEntity {
return ErrResourceAlreadyExists
}
return err
}
func (r *githubClient) UpdateFile(ctx context.Context, owner, repository, path, branch, message, hash string, content []byte) error {
if message == "" {
message = fmt.Sprintf("Update %s", path)
}
_, _, err := r.gh.Repositories.UpdateFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
Branch: &branch,
Message: &message,
Content: content,
SHA: &hash,
})
if err == nil {
return nil
}
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return err
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return ErrResourceNotFound
}
if ghErr.Response.StatusCode == http.StatusConflict {
return ErrMismatchedHash
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return ErrServiceUnavailable
}
return err
}
func (r *githubClient) DeleteFile(ctx context.Context, owner, repository, path, branch, message, hash string) error {
if message == "" {
message = fmt.Sprintf("Delete %s", path)
}
_, _, err := r.gh.Repositories.DeleteFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
Branch: &branch,
Message: &message,
SHA: &hash,
})
if err == nil {
return nil
}
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return err
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return ErrResourceNotFound
}
if ghErr.Response.StatusCode == http.StatusConflict {
return ErrMismatchedHash
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return ErrServiceUnavailable
}
return err
}
// Commits returns a list of commits for a given repository and branch.
func (r *githubClient) Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
@@ -343,105 +83,6 @@ func (r *githubClient) Commits(ctx context.Context, owner, repository, path, bra
return ret, nil
}
func (r *githubClient) CompareCommits(ctx context.Context, owner, repository, base, head string) ([]CommitFile, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.CommitFile, *github.Response, error) {
compare, resp, err := r.gh.Repositories.CompareCommits(ctx, owner, repository, base, head, opts)
if err != nil {
return nil, resp, err
}
return compare.Files, resp, nil
}
files, err := paginatedList(
ctx,
listFn,
defaultListOptions(maxCompareFiles),
)
if errors.Is(err, ErrTooManyItems) {
return nil, fmt.Errorf("too many files changed between commits (more than %d)", maxCompareFiles)
}
if err != nil {
return nil, err
}
// Convert to the interface type
ret := make([]CommitFile, 0, len(files))
for _, f := range files {
ret = append(ret, f)
}
return ret, nil
}
func (r *githubClient) GetBranch(ctx context.Context, owner, repository, branchName string) (Branch, error) {
branch, resp, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0)
if err != nil {
// For some reason, GitHub client handles this case differently by failing with a wrapped error
if resp != nil && resp.StatusCode == http.StatusNotFound {
return Branch{}, ErrResourceNotFound
}
if resp != nil && resp.StatusCode == http.StatusServiceUnavailable {
return Branch{}, ErrServiceUnavailable
}
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return Branch{}, err
}
// Leaving these just in case
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return Branch{}, ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return Branch{}, ErrResourceNotFound
}
return Branch{}, err
}
return Branch{
Name: branch.GetName(),
Sha: branch.GetCommit().GetSHA(),
}, nil
}
func (r *githubClient) CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error {
// Fail if the branch already exists
if _, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0); err == nil {
return ErrResourceAlreadyExists
}
// Branch out based on the repository branch
baseRef, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, sourceBranch, 0)
if err != nil {
return fmt.Errorf("get base branch: %w", err)
}
if _, _, err := r.gh.Git.CreateRef(ctx, owner, repository, &github.Reference{
Ref: github.Ptr(fmt.Sprintf("refs/heads/%s", branchName)),
Object: &github.GitObject{
SHA: baseRef.Commit.SHA,
},
}); err != nil {
return fmt.Errorf("create branch ref: %w", err)
}
return nil
}
func (r *githubClient) BranchExists(ctx context.Context, owner, repository, branchName string) (bool, error) {
_, resp, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0)
if err == nil {
return true, nil
}
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
return false, err
}
func (r *githubClient) ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) {
return r.gh.Repositories.ListHooks(ctx, owner, repository, opts)
@@ -626,44 +267,6 @@ func (r *githubClient) CreatePullRequestComment(ctx context.Context, owner, repo
return nil
}
type realRepositoryContent struct {
real *github.RepositoryContent
}
var _ RepositoryContent = realRepositoryContent{}
func (c realRepositoryContent) IsDirectory() bool {
return c.real.GetType() == "dir"
}
func (c realRepositoryContent) GetFileContent() (string, error) {
return c.real.GetContent()
}
func (c realRepositoryContent) IsSymlink() bool {
return c.real.Target != nil
}
func (c realRepositoryContent) GetPath() string {
return c.real.GetPath()
}
func (c realRepositoryContent) GetSHA() string {
return c.real.GetSHA()
}
func (c realRepositoryContent) GetSize() int64 {
if c.real.Size != nil {
return int64(*c.real.Size)
}
if c.real.Content != nil {
if c, err := c.real.GetContent(); err == nil {
return int64(len(c))
}
}
return 0
}
// listOptions represents pagination parameters for list operations
type listOptions struct {
github.ListOptions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package github
@@ -21,65 +21,6 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// BranchExists provides a mock function with given fields: ctx, owner, repository, branchName
func (_m *MockClient) BranchExists(ctx context.Context, owner string, repository string, branchName string) (bool, error) {
ret := _m.Called(ctx, owner, repository, branchName)
if len(ret) == 0 {
panic("no return value specified for BranchExists")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (bool, error)); ok {
return rf(ctx, owner, repository, branchName)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) bool); ok {
r0 = rf(ctx, owner, repository, branchName)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
r1 = rf(ctx, owner, repository, branchName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_BranchExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BranchExists'
type MockClient_BranchExists_Call struct {
*mock.Call
}
// BranchExists is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - branchName string
func (_e *MockClient_Expecter) BranchExists(ctx interface{}, owner interface{}, repository interface{}, branchName interface{}) *MockClient_BranchExists_Call {
return &MockClient_BranchExists_Call{Call: _e.mock.On("BranchExists", ctx, owner, repository, branchName)}
}
func (_c *MockClient_BranchExists_Call) Run(run func(ctx context.Context, owner string, repository string, branchName string)) *MockClient_BranchExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockClient_BranchExists_Call) Return(_a0 bool, _a1 error) *MockClient_BranchExists_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_BranchExists_Call) RunAndReturn(run func(context.Context, string, string, string) (bool, error)) *MockClient_BranchExists_Call {
_c.Call.Return(run)
return _c
}
// Commits provides a mock function with given fields: ctx, owner, repository, path, branch
func (_m *MockClient) Commits(ctx context.Context, owner string, repository string, path string, branch string) ([]Commit, error) {
ret := _m.Called(ctx, owner, repository, path, branch)
@@ -142,170 +83,6 @@ func (_c *MockClient_Commits_Call) RunAndReturn(run func(context.Context, string
return _c
}
// CompareCommits provides a mock function with given fields: ctx, owner, repository, base, head
func (_m *MockClient) CompareCommits(ctx context.Context, owner string, repository string, base string, head string) ([]CommitFile, error) {
ret := _m.Called(ctx, owner, repository, base, head)
if len(ret) == 0 {
panic("no return value specified for CompareCommits")
}
var r0 []CommitFile
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) ([]CommitFile, error)); ok {
return rf(ctx, owner, repository, base, head)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) []CommitFile); ok {
r0 = rf(ctx, owner, repository, base, head)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]CommitFile)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok {
r1 = rf(ctx, owner, repository, base, head)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_CompareCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompareCommits'
type MockClient_CompareCommits_Call struct {
*mock.Call
}
// CompareCommits is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - base string
// - head string
func (_e *MockClient_Expecter) CompareCommits(ctx interface{}, owner interface{}, repository interface{}, base interface{}, head interface{}) *MockClient_CompareCommits_Call {
return &MockClient_CompareCommits_Call{Call: _e.mock.On("CompareCommits", ctx, owner, repository, base, head)}
}
func (_c *MockClient_CompareCommits_Call) Run(run func(ctx context.Context, owner string, repository string, base string, head string)) *MockClient_CompareCommits_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
})
return _c
}
func (_c *MockClient_CompareCommits_Call) Return(_a0 []CommitFile, _a1 error) *MockClient_CompareCommits_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_CompareCommits_Call) RunAndReturn(run func(context.Context, string, string, string, string) ([]CommitFile, error)) *MockClient_CompareCommits_Call {
_c.Call.Return(run)
return _c
}
// CreateBranch provides a mock function with given fields: ctx, owner, repository, sourceBranch, branchName
func (_m *MockClient) CreateBranch(ctx context.Context, owner string, repository string, sourceBranch string, branchName string) error {
ret := _m.Called(ctx, owner, repository, sourceBranch, branchName)
if len(ret) == 0 {
panic("no return value specified for CreateBranch")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok {
r0 = rf(ctx, owner, repository, sourceBranch, branchName)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_CreateBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateBranch'
type MockClient_CreateBranch_Call struct {
*mock.Call
}
// CreateBranch is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - sourceBranch string
// - branchName string
func (_e *MockClient_Expecter) CreateBranch(ctx interface{}, owner interface{}, repository interface{}, sourceBranch interface{}, branchName interface{}) *MockClient_CreateBranch_Call {
return &MockClient_CreateBranch_Call{Call: _e.mock.On("CreateBranch", ctx, owner, repository, sourceBranch, branchName)}
}
func (_c *MockClient_CreateBranch_Call) Run(run func(ctx context.Context, owner string, repository string, sourceBranch string, branchName string)) *MockClient_CreateBranch_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
})
return _c
}
func (_c *MockClient_CreateBranch_Call) Return(_a0 error) *MockClient_CreateBranch_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_CreateBranch_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *MockClient_CreateBranch_Call {
_c.Call.Return(run)
return _c
}
// CreateFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, content
func (_m *MockClient) CreateFile(ctx context.Context, owner string, repository string, path string, branch string, message string, content []byte) error {
ret := _m.Called(ctx, owner, repository, path, branch, message, content)
if len(ret) == 0 {
panic("no return value specified for CreateFile")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, []byte) error); ok {
r0 = rf(ctx, owner, repository, path, branch, message, content)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_CreateFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateFile'
type MockClient_CreateFile_Call struct {
*mock.Call
}
// CreateFile is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - path string
// - branch string
// - message string
// - content []byte
func (_e *MockClient_Expecter) CreateFile(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}, message interface{}, content interface{}) *MockClient_CreateFile_Call {
return &MockClient_CreateFile_Call{Call: _e.mock.On("CreateFile", ctx, owner, repository, path, branch, message, content)}
}
func (_c *MockClient_CreateFile_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string, message string, content []byte)) *MockClient_CreateFile_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].([]byte))
})
return _c
}
func (_c *MockClient_CreateFile_Call) Return(_a0 error) *MockClient_CreateFile_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_CreateFile_Call) RunAndReturn(run func(context.Context, string, string, string, string, string, []byte) error) *MockClient_CreateFile_Call {
_c.Call.Return(run)
return _c
}
// CreatePullRequestComment provides a mock function with given fields: ctx, owner, repository, number, body
func (_m *MockClient) CreatePullRequestComment(ctx context.Context, owner string, repository string, number int, body string) error {
ret := _m.Called(ctx, owner, repository, number, body)
@@ -415,58 +192,6 @@ func (_c *MockClient_CreateWebhook_Call) RunAndReturn(run func(context.Context,
return _c
}
// DeleteFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, hash
func (_m *MockClient) DeleteFile(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string) error {
ret := _m.Called(ctx, owner, repository, path, branch, message, hash)
if len(ret) == 0 {
panic("no return value specified for DeleteFile")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, string) error); ok {
r0 = rf(ctx, owner, repository, path, branch, message, hash)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_DeleteFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFile'
type MockClient_DeleteFile_Call struct {
*mock.Call
}
// DeleteFile is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - path string
// - branch string
// - message string
// - hash string
func (_e *MockClient_Expecter) DeleteFile(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}, message interface{}, hash interface{}) *MockClient_DeleteFile_Call {
return &MockClient_DeleteFile_Call{Call: _e.mock.On("DeleteFile", ctx, owner, repository, path, branch, message, hash)}
}
func (_c *MockClient_DeleteFile_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string)) *MockClient_DeleteFile_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].(string))
})
return _c
}
func (_c *MockClient_DeleteFile_Call) Return(_a0 error) *MockClient_DeleteFile_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_DeleteFile_Call) RunAndReturn(run func(context.Context, string, string, string, string, string, string) error) *MockClient_DeleteFile_Call {
_c.Call.Return(run)
return _c
}
// DeleteWebhook provides a mock function with given fields: ctx, owner, repository, webhookID
func (_m *MockClient) DeleteWebhook(ctx context.Context, owner string, repository string, webhookID int64) error {
ret := _m.Called(ctx, owner, repository, webhookID)
@@ -565,206 +290,6 @@ func (_c *MockClient_EditWebhook_Call) RunAndReturn(run func(context.Context, st
return _c
}
// GetBranch provides a mock function with given fields: ctx, owner, repository, branchName
func (_m *MockClient) GetBranch(ctx context.Context, owner string, repository string, branchName string) (Branch, error) {
ret := _m.Called(ctx, owner, repository, branchName)
if len(ret) == 0 {
panic("no return value specified for GetBranch")
}
var r0 Branch
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (Branch, error)); ok {
return rf(ctx, owner, repository, branchName)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) Branch); ok {
r0 = rf(ctx, owner, repository, branchName)
} else {
r0 = ret.Get(0).(Branch)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
r1 = rf(ctx, owner, repository, branchName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBranch'
type MockClient_GetBranch_Call struct {
*mock.Call
}
// GetBranch is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - branchName string
func (_e *MockClient_Expecter) GetBranch(ctx interface{}, owner interface{}, repository interface{}, branchName interface{}) *MockClient_GetBranch_Call {
return &MockClient_GetBranch_Call{Call: _e.mock.On("GetBranch", ctx, owner, repository, branchName)}
}
func (_c *MockClient_GetBranch_Call) Run(run func(ctx context.Context, owner string, repository string, branchName string)) *MockClient_GetBranch_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockClient_GetBranch_Call) Return(_a0 Branch, _a1 error) *MockClient_GetBranch_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_GetBranch_Call) RunAndReturn(run func(context.Context, string, string, string) (Branch, error)) *MockClient_GetBranch_Call {
_c.Call.Return(run)
return _c
}
// GetContents provides a mock function with given fields: ctx, owner, repository, path, ref
func (_m *MockClient) GetContents(ctx context.Context, owner string, repository string, path string, ref string) (RepositoryContent, []RepositoryContent, error) {
ret := _m.Called(ctx, owner, repository, path, ref)
if len(ret) == 0 {
panic("no return value specified for GetContents")
}
var r0 RepositoryContent
var r1 []RepositoryContent
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) (RepositoryContent, []RepositoryContent, error)); ok {
return rf(ctx, owner, repository, path, ref)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) RepositoryContent); ok {
r0 = rf(ctx, owner, repository, path, ref)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(RepositoryContent)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) []RepositoryContent); ok {
r1 = rf(ctx, owner, repository, path, ref)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).([]RepositoryContent)
}
}
if rf, ok := ret.Get(2).(func(context.Context, string, string, string, string) error); ok {
r2 = rf(ctx, owner, repository, path, ref)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// MockClient_GetContents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContents'
type MockClient_GetContents_Call struct {
*mock.Call
}
// GetContents is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - path string
// - ref string
func (_e *MockClient_Expecter) GetContents(ctx interface{}, owner interface{}, repository interface{}, path interface{}, ref interface{}) *MockClient_GetContents_Call {
return &MockClient_GetContents_Call{Call: _e.mock.On("GetContents", ctx, owner, repository, path, ref)}
}
func (_c *MockClient_GetContents_Call) Run(run func(ctx context.Context, owner string, repository string, path string, ref string)) *MockClient_GetContents_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
})
return _c
}
func (_c *MockClient_GetContents_Call) Return(fileContents RepositoryContent, dirContents []RepositoryContent, err error) *MockClient_GetContents_Call {
_c.Call.Return(fileContents, dirContents, err)
return _c
}
func (_c *MockClient_GetContents_Call) RunAndReturn(run func(context.Context, string, string, string, string) (RepositoryContent, []RepositoryContent, error)) *MockClient_GetContents_Call {
_c.Call.Return(run)
return _c
}
// GetTree provides a mock function with given fields: ctx, owner, repository, basePath, ref, recursive
func (_m *MockClient) GetTree(ctx context.Context, owner string, repository string, basePath string, ref string, recursive bool) ([]RepositoryContent, bool, error) {
ret := _m.Called(ctx, owner, repository, basePath, ref, recursive)
if len(ret) == 0 {
panic("no return value specified for GetTree")
}
var r0 []RepositoryContent
var r1 bool
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, bool) ([]RepositoryContent, bool, error)); ok {
return rf(ctx, owner, repository, basePath, ref, recursive)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, bool) []RepositoryContent); ok {
r0 = rf(ctx, owner, repository, basePath, ref, recursive)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]RepositoryContent)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string, bool) bool); ok {
r1 = rf(ctx, owner, repository, basePath, ref, recursive)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(context.Context, string, string, string, string, bool) error); ok {
r2 = rf(ctx, owner, repository, basePath, ref, recursive)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// MockClient_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree'
type MockClient_GetTree_Call struct {
*mock.Call
}
// GetTree is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - basePath string
// - ref string
// - recursive bool
func (_e *MockClient_Expecter) GetTree(ctx interface{}, owner interface{}, repository interface{}, basePath interface{}, ref interface{}, recursive interface{}) *MockClient_GetTree_Call {
return &MockClient_GetTree_Call{Call: _e.mock.On("GetTree", ctx, owner, repository, basePath, ref, recursive)}
}
func (_c *MockClient_GetTree_Call) Run(run func(ctx context.Context, owner string, repository string, basePath string, ref string, recursive bool)) *MockClient_GetTree_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(bool))
})
return _c
}
func (_c *MockClient_GetTree_Call) Return(entries []RepositoryContent, truncated bool, err error) *MockClient_GetTree_Call {
_c.Call.Return(entries, truncated, err)
return _c
}
func (_c *MockClient_GetTree_Call) RunAndReturn(run func(context.Context, string, string, string, string, bool) ([]RepositoryContent, bool, error)) *MockClient_GetTree_Call {
_c.Call.Return(run)
return _c
}
// GetWebhook provides a mock function with given fields: ctx, owner, repository, webhookID
func (_m *MockClient) GetWebhook(ctx context.Context, owner string, repository string, webhookID int64) (WebhookConfig, error) {
ret := _m.Called(ctx, owner, repository, webhookID)
@@ -824,52 +349,6 @@ func (_c *MockClient_GetWebhook_Call) RunAndReturn(run func(context.Context, str
return _c
}
// IsAuthenticated provides a mock function with given fields: ctx
func (_m *MockClient) IsAuthenticated(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for IsAuthenticated")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_IsAuthenticated_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAuthenticated'
type MockClient_IsAuthenticated_Call struct {
*mock.Call
}
// IsAuthenticated is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockClient_Expecter) IsAuthenticated(ctx interface{}) *MockClient_IsAuthenticated_Call {
return &MockClient_IsAuthenticated_Call{Call: _e.mock.On("IsAuthenticated", ctx)}
}
func (_c *MockClient_IsAuthenticated_Call) Run(run func(ctx context.Context)) *MockClient_IsAuthenticated_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockClient_IsAuthenticated_Call) Return(_a0 error) *MockClient_IsAuthenticated_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_IsAuthenticated_Call) RunAndReturn(run func(context.Context) error) *MockClient_IsAuthenticated_Call {
_c.Call.Return(run)
return _c
}
// ListPullRequestFiles provides a mock function with given fields: ctx, owner, repository, number
func (_m *MockClient) ListPullRequestFiles(ctx context.Context, owner string, repository string, number int) ([]CommitFile, error) {
ret := _m.Called(ctx, owner, repository, number)
@@ -991,117 +470,6 @@ func (_c *MockClient_ListWebhooks_Call) RunAndReturn(run func(context.Context, s
return _c
}
// RepoExists provides a mock function with given fields: ctx, owner, repository
func (_m *MockClient) RepoExists(ctx context.Context, owner string, repository string) (bool, error) {
ret := _m.Called(ctx, owner, repository)
if len(ret) == 0 {
panic("no return value specified for RepoExists")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (bool, error)); ok {
return rf(ctx, owner, repository)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok {
r0 = rf(ctx, owner, repository)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, owner, repository)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_RepoExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoExists'
type MockClient_RepoExists_Call struct {
*mock.Call
}
// RepoExists is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
func (_e *MockClient_Expecter) RepoExists(ctx interface{}, owner interface{}, repository interface{}) *MockClient_RepoExists_Call {
return &MockClient_RepoExists_Call{Call: _e.mock.On("RepoExists", ctx, owner, repository)}
}
func (_c *MockClient_RepoExists_Call) Run(run func(ctx context.Context, owner string, repository string)) *MockClient_RepoExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockClient_RepoExists_Call) Return(_a0 bool, _a1 error) *MockClient_RepoExists_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_RepoExists_Call) RunAndReturn(run func(context.Context, string, string) (bool, error)) *MockClient_RepoExists_Call {
_c.Call.Return(run)
return _c
}
// UpdateFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, hash, content
func (_m *MockClient) UpdateFile(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string, content []byte) error {
ret := _m.Called(ctx, owner, repository, path, branch, message, hash, content)
if len(ret) == 0 {
panic("no return value specified for UpdateFile")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, string, []byte) error); ok {
r0 = rf(ctx, owner, repository, path, branch, message, hash, content)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_UpdateFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFile'
type MockClient_UpdateFile_Call struct {
*mock.Call
}
// UpdateFile is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - path string
// - branch string
// - message string
// - hash string
// - content []byte
func (_e *MockClient_Expecter) UpdateFile(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}, message interface{}, hash interface{}, content interface{}) *MockClient_UpdateFile_Call {
return &MockClient_UpdateFile_Call{Call: _e.mock.On("UpdateFile", ctx, owner, repository, path, branch, message, hash, content)}
}
func (_c *MockClient_UpdateFile_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string, content []byte)) *MockClient_UpdateFile_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].(string), args[7].([]byte))
})
return _c
}
func (_c *MockClient_UpdateFile_Call) Return(_a0 error) *MockClient_UpdateFile_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_UpdateFile_Call) RunAndReturn(run func(context.Context, string, string, string, string, string, string, []byte) error) *MockClient_UpdateFile_Call {
_c.Call.Return(run)
return _c
}
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package github

View File

@@ -1,312 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
import mock "github.com/stretchr/testify/mock"
// MockRepositoryContent is an autogenerated mock type for the RepositoryContent type
type MockRepositoryContent struct {
mock.Mock
}
type MockRepositoryContent_Expecter struct {
mock *mock.Mock
}
func (_m *MockRepositoryContent) EXPECT() *MockRepositoryContent_Expecter {
return &MockRepositoryContent_Expecter{mock: &_m.Mock}
}
// GetFileContent provides a mock function with no fields
func (_m *MockRepositoryContent) GetFileContent() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetFileContent")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositoryContent_GetFileContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFileContent'
type MockRepositoryContent_GetFileContent_Call struct {
*mock.Call
}
// GetFileContent is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetFileContent() *MockRepositoryContent_GetFileContent_Call {
return &MockRepositoryContent_GetFileContent_Call{Call: _e.mock.On("GetFileContent")}
}
func (_c *MockRepositoryContent_GetFileContent_Call) Run(run func()) *MockRepositoryContent_GetFileContent_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetFileContent_Call) Return(_a0 string, _a1 error) *MockRepositoryContent_GetFileContent_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockRepositoryContent_GetFileContent_Call) RunAndReturn(run func() (string, error)) *MockRepositoryContent_GetFileContent_Call {
_c.Call.Return(run)
return _c
}
// GetPath provides a mock function with no fields
func (_m *MockRepositoryContent) GetPath() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPath")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockRepositoryContent_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
type MockRepositoryContent_GetPath_Call struct {
*mock.Call
}
// GetPath is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetPath() *MockRepositoryContent_GetPath_Call {
return &MockRepositoryContent_GetPath_Call{Call: _e.mock.On("GetPath")}
}
func (_c *MockRepositoryContent_GetPath_Call) Run(run func()) *MockRepositoryContent_GetPath_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetPath_Call) Return(_a0 string) *MockRepositoryContent_GetPath_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_GetPath_Call) RunAndReturn(run func() string) *MockRepositoryContent_GetPath_Call {
_c.Call.Return(run)
return _c
}
// GetSHA provides a mock function with no fields
func (_m *MockRepositoryContent) GetSHA() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSHA")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockRepositoryContent_GetSHA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSHA'
type MockRepositoryContent_GetSHA_Call struct {
*mock.Call
}
// GetSHA is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetSHA() *MockRepositoryContent_GetSHA_Call {
return &MockRepositoryContent_GetSHA_Call{Call: _e.mock.On("GetSHA")}
}
func (_c *MockRepositoryContent_GetSHA_Call) Run(run func()) *MockRepositoryContent_GetSHA_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetSHA_Call) Return(_a0 string) *MockRepositoryContent_GetSHA_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_GetSHA_Call) RunAndReturn(run func() string) *MockRepositoryContent_GetSHA_Call {
_c.Call.Return(run)
return _c
}
// GetSize provides a mock function with no fields
func (_m *MockRepositoryContent) GetSize() int64 {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSize")
}
var r0 int64
if rf, ok := ret.Get(0).(func() int64); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(int64)
}
return r0
}
// MockRepositoryContent_GetSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSize'
type MockRepositoryContent_GetSize_Call struct {
*mock.Call
}
// GetSize is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetSize() *MockRepositoryContent_GetSize_Call {
return &MockRepositoryContent_GetSize_Call{Call: _e.mock.On("GetSize")}
}
func (_c *MockRepositoryContent_GetSize_Call) Run(run func()) *MockRepositoryContent_GetSize_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetSize_Call) Return(_a0 int64) *MockRepositoryContent_GetSize_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_GetSize_Call) RunAndReturn(run func() int64) *MockRepositoryContent_GetSize_Call {
_c.Call.Return(run)
return _c
}
// IsDirectory provides a mock function with no fields
func (_m *MockRepositoryContent) IsDirectory() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsDirectory")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockRepositoryContent_IsDirectory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsDirectory'
type MockRepositoryContent_IsDirectory_Call struct {
*mock.Call
}
// IsDirectory is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) IsDirectory() *MockRepositoryContent_IsDirectory_Call {
return &MockRepositoryContent_IsDirectory_Call{Call: _e.mock.On("IsDirectory")}
}
func (_c *MockRepositoryContent_IsDirectory_Call) Run(run func()) *MockRepositoryContent_IsDirectory_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_IsDirectory_Call) Return(_a0 bool) *MockRepositoryContent_IsDirectory_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_IsDirectory_Call) RunAndReturn(run func() bool) *MockRepositoryContent_IsDirectory_Call {
_c.Call.Return(run)
return _c
}
// IsSymlink provides a mock function with no fields
func (_m *MockRepositoryContent) IsSymlink() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsSymlink")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockRepositoryContent_IsSymlink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsSymlink'
type MockRepositoryContent_IsSymlink_Call struct {
*mock.Call
}
// IsSymlink is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) IsSymlink() *MockRepositoryContent_IsSymlink_Call {
return &MockRepositoryContent_IsSymlink_Call{Call: _e.mock.On("IsSymlink")}
}
func (_c *MockRepositoryContent_IsSymlink_Call) Run(run func()) *MockRepositoryContent_IsSymlink_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_IsSymlink_Call) Return(_a0 bool) *MockRepositoryContent_IsSymlink_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_IsSymlink_Call) RunAndReturn(run func() bool) *MockRepositoryContent_IsSymlink_Call {
_c.Call.Return(run)
return _c
}
// NewMockRepositoryContent creates a new instance of MockRepositoryContent. 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 NewMockRepositoryContent(t interface {
mock.TestingT
Cleanup(func())
}) *MockRepositoryContent {
mock := &MockRepositoryContent{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,240 @@
package github
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
type githubRepository struct {
gitRepo git.GitRepository
config *provisioning.Repository
gh Client // assumes github.com base URL
owner string
repo string
}
// GithubRepository is an interface that combines all repository capabilities
// needed for GitHub repositories.
//go:generate mockery --name GithubRepository --structname MockGithubRepository --inpackage --filename github_repository_mock.go --with-expecter
type GithubRepository interface {
repository.Repository
repository.Versioned
repository.Writer
repository.Reader
repository.RepositoryWithURLs
repository.StageableRepository
Owner() string
Repo() string
Client() Client
}
func NewGitHub(
ctx context.Context,
config *provisioning.Repository,
gitRepo git.GitRepository,
factory *Factory,
token string,
) (GithubRepository, error) {
owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
if err != nil {
return nil, fmt.Errorf("parse owner and repo: %w", err)
}
return &githubRepository{
config: config,
gitRepo: gitRepo,
gh: factory.New(ctx, token), // TODO, baseURL from config
owner: owner,
repo: repo,
}, nil
}
func (r *githubRepository) Config() *provisioning.Repository {
return r.gitRepo.Config()
}
func (r *githubRepository) Owner() string {
return r.owner
}
func (r *githubRepository) Repo() string {
return r.repo
}
func (r *githubRepository) Client() Client {
return r.gh
}
// Validate implements provisioning.Repository.
func (r *githubRepository) Validate() (list field.ErrorList) {
cfg := r.gitRepo.Config()
gh := cfg.Spec.GitHub
if gh == nil {
list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
return list
}
if gh.URL == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
} else {
_, _, err := ParseOwnerRepoGithub(gh.URL)
if err != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
} else if !strings.HasPrefix(gh.URL, "https://github.com/") {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
}
}
if len(list) > 0 {
return list
}
return r.gitRepo.Validate()
}
func ParseOwnerRepoGithub(giturl string) (owner string, repo string, err error) {
parsed, e := url.Parse(strings.TrimSuffix(giturl, ".git"))
if e != nil {
err = e
return
}
parts := strings.Split(parsed.Path, "/")
if len(parts) < 3 {
err = fmt.Errorf("unable to parse repo+owner from url")
return
}
return parts[1], parts[2], nil
}
// Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
url := r.config.Spec.GitHub.URL
_, _, err := ParseOwnerRepoGithub(url)
if err != nil {
return repository.FromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
return r.gitRepo.Test(ctx)
}
// ReadResource implements provisioning.Repository.
func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) {
return r.gitRepo.Read(ctx, filePath, ref)
}
func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
return r.gitRepo.ReadTree(ctx, ref)
}
func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
return r.gitRepo.Create(ctx, path, ref, data, comment)
}
func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
return r.gitRepo.Update(ctx, path, ref, data, comment)
}
func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
return r.gitRepo.Write(ctx, path, ref, data, message)
}
func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
return r.gitRepo.Delete(ctx, path, ref, comment)
}
func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref)
if err != nil {
if errors.Is(err, ErrResourceNotFound) {
return nil, repository.ErrFileNotFound
}
return nil, fmt.Errorf("get commits: %w", err)
}
ret := make([]provisioning.HistoryItem, 0, len(commits))
for _, commit := range commits {
authors := make([]provisioning.Author, 0)
if commit.Author != nil {
authors = append(authors, provisioning.Author{
Name: commit.Author.Name,
Username: commit.Author.Username,
AvatarURL: commit.Author.AvatarURL,
})
}
if commit.Committer != nil && commit.Author != nil && commit.Author.Name != commit.Committer.Name {
authors = append(authors, provisioning.Author{
Name: commit.Committer.Name,
Username: commit.Committer.Username,
AvatarURL: commit.Committer.AvatarURL,
})
}
ret = append(ret, provisioning.HistoryItem{
Ref: commit.Ref,
Message: commit.Message,
Authors: authors,
CreatedAt: commit.CreatedAt.UnixMilli(),
})
}
return ret, nil
}
func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
return r.gitRepo.LatestRef(ctx)
}
func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) {
return r.gitRepo.CompareFiles(ctx, base, ref)
}
// ResourceURLs implements RepositoryWithURLs.
func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.ResourceURLs, error) {
cfg := r.config.Spec.GitHub
if file.Path == "" || cfg == nil {
return nil, nil
}
ref := file.Ref
if ref == "" {
ref = cfg.Branch
}
urls := &provisioning.ResourceURLs{
RepositoryURL: cfg.URL,
SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path),
}
if ref != cfg.Branch {
urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
// Create a new pull request
urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
}
return urls, nil
}
func (r *githubRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
return r.gitRepo.Stage(ctx, opts)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
package gogit
import (
"bufio"
"bytes"
"io"
)
func Progress(lines func(line string), final string) io.WriteCloser {
reader, writer := io.Pipe()
scanner := bufio.NewScanner(reader)
scanner.Split(scanLines)
go func() {
for scanner.Scan() {
line := scanner.Text()
if line != "" {
lines(line)
}
}
lines(final)
}()
return writer
}
// Copied from bufio.ScanLines and modifed to accept standalone \r as input
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\r'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, data[0:i], nil
}
// Support standalone newlines also
if i := bytes.IndexByte(data, '\n'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}

View File

@@ -1,58 +0,0 @@
package gogit
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProgressParsing(t *testing.T) {
tests := []struct {
name string
input string
expect []string
}{
{
name: "no breaks",
input: "some text",
expect: []string{"some text"},
},
{
name: "with cr",
input: "hello\rworld",
expect: []string{"hello", "world"},
},
{
name: "with nl",
input: "hello\nworld",
expect: []string{"hello", "world"},
},
{
name: "with cr+nl",
input: "hello\r\nworld",
expect: []string{"hello", "world"},
},
}
for _, tt := range tests {
lastLine := "***LAST*LINE***"
t.Run(tt.name, func(t *testing.T) {
lines := []string{}
writer := Progress(func(line string) {
lines = append(lines, line)
}, lastLine)
_, _ = writer.Write([]byte(tt.input))
err := writer.Close()
require.NoError(t, err)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
assert.NotEmpty(c, lines)
assert.Equal(c, lastLine, lines[len(lines)-1])
// Compare the results
require.Equal(c, tt.expect, lines[0:len(lines)-1])
}, time.Millisecond*100, time.Microsecond*50)
})
}
}

View File

@@ -1,84 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package gogit
import (
context "context"
git "github.com/go-git/go-git/v5"
mock "github.com/stretchr/testify/mock"
)
// MockRepository is an autogenerated mock type for the Repository type
type MockRepository struct {
mock.Mock
}
type MockRepository_Expecter struct {
mock *mock.Mock
}
func (_m *MockRepository) EXPECT() *MockRepository_Expecter {
return &MockRepository_Expecter{mock: &_m.Mock}
}
// PushContext provides a mock function with given fields: ctx, o
func (_m *MockRepository) PushContext(ctx context.Context, o *git.PushOptions) error {
ret := _m.Called(ctx, o)
if len(ret) == 0 {
panic("no return value specified for PushContext")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *git.PushOptions) error); ok {
r0 = rf(ctx, o)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockRepository_PushContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushContext'
type MockRepository_PushContext_Call struct {
*mock.Call
}
// PushContext is a helper method to define mock.On call
// - ctx context.Context
// - o *git.PushOptions
func (_e *MockRepository_Expecter) PushContext(ctx interface{}, o interface{}) *MockRepository_PushContext_Call {
return &MockRepository_PushContext_Call{Call: _e.mock.On("PushContext", ctx, o)}
}
func (_c *MockRepository_PushContext_Call) Run(run func(ctx context.Context, o *git.PushOptions)) *MockRepository_PushContext_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*git.PushOptions))
})
return _c
}
func (_c *MockRepository_PushContext_Call) Return(_a0 error) *MockRepository_PushContext_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepository_PushContext_Call) RunAndReturn(run func(context.Context, *git.PushOptions) error) *MockRepository_PushContext_Call {
_c.Call.Return(run)
return _c
}
// NewMockRepository creates a new instance of MockRepository. 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 NewMockRepository(t interface {
mock.TestingT
Cleanup(func())
}) *MockRepository {
mock := &MockRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1,73 +0,0 @@
package gogit
import (
"fmt"
"io"
"net/http"
"sync/atomic"
"github.com/grafana/grafana/pkg/util/httpclient"
)
var errBytesLimitExceeded = fmt.Errorf("bytes limit exceeded")
// ByteLimitedTransport wraps http.RoundTripper to enforce a max byte limit
type ByteLimitedTransport struct {
Transport http.RoundTripper
Limit int64
Bytes int64
}
// NewByteLimitedTransport creates a new ByteLimitedTransport with the specified transport and byte limit.
// If transport is nil, a new http.Transport modeled after http.DefaultTransport will be used.
func NewByteLimitedTransport(transport http.RoundTripper, limit int64) *ByteLimitedTransport {
if transport == nil {
transport = httpclient.NewHTTPTransport()
}
return &ByteLimitedTransport{
Transport: transport,
Limit: limit,
Bytes: 0,
}
}
// RoundTrip tracks downloaded bytes and aborts if limit is exceeded
func (b *ByteLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := b.Transport.RoundTrip(req)
if err != nil {
return nil, err
}
// Wrap response body to track bytes read
resp.Body = &byteLimitedReader{
reader: resp.Body,
limit: b.Limit,
bytes: &b.Bytes,
}
return resp, nil
}
// byteLimitedReader tracks and enforces a download limit
type byteLimitedReader struct {
reader io.ReadCloser
limit int64
bytes *int64
}
func (r *byteLimitedReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
if err != nil {
return n, err
}
if atomic.AddInt64(r.bytes, int64(n)) > r.limit {
return 0, errBytesLimitExceeded
}
return n, nil
}
func (r *byteLimitedReader) Close() error {
return r.reader.Close()
}

View File

@@ -1,140 +0,0 @@
package gogit
import (
"bytes"
"errors"
"io"
"net/http"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockTransport struct {
response *http.Response
err error
}
func (m *mockTransport) RoundTrip(*http.Request) (*http.Response, error) {
return m.response, m.err
}
func TestNewByteLimitedTransport(t *testing.T) {
tests := []struct {
name string
transport http.RoundTripper
limit int64
}{
{
name: "with custom transport",
transport: &mockTransport{},
limit: 1000,
},
{
name: "with nil transport",
transport: nil,
limit: 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
blt := NewByteLimitedTransport(tt.transport, tt.limit)
assert.NotNil(t, blt)
assert.Equal(t, tt.limit, blt.Limit)
assert.Equal(t, int64(0), blt.Bytes)
if tt.transport == nil {
assert.NotNil(t, blt.Transport)
assert.NotEqual(t, http.DefaultTransport, blt.Transport)
} else {
assert.Equal(t, tt.transport, blt.Transport)
}
})
}
}
func TestByteLimitedTransport_RoundTrip(t *testing.T) {
tests := []struct {
name string
responseBody string
limit int64
expectedError error
}{
{
name: "under limit",
responseBody: "small response",
limit: 100,
expectedError: nil,
},
{
name: "exceeds limit",
responseBody: "this response will exceed the byte limit",
limit: 10,
expectedError: errBytesLimitExceeded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockResp := &http.Response{
Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
}
mockTransport := &mockTransport{response: mockResp}
blt := NewByteLimitedTransport(mockTransport, tt.limit)
resp, err := blt.RoundTrip(&http.Request{})
require.NoError(t, err)
defer func() {
closeErr := resp.Body.Close()
assert.NoError(t, closeErr, "failed to close response body")
}()
data, err := io.ReadAll(resp.Body)
if tt.expectedError != nil {
assert.True(t, errors.Is(err, tt.expectedError), "expected error %v, got %v", tt.expectedError, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.responseBody, string(data))
}
})
}
}
func TestByteLimitedReader_Close(t *testing.T) {
mockBody := io.NopCloser(bytes.NewBufferString("test"))
var byteCount int64
reader := &byteLimitedReader{
reader: mockBody,
limit: 100,
bytes: &byteCount,
}
err := reader.Close()
assert.NoError(t, err)
}
func TestByteLimitedReader_AtomicCounting(t *testing.T) {
var byteCount int64
reader := &byteLimitedReader{
reader: io.NopCloser(bytes.NewBufferString("test data")),
limit: 5,
bytes: &byteCount,
}
// First read should succeed
buf := make([]byte, 4)
n, err := reader.Read(buf)
assert.NoError(t, err)
assert.Equal(t, 4, n)
// Second read should fail due to limit
n, err = reader.Read(buf)
assert.True(t, errors.Is(err, errBytesLimitExceeded), "expected error %v, got %v", errBytesLimitExceeded, err)
assert.Equal(t, 0, n)
// Verify atomic counter
assert.Greater(t, atomic.LoadInt64(&byteCount), int64(5))
}

View File

@@ -1,261 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package gogit
import (
billy "github.com/go-git/go-billy/v5"
git "github.com/go-git/go-git/v5"
mock "github.com/stretchr/testify/mock"
plumbing "github.com/go-git/go-git/v5/plumbing"
)
// MockWorktree is an autogenerated mock type for the Worktree type
type MockWorktree struct {
mock.Mock
}
type MockWorktree_Expecter struct {
mock *mock.Mock
}
func (_m *MockWorktree) EXPECT() *MockWorktree_Expecter {
return &MockWorktree_Expecter{mock: &_m.Mock}
}
// Add provides a mock function with given fields: path
func (_m *MockWorktree) Add(path string) (plumbing.Hash, error) {
ret := _m.Called(path)
if len(ret) == 0 {
panic("no return value specified for Add")
}
var r0 plumbing.Hash
var r1 error
if rf, ok := ret.Get(0).(func(string) (plumbing.Hash, error)); ok {
return rf(path)
}
if rf, ok := ret.Get(0).(func(string) plumbing.Hash); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(plumbing.Hash)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockWorktree_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add'
type MockWorktree_Add_Call struct {
*mock.Call
}
// Add is a helper method to define mock.On call
// - path string
func (_e *MockWorktree_Expecter) Add(path interface{}) *MockWorktree_Add_Call {
return &MockWorktree_Add_Call{Call: _e.mock.On("Add", path)}
}
func (_c *MockWorktree_Add_Call) Run(run func(path string)) *MockWorktree_Add_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockWorktree_Add_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Add_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockWorktree_Add_Call) RunAndReturn(run func(string) (plumbing.Hash, error)) *MockWorktree_Add_Call {
_c.Call.Return(run)
return _c
}
// Commit provides a mock function with given fields: message, opts
func (_m *MockWorktree) Commit(message string, opts *git.CommitOptions) (plumbing.Hash, error) {
ret := _m.Called(message, opts)
if len(ret) == 0 {
panic("no return value specified for Commit")
}
var r0 plumbing.Hash
var r1 error
if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) (plumbing.Hash, error)); ok {
return rf(message, opts)
}
if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) plumbing.Hash); ok {
r0 = rf(message, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(plumbing.Hash)
}
}
if rf, ok := ret.Get(1).(func(string, *git.CommitOptions) error); ok {
r1 = rf(message, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockWorktree_Commit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commit'
type MockWorktree_Commit_Call struct {
*mock.Call
}
// Commit is a helper method to define mock.On call
// - message string
// - opts *git.CommitOptions
func (_e *MockWorktree_Expecter) Commit(message interface{}, opts interface{}) *MockWorktree_Commit_Call {
return &MockWorktree_Commit_Call{Call: _e.mock.On("Commit", message, opts)}
}
func (_c *MockWorktree_Commit_Call) Run(run func(message string, opts *git.CommitOptions)) *MockWorktree_Commit_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(*git.CommitOptions))
})
return _c
}
func (_c *MockWorktree_Commit_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Commit_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockWorktree_Commit_Call) RunAndReturn(run func(string, *git.CommitOptions) (plumbing.Hash, error)) *MockWorktree_Commit_Call {
_c.Call.Return(run)
return _c
}
// Filesystem provides a mock function with no fields
func (_m *MockWorktree) Filesystem() billy.Filesystem {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Filesystem")
}
var r0 billy.Filesystem
if rf, ok := ret.Get(0).(func() billy.Filesystem); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(billy.Filesystem)
}
}
return r0
}
// MockWorktree_Filesystem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Filesystem'
type MockWorktree_Filesystem_Call struct {
*mock.Call
}
// Filesystem is a helper method to define mock.On call
func (_e *MockWorktree_Expecter) Filesystem() *MockWorktree_Filesystem_Call {
return &MockWorktree_Filesystem_Call{Call: _e.mock.On("Filesystem")}
}
func (_c *MockWorktree_Filesystem_Call) Run(run func()) *MockWorktree_Filesystem_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockWorktree_Filesystem_Call) Return(_a0 billy.Filesystem) *MockWorktree_Filesystem_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockWorktree_Filesystem_Call) RunAndReturn(run func() billy.Filesystem) *MockWorktree_Filesystem_Call {
_c.Call.Return(run)
return _c
}
// Remove provides a mock function with given fields: path
func (_m *MockWorktree) Remove(path string) (plumbing.Hash, error) {
ret := _m.Called(path)
if len(ret) == 0 {
panic("no return value specified for Remove")
}
var r0 plumbing.Hash
var r1 error
if rf, ok := ret.Get(0).(func(string) (plumbing.Hash, error)); ok {
return rf(path)
}
if rf, ok := ret.Get(0).(func(string) plumbing.Hash); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(plumbing.Hash)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockWorktree_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
type MockWorktree_Remove_Call struct {
*mock.Call
}
// Remove is a helper method to define mock.On call
// - path string
func (_e *MockWorktree_Expecter) Remove(path interface{}) *MockWorktree_Remove_Call {
return &MockWorktree_Remove_Call{Call: _e.mock.On("Remove", path)}
}
func (_c *MockWorktree_Remove_Call) Run(run func(path string)) *MockWorktree_Remove_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockWorktree_Remove_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Remove_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockWorktree_Remove_Call) RunAndReturn(run func(string) (plumbing.Hash, error)) *MockWorktree_Remove_Call {
_c.Call.Return(run)
return _c
}
// NewMockWorktree creates a new instance of MockWorktree. 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 NewMockWorktree(t interface {
mock.TestingT
Cleanup(func())
}) *MockWorktree {
mock := &MockWorktree{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1,468 +0,0 @@
package gogit
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/util"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/util/httpclient"
)
const (
// maxOperationBytes is the maximum size of a git operation in bytes (1 GB)
maxOperationBytes = int64(1 << 30)
maxOperationTimeout = 10 * time.Minute
)
func init() {
// Create a size-limited writer that will cancel the context if size is exceeded
limitedTransport := NewByteLimitedTransport(httpclient.NewHTTPTransport(), maxOperationBytes)
httpClient := githttp.NewClient(&http.Client{
Transport: limitedTransport,
})
client.InstallProtocol("https", httpClient)
client.InstallProtocol("http", httpClient)
}
//go:generate mockery --name=Worktree --output=mocks --inpackage --filename=worktree_mock.go --with-expecter
type Worktree interface {
Commit(message string, opts *git.CommitOptions) (plumbing.Hash, error)
Remove(path string) (plumbing.Hash, error)
Add(path string) (plumbing.Hash, error)
Filesystem() billy.Filesystem
}
type worktree struct {
*git.Worktree
}
//go:generate mockery --name=Repository --output=mocks --inpackage --filename=repository_mock.go --with-expecter
type Repository interface {
PushContext(ctx context.Context, o *git.PushOptions) error
}
func (w *worktree) Filesystem() billy.Filesystem {
return w.Worktree.Filesystem
}
var _ repository.Repository = (*GoGitRepo)(nil)
type GoGitRepo struct {
config *provisioning.Repository
decryptedPassword string
opts repository.CloneOptions
repo Repository
tree Worktree
dir string // file path to worktree root (necessary? should use billy)
}
// This will create a new clone every time
// As structured, it is valid for one context and should not be shared across multiple requests
func Clone(
ctx context.Context,
root string,
config *provisioning.Repository,
opts repository.CloneOptions,
secrets secrets.Service,
) (repository.ClonedRepository, error) {
if root == "" {
return nil, fmt.Errorf("missing root config")
}
if config.Namespace == "" {
return nil, fmt.Errorf("config is missing namespace")
}
if config.Name == "" {
return nil, fmt.Errorf("config is missing name")
}
if opts.BeforeFn != nil {
if err := opts.BeforeFn(); err != nil {
return nil, err
}
}
// add a timeout to the operation
timeout := maxOperationTimeout
if opts.Timeout > 0 {
timeout = opts.Timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("error decrypting token: %w", err)
}
if err := os.MkdirAll(root, 0700); err != nil {
return nil, fmt.Errorf("create root dir: %w", err)
}
dir, err := os.MkdirTemp(root, fmt.Sprintf("clone-%s-%s-", config.Namespace, config.Name))
if err != nil {
return nil, fmt.Errorf("create temp clone dir: %w", err)
}
progress := opts.Progress
if progress == nil {
progress = io.Discard
}
repo, tree, err := clone(ctx, config, opts, decrypted, dir, progress)
if err != nil {
if err := os.RemoveAll(dir); err != nil {
return nil, fmt.Errorf("remove temp clone dir after clone failed: %w", err)
}
return nil, fmt.Errorf("clone: %w", err)
}
return &GoGitRepo{
config: config,
tree: &worktree{Worktree: tree},
opts: opts,
decryptedPassword: string(decrypted),
repo: repo,
dir: dir,
}, nil
}
func clone(ctx context.Context, config *provisioning.Repository, opts repository.CloneOptions, decrypted []byte, dir string, progress io.Writer) (*git.Repository, *git.Worktree, error) {
gitcfg := config.Spec.GitHub
url := gitcfg.URL
if !strings.HasPrefix(url, "file://") {
url = fmt.Sprintf("%s.git", url)
}
branch := plumbing.NewBranchReferenceName(gitcfg.Branch)
cloneOpts := &git.CloneOptions{
ReferenceName: branch,
Auth: &githttp.BasicAuth{
Username: "grafana", // this can be anything except an empty string for PAT
Password: string(decrypted), // TODO... will need to get from a service!
},
URL: url,
Progress: progress,
}
repo, err := git.PlainCloneContext(ctx, dir, false, cloneOpts)
if errors.Is(err, plumbing.ErrReferenceNotFound) && opts.CreateIfNotExists {
cloneOpts.ReferenceName = "" // empty
repo, err = git.PlainCloneContext(ctx, dir, false, cloneOpts)
if err == nil {
worktree, err := repo.Worktree()
if err != nil {
return nil, nil, err
}
err = worktree.Checkout(&git.CheckoutOptions{
Branch: branch,
Force: true,
Create: true,
})
if err != nil {
return nil, nil, fmt.Errorf("unable to create new branch: %w", err)
}
}
} else if err != nil {
return nil, nil, fmt.Errorf("clone error: %w", err)
}
rcfg, err := repo.Config()
if err != nil {
return nil, nil, fmt.Errorf("error reading repository config %w", err)
}
origin := rcfg.Remotes["origin"]
if origin == nil {
return nil, nil, fmt.Errorf("missing origin remote %w", err)
}
if url != origin.URLs[0] {
return nil, nil, fmt.Errorf("unexpected remote (expected: %s, found: %s)", url, origin.URLs[0])
}
worktree, err := repo.Worktree()
if err != nil {
return nil, nil, fmt.Errorf("get worktree: %w", err)
}
return repo, worktree, nil
}
// After making changes to the worktree, push changes
func (g *GoGitRepo) Push(ctx context.Context, opts repository.PushOptions) error {
timeout := maxOperationTimeout
if opts.Timeout > 0 {
timeout = opts.Timeout
}
progress := opts.Progress
if progress == nil {
progress = io.Discard
}
if opts.BeforeFn != nil {
if err := opts.BeforeFn(); err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if !g.opts.PushOnWrites {
_, err := g.tree.Commit("exported from grafana", &git.CommitOptions{
All: true, // Add everything that changed
})
if err != nil {
// empty commit is fine -- no change
if !errors.Is(err, git.ErrEmptyCommit) {
return err
}
}
}
err := g.repo.PushContext(ctx, &git.PushOptions{
Progress: progress,
Force: true, // avoid fast-forward-errors
Auth: &githttp.BasicAuth{ // reuse logic from clone?
Username: "grafana",
Password: g.decryptedPassword,
},
})
if errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil // same as the target
}
return err
}
func (g *GoGitRepo) Remove(ctx context.Context) error {
return os.RemoveAll(g.dir)
}
// Config implements repository.Repository.
func (g *GoGitRepo) Config() *provisioning.Repository {
return g.config
}
// ReadTree implements repository.Repository.
func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
var treePath string
if g.config.Spec.GitHub.Path != "" {
treePath = g.config.Spec.GitHub.Path
}
treePath = safepath.Clean(treePath)
entries := make([]repository.FileTreeEntry, 0, 100)
err := util.Walk(g.tree.Filesystem(), treePath, func(path string, info fs.FileInfo, err error) error {
// We already have an error, just pass it onwards.
if err != nil ||
// This is the root of the repository (or should pretend to be)
safepath.Clean(path) == "" || path == treePath ||
// This is the Git data
(treePath == "" && (strings.HasPrefix(path, ".git/") || path == ".git")) {
return err
}
if treePath != "" {
path = strings.TrimPrefix(path, treePath)
}
entry := repository.FileTreeEntry{
Path: strings.TrimLeft(path, "/"),
Size: info.Size(),
}
if !info.IsDir() {
entry.Blob = true
// For a real instance, this will likely be based on:
// https://github.com/go-git/go-git/blob/main/_examples/ls/main.go#L25
entry.Hash = fmt.Sprintf("TODO/%d", info.Size()) // but not used for
}
entries = append(entries, entry)
return err
})
if errors.Is(err, fs.ErrNotExist) {
// We intentionally ignore this case, as it is expected
} else if err != nil {
return nil, fmt.Errorf("walk tree for ref '%s': %w", ref, err)
}
return entries, nil
}
func (g *GoGitRepo) Test(ctx context.Context) (*provisioning.TestResults, error) {
return &provisioning.TestResults{
Success: g.tree != nil,
}, nil
}
// Update implements repository.Repository.
func (g *GoGitRepo) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
return g.Write(ctx, path, ref, data, message)
}
// Create implements repository.Repository.
func (g *GoGitRepo) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
// FIXME: this means we would override files
return g.Write(ctx, path, ref, data, message)
}
// Write implements repository.Repository.
func (g *GoGitRepo) Write(ctx context.Context, fpath string, ref string, data []byte, message string) error {
if err := verifyPathWithoutRef(fpath, ref); err != nil {
return err
}
fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath)
// FIXME: this means that won't export empty folders
// should we create them with a .keep file?
// For folders, just create the folder and ignore the commit
if safepath.IsDir(fpath) {
return g.tree.Filesystem().MkdirAll(fpath, 0750)
}
dir := safepath.Dir(fpath)
if dir != "" {
err := g.tree.Filesystem().MkdirAll(dir, 0750)
if err != nil {
return err
}
}
file, err := g.tree.Filesystem().Create(fpath)
if err != nil {
return err
}
_, err = file.Write(data)
if err != nil {
return err
}
_, err = g.tree.Add(fpath)
if err != nil {
return err
}
return g.maybeCommit(ctx, message)
}
func (g *GoGitRepo) maybeCommit(ctx context.Context, message string) error {
// Skip commit for each file
if !g.opts.PushOnWrites {
return nil
}
opts := &git.CommitOptions{
Author: &object.Signature{
Name: "grafana",
},
}
sig := repository.GetAuthorSignature(ctx)
if sig != nil && sig.Name != "" {
opts.Author.Name = sig.Name
opts.Author.Email = sig.Email
opts.Author.When = sig.When
}
if opts.Author.When.IsZero() {
opts.Author.When = time.Now()
}
_, err := g.tree.Commit(message, opts)
if errors.Is(err, git.ErrEmptyCommit) {
return nil // empty commit is fine -- no change
}
return err
}
// Delete implements repository.Repository.
func (g *GoGitRepo) Delete(ctx context.Context, fpath string, ref string, message string) error {
if err := verifyPathWithoutRef(fpath, ref); err != nil {
return err
}
fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath)
if _, err := g.tree.Remove(fpath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return repository.ErrFileNotFound
}
return err
}
return g.maybeCommit(ctx, message)
}
// Read implements repository.Repository.
func (g *GoGitRepo) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
if err := verifyPathWithoutRef(path, ref); err != nil {
return nil, err
}
readPath := safepath.Join(g.config.Spec.GitHub.Path, path)
stat, err := g.tree.Filesystem().Lstat(readPath)
if errors.Is(err, fs.ErrNotExist) {
return nil, repository.ErrFileNotFound
} else if err != nil {
return nil, fmt.Errorf("stat path '%s': %w", readPath, err)
}
info := &repository.FileInfo{
Path: path,
Modified: &metav1.Time{
Time: stat.ModTime(),
},
}
if !stat.IsDir() {
f, err := g.tree.Filesystem().Open(readPath)
if err != nil {
return nil, fmt.Errorf("open file '%s': %w", readPath, err)
}
info.Data, err = io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read file '%s': %w", readPath, err)
}
}
return info, err
}
func verifyPathWithoutRef(path string, ref string) error {
if path == "" {
return fmt.Errorf("expected path")
}
if ref != "" {
return fmt.Errorf("ref unsupported")
}
return nil
}
// History implements repository.Repository.
func (g *GoGitRepo) History(ctx context.Context, path string, ref string) ([]provisioning.HistoryItem, error) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "history is not yet implemented",
Code: http.StatusNotImplemented,
},
}
}
// Validate implements repository.Repository.
func (g *GoGitRepo) Validate() field.ErrorList {
return nil
}

View File

@@ -1,4 +1,4 @@
package repository
package local
import (
"context"
@@ -23,6 +23,7 @@ import (
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
@@ -75,9 +76,9 @@ func (r *LocalFolderResolver) LocalPath(p string) (string, error) {
}
var (
_ Repository = (*localRepository)(nil)
_ Writer = (*localRepository)(nil)
_ Reader = (*localRepository)(nil)
_ repository.Repository = (*localRepository)(nil)
_ repository.Writer = (*localRepository)(nil)
_ repository.Reader = (*localRepository)(nil)
)
type localRepository struct {
@@ -147,17 +148,17 @@ func (r *localRepository) Validate() field.ErrorList {
func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
path := field.NewPath("spec", "local", "path")
if r.config.Spec.Local.Path == "" {
return fromFieldError(field.Required(path, "no path is configured")), nil
return repository.FromFieldError(field.Required(path, "no path is configured")), nil
}
_, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
if err != nil {
return fromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
return repository.FromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
}
_, err = os.Stat(r.path)
if errors.Is(err, os.ErrNotExist) {
return fromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
return repository.FromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
}
return &provisioning.TestResults{
@@ -176,7 +177,7 @@ func (r *localRepository) validateRequest(ref string) error {
}
// ReadResource implements provisioning.Repository.
func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*FileInfo, error) {
func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*repository.FileInfo, error) {
if err := r.validateRequest(ref); err != nil {
return nil, err
}
@@ -184,13 +185,13 @@ func (r *localRepository) Read(ctx context.Context, filePath string, ref string)
actualPath := safepath.Join(r.path, filePath)
info, err := os.Stat(actualPath)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrFileNotFound
return nil, repository.ErrFileNotFound
} else if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
if info.IsDir() {
return &FileInfo{
return &repository.FileInfo{
Path: filePath,
Modified: &metav1.Time{
Time: info.ModTime(),
@@ -209,7 +210,7 @@ func (r *localRepository) Read(ctx context.Context, filePath string, ref string)
return nil, fmt.Errorf("calculate hash of file: %w", err)
}
return &FileInfo{
return &repository.FileInfo{
Path: filePath,
Data: data,
Hash: hash,
@@ -220,7 +221,7 @@ func (r *localRepository) Read(ctx context.Context, filePath string, ref string)
}
// ReadResource implements provisioning.Repository.
func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
if err := r.validateRequest(ref); err != nil {
return nil, err
}
@@ -228,16 +229,16 @@ func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeE
// Return an empty list when folder does not exist
_, err := os.Stat(r.path)
if errors.Is(err, fs.ErrNotExist) {
return []FileTreeEntry{}, nil
return []repository.FileTreeEntry{}, nil
}
rootlen := len(r.path)
entries := make([]FileTreeEntry, 0, 100)
entries := make([]repository.FileTreeEntry, 0, 100)
err = filepath.Walk(r.path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
entry := FileTreeEntry{
entry := repository.FileTreeEntry{
Path: strings.TrimLeft(path[rootlen:], "/"),
Size: info.Size(),
}
@@ -251,8 +252,10 @@ func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeE
if err != nil {
return fmt.Errorf("read and calculate hash of path %s: %w", path, err)
}
} else if !strings.HasSuffix(entry.Path, "/") {
// ensure trailing slash for directories
entry.Path = entry.Path + "/"
}
// TODO: do folders have a trailing slash?
entries = append(entries, entry)
return err
})
@@ -263,7 +266,7 @@ func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeE
func (r *localRepository) calculateFileHash(path string) (string, int64, error) {
// Treats https://securego.io/docs/rules/g304.html
if !safepath.InDir(path, r.path) {
return "", 0, ErrFileNotFound
return "", 0, repository.ErrFileNotFound
}
// We've already made sure the path is safe, so we'll ignore the gosec lint.
@@ -329,7 +332,7 @@ func (r *localRepository) Update(ctx context.Context, path string, ref string, d
f, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
return ErrFileNotFound
return repository.ErrFileNotFound
}
if f.IsDir() {
return apierrors.NewBadRequest("path exists but it is a directory")
@@ -360,5 +363,12 @@ func (r *localRepository) Delete(ctx context.Context, path string, ref string, c
return err
}
return os.Remove(safepath.Join(r.path, path))
fullPath := safepath.Join(r.path, path)
if safepath.IsDir(path) {
// if it is a folder, delete all of its contents
return os.RemoveAll(fullPath)
}
return os.Remove(fullPath)
}

View File

@@ -1,4 +1,4 @@
package repository
package local
import (
"context"
@@ -19,6 +19,7 @@ import (
field "k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
func TestLocalResolver(t *testing.T) {
@@ -91,14 +92,14 @@ func TestLocalResolver(t *testing.T) {
// Verify all directories and files are present
expectedPaths := []string{
"another",
"another/path",
"another/",
"another/path/",
"another/path/file.txt",
"level1",
"level1/",
"level1/file1.txt",
"level1/level2",
"level1/level2/",
"level1/level2/file2.txt",
"level1/level2/level3",
"level1/level2/level3/",
"level1/level2/level3/file3.txt",
"root.txt",
}
@@ -542,6 +543,41 @@ func TestLocalRepository_Delete(t *testing.T) {
comment: "test delete with ref",
expectedErr: apierrors.NewBadRequest("local repository does not support ref"),
},
{
name: "delete folder with nested files",
setup: func(t *testing.T) (string, *localRepository) {
tempDir := t.TempDir()
nestedFolderPath := filepath.Join(tempDir, "folder")
err := os.MkdirAll(nestedFolderPath, 0700)
require.NoError(t, err)
subFolderPath := filepath.Join(nestedFolderPath, "nested-folder")
err = os.MkdirAll(subFolderPath, 0700)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(nestedFolderPath, "nested-dash.txt"), []byte("content1"), 0600)
require.NoError(t, err)
// Create repository with the temp directory as permitted prefix
repo := &localRepository{
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Local: &provisioning.LocalRepositoryConfig{
Path: tempDir,
},
},
},
resolver: &LocalFolderResolver{
PermittedPrefixes: []string{tempDir},
},
path: tempDir,
}
return tempDir, repo
},
path: "folder/",
ref: "",
comment: "test delete folder with nested content",
expectedErr: nil,
},
}
for _, tc := range testCases {
@@ -668,7 +704,7 @@ func TestLocalRepository_Update(t *testing.T) {
ref: "",
data: []byte("content"),
comment: "",
expectedErr: ErrFileNotFound,
expectedErr: repository.ErrFileNotFound,
},
{
name: "update directory",
@@ -1139,7 +1175,7 @@ func TestLocalRepository_Read(t *testing.T) {
path string
ref string
expectedErr error
expected *FileInfo
expected *repository.FileInfo
}{
{
name: "read existing file",
@@ -1168,7 +1204,7 @@ func TestLocalRepository_Read(t *testing.T) {
return tempDir, repo
},
path: "test-file.txt",
expected: &FileInfo{
expected: &repository.FileInfo{
Path: "test-file.txt",
Modified: &metav1.Time{Time: time.Now()},
Data: []byte("test content"),
@@ -1196,7 +1232,7 @@ func TestLocalRepository_Read(t *testing.T) {
return tempDir, repo
},
path: "non-existent-file.txt",
expectedErr: ErrFileNotFound,
expectedErr: repository.ErrFileNotFound,
},
{
name: "read with ref should fail",
@@ -1254,7 +1290,7 @@ func TestLocalRepository_Read(t *testing.T) {
return tempDir, repo
},
path: "test-dir",
expected: &FileInfo{
expected: &repository.FileInfo{
Path: "test-dir",
Modified: &metav1.Time{Time: time.Now()},
},
@@ -1292,7 +1328,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
setup func(t *testing.T) (string, *localRepository)
ref string
expectedErr error
expected []FileTreeEntry
expected []repository.FileTreeEntry
}{
{
name: "read empty directory",
@@ -1314,7 +1350,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
return tempDir, repo
},
expected: []FileTreeEntry{},
expected: []repository.FileTreeEntry{},
expectedErr: nil,
},
{
@@ -1344,10 +1380,10 @@ func TestLocalRepository_ReadTree(t *testing.T) {
return tempDir, repo
},
expected: []FileTreeEntry{
expected: []repository.FileTreeEntry{
{Path: "file1.txt", Blob: true, Size: 8},
{Path: "file2.txt", Blob: true, Size: 8},
{Path: "subdir", Blob: false},
{Path: "subdir/", Blob: false},
{Path: "subdir/file3.txt", Blob: true, Size: 8},
},
expectedErr: nil,
@@ -1397,7 +1433,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
return tempDir, repo
},
expected: []FileTreeEntry{},
expected: []repository.FileTreeEntry{},
expectedErr: nil,
},
}

View File

@@ -1,125 +0,0 @@
package nanogit
import (
"context"
"strings"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
// githubRepository is a repository implementation that integrates both a GitHub API-backed repository and a nanogit-based repository.
// It combines the features of the GitHub API with those of a standard Git repository.
// This is an interim solution to support both backends within a single repository abstraction.
// Once nanogit is fully integrated, functionality from GithubRepository should be migrated here, and this type should extend the nanogit.GitRepository interface.
type githubRepository struct {
apiRepo repository.GithubRepository
nanogitRepo repository.GitRepository
}
func NewGithubRepository(
apiRepo repository.GithubRepository,
nanogitRepo repository.GitRepository,
) repository.GithubRepository {
return &githubRepository{
apiRepo: apiRepo,
nanogitRepo: nanogitRepo,
}
}
func (r *githubRepository) Config() *provisioning.Repository {
return r.nanogitRepo.Config()
}
func (r *githubRepository) Owner() string {
return r.apiRepo.Owner()
}
func (r *githubRepository) Repo() string {
return r.apiRepo.Repo()
}
func (r *githubRepository) Client() pgh.Client {
return r.apiRepo.Client()
}
// Validate extends the nanogit repo validation with github specific validation
func (r *githubRepository) Validate() (list field.ErrorList) {
cfg := r.nanogitRepo.Config()
gh := cfg.Spec.GitHub
if gh == nil {
list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
return list
}
if gh.URL == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
} else {
_, _, err := repository.ParseOwnerRepoGithub(gh.URL)
if err != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
} else if !strings.HasPrefix(gh.URL, "https://github.com/") {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
}
}
if len(list) > 0 {
return list
}
return r.nanogitRepo.Validate()
}
// Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
return r.apiRepo.Test(ctx)
}
// ReadResource implements provisioning.Repository.
func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) {
return r.nanogitRepo.Read(ctx, filePath, ref)
}
func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
return r.nanogitRepo.ReadTree(ctx, ref)
}
func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
return r.nanogitRepo.Create(ctx, path, ref, data, comment)
}
func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
return r.nanogitRepo.Update(ctx, path, ref, data, comment)
}
func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
return r.nanogitRepo.Write(ctx, path, ref, data, message)
}
func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
return r.nanogitRepo.Delete(ctx, path, ref, comment)
}
func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
// Github API provides avatar URLs which nanogit does not, so we delegate to the github repo.
return r.apiRepo.History(ctx, path, ref)
}
func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
return r.nanogitRepo.LatestRef(ctx)
}
func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) {
return r.nanogitRepo.CompareFiles(ctx, base, ref)
}
// ResourceURLs implements RepositoryWithURLs.
func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.ResourceURLs, error) {
return r.apiRepo.ResourceURLs(ctx, file)
}
func (r *githubRepository) Clone(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
return r.nanogitRepo.Clone(ctx, opts)
}

View File

@@ -1,356 +0,0 @@
package nanogit
import (
"context"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation/field"
)
func TestGithubRepository(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
// Create a proper config for testing
expectedConfig := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
}
// Set up mock expectations for the methods that exist
gitRepo.EXPECT().Config().Return(expectedConfig)
apiRepo.EXPECT().Owner().Return("test")
apiRepo.EXPECT().Repo().Return("repo")
mockClient := pgh.NewMockClient(t)
apiRepo.EXPECT().Client().Return(mockClient)
repo := NewGithubRepository(apiRepo, gitRepo)
t.Run("delegates config to nanogit repo", func(t *testing.T) {
result := repo.Config()
require.Equal(t, expectedConfig, result)
})
t.Run("delegates owner to api repo", func(t *testing.T) {
result := repo.Owner()
require.Equal(t, "test", result)
})
t.Run("delegates repo to api repo", func(t *testing.T) {
result := repo.Repo()
require.Equal(t, "repo", result)
})
t.Run("delegates client to api repo", func(t *testing.T) {
result := repo.Client()
require.Equal(t, mockClient, result)
})
}
func TestGithubRepositoryDelegation(t *testing.T) {
ctx := context.Background()
t.Run("delegates test to api repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
expectedResult := &provisioning.TestResults{
Code: 200,
Success: true,
}
apiRepo.EXPECT().Test(ctx).Return(expectedResult, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.Test(ctx)
require.NoError(t, err)
require.Equal(t, expectedResult, result)
})
t.Run("delegates read to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
expectedFileInfo := &repository.FileInfo{
Path: "test.yaml",
Data: []byte("test data"),
Ref: "main",
Hash: "abc123",
}
gitRepo.EXPECT().Read(ctx, "test.yaml", "main").Return(expectedFileInfo, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.Read(ctx, "test.yaml", "main")
require.NoError(t, err)
require.Equal(t, expectedFileInfo, result)
})
t.Run("delegates read tree to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
expectedEntries := []repository.FileTreeEntry{
{Path: "file1.yaml", Size: 100, Hash: "hash1", Blob: true},
{Path: "dir/", Size: 0, Hash: "hash2", Blob: false},
}
gitRepo.EXPECT().ReadTree(ctx, "main").Return(expectedEntries, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.ReadTree(ctx, "main")
require.NoError(t, err)
require.Equal(t, expectedEntries, result)
})
t.Run("delegates create to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
data := []byte("test content")
gitRepo.EXPECT().Create(ctx, "new-file.yaml", "main", data, "Create new file").Return(nil)
repo := NewGithubRepository(apiRepo, gitRepo)
err := repo.Create(ctx, "new-file.yaml", "main", data, "Create new file")
require.NoError(t, err)
})
t.Run("delegates update to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
data := []byte("updated content")
gitRepo.EXPECT().Update(ctx, "existing-file.yaml", "main", data, "Update file").Return(nil)
repo := NewGithubRepository(apiRepo, gitRepo)
err := repo.Update(ctx, "existing-file.yaml", "main", data, "Update file")
require.NoError(t, err)
})
t.Run("delegates write to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
data := []byte("file content")
gitRepo.EXPECT().Write(ctx, "file.yaml", "main", data, "Write file").Return(nil)
repo := NewGithubRepository(apiRepo, gitRepo)
err := repo.Write(ctx, "file.yaml", "main", data, "Write file")
require.NoError(t, err)
})
t.Run("delegates delete to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
gitRepo.EXPECT().Delete(ctx, "file.yaml", "main", "Delete file").Return(nil)
repo := NewGithubRepository(apiRepo, gitRepo)
err := repo.Delete(ctx, "file.yaml", "main", "Delete file")
require.NoError(t, err)
})
t.Run("delegates history to api repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
expectedHistory := []provisioning.HistoryItem{
{
Ref: "commit1",
Message: "First commit",
Authors: []provisioning.Author{{Name: "Test User"}},
},
}
apiRepo.EXPECT().History(ctx, "file.yaml", "main").Return(expectedHistory, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.History(ctx, "file.yaml", "main")
require.NoError(t, err)
require.Equal(t, expectedHistory, result)
})
t.Run("delegates latest ref to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
expectedRef := "abc123def456"
gitRepo.EXPECT().LatestRef(ctx).Return(expectedRef, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.LatestRef(ctx)
require.NoError(t, err)
require.Equal(t, expectedRef, result)
})
t.Run("delegates compare files to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
expectedChanges := []repository.VersionedFileChange{
{
Action: repository.FileActionCreated,
Path: "new-file.yaml",
Ref: "feature-branch",
},
}
gitRepo.EXPECT().CompareFiles(ctx, "main", "feature-branch").Return(expectedChanges, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.CompareFiles(ctx, "main", "feature-branch")
require.NoError(t, err)
require.Equal(t, expectedChanges, result)
})
t.Run("delegates resource URLs to api repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
fileInfo := &repository.FileInfo{
Path: "dashboard.json",
Ref: "main",
Hash: "hash123",
}
expectedURLs := &provisioning.ResourceURLs{
SourceURL: "https://github.com/test/repo/blob/main/dashboard.json",
RepositoryURL: "https://github.com/test/repo",
NewPullRequestURL: "https://github.com/test/repo/compare/main...feature",
}
apiRepo.EXPECT().ResourceURLs(ctx, fileInfo).Return(expectedURLs, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.ResourceURLs(ctx, fileInfo)
require.NoError(t, err)
require.Equal(t, expectedURLs, result)
})
t.Run("delegates clone to nanogit repo", func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
mockClonedRepo := repository.NewMockClonedRepository(t)
opts := repository.CloneOptions{
CreateIfNotExists: true,
PushOnWrites: true,
}
gitRepo.EXPECT().Clone(ctx, opts).Return(mockClonedRepo, nil)
repo := NewGithubRepository(apiRepo, gitRepo)
result, err := repo.Clone(ctx, opts)
require.NoError(t, err)
require.Equal(t, mockClonedRepo, result)
})
}
func TestGithubRepositoryValidation(t *testing.T) {
tests := []struct {
name string
config *provisioning.Repository
expectedErrors int
}{
{
name: "missing github config",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
},
},
expectedErrors: 1,
},
{
name: "missing github url",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
},
expectedErrors: 1,
},
{
name: "invalid github url",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "invalid-url",
Branch: "main",
},
},
},
expectedErrors: 1,
},
{
name: "non-github url",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://gitlab.com/test/repo",
Branch: "main",
},
},
},
expectedErrors: 1,
},
{
name: "valid github config",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
expectedErrors: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiRepo := repository.NewMockGithubRepository(t)
gitRepo := repository.NewMockGitRepository(t)
// Set up mock expectations
gitRepo.EXPECT().Config().Return(tt.config)
if tt.expectedErrors == 0 {
// If no validation errors expected, nanogit validation should be called
gitRepo.EXPECT().Validate().Return(field.ErrorList{})
}
repo := NewGithubRepository(apiRepo, gitRepo)
result := repo.Validate()
require.Len(t, result, tt.expectedErrors)
})
}
}

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository

View File

@@ -2,9 +2,7 @@ package repository
import (
"context"
"io"
"net/http"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -67,47 +65,6 @@ type FileInfo struct {
Modified *metav1.Time
}
//go:generate mockery --name CloneFn --structname MockCloneFn --inpackage --filename clone_fn_mock.go --with-expecter
type CloneFn func(ctx context.Context, opts CloneOptions) (ClonedRepository, error)
type CloneOptions struct {
// If the branch does not exist, create it
CreateIfNotExists bool
// Push on every write
PushOnWrites bool
// Maximum allowed size for repository clone in bytes (0 means no limit)
MaxSize int64
// Maximum time allowed for clone operation in seconds (0 means no limit)
Timeout time.Duration
// Progress is the writer to report progress to
Progress io.Writer
// BeforeFn is called before the clone operation starts
BeforeFn func() error
}
//go:generate mockery --name ClonableRepository --structname MockClonableRepository --inpackage --filename clonable_repository_mock.go --with-expecter
type ClonableRepository interface {
Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error)
}
type PushOptions struct {
Timeout time.Duration
Progress io.Writer
BeforeFn func() error
}
//go:generate mockery --name ClonedRepository --structname MockClonedRepository --inpackage --filename cloned_repository_mock.go --with-expecter
type ClonedRepository interface {
ReaderWriter
Push(ctx context.Context, opts PushOptions) error
Remove(ctx context.Context) error
}
// An entry in the file tree, as returned by 'ReadFileTree'. Like FileInfo, but contains less information.
type FileTreeEntry struct {
// The path to the file from the base path given (if any).

View File

@@ -0,0 +1,95 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockStageableRepository is an autogenerated mock type for the StageableRepository type
type MockStageableRepository struct {
mock.Mock
}
type MockStageableRepository_Expecter struct {
mock *mock.Mock
}
func (_m *MockStageableRepository) EXPECT() *MockStageableRepository_Expecter {
return &MockStageableRepository_Expecter{mock: &_m.Mock}
}
// Stage provides a mock function with given fields: ctx, opts
func (_m *MockStageableRepository) Stage(ctx context.Context, opts StageOptions) (StagedRepository, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for Stage")
}
var r0 StagedRepository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, StageOptions) (StagedRepository, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, StageOptions) StagedRepository); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(StagedRepository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, StageOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockStageableRepository_Stage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stage'
type MockStageableRepository_Stage_Call struct {
*mock.Call
}
// Stage is a helper method to define mock.On call
// - ctx context.Context
// - opts StageOptions
func (_e *MockStageableRepository_Expecter) Stage(ctx interface{}, opts interface{}) *MockStageableRepository_Stage_Call {
return &MockStageableRepository_Stage_Call{Call: _e.mock.On("Stage", ctx, opts)}
}
func (_c *MockStageableRepository_Stage_Call) Run(run func(ctx context.Context, opts StageOptions)) *MockStageableRepository_Stage_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(StageOptions))
})
return _c
}
func (_c *MockStageableRepository_Stage_Call) Return(_a0 StagedRepository, _a1 error) *MockStageableRepository_Stage_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockStageableRepository_Stage_Call) RunAndReturn(run func(context.Context, StageOptions) (StagedRepository, error)) *MockStageableRepository_Stage_Call {
_c.Call.Return(run)
return _c
}
// NewMockStageableRepository creates a new instance of MockStageableRepository. 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 NewMockStageableRepository(t interface {
mock.TestingT
Cleanup(func())
}) *MockStageableRepository {
mock := &MockStageableRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,71 @@
package repository
import (
context "context"
"errors"
"fmt"
"time"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/nanogit"
)
type StageOptions struct {
// Push on every write
PushOnWrites bool
// Maximum time allowed for clone operation in seconds (0 means no limit)
Timeout time.Duration
}
//go:generate mockery --name StageableRepository --structname MockStageableRepository --inpackage --filename stageable_repository_mock.go --with-expecter
type StageableRepository interface {
Stage(ctx context.Context, opts StageOptions) (StagedRepository, error)
}
//go:generate mockery --name StagedRepository --structname MockStagedRepository --inpackage --filename staged_repository_mock.go --with-expecter
type StagedRepository interface {
ReaderWriter
Push(ctx context.Context) error
Remove(ctx context.Context) error
}
// WrapWithStageAndPushIfPossible attempts to stage the given repository. If staging is supported,
// it runs the provided function on the staged repository, then pushes any changes and cleans up the staged repository.
// If staging is not supported, it runs the function on the original repository without pushing.
// The 'staged' argument to the function indicates whether a staged repository was used.
func WrapWithStageAndPushIfPossible(
ctx context.Context,
repo Repository,
stageOptions StageOptions,
fn func(repo Repository, staged bool) error,
) error {
stageable, ok := repo.(StageableRepository)
if !ok {
return fn(repo, false)
}
staged, err := stageable.Stage(ctx, stageOptions)
if err != nil {
return fmt.Errorf("stage repository: %w", err)
}
// We don't, we simply log it
// FIXME: should we handle this differently?
defer func() {
if err := staged.Remove(ctx); err != nil {
logging.FromContext(ctx).Error("failed to remove staged repository after export", "err", err)
}
}()
if err := fn(staged, true); err != nil {
return err
}
if err = staged.Push(ctx); err != nil {
if errors.Is(err, nanogit.ErrNothingToPush) {
return nil // OK, already pushed
}
return fmt.Errorf("wrapped push error: %w", err)
}
return nil
}

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
@@ -11,21 +11,21 @@ import (
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
// MockClonedRepository is an autogenerated mock type for the ClonedRepository type
type MockClonedRepository struct {
// MockStagedRepository is an autogenerated mock type for the StagedRepository type
type MockStagedRepository struct {
mock.Mock
}
type MockClonedRepository_Expecter struct {
type MockStagedRepository_Expecter struct {
mock *mock.Mock
}
func (_m *MockClonedRepository) EXPECT() *MockClonedRepository_Expecter {
return &MockClonedRepository_Expecter{mock: &_m.Mock}
func (_m *MockStagedRepository) EXPECT() *MockStagedRepository_Expecter {
return &MockStagedRepository_Expecter{mock: &_m.Mock}
}
// Config provides a mock function with no fields
func (_m *MockClonedRepository) Config() *v0alpha1.Repository {
func (_m *MockStagedRepository) Config() *v0alpha1.Repository {
ret := _m.Called()
if len(ret) == 0 {
@@ -44,35 +44,35 @@ func (_m *MockClonedRepository) Config() *v0alpha1.Repository {
return r0
}
// MockClonedRepository_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
type MockClonedRepository_Config_Call struct {
// MockStagedRepository_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
type MockStagedRepository_Config_Call struct {
*mock.Call
}
// Config is a helper method to define mock.On call
func (_e *MockClonedRepository_Expecter) Config() *MockClonedRepository_Config_Call {
return &MockClonedRepository_Config_Call{Call: _e.mock.On("Config")}
func (_e *MockStagedRepository_Expecter) Config() *MockStagedRepository_Config_Call {
return &MockStagedRepository_Config_Call{Call: _e.mock.On("Config")}
}
func (_c *MockClonedRepository_Config_Call) Run(run func()) *MockClonedRepository_Config_Call {
func (_c *MockStagedRepository_Config_Call) Run(run func()) *MockStagedRepository_Config_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClonedRepository_Config_Call) Return(_a0 *v0alpha1.Repository) *MockClonedRepository_Config_Call {
func (_c *MockStagedRepository_Config_Call) Return(_a0 *v0alpha1.Repository) *MockStagedRepository_Config_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockClonedRepository_Config_Call {
func (_c *MockStagedRepository_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockStagedRepository_Config_Call {
_c.Call.Return(run)
return _c
}
// Create provides a mock function with given fields: ctx, path, ref, data, message
func (_m *MockClonedRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
func (_m *MockStagedRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
ret := _m.Called(ctx, path, ref, data, message)
if len(ret) == 0 {
@@ -89,8 +89,8 @@ func (_m *MockClonedRepository) Create(ctx context.Context, path string, ref str
return r0
}
// MockClonedRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
type MockClonedRepository_Create_Call struct {
// MockStagedRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
type MockStagedRepository_Create_Call struct {
*mock.Call
}
@@ -100,29 +100,29 @@ type MockClonedRepository_Create_Call struct {
// - ref string
// - data []byte
// - message string
func (_e *MockClonedRepository_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockClonedRepository_Create_Call {
return &MockClonedRepository_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)}
func (_e *MockStagedRepository_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockStagedRepository_Create_Call {
return &MockStagedRepository_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)}
}
func (_c *MockClonedRepository_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockClonedRepository_Create_Call {
func (_c *MockStagedRepository_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockStagedRepository_Create_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string))
})
return _c
}
func (_c *MockClonedRepository_Create_Call) Return(_a0 error) *MockClonedRepository_Create_Call {
func (_c *MockStagedRepository_Create_Call) Return(_a0 error) *MockStagedRepository_Create_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockClonedRepository_Create_Call {
func (_c *MockStagedRepository_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockStagedRepository_Create_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with given fields: ctx, path, ref, message
func (_m *MockClonedRepository) Delete(ctx context.Context, path string, ref string, message string) error {
func (_m *MockStagedRepository) Delete(ctx context.Context, path string, ref string, message string) error {
ret := _m.Called(ctx, path, ref, message)
if len(ret) == 0 {
@@ -139,8 +139,8 @@ func (_m *MockClonedRepository) Delete(ctx context.Context, path string, ref str
return r0
}
// MockClonedRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockClonedRepository_Delete_Call struct {
// MockStagedRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockStagedRepository_Delete_Call struct {
*mock.Call
}
@@ -149,38 +149,38 @@ type MockClonedRepository_Delete_Call struct {
// - path string
// - ref string
// - message string
func (_e *MockClonedRepository_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockClonedRepository_Delete_Call {
return &MockClonedRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)}
func (_e *MockStagedRepository_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockStagedRepository_Delete_Call {
return &MockStagedRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)}
}
func (_c *MockClonedRepository_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockClonedRepository_Delete_Call {
func (_c *MockStagedRepository_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockStagedRepository_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockClonedRepository_Delete_Call) Return(_a0 error) *MockClonedRepository_Delete_Call {
func (_c *MockStagedRepository_Delete_Call) Return(_a0 error) *MockStagedRepository_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockClonedRepository_Delete_Call {
func (_c *MockStagedRepository_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockStagedRepository_Delete_Call {
_c.Call.Return(run)
return _c
}
// Push provides a mock function with given fields: ctx, opts
func (_m *MockClonedRepository) Push(ctx context.Context, opts PushOptions) error {
ret := _m.Called(ctx, opts)
// Push provides a mock function with given fields: ctx
func (_m *MockStagedRepository) Push(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for Push")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, PushOptions) error); ok {
r0 = rf(ctx, opts)
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
@@ -188,37 +188,36 @@ func (_m *MockClonedRepository) Push(ctx context.Context, opts PushOptions) erro
return r0
}
// MockClonedRepository_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
type MockClonedRepository_Push_Call struct {
// MockStagedRepository_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
type MockStagedRepository_Push_Call struct {
*mock.Call
}
// Push is a helper method to define mock.On call
// - ctx context.Context
// - opts PushOptions
func (_e *MockClonedRepository_Expecter) Push(ctx interface{}, opts interface{}) *MockClonedRepository_Push_Call {
return &MockClonedRepository_Push_Call{Call: _e.mock.On("Push", ctx, opts)}
func (_e *MockStagedRepository_Expecter) Push(ctx interface{}) *MockStagedRepository_Push_Call {
return &MockStagedRepository_Push_Call{Call: _e.mock.On("Push", ctx)}
}
func (_c *MockClonedRepository_Push_Call) Run(run func(ctx context.Context, opts PushOptions)) *MockClonedRepository_Push_Call {
func (_c *MockStagedRepository_Push_Call) Run(run func(ctx context.Context)) *MockStagedRepository_Push_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(PushOptions))
run(args[0].(context.Context))
})
return _c
}
func (_c *MockClonedRepository_Push_Call) Return(_a0 error) *MockClonedRepository_Push_Call {
func (_c *MockStagedRepository_Push_Call) Return(_a0 error) *MockStagedRepository_Push_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Push_Call) RunAndReturn(run func(context.Context, PushOptions) error) *MockClonedRepository_Push_Call {
func (_c *MockStagedRepository_Push_Call) RunAndReturn(run func(context.Context) error) *MockStagedRepository_Push_Call {
_c.Call.Return(run)
return _c
}
// Read provides a mock function with given fields: ctx, path, ref
func (_m *MockClonedRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
func (_m *MockStagedRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
ret := _m.Called(ctx, path, ref)
if len(ret) == 0 {
@@ -247,8 +246,8 @@ func (_m *MockClonedRepository) Read(ctx context.Context, path string, ref strin
return r0, r1
}
// MockClonedRepository_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
type MockClonedRepository_Read_Call struct {
// MockStagedRepository_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
type MockStagedRepository_Read_Call struct {
*mock.Call
}
@@ -256,29 +255,29 @@ type MockClonedRepository_Read_Call struct {
// - ctx context.Context
// - path string
// - ref string
func (_e *MockClonedRepository_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockClonedRepository_Read_Call {
return &MockClonedRepository_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)}
func (_e *MockStagedRepository_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockStagedRepository_Read_Call {
return &MockStagedRepository_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)}
}
func (_c *MockClonedRepository_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockClonedRepository_Read_Call {
func (_c *MockStagedRepository_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockStagedRepository_Read_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockClonedRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockClonedRepository_Read_Call {
func (_c *MockStagedRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockStagedRepository_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClonedRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockClonedRepository_Read_Call {
func (_c *MockStagedRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockStagedRepository_Read_Call {
_c.Call.Return(run)
return _c
}
// ReadTree provides a mock function with given fields: ctx, ref
func (_m *MockClonedRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
func (_m *MockStagedRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
ret := _m.Called(ctx, ref)
if len(ret) == 0 {
@@ -307,37 +306,37 @@ func (_m *MockClonedRepository) ReadTree(ctx context.Context, ref string) ([]Fil
return r0, r1
}
// MockClonedRepository_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree'
type MockClonedRepository_ReadTree_Call struct {
// MockStagedRepository_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree'
type MockStagedRepository_ReadTree_Call struct {
*mock.Call
}
// ReadTree is a helper method to define mock.On call
// - ctx context.Context
// - ref string
func (_e *MockClonedRepository_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockClonedRepository_ReadTree_Call {
return &MockClonedRepository_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)}
func (_e *MockStagedRepository_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockStagedRepository_ReadTree_Call {
return &MockStagedRepository_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)}
}
func (_c *MockClonedRepository_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockClonedRepository_ReadTree_Call {
func (_c *MockStagedRepository_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockStagedRepository_ReadTree_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockClonedRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockClonedRepository_ReadTree_Call {
func (_c *MockStagedRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockStagedRepository_ReadTree_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClonedRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockClonedRepository_ReadTree_Call {
func (_c *MockStagedRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockStagedRepository_ReadTree_Call {
_c.Call.Return(run)
return _c
}
// Remove provides a mock function with given fields: ctx
func (_m *MockClonedRepository) Remove(ctx context.Context) error {
func (_m *MockStagedRepository) Remove(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
@@ -354,36 +353,36 @@ func (_m *MockClonedRepository) Remove(ctx context.Context) error {
return r0
}
// MockClonedRepository_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
type MockClonedRepository_Remove_Call struct {
// MockStagedRepository_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
type MockStagedRepository_Remove_Call struct {
*mock.Call
}
// Remove is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockClonedRepository_Expecter) Remove(ctx interface{}) *MockClonedRepository_Remove_Call {
return &MockClonedRepository_Remove_Call{Call: _e.mock.On("Remove", ctx)}
func (_e *MockStagedRepository_Expecter) Remove(ctx interface{}) *MockStagedRepository_Remove_Call {
return &MockStagedRepository_Remove_Call{Call: _e.mock.On("Remove", ctx)}
}
func (_c *MockClonedRepository_Remove_Call) Run(run func(ctx context.Context)) *MockClonedRepository_Remove_Call {
func (_c *MockStagedRepository_Remove_Call) Run(run func(ctx context.Context)) *MockStagedRepository_Remove_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockClonedRepository_Remove_Call) Return(_a0 error) *MockClonedRepository_Remove_Call {
func (_c *MockStagedRepository_Remove_Call) Return(_a0 error) *MockStagedRepository_Remove_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Remove_Call) RunAndReturn(run func(context.Context) error) *MockClonedRepository_Remove_Call {
func (_c *MockStagedRepository_Remove_Call) RunAndReturn(run func(context.Context) error) *MockStagedRepository_Remove_Call {
_c.Call.Return(run)
return _c
}
// Test provides a mock function with given fields: ctx
func (_m *MockClonedRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) {
func (_m *MockStagedRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
@@ -412,36 +411,36 @@ func (_m *MockClonedRepository) Test(ctx context.Context) (*v0alpha1.TestResults
return r0, r1
}
// MockClonedRepository_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test'
type MockClonedRepository_Test_Call struct {
// MockStagedRepository_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test'
type MockStagedRepository_Test_Call struct {
*mock.Call
}
// Test is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockClonedRepository_Expecter) Test(ctx interface{}) *MockClonedRepository_Test_Call {
return &MockClonedRepository_Test_Call{Call: _e.mock.On("Test", ctx)}
func (_e *MockStagedRepository_Expecter) Test(ctx interface{}) *MockStagedRepository_Test_Call {
return &MockStagedRepository_Test_Call{Call: _e.mock.On("Test", ctx)}
}
func (_c *MockClonedRepository_Test_Call) Run(run func(ctx context.Context)) *MockClonedRepository_Test_Call {
func (_c *MockStagedRepository_Test_Call) Run(run func(ctx context.Context)) *MockStagedRepository_Test_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockClonedRepository_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockClonedRepository_Test_Call {
func (_c *MockStagedRepository_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockStagedRepository_Test_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClonedRepository_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockClonedRepository_Test_Call {
func (_c *MockStagedRepository_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockStagedRepository_Test_Call {
_c.Call.Return(run)
return _c
}
// Update provides a mock function with given fields: ctx, path, ref, data, message
func (_m *MockClonedRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
func (_m *MockStagedRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
ret := _m.Called(ctx, path, ref, data, message)
if len(ret) == 0 {
@@ -458,8 +457,8 @@ func (_m *MockClonedRepository) Update(ctx context.Context, path string, ref str
return r0
}
// MockClonedRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
type MockClonedRepository_Update_Call struct {
// MockStagedRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
type MockStagedRepository_Update_Call struct {
*mock.Call
}
@@ -469,29 +468,29 @@ type MockClonedRepository_Update_Call struct {
// - ref string
// - data []byte
// - message string
func (_e *MockClonedRepository_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockClonedRepository_Update_Call {
return &MockClonedRepository_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)}
func (_e *MockStagedRepository_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockStagedRepository_Update_Call {
return &MockStagedRepository_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)}
}
func (_c *MockClonedRepository_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockClonedRepository_Update_Call {
func (_c *MockStagedRepository_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockStagedRepository_Update_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string))
})
return _c
}
func (_c *MockClonedRepository_Update_Call) Return(_a0 error) *MockClonedRepository_Update_Call {
func (_c *MockStagedRepository_Update_Call) Return(_a0 error) *MockStagedRepository_Update_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockClonedRepository_Update_Call {
func (_c *MockStagedRepository_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockStagedRepository_Update_Call {
_c.Call.Return(run)
return _c
}
// Validate provides a mock function with no fields
func (_m *MockClonedRepository) Validate() field.ErrorList {
func (_m *MockStagedRepository) Validate() field.ErrorList {
ret := _m.Called()
if len(ret) == 0 {
@@ -510,35 +509,35 @@ func (_m *MockClonedRepository) Validate() field.ErrorList {
return r0
}
// MockClonedRepository_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
type MockClonedRepository_Validate_Call struct {
// MockStagedRepository_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
type MockStagedRepository_Validate_Call struct {
*mock.Call
}
// Validate is a helper method to define mock.On call
func (_e *MockClonedRepository_Expecter) Validate() *MockClonedRepository_Validate_Call {
return &MockClonedRepository_Validate_Call{Call: _e.mock.On("Validate")}
func (_e *MockStagedRepository_Expecter) Validate() *MockStagedRepository_Validate_Call {
return &MockStagedRepository_Validate_Call{Call: _e.mock.On("Validate")}
}
func (_c *MockClonedRepository_Validate_Call) Run(run func()) *MockClonedRepository_Validate_Call {
func (_c *MockStagedRepository_Validate_Call) Run(run func()) *MockStagedRepository_Validate_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClonedRepository_Validate_Call) Return(_a0 field.ErrorList) *MockClonedRepository_Validate_Call {
func (_c *MockStagedRepository_Validate_Call) Return(_a0 field.ErrorList) *MockStagedRepository_Validate_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockClonedRepository_Validate_Call {
func (_c *MockStagedRepository_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockStagedRepository_Validate_Call {
_c.Call.Return(run)
return _c
}
// Write provides a mock function with given fields: ctx, path, ref, data, message
func (_m *MockClonedRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
func (_m *MockStagedRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
ret := _m.Called(ctx, path, ref, data, message)
if len(ret) == 0 {
@@ -555,8 +554,8 @@ func (_m *MockClonedRepository) Write(ctx context.Context, path string, ref stri
return r0
}
// MockClonedRepository_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
type MockClonedRepository_Write_Call struct {
// MockStagedRepository_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
type MockStagedRepository_Write_Call struct {
*mock.Call
}
@@ -566,34 +565,34 @@ type MockClonedRepository_Write_Call struct {
// - ref string
// - data []byte
// - message string
func (_e *MockClonedRepository_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockClonedRepository_Write_Call {
return &MockClonedRepository_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)}
func (_e *MockStagedRepository_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockStagedRepository_Write_Call {
return &MockStagedRepository_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)}
}
func (_c *MockClonedRepository_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockClonedRepository_Write_Call {
func (_c *MockStagedRepository_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockStagedRepository_Write_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string))
})
return _c
}
func (_c *MockClonedRepository_Write_Call) Return(_a0 error) *MockClonedRepository_Write_Call {
func (_c *MockStagedRepository_Write_Call) Return(_a0 error) *MockStagedRepository_Write_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClonedRepository_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockClonedRepository_Write_Call {
func (_c *MockStagedRepository_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockStagedRepository_Write_Call {
_c.Call.Return(run)
return _c
}
// NewMockClonedRepository creates a new instance of MockClonedRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// NewMockStagedRepository creates a new instance of MockStagedRepository. 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 NewMockClonedRepository(t interface {
func NewMockStagedRepository(t interface {
mock.TestingT
Cleanup(func())
}) *MockClonedRepository {
mock := &MockClonedRepository{}
}) *MockStagedRepository {
mock := &MockStagedRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })

View File

@@ -0,0 +1,144 @@
package repository
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type mockStagedRepo struct {
*MockStageableRepository
*MockStagedRepository
}
func Test_WrapWithStageAndPushIfPossible_NonStageableRepository(t *testing.T) {
nonStageable := NewMockRepository(t)
var called bool
fn := func(repo Repository, staged bool) error {
called = true
return errors.New("operation failed")
}
err := WrapWithStageAndPushIfPossible(context.Background(), nonStageable, StageOptions{}, fn)
require.EqualError(t, err, "operation failed")
require.True(t, called)
}
func TestWrapWithStageAndPushIfPossible(t *testing.T) {
tests := []struct {
name string
setupMocks func(t *testing.T) *mockStagedRepo
operation func(repo Repository, staged bool) error
expectedError string
}{
{
name: "successful stage, operation, and push",
setupMocks: func(t *testing.T) *mockStagedRepo {
mockRepo := NewMockStageableRepository(t)
mockStaged := NewMockStagedRepository(t)
mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
mockStaged.EXPECT().Push(mock.Anything).Return(nil)
mockStaged.EXPECT().Remove(mock.Anything).Return(nil)
return &mockStagedRepo{
MockStageableRepository: mockRepo,
MockStagedRepository: mockStaged,
}
},
operation: func(repo Repository, staged bool) error {
require.True(t, staged)
return nil
},
},
{
name: "stage failure",
setupMocks: func(t *testing.T) *mockStagedRepo {
mockRepo := NewMockStageableRepository(t)
mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(nil, errors.New("stage failed"))
return &mockStagedRepo{
MockStageableRepository: mockRepo,
}
},
operation: func(repo Repository, staged bool) error {
return nil
},
expectedError: "stage repository: stage failed",
},
{
name: "operation failure",
setupMocks: func(t *testing.T) *mockStagedRepo {
mockRepo := NewMockStageableRepository(t)
mockStaged := NewMockStagedRepository(t)
mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
mockStaged.EXPECT().Remove(mock.Anything).Return(nil)
return &mockStagedRepo{
MockStageableRepository: mockRepo,
MockStagedRepository: mockStaged,
}
},
operation: func(repo Repository, staged bool) error {
return errors.New("operation failed")
},
expectedError: "operation failed",
},
{
name: "push failure",
setupMocks: func(t *testing.T) *mockStagedRepo {
mockRepo := NewMockStageableRepository(t)
mockStaged := NewMockStagedRepository(t)
mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
mockStaged.EXPECT().Push(mock.Anything).Return(errors.New("push failed"))
mockStaged.EXPECT().Remove(mock.Anything).Return(nil)
return &mockStagedRepo{
MockStageableRepository: mockRepo,
MockStagedRepository: mockStaged,
}
},
operation: func(repo Repository, staged bool) error {
return nil
},
expectedError: "wrapped push error: push failed",
},
{
name: "remove failure should only log",
setupMocks: func(t *testing.T) *mockStagedRepo {
mockRepo := NewMockStageableRepository(t)
mockStaged := NewMockStagedRepository(t)
mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
mockStaged.EXPECT().Push(mock.Anything).Return(nil)
mockStaged.EXPECT().Remove(mock.Anything).Return(errors.New("remove failed"))
return &mockStagedRepo{
MockStageableRepository: mockRepo,
MockStagedRepository: mockStaged,
}
},
operation: func(repo Repository, staged bool) error {
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := tt.setupMocks(t)
err := WrapWithStageAndPushIfPossible(context.Background(), repo, StageOptions{}, tt.operation)
if tt.expectedError != "" {
require.EqualError(t, err, tt.expectedError)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -96,7 +96,7 @@ func ValidateRepository(repo Repository) field.ErrorList {
return list
}
func fromFieldError(err *field.Error) *provisioning.TestResults {
func FromFieldError(err *field.Error) *provisioning.TestResults {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,

View File

@@ -431,7 +431,7 @@ func TestFromFieldError(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := fromFieldError(tt.fieldError)
result := FromFieldError(tt.fieldError)
require.NotNil(t, result)
require.Equal(t, tt.expectedCode, result.Code)

View File

@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository

View File

@@ -6,6 +6,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
@@ -76,9 +77,8 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return nil, err
}
// TODO: implement this
if safepath.IsDir(opts.Path) {
return nil, fmt.Errorf("folder delete not supported")
return r.deleteFolder(ctx, opts)
}
// Read the file from the default branch as it won't exist in the possibly new branch
@@ -166,6 +166,12 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions
},
}
urls, err := getFolderURLs(ctx, opts.Path, opts.Ref, r.repo)
if err != nil {
return nil, err
}
wrap.URLs = urls
if opts.Ref == "" {
folderName, err := r.folders.EnsureFolderPathExist(ctx, opts.Path)
if err != nil {
@@ -317,3 +323,141 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) er
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
fmt.Errorf("must be admin or editor to access folders with provisioning"))
}
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// if the ref is set, it is not the active branch, so just delete the files from the branch
// and do not delete the items from grafana itself
if opts.Ref != "" {
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("error deleting folder from repository: %w", err)
}
return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
// before deleting from the repo, first get all children resources to delete from grafana afterwards
treeEntries, err := r.repo.ReadTree(ctx, "")
if err != nil {
return nil, fmt.Errorf("read repository tree: %w", err)
}
// note: parsedFolders will include the folder itself
parsedResources, parsedFolders, err := r.getChildren(ctx, opts.Path, treeEntries)
if err != nil {
return nil, fmt.Errorf("parse resources in folder: %w", err)
}
// delete from the repo
err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("delete folder from repository: %w", err)
}
// delete from grafana
ctx, _, err = identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
return nil, err
}
if err := r.deleteChildren(ctx, parsedResources, parsedFolders); err != nil {
return nil, fmt.Errorf("delete folder from grafana: %w", err)
}
return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
func getFolderURLs(ctx context.Context, path, ref string, repo repository.Repository) (*provisioning.ResourceURLs, error) {
if urlRepo, ok := repo.(repository.RepositoryWithURLs); ok && ref != "" {
urls, err := urlRepo.ResourceURLs(ctx, &repository.FileInfo{Path: path, Ref: ref})
if err != nil {
return nil, err
}
return urls, nil
}
return nil, nil
}
func folderDeleteResponse(ctx context.Context, path, ref string, repo repository.Repository) (*ParsedResource, error) {
urls, err := getFolderURLs(ctx, path, ref, repo)
if err != nil {
return nil, err
}
parsed := &ParsedResource{
Action: provisioning.ResourceActionDelete,
Info: &repository.FileInfo{
Path: path,
Ref: ref,
},
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: repo.Config().Spec.Type,
Namespace: repo.Config().Namespace,
Name: repo.Config().Name,
Title: repo.Config().Spec.Title,
},
URLs: urls,
}
return parsed, nil
}
func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) {
var resourcesInFolder []repository.FileTreeEntry
var foldersInFolder []Folder
for _, entry := range treeEntries {
// make sure the path is supported (i.e. not ignored by git sync) and that the path is the folder itself or a child of the folder
if IsPathSupported(entry.Path) != nil || !safepath.InDir(entry.Path, folderPath) {
continue
}
// folders cannot be parsed as resources, so handle them separately
if entry.Blob {
resourcesInFolder = append(resourcesInFolder, entry)
} else {
folder := ParseFolder(entry.Path, r.repo.Config().Name)
foldersInFolder = append(foldersInFolder, folder)
}
}
parsedResources := make([]*ParsedResource, len(resourcesInFolder))
for i, entry := range resourcesInFolder {
fileInfo, err := r.repo.Read(ctx, entry.Path, "")
if err != nil && !apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf("could not find resource in repository: %w", err)
}
parsed, err := r.parser.Parse(ctx, fileInfo)
if err != nil {
return nil, nil, fmt.Errorf("could not parse resource: %w", err)
}
parsedResources[i] = parsed
}
return parsedResources, foldersInFolder, nil
}
func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources []*ParsedResource, folders []Folder) error {
for _, parsed := range childrenResources {
err := parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete nested resource from grafana: %w", err)
}
}
// we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it
safepath.SortByDepth(folders, func(f Folder) string { return f.Path }, false)
for _, f := range folders {
err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("failed to delete folder from grafana: %w", err)
}
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"slices"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -121,7 +122,7 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj
err = r.repo.Write(ctx, fileName, options.Ref, body, commitMessage)
if err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
return "", fmt.Errorf("failed to write file: %s, %w", fileName, err)
}
return fileName, nil
@@ -206,6 +207,10 @@ func (r *ResourcesManager) RemoveResourceFromFile(ctx context.Context, path stri
err = client.Delete(ctx, objName, metav1.DeleteOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return objName, schema.GroupVersionKind{}, nil // Already deleted or simply non-existing, nothing to do
}
return "", schema.GroupVersionKind{}, fmt.Errorf("failed to delete: %w", err)
}

View File

@@ -3,7 +3,6 @@ package resources
import (
"context"
"fmt"
"sort"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -95,10 +94,8 @@ func (t *folderTree) Walk(ctx context.Context, fn WalkFunc) error {
toWalk = append(toWalk, folder)
}
// sort by depth of the paths
sort.Slice(toWalk, func(i, j int) bool {
return safepath.Depth(toWalk[i].Path) < safepath.Depth(toWalk[j].Path)
})
// sort by depth (shallowest first)
safepath.SortByDepth(toWalk, func(f Folder) string { return f.Path }, true)
for _, folder := range toWalk {
if err := fn(ctx, folder, t.tree[folder.ID]); err != nil {

View File

@@ -3,6 +3,7 @@ package safepath
import (
"context"
"path"
"sort"
"strings"
)
@@ -43,3 +44,22 @@ func Split(p string) []string {
}
return strings.Split(trimmed, "/")
}
// SortByDepth will sort any resource, by its path depth. You must pass in
// a way to get said path. Ties are alphabetical by default.
func SortByDepth[T any](items []T, pathExtractor func(T) string, asc bool) {
sort.Slice(items, func(i, j int) bool {
pathI, pathJ := pathExtractor(items[i]), pathExtractor(items[j])
depthI, depthJ := Depth(pathI), Depth(pathJ)
if depthI == depthJ {
// alphabetical by default if depth is the same
return pathI < pathJ
}
if asc {
return depthI < depthJ
}
return depthI > depthJ
})
}

View File

@@ -176,3 +176,56 @@ func TestWalkError(t *testing.T) {
require.ErrorIs(t, err, expectedErr)
}
func TestSortByDepth(t *testing.T) {
tests := []struct {
name string
asc bool
paths []string
expected []string
}{
{
name: "ascending sort (shallowest first)",
paths: []string{"a/b/c", "a", "a/b", "d/e/f/g"},
asc: true,
expected: []string{"a", "a/b", "a/b/c", "d/e/f/g"},
},
{
name: "descending sort with alphabetical tie-break",
paths: []string{"a/b/c", "a", "a/b", "d/e/f/g", "x/y/z"},
asc: false,
expected: []string{"d/e/f/g", "a/b/c", "x/y/z", "a/b", "a"},
},
{
name: "paths with empty string",
paths: []string{"a/b/c", "", "a", "a/b"},
asc: true,
expected: []string{"", "a", "a/b", "a/b/c"},
},
{
name: "paths with trailing slashes",
paths: []string{"a/b/", "a/b/c", "b/", "a/", "a"},
asc: true,
expected: []string{"a", "a/", "b/", "a/b/", "a/b/c"},
},
{
name: "single path",
paths: []string{"a/b/c"},
expected: []string{"a/b/c"},
},
{
name: "empty paths",
paths: []string{},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths := make([]string, len(tt.paths))
copy(paths, tt.paths)
SortByDepth(paths, func(s string) string { return s }, tt.asc)
assert.Equal(t, tt.expected, paths)
})
}
}

View File

@@ -11,9 +11,8 @@ import (
provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/nanogit"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest"
@@ -180,42 +179,41 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit
gvr.Resource,
r.GetName(),
)
cloneFn := func(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
return gogit.Clone(ctx, e.clonedir, r, opts, e.secrets)
}
apiRepo, err := repository.NewGitHub(ctx, r, e.ghFactory, e.secrets, cloneFn)
if err != nil {
return nil, fmt.Errorf("create github API repository: %w", err)
}
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
if !e.features.IsEnabledGlobally(featuremgmt.FlagNanoGit) {
logger.Debug("Instantiating Github repository with go-git and Github API")
return NewGithubWebhookRepository(apiRepo, webhookURL, e.secrets), nil
}
logger.Info("Instantiating Github repository with nanogit")
logger.Info("Instantiating Github repository with webhooks")
ghCfg := r.Spec.GitHub
if ghCfg == nil {
return nil, fmt.Errorf("github configuration is required for nano git")
}
gitCfg := nanogit.RepositoryConfig{
// Decrypt GitHub token if needed
ghToken := ghCfg.Token
if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
decrypted, err := e.secrets.Decrypt(ctx, ghCfg.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}
ghToken = string(decrypted)
}
gitCfg := git.RepositoryConfig{
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
Token: ghCfg.Token,
Token: ghToken,
EncryptedToken: ghCfg.EncryptedToken,
}
nanogitRepo, err := nanogit.NewGitRepository(ctx, e.secrets, r, gitCfg)
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
if err != nil {
return nil, fmt.Errorf("error creating nanogit repository: %w", err)
return nil, fmt.Errorf("error creating git repository: %w", err)
}
basicRepo := nanogit.NewGithubRepository(apiRepo, nanogitRepo)
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}
return NewGithubWebhookRepository(basicRepo, webhookURL, e.secrets), nil
}

View File

@@ -25,14 +25,14 @@ type WebhookRepository interface {
}
type GithubWebhookRepository interface {
repository.GithubRepository
pgh.GithubRepository
repository.Hooks
WebhookRepository
}
type githubWebhookRepository struct {
repository.GithubRepository
pgh.GithubRepository
config *provisioning.Repository
owner string
repo string
@@ -42,7 +42,7 @@ type githubWebhookRepository struct {
}
func NewGithubWebhookRepository(
basic repository.GithubRepository,
basic pgh.GithubRepository,
webhookURL string,
secrets secrets.Service,
) GithubWebhookRepository {

View File

@@ -15,7 +15,7 @@ import (
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -983,14 +983,14 @@ func TestGitHubRepository_Webhook(t *testing.T) {
func TestGitHubRepository_CommentPullRequest(t *testing.T) {
tests := []struct {
name string
setupMock func(m *pgh.MockClient)
setupMock func(m *github.MockClient)
prNumber int
comment string
expectedError error
}{
{
name: "successfully comment on pull request",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
m.On("CreatePullRequestComment", mock.Anything, "grafana", "grafana", 123, "Test comment").
Return(nil)
},
@@ -1000,7 +1000,7 @@ func TestGitHubRepository_CommentPullRequest(t *testing.T) {
},
{
name: "error commenting on pull request",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
m.On("CreatePullRequestComment", mock.Anything, "grafana", "grafana", 456, "Error comment").
Return(fmt.Errorf("failed to create comment"))
},
@@ -1013,7 +1013,7 @@ func TestGitHubRepository_CommentPullRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
mockGH := pgh.NewMockClient(t)
mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
@@ -1050,7 +1050,7 @@ func TestGitHubRepository_CommentPullRequest(t *testing.T) {
func TestGitHubRepository_OnCreate(t *testing.T) {
tests := []struct {
name string
setupMock func(m *pgh.MockClient)
setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedHook *provisioning.WebhookStatus
@@ -1058,12 +1058,12 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
}{
{
name: "successfully create webhook",
setupMock: func(m *pgh.MockClient) {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(cfg pgh.WebhookConfig) bool {
setupMock: func(m *github.MockClient) {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(cfg github.WebhookConfig) bool {
return cfg.URL == "https://example.com/webhook" &&
cfg.ContentType == "json" &&
cfg.Active == true
})).Return(pgh.WebhookConfig{
})).Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Secret: "test-secret",
@@ -1086,7 +1086,7 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
},
{
name: "no webhook URL",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// No webhook creation expected
},
config: &provisioning.Repository{
@@ -1102,9 +1102,9 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
},
{
name: "error creating webhook",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
Return(pgh.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
Return(github.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1122,7 +1122,7 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
mockGH := pgh.NewMockClient(t)
mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
@@ -1166,7 +1166,7 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
func TestGitHubRepository_OnUpdate(t *testing.T) {
tests := []struct {
name string
setupMock func(m *pgh.MockClient)
setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedHook *provisioning.WebhookStatus
@@ -1174,17 +1174,17 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
}{
{
name: "successfully update webhook when webhook exists",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock getting the existing webhook
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{
Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Events: []string{"push"},
}, nil)
// Mock editing the webhook
m.On("EditWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
m.On("EditWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.ID == 123 && hook.URL == "https://example.com/webhook-updated" &&
slices.Equal(hook.Events, subscribedEvents)
})).Return(nil)
@@ -1212,18 +1212,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "create webhook when it doesn't exist",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock webhook not found
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{}, pgh.ErrResourceNotFound)
Return(github.WebhookConfig{}, github.ErrResourceNotFound)
// Mock creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.URL == "https://example.com/webhook" &&
hook.ContentType == "json" &&
slices.Equal(hook.Events, subscribedEvents) &&
hook.Active == true
})).Return(pgh.WebhookConfig{
})).Return(github.WebhookConfig{
ID: 456,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1252,7 +1252,7 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "no webhook URL provided",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// No mocks needed
},
config: &provisioning.Repository{},
@@ -1262,9 +1262,9 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error getting webhook",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{}, fmt.Errorf("failed to get webhook"))
Return(github.WebhookConfig{}, fmt.Errorf("failed to get webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1285,10 +1285,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error editing webhook",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock getting the existing webhook
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{
Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Events: []string{"push"},
@@ -1317,10 +1317,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "create webhook when webhook status is nil",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
Return(pgh.WebhookConfig{
Return(github.WebhookConfig{
ID: 456,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1348,10 +1348,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "create webhook when webhook ID is zero",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
Return(pgh.WebhookConfig{
Return(github.WebhookConfig{
ID: 789,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1382,10 +1382,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error when creating webhook fails",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock webhook creation failure
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
Return(pgh.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
Return(github.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1403,18 +1403,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "creates webhook when ErrResourceNotFound",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock webhook not found
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{}, pgh.ErrResourceNotFound)
Return(github.WebhookConfig{}, github.ErrResourceNotFound)
// Mock creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.URL == "https://example.com/webhook" &&
hook.ContentType == "json" &&
slices.Equal(hook.Events, subscribedEvents) &&
hook.Active == true
})).Return(pgh.WebhookConfig{
})).Return(github.WebhookConfig{
ID: 456,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1443,18 +1443,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error on create when not found",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock webhook not found
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{}, pgh.ErrResourceNotFound)
Return(github.WebhookConfig{}, github.ErrResourceNotFound)
// Mock error when creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.URL == "https://example.com/webhook" &&
hook.ContentType == "json" &&
slices.Equal(hook.Events, subscribedEvents) &&
hook.Active == true
})).Return(pgh.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
})).Return(github.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1475,10 +1475,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "no update needed when URL and events match",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock getting the existing webhook with matching URL and events
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(pgh.WebhookConfig{
Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1514,7 +1514,7 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
mockGH := pgh.NewMockClient(t)
mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
@@ -1563,14 +1563,14 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
func TestGitHubRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(m *pgh.MockClient)
setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedError error
}{
{
name: "successfully delete webhook",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock deleting the webhook
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(nil)
@@ -1593,7 +1593,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "no webhook URL provided",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// No mocks needed
},
config: &provisioning.Repository{},
@@ -1602,7 +1602,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "webhook not found in status",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// No mocks needed
},
config: &provisioning.Repository{
@@ -1620,7 +1620,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "error deleting webhook",
setupMock: func(m *pgh.MockClient) {
setupMock: func(m *github.MockClient) {
// Mock webhook deletion failure
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(fmt.Errorf("failed to delete webhook"))
@@ -1646,7 +1646,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
mockGH := pgh.NewMockClient(t)
mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock

View File

@@ -179,6 +179,8 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O
return
}
logEmptyRefids(raw.Queries, b.log)
for i := range req.Requests {
req.Requests[i].Headers = ExtractKnownHeaders(httpreq.Header)
}
@@ -215,6 +217,20 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O
}), nil
}
func logEmptyRefids(queries []v0alpha1.DataQuery, logger log.Logger) {
emptyCount := 0
for _, q := range queries {
if q.RefID == "" {
emptyCount += 1
}
}
if emptyCount > 0 {
logger.Info("empty refid found", "empty_count", emptyCount, "query_count", len(queries))
}
}
func (b *QueryAPIBuilder) execute(ctx context.Context, req parsedRequestInfo, instanceConfig clientapi.InstanceConfigurationSettings) (qdr *backend.QueryDataResponse, err error) {
switch len(req.Requests) {
case 0:

View File

@@ -648,7 +648,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
if err != nil {
return nil, err
}
idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer)
idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer)
verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationService, idimplService)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service12, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service13, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationService, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service11, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, noop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokenService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
if err != nil {
@@ -1161,7 +1161,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
if err != nil {
return nil, err
}
idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer)
idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer)
verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationServiceMock, idimplService)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service12, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service13, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationServiceMock, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service11, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, noop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokentestService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
if err != nil {

View File

@@ -80,6 +80,9 @@ type UserAuthTokenService struct {
}
func (s *UserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.CreateToken")
defer span.End()
token, hashedToken, err := generateAndHashToken(s.cfg.SecretKey)
if err != nil {
return nil, err
@@ -136,6 +139,9 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.Create
}
func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.LookupToken")
defer span.End()
hashedToken := hashToken(s.cfg.SecretKey, unhashedToken)
var model userAuthToken
var exists bool
@@ -234,6 +240,9 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
}
func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.GetTokenByExternalSessionID")
defer span.End()
var token userAuthToken
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.Where("external_session_id = ?", externalSessionID).Get(&token)
@@ -258,14 +267,23 @@ func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context,
}
func (s *UserAuthTokenService) GetExternalSession(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.GetExternalSession")
defer span.End()
return s.externalSessionStore.Get(ctx, externalSessionID)
}
func (s *UserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.FindExternalSessions")
defer span.End()
return s.externalSessionStore.List(ctx, query)
}
func (s *UserAuthTokenService) UpdateExternalSession(ctx context.Context, externalSessionID int64, cmd *auth.UpdateExternalSessionCommand) error {
ctx, span := s.tracer.Start(ctx, "authtoken.UpdateExternalSession")
defer span.End()
return s.externalSessionStore.Update(ctx, externalSessionID, cmd)
}
@@ -329,6 +347,9 @@ func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateC
}
func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.rotateToken")
defer span.End()
var clientIPStr string
if clientIP != nil {
clientIPStr = clientIP.String()
@@ -385,6 +406,9 @@ func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.User
}
func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.UserToken, soft bool) error {
ctx, span := s.tracer.Start(ctx, "authtoken.RevokeToken")
defer span.End()
if token == nil {
return auth.ErrUserTokenNotFound
}
@@ -434,6 +458,9 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
}
func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error {
ctx, span := s.tracer.Start(ctx, "authtoken.RevokeAllUserTokens")
defer span.End()
return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
@@ -466,6 +493,9 @@ func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId i
}
func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error {
ctx, span := s.tracer.Start(ctx, "authtoken.BatchRevokeAllUserTokens")
defer span.End()
return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
if len(userIds) == 0 {
@@ -507,6 +537,9 @@ func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, use
}
func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.GetUserToken")
defer span.End()
var result auth.UserToken
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
var token userAuthToken
@@ -526,6 +559,9 @@ func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTok
}
func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.GetUserTokens")
defer span.End()
result := []*auth.UserToken{}
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
var tokens []*userAuthToken
@@ -554,6 +590,9 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64)
// ActiveTokenCount returns the number of active tokens. If userID is nil, the count is for all users.
func (s *UserAuthTokenService) ActiveTokenCount(ctx context.Context, userID *int64) (int64, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.ActiveTokenCount")
defer span.End()
if userID != nil && *userID < 1 {
return 0, errUserIDInvalid
}
@@ -574,6 +613,9 @@ func (s *UserAuthTokenService) ActiveTokenCount(ctx context.Context, userID *int
}
func (s *UserAuthTokenService) DeleteUserRevokedTokens(ctx context.Context, userID int64, window time.Duration) error {
ctx, span := s.tracer.Start(ctx, "authtoken.DeleteUserRevokedTokens")
defer span.End()
return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
query := "DELETE FROM user_auth_token WHERE user_id = ? AND revoked_at > 0 AND revoked_at <= ?"
res, err := sess.Exec(query, userID, time.Now().Add(-window).Unix())
@@ -592,6 +634,9 @@ func (s *UserAuthTokenService) DeleteUserRevokedTokens(ctx context.Context, user
}
func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) {
ctx, span := s.tracer.Start(ctx, "authtoken.GetUserRevokedTokens")
defer span.End()
result := []*auth.UserToken{}
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
var tokens []*userAuthToken

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-jose/go-jose/v3/jwt"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/singleflight"
authnlib "github.com/grafana/authlib/authn"
@@ -32,18 +33,18 @@ var _ auth.IDService = (*Service)(nil)
func ProvideService(
cfg *setting.Cfg, signer auth.IDSigner,
cache remotecache.CacheStorage,
authnService authn.Service,
reg prometheus.Registerer,
cache remotecache.CacheStorage, authnService authn.Service,
reg prometheus.Registerer, tracer trace.Tracer,
) *Service {
s := &Service{
cfg: cfg, logger: log.New("id-service"),
signer: signer, cache: cache,
metrics: newMetrics(reg),
nsMapper: request.GetNamespaceMapper(cfg),
tracer: tracer,
}
authnService.RegisterPostAuthHook(s.hook, 140)
authnService.RegisterPostAuthHook(s.SyncIDToken, 140)
return s
}
@@ -55,10 +56,14 @@ type Service struct {
cache remotecache.CacheStorage
si singleflight.Group
metrics *metrics
tracer trace.Tracer
nsMapper request.NamespaceMapper
}
func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) {
ctx, span := s.tracer.Start(ctx, "user.sync.SignIdentity")
defer span.End()
defer func(t time.Time) {
s.metrics.tokenSigningDurationHistogram.Observe(time.Since(t).Seconds())
}(time.Now())
@@ -140,10 +145,15 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
}
func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) error {
ctx, span := s.tracer.Start(ctx, "user.sync.RemoveIDToken")
defer span.End()
return s.cache.Delete(ctx, getCacheKey(id))
}
func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
func (s *Service) SyncIDToken(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
ctx, span := s.tracer.Start(ctx, "user.sync.SyncIDToken")
defer span.End()
// FIXME(kalleep): we should probably lazy load this
token, idClaims, err := s.SignIdentity(ctx, identity)
if err != nil {

View File

@@ -11,6 +11,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/idtest"
"github.com/grafana/grafana/pkg/services/authn"
@@ -29,7 +30,7 @@ func Test_ProvideService(t *testing.T) {
},
}
_ = ProvideService(setting.NewCfg(), nil, nil, authnService, nil)
_ = ProvideService(setting.NewCfg(), nil, nil, authnService, nil, tracing.InitializeTracerForTest())
assert.True(t, hookRegistered)
})
}
@@ -51,7 +52,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign identity", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
&authntest.FakeService{}, nil,
&authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
token, _, err := s.SignIdentity(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser})
require.NoError(t, err)
@@ -61,7 +62,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign identity with authenticated by if user is externally authenticated", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
&authntest.FakeService{}, nil,
&authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
token, _, err := s.SignIdentity(context.Background(), &authn.Identity{
ID: "1",
@@ -86,7 +87,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign identity with authenticated by if user is externally authenticated", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
&authntest.FakeService{}, nil,
&authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
_, gotClaims, err := s.SignIdentity(context.Background(), &authn.Identity{
ID: "1",
@@ -106,7 +107,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign new token if org role has changed", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
&authntest.FakeService{}, nil,
&authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
ident := &authn.Identity{

View File

@@ -48,10 +48,10 @@ func ProvideRegistration(
logger := log.New("authn.registration")
authnSvc.RegisterClient(clients.ProvideRender(renderService))
authnSvc.RegisterClient(clients.ProvideAPIKey(apikeyService))
authnSvc.RegisterClient(clients.ProvideAPIKey(apikeyService, tracer))
if cfg.LoginCookieName != "" {
authnSvc.RegisterClient(clients.ProvideSession(cfg, sessionService, authInfoService))
authnSvc.RegisterClient(clients.ProvideSession(cfg, sessionService, authInfoService, tracer))
}
var proxyClients []authn.ProxyClient
@@ -59,20 +59,20 @@ func ProvideRegistration(
// always register LDAP if LDAP is enabled in SSO settings
if cfg.LDAPAuthEnabled || features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsLDAP) {
ldap := clients.ProvideLDAP(cfg, ldapService, userService, authInfoService)
ldap := clients.ProvideLDAP(cfg, ldapService, userService, authInfoService, tracer)
proxyClients = append(proxyClients, ldap)
passwordClients = append(passwordClients, ldap)
}
if !cfg.DisableLogin {
grafana := clients.ProvideGrafana(cfg, userService)
grafana := clients.ProvideGrafana(cfg, userService, tracer)
proxyClients = append(proxyClients, grafana)
passwordClients = append(passwordClients, grafana)
}
// if we have password clients configure check if basic auth or form auth is enabled
if len(passwordClients) > 0 {
passwordClient := clients.ProvidePassword(loginAttempts, passwordClients...)
passwordClient := clients.ProvidePassword(loginAttempts, tracer, passwordClients...)
if cfg.BasicAuthEnabled {
authnSvc.RegisterClient(clients.ProvideBasic(passwordClient))
}
@@ -103,7 +103,7 @@ func ProvideRegistration(
}
if cfg.AuthProxy.Enabled && len(proxyClients) > 0 {
proxy, err := clients.ProvideProxy(cfg, cache, proxyClients...)
proxy, err := clients.ProvideProxy(cfg, cache, tracer, proxyClients...)
if err != nil {
logger.Error("Failed to configure auth proxy", "err", err)
} else {
@@ -113,16 +113,16 @@ func ProvideRegistration(
if cfg.JWTAuth.Enabled {
orgRoleMapper := connectors.ProvideOrgRoleMapper(cfg, orgService)
authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg))
authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg, tracer))
}
if cfg.ExtJWTAuth.Enabled {
authnSvc.RegisterClient(clients.ProvideExtendedJWT(cfg))
authnSvc.RegisterClient(clients.ProvideExtendedJWT(cfg, tracer))
}
for name := range socialService.GetOAuthProviders() {
clientName := authn.ClientWithPrefix(name)
authnSvc.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService, features))
authnSvc.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService, features, tracer))
}
if features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {

View File

@@ -7,6 +7,8 @@ import (
"strings"
"time"
"go.opentelemetry.io/otel/trace"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/components/apikeygen"
@@ -35,16 +37,18 @@ const (
metaKeySkipLastUsed = "keySkipLastUsed"
)
func ProvideAPIKey(apiKeyService apikey.Service) *APIKey {
func ProvideAPIKey(apiKeyService apikey.Service, tracer trace.Tracer) *APIKey {
return &APIKey{
log: log.New(authn.ClientAPIKey),
apiKeyService: apiKeyService,
tracer: tracer,
}
}
type APIKey struct {
log log.Logger
apiKeyService apikey.Service
tracer trace.Tracer
}
func (s *APIKey) Name() string {
@@ -52,6 +56,8 @@ func (s *APIKey) Name() string {
}
func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
ctx, span := s.tracer.Start(ctx, "authn.apikey.Authenticate")
defer span.End()
key, err := s.getAPIKey(ctx, getTokenFromRequest(r))
if err != nil {
if errors.Is(err, apikeygen.ErrInvalidApiKey) {
@@ -84,6 +90,8 @@ func (s *APIKey) IsEnabled() bool {
}
func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) {
ctx, span := s.tracer.Start(ctx, "authn.apikey.getAPIKey")
defer span.End()
fn := s.getFromToken
if !strings.HasPrefix(token, satokengen.GrafanaPrefix) {
fn = s.getFromTokenLegacy
@@ -98,6 +106,8 @@ func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, e
}
func (s *APIKey) getFromToken(ctx context.Context, token string) (*apikey.APIKey, error) {
ctx, span := s.tracer.Start(ctx, "authn.apikey.getFromToken")
defer span.End()
decoded, err := satokengen.Decode(token)
if err != nil {
return nil, err
@@ -112,6 +122,8 @@ func (s *APIKey) getFromToken(ctx context.Context, token string) (*apikey.APIKey
}
func (s *APIKey) getFromTokenLegacy(ctx context.Context, token string) (*apikey.APIKey, error) {
ctx, span := s.tracer.Start(ctx, "authn.apikey.getFromTokenLegacy")
defer span.End()
decoded, err := apikeygen.Decode(token)
if err != nil {
return nil, err
@@ -144,6 +156,9 @@ func (s *APIKey) Priority() uint {
}
func (s *APIKey) Hook(ctx context.Context, identity *authn.Identity, r *authn.Request) error {
ctx, span := s.tracer.Start(ctx, "authn.apikey.Hook") //nolint:ineffassign,staticcheck
defer span.End()
if r.GetMeta(metaKeySkipLastUsed) != "" {
return nil
}

View File

@@ -12,6 +12,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/components/satokengen"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/apikey/apikeytest"
"github.com/grafana/grafana/pkg/services/authn"
@@ -106,7 +107,7 @@ func TestAPIKey_Authenticate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideAPIKey(&apikeytest.Service{ExpectedAPIKey: tt.expectedKey})
c := ProvideAPIKey(&apikeytest.Service{ExpectedAPIKey: tt.expectedKey}, tracing.InitializeTracerForTest())
identity, err := c.Authenticate(context.Background(), tt.req)
if tt.expectedErr != nil {
@@ -173,7 +174,7 @@ func TestAPIKey_Test(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideAPIKey(&apikeytest.Service{})
c := ProvideAPIKey(&apikeytest.Service{}, tracing.InitializeTracerForTest())
assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req))
})
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/go-jose/go-jose/v3/jwt"
"go.opentelemetry.io/otel/trace"
authlib "github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
@@ -41,7 +42,7 @@ var (
)
)
func ProvideExtendedJWT(cfg *setting.Cfg) *ExtendedJWT {
func ProvideExtendedJWT(cfg *setting.Cfg, tracer trace.Tracer) *ExtendedJWT {
keys := authlib.NewKeyRetriever(authlib.KeyRetrieverConfig{
SigningKeysURL: cfg.ExtJWTAuth.JWKSUrl,
})
@@ -60,6 +61,7 @@ func ProvideExtendedJWT(cfg *setting.Cfg) *ExtendedJWT {
namespaceMapper: request.GetNamespaceMapper(cfg),
accessTokenVerifier: accessTokenVerifier,
idTokenVerifier: idTokenVerifier,
tracer: tracer,
}
}
@@ -69,9 +71,13 @@ type ExtendedJWT struct {
accessTokenVerifier authlib.Verifier[authlib.AccessTokenClaims]
idTokenVerifier authlib.Verifier[authlib.IDTokenClaims]
namespaceMapper request.NamespaceMapper
tracer trace.Tracer
}
func (s *ExtendedJWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
ctx, span := s.tracer.Start(ctx, "authn.extjwt.Authenticate")
defer span.End()
jwtToken := s.retrieveAuthenticationToken(r.HTTPRequest)
accessTokenClaims, err := s.accessTokenVerifier.Verify(ctx, jwtToken)

View File

@@ -17,6 +17,7 @@ import (
authnlib "github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/setting"
)
@@ -699,7 +700,7 @@ func setupTestCtx(cfg *setting.Cfg) *testEnv {
}
}
extJwtClient := ProvideExtendedJWT(cfg)
extJwtClient := ProvideExtendedJWT(cfg, tracing.InitializeTracerForTest())
return &testEnv{
s: extJwtClient,

View File

@@ -7,6 +7,8 @@ import (
"net/mail"
"strconv"
"go.opentelemetry.io/otel/trace"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login"
@@ -19,13 +21,14 @@ import (
var _ authn.ProxyClient = new(Grafana)
var _ authn.PasswordClient = new(Grafana)
func ProvideGrafana(cfg *setting.Cfg, userService user.Service) *Grafana {
return &Grafana{cfg, userService}
func ProvideGrafana(cfg *setting.Cfg, userService user.Service, tracer trace.Tracer) *Grafana {
return &Grafana{cfg, userService, tracer}
}
type Grafana struct {
cfg *setting.Cfg
userService user.Service
tracer trace.Tracer
}
func (c *Grafana) String() string {
@@ -33,6 +36,9 @@ func (c *Grafana) String() string {
}
func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, additional map[string]string) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.grafana.AuthenticateProxy") //nolint:ineffassign,staticcheck
defer span.End()
identity := &authn.Identity{
AuthenticatedBy: login.AuthProxyAuthModule,
AuthID: username,
@@ -91,6 +97,9 @@ func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, usern
}
func (c *Grafana) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.grafana.AuthenticatePassword")
defer span.End()
usr, err := c.userService.GetByLogin(ctx, &user.GetUserByLoginQuery{LoginOrEmail: username})
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
@@ -97,7 +98,7 @@ func TestGrafana_AuthenticateProxy(t *testing.T) {
cfg := setting.NewCfg()
cfg.AuthProxy.AutoSignUp = true
cfg.AuthProxy.HeaderProperty = tt.proxyProperty
c := ProvideGrafana(cfg, usertest.NewUserServiceFake())
c := ProvideGrafana(cfg, usertest.NewUserServiceFake(), tracing.InitializeTracerForTest())
identity, err := c.AuthenticateProxy(context.Background(), tt.req, tt.username, tt.additional)
assert.ErrorIs(t, err, tt.expectedErr)
@@ -175,7 +176,7 @@ func TestGrafana_AuthenticatePassword(t *testing.T) {
userService.ExpectedError = user.ErrUserNotFound
}
c := ProvideGrafana(setting.NewCfg(), userService)
c := ProvideGrafana(setting.NewCfg(), userService, tracing.InitializeTracerForTest())
identity, err := c.AuthenticatePassword(context.Background(), &authn.Request{OrgID: 1}, tt.username, tt.password)
assert.ErrorIs(t, err, tt.expectedErr)
assert.EqualValues(t, tt.expectedIdentity, identity)

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"strings"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social/connectors"
@@ -30,13 +32,14 @@ var (
"jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim"))
)
func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg) *JWT {
func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg, tracer trace.Tracer) *JWT {
return &JWT{
cfg: cfg,
log: log.New(authn.ClientJWT),
jwtService: jwtService,
orgRoleMapper: orgRoleMapper,
orgMappingCfg: orgRoleMapper.ParseOrgMappingSettings(context.Background(), cfg.JWTAuth.OrgMapping, cfg.JWTAuth.RoleAttributeStrict),
tracer: tracer,
}
}
@@ -46,6 +49,7 @@ type JWT struct {
orgMappingCfg connectors.MappingConfiguration
log log.Logger
jwtService auth.JWTVerifierService
tracer trace.Tracer
}
func (s *JWT) Name() string {
@@ -53,6 +57,9 @@ func (s *JWT) Name() string {
}
func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
ctx, span := s.tracer.Start(ctx, "authn.jwt.Authenticate")
defer span.End()
jwtToken := s.retrieveToken(r.HTTPRequest)
s.stripSensitiveParam(r.HTTPRequest)

View File

@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/authn"
@@ -262,7 +263,7 @@ func TestAuthenticateJWT(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(tc.cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
tc.cfg)
tc.cfg, tracing.InitializeTracerForTest())
validHTTPReq := &http.Request{
Header: map[string][]string{
jwtHeaderName: {"sample-token"}},
@@ -380,7 +381,7 @@ func TestJWTClaimConfig(t *testing.T) {
}
jwtClient := ProvideJWT(jwtService, connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
cfg)
cfg, tracing.InitializeTracerForTest())
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
@@ -493,7 +494,7 @@ func TestJWTTest(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
cfg)
cfg, tracing.InitializeTracerForTest())
httpReq := &http.Request{
URL: &url.URL{RawQuery: "auth_token=" + tc.token},
Header: map[string][]string{
@@ -549,7 +550,7 @@ func TestJWTStripParam(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
cfg)
cfg, tracing.InitializeTracerForTest())
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
@@ -608,7 +609,7 @@ func TestJWTSubClaimsConfig(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
cfg)
cfg, tracing.InitializeTracerForTest())
identity, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
@@ -20,8 +22,8 @@ type ldapService interface {
User(username string) (*login.ExternalUserInfo, error)
}
func ProvideLDAP(cfg *setting.Cfg, ldapService ldapService, userService user.Service, authInfoService login.AuthInfoService) *LDAP {
return &LDAP{cfg, log.New("authn.ldap"), ldapService, userService, authInfoService}
func ProvideLDAP(cfg *setting.Cfg, ldapService ldapService, userService user.Service, authInfoService login.AuthInfoService, tracer trace.Tracer) *LDAP {
return &LDAP{cfg, log.New("authn.ldap"), ldapService, userService, authInfoService, tracer}
}
type LDAP struct {
@@ -30,6 +32,7 @@ type LDAP struct {
service ldapService
userService user.Service
authInfoService login.AuthInfoService
tracer trace.Tracer
}
func (c *LDAP) String() string {
@@ -37,6 +40,8 @@ func (c *LDAP) String() string {
}
func (c *LDAP) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, _ map[string]string) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.ldap.AuthenticateProxy")
defer span.End()
info, err := c.service.User(username)
if errors.Is(err, multildap.ErrDidNotFindUser) {
return c.disableUser(ctx, username)
@@ -50,6 +55,8 @@ func (c *LDAP) AuthenticateProxy(ctx context.Context, r *authn.Request, username
}
func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.ldap.AuthenticatePassword")
defer span.End()
info, err := c.service.Login(&login.LoginUserQuery{
Username: username,
Password: password,
@@ -75,6 +82,8 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, usern
// disableUser will disable users if they logged in via LDAP previously
func (c *LDAP) disableUser(ctx context.Context, username string) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.ldap.disableUser")
defer span.End()
c.logger.Debug("User was not found in the LDAP directory tree", "username", username)
retErr := errIdentityNotFound.Errorf("no user found: %w", multildap.ErrDidNotFindUser)

View File

@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
@@ -197,13 +197,13 @@ func setupLDAPTestCase(tt *ldapTestCase) *LDAP {
ExpectedError: tt.expectedAuthInfoErr,
}
c := &LDAP{
cfg: setting.NewCfg(),
logger: log.New("authn.ldap.test"),
service: &service.LDAPFakeService{ExpectedUser: tt.expectedLDAPInfo, ExpectedError: tt.expectedLDAPErr},
userService: userService,
authInfoService: authInfoService,
}
c := ProvideLDAP(
setting.NewCfg(),
&service.LDAPFakeService{ExpectedUser: tt.expectedLDAPInfo, ExpectedError: tt.expectedLDAPErr},
userService,
authInfoService,
tracing.InitializeTracerForTest(),
)
return c
}

View File

@@ -12,6 +12,7 @@ import (
"os"
"strings"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
@@ -70,12 +71,14 @@ var (
func ProvideOAuth(
name string, cfg *setting.Cfg, oauthService oauthtoken.OAuthTokenService,
socialService social.Service, settingsProviderService setting.Provider, features featuremgmt.FeatureToggles,
socialService social.Service, settingsProviderService setting.Provider,
features featuremgmt.FeatureToggles, tracer trace.Tracer,
) *OAuth {
providerName := strings.TrimPrefix(name, "auth.client.")
return &OAuth{
name, fmt.Sprintf("oauth_%s", providerName), providerName,
log.New(name), cfg, settingsProviderService, oauthService, socialService, features,
log.New(name), cfg, tracer, settingsProviderService, oauthService,
socialService, features,
}
}
@@ -85,6 +88,7 @@ type OAuth struct {
providerName string
log log.Logger
cfg *setting.Cfg
tracer trace.Tracer
settingsProviderSvc setting.Provider
oauthService oauthtoken.OAuthTokenService
@@ -97,6 +101,9 @@ func (c *OAuth) Name() string {
}
func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.oauth.Authenticate")
defer span.End()
r.SetMeta(authn.MetaKeyAuthModule, c.moduleName)
oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName)
@@ -232,6 +239,9 @@ func (c *OAuth) GetConfig() authn.SSOClientConfig {
}
func (c *OAuth) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redirect, error) {
ctx, span := c.tracer.Start(ctx, "authn.oauth.RedirectURL") //nolint:ineffassign,staticcheck
defer span.End()
var opts []oauth2.AuthCodeOption
oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName)
@@ -274,6 +284,9 @@ func (c *OAuth) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redir
}
func (c *OAuth) Logout(ctx context.Context, user identity.Requester, sessionToken *auth.UserToken) (*authn.Redirect, bool) {
ctx, span := c.tracer.Start(ctx, "authn.oauth.Logout")
defer span.End()
token := c.oauthService.GetCurrentOAuthToken(ctx, user, sessionToken)
userID, err := identity.UserIdentifier(user.GetID())

View File

@@ -16,6 +16,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/socialtest"
"github.com/grafana/grafana/pkg/services/auth"
@@ -296,7 +297,7 @@ func TestOAuth_Authenticate(t *testing.T) {
},
}
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, settingsProvider, featuremgmt.WithFeatures(tt.features...))
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, settingsProvider, featuremgmt.WithFeatures(tt.features...), tracing.InitializeTracerForTest())
identity, err := c.Authenticate(context.Background(), tt.req)
assert.ErrorIs(t, err, tt.expectedErr)
@@ -376,7 +377,7 @@ func TestOAuth_RedirectURL(t *testing.T) {
cfg := setting.NewCfg()
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, &setting.OSSImpl{Cfg: cfg}, featuremgmt.WithFeatures())
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, &setting.OSSImpl{Cfg: cfg}, featuremgmt.WithFeatures(), tracing.InitializeTracerForTest())
redirect, err := c.RedirectURL(context.Background(), nil)
assert.ErrorIs(t, err, tt.expectedErr)
@@ -489,7 +490,7 @@ func TestOAuth_Logout(t *testing.T) {
fakeSocialSvc := &socialtest.FakeSocialService{
ExpectedAuthInfoProvider: tt.oauthCfg,
}
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures())
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures(), tracing.InitializeTracerForTest())
redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, nil)
@@ -549,7 +550,8 @@ func TestIsEnabled(t *testing.T) {
nil,
fakeSocialSvc,
&setting.OSSImpl{Cfg: cfg},
featuremgmt.WithFeatures())
featuremgmt.WithFeatures(),
tracing.InitializeTracerForTest())
assert.Equal(t, tt.expected, c.IsEnabled())
})
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn"
@@ -18,17 +20,20 @@ var (
var _ authn.PasswordClient = new(Password)
func ProvidePassword(loginAttempts loginattempt.Service, clients ...authn.PasswordClient) *Password {
return &Password{loginAttempts, clients, log.New("authn.password")}
func ProvidePassword(loginAttempts loginattempt.Service, tracer trace.Tracer, clients ...authn.PasswordClient) *Password {
return &Password{loginAttempts, clients, log.New("authn.password"), tracer}
}
type Password struct {
loginAttempts loginattempt.Service
clients []authn.PasswordClient
log log.Logger
tracer trace.Tracer
}
func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.password.AuthenticatePassword")
defer span.End()
r.SetMeta(authn.MetaKeyUsername, username)
ok, err := c.loginAttempts.Validate(ctx, username)

View File

@@ -10,6 +10,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest"
@@ -65,7 +66,7 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tt.clients...)
c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tracing.InitializeTracerForTest(), tt.clients...)
r := &authn.Request{
OrgID: 12345,
HTTPRequest: &http.Request{

View File

@@ -13,6 +13,7 @@ import (
"time"
claims "github.com/grafana/authlib/types"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
@@ -45,12 +46,12 @@ var (
_ authn.ContextAwareClient = new(Proxy)
)
func ProvideProxy(cfg *setting.Cfg, cache proxyCache, clients ...authn.ProxyClient) (*Proxy, error) {
func ProvideProxy(cfg *setting.Cfg, cache proxyCache, tracer trace.Tracer, clients ...authn.ProxyClient) (*Proxy, error) {
list, err := parseAcceptList(cfg.AuthProxy.Whitelist)
if err != nil {
return nil, err
}
return &Proxy{log.New(authn.ClientProxy), cfg, cache, clients, list}, nil
return &Proxy{log.New(authn.ClientProxy), cfg, cache, clients, list, tracer}, nil
}
type proxyCache interface {
@@ -65,6 +66,7 @@ type Proxy struct {
cache proxyCache
clients []authn.ProxyClient
acceptedIPs []*net.IPNet
tracer trace.Tracer
}
func (c *Proxy) Name() string {
@@ -72,6 +74,8 @@ func (c *Proxy) Name() string {
}
func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.proxy.Authenticate")
defer span.End()
if !c.isAllowedIP(r) {
return nil, errNotAcceptedIP.Errorf("request ip is not in the configured accept list")
}
@@ -115,6 +119,8 @@ func (c *Proxy) IsEnabled() bool {
// See if we have cached the user id, in that case we can fetch the signed-in user and skip sync.
// Error here means that we could not find anything in cache, so we can proceed as usual
func (c *Proxy) retrieveIDFromCache(ctx context.Context, cacheKey string, r *authn.Request) (*authn.Identity, error) {
ctx, span := c.tracer.Start(ctx, "authn.proxy.retrieveIDFromCache")
defer span.End()
entry, err := c.cache.Get(ctx, cacheKey)
if err != nil {
return nil, err
@@ -148,6 +154,8 @@ func (c *Proxy) Priority() uint {
}
func (c *Proxy) Hook(ctx context.Context, id *authn.Identity, r *authn.Request) error {
ctx, span := c.tracer.Start(ctx, "authn.proxy.Hook")
defer span.End()
if id.ClientParams.CacheAuthProxyKey == "" {
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/setting"
@@ -113,7 +114,7 @@ func TestProxy_Authenticate(t *testing.T) {
calledAdditional = additional
return nil, nil
}}
c, err := ProvideProxy(cfg, &fakeCache{expectedErr: errors.New("")}, proxyClient)
c, err := ProvideProxy(cfg, &fakeCache{expectedErr: errors.New("")}, tracing.InitializeTracerForTest(), proxyClient)
require.NoError(t, err)
_, err = c.Authenticate(context.Background(), tt.req)
@@ -169,7 +170,7 @@ func TestProxy_Test(t *testing.T) {
cfg := setting.NewCfg()
cfg.AuthProxy.HeaderName = "Proxy-Header"
c, _ := ProvideProxy(cfg, nil, nil, nil)
c, _ := ProvideProxy(cfg, nil, tracing.InitializeTracerForTest(), nil)
assert.Equal(t, tt.expectedOK, c.Test(context.Background(), tt.req))
})
}
@@ -208,7 +209,7 @@ func TestProxy_Hook(t *testing.T) {
withRole := func(role string) func(t *testing.T) {
cacheKey := fmt.Sprintf("users:johndoe-%s", role)
return func(t *testing.T) {
c, err := ProvideProxy(cfg, cache, authntest.MockProxyClient{})
c, err := ProvideProxy(cfg, cache, tracing.InitializeTracerForTest(), authntest.MockProxyClient{})
require.NoError(t, err)
userIdentity := &authn.Identity{
ID: "1",

View File

@@ -7,6 +7,8 @@ import (
"strconv"
"time"
"go.opentelemetry.io/otel/trace"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth"
@@ -18,12 +20,14 @@ import (
var _ authn.ContextAwareClient = new(Session)
func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService, authInfoService login.AuthInfoService) *Session {
func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService,
authInfoService login.AuthInfoService, tracer trace.Tracer) *Session {
return &Session{
cfg: cfg,
log: log.New(authn.ClientSession),
sessionService: sessionService,
authInfoService: authInfoService,
tracer: tracer,
}
}
@@ -32,6 +36,7 @@ type Session struct {
log log.Logger
sessionService auth.UserTokenService
authInfoService login.AuthInfoService
tracer trace.Tracer
}
func (s *Session) Name() string {

View File

@@ -11,6 +11,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
@@ -31,7 +32,7 @@ func TestSession_Test(t *testing.T) {
cfg := setting.NewCfg()
cfg.LoginCookieName = ""
cfg.LoginMaxLifetime = 20 * time.Second
s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, &authinfotest.FakeService{})
s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, &authinfotest.FakeService{}, tracing.InitializeTracerForTest())
disabled := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq})
assert.False(t, disabled)
@@ -194,7 +195,7 @@ func TestSession_Authenticate(t *testing.T) {
cfg.LoginCookieName = cookieName
cfg.TokenRotationIntervalMinutes = 10
cfg.LoginMaxLifetime = 20 * time.Second
s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.authInfoService)
s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.authInfoService, tracing.InitializeTracerForTest())
got, err := s.Authenticate(context.Background(), tt.args.r)
require.True(t, (err != nil) == tt.wantErr, err)

View File

@@ -337,13 +337,6 @@ var (
RequiresRestart: true,
Owner: grafanaAppPlatformSquad,
},
{
Name: "nanoGit",
Description: "Use experimental git library for provisioning",
Stage: FeatureStageExperimental,
RequiresRestart: true,
Owner: grafanaAppPlatformSquad,
},
{
Name: "grafanaAPIServerEnsureKubectlAccess",
Description: "Start an additional https handler and write kubectl options",
@@ -1261,13 +1254,6 @@ var (
Owner: grafanaObservabilityTracesAndProfilingSquad,
FrontendOnly: true,
},
{
Name: "jaegerBackendMigration",
Description: "Enables querying the Jaeger data source without the proxy",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaOSSBigTent,
Expression: "true",
},
{
Name: "alertingUIOptimizeReducer",
Description: "Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query",
@@ -1320,6 +1306,8 @@ var (
FrontendOnly: true,
},
{
// Remove this flag once Loki v4 is released and the min supported version is v3.0+,
// since users on v2.9 need it to disable the feature, as it doesn't work for them.
Name: "lokiLabelNamesQueryApi",
Description: "Defaults to using the Loki `/labels` API instead of `/series`",
Stage: FeatureStageGeneralAvailability,

View File

@@ -43,7 +43,6 @@ mlExpressions,experimental,@grafana/alerting-squad,false,false,false
datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false
nanoGit,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false
featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,true,false
awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false
@@ -165,7 +164,6 @@ prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,fal
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
enableSCIM,preview,@grafana/identity-access-team,false,false,false
crashDetection,experimental,@grafana/observability-traces-and-profiling,false,false,true
jaegerBackendMigration,GA,@grafana/oss-big-tent,false,false,false
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
43 datasourceAPIServers experimental @grafana/grafana-app-platform-squad false true false
44 grafanaAPIServerWithExperimentalAPIs experimental @grafana/grafana-app-platform-squad true true false
45 provisioning experimental @grafana/grafana-app-platform-squad false true false
nanoGit experimental @grafana/grafana-app-platform-squad false true false
46 grafanaAPIServerEnsureKubectlAccess experimental @grafana/grafana-app-platform-squad true true false
47 featureToggleAdminPage experimental @grafana/grafana-operator-experience-squad false true false
48 awsAsyncQueryCaching GA @grafana/aws-datasources false false false
164 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
165 enableSCIM preview @grafana/identity-access-team false false false
166 crashDetection experimental @grafana/observability-traces-and-profiling false false true
jaegerBackendMigration GA @grafana/oss-big-tent false false false
167 alertingUIOptimizeReducer GA @grafana/alerting-squad false false true
168 azureMonitorEnableUserAuth GA @grafana/partner-datasources false false false
169 alertingNotificationsStepMode GA @grafana/alerting-squad false false true

View File

@@ -183,10 +183,6 @@ const (
// Next generation provisioning... and git
FlagProvisioning = "provisioning"
// FlagNanoGit
// Use experimental git library for provisioning
FlagNanoGit = "nanoGit"
// FlagGrafanaAPIServerEnsureKubectlAccess
// Start an additional https handler and write kubectl options
FlagGrafanaAPIServerEnsureKubectlAccess = "grafanaAPIServerEnsureKubectlAccess"
@@ -671,10 +667,6 @@ const (
// Enables browser crash detection reporting to Faro.
FlagCrashDetection = "crashDetection"
// FlagJaegerBackendMigration
// Enables querying the Jaeger data source without the proxy
FlagJaegerBackendMigration = "jaegerBackendMigration"
// FlagAlertingUIOptimizeReducer
// Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query
FlagAlertingUIOptimizeReducer = "alertingUIOptimizeReducer"

Some files were not shown because too many files have changed in this diff Show More