Provisioning: refactor commit signature logic (#104055)

* WIP: Separate signature package

* Add some unit tests

* Test factory

* Finish unit test coverage signature package

* Fix register

* Add FIXME

* Add more coverage

* Add more coverage

* Fix migrate tests

* Fix unit tests
This commit is contained in:
Roberto Jiménez Sánchez
2025-04-16 10:04:19 +01:00
committed by GitHub
parent 61cd19c540
commit 55a2b77386
24 changed files with 3867 additions and 340 deletions
+2
View File
@@ -76,6 +76,8 @@ const AnnoKeyFullpathUIDs = "grafana.app/fullpathUIDs"
const LabelKeyDeprecatedInternalID = "grafana.app/deprecatedInternalID"
// Accessor functions for k8s objects
//
//go:generate mockery --name GrafanaMetaAccessor --structname MockGrafanaMetaAccessor --inpackage --filename meta_mock.go --with-expecter
type GrafanaMetaAccessor interface {
metav1.Object
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import (
)
func ExportAll(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, folderClient dynamic.ResourceInterface, progress jobs.JobProgressRecorder) error {
// FIXME: should we sign with grafana user?
if err := ExportFolders(ctx, repoName, options, folderClient, repositoryResources, progress); err != nil {
return err
}
@@ -97,7 +97,7 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return errors.New("export job submitted targeting repository that is not a ReaderWriter")
}
repositoryResources, err := r.repositoryResources.Client(ctx, rw, resources.RepositoryResourcesOptions{})
repositoryResources, err := r.repositoryResources.Client(ctx, rw)
if err != nil {
return fmt.Errorf("create repository resource client: %w", err)
}
@@ -249,7 +249,7 @@ func TestExportWorker_ProcessRepositoryResourcesError(t *testing.T) {
mockClients.On("Clients", context.Background(), "test-namespace").Return(resourceClients, nil)
mockRepoResources := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResources.On("Client", context.Background(), mockRepo, resources.RepositoryResourcesOptions{}).Return(nil, fmt.Errorf("failed to create repository resources client"))
mockRepoResources.On("Client", context.Background(), mockRepo).Return(nil, fmt.Errorf("failed to create repository resources client"))
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockCloneFn := NewMockWrapWithCloneFn(t)
@@ -292,7 +292,7 @@ func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
mockRepoResources := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesClient := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResourcesClient, nil)
mockRepoResources.On("Client", mock.Anything, mock.Anything).Return(mockRepoResourcesClient, nil)
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
@@ -343,7 +343,7 @@ func TestExportWorker_ProcessExportFnError(t *testing.T) {
mockRepoResources := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesClient := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResourcesClient, nil)
mockRepoResources.On("Client", mock.Anything, mock.Anything).Return(mockRepoResourcesClient, nil)
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"))
@@ -74,6 +74,7 @@ func (f *legacyFoldersMigrator) Migrate(ctx context.Context, namespace string, r
}
progress.SetMessage(ctx, "export folders from SQL")
// FIXME: we don't sign folders, not even with grafana user
if err := repositoryResources.EnsureFolderTreeExists(ctx, "", "", f.tree, func(folder resources.Folder, created bool, err error) error {
result := jobs.JobResourceResult{
Action: repository.FileActionCreated,
@@ -12,6 +12,7 @@ import (
"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/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/storage/unified/parquet"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -28,6 +29,7 @@ type legacyResourcesMigrator struct {
parsers resources.ParserFactory
legacyMigrator legacy.LegacyMigrator
folderMigrator LegacyFoldersMigrator
signerFactory signature.SignerFactory
}
func NewLegacyResourcesMigrator(
@@ -35,12 +37,14 @@ func NewLegacyResourcesMigrator(
parsers resources.ParserFactory,
legacyMigrator legacy.LegacyMigrator,
folderMigrator LegacyFoldersMigrator,
signerFactory signature.SignerFactory,
) LegacyResourcesMigrator {
return &legacyResourcesMigrator{
repositoryResources: repositoryResources,
parsers: parsers,
legacyMigrator: legacyMigrator,
folderMigrator: folderMigrator,
signerFactory: signerFactory,
}
}
@@ -50,15 +54,21 @@ func (m *legacyResourcesMigrator) Migrate(ctx context.Context, rw repository.Rea
return fmt.Errorf("get parser: %w", err)
}
repoOpts := resources.RepositoryResourcesOptions{
PreloadAllUserInfo: opts.History,
}
repositoryResources, err := m.repositoryResources.Client(ctx, rw, repoOpts)
repositoryResources, err := m.repositoryResources.Client(ctx, rw)
if err != nil {
return fmt.Errorf("get repository resources: %w", err)
}
// FIXME: signature is only relevant for repositories which support signature
// Not all repositories support history
signer, err := m.signerFactory.New(ctx, signature.SignOptions{
Namespace: namespace,
History: opts.History,
})
if err != nil {
return fmt.Errorf("get signer: %w", err)
}
progress.SetMessage(ctx, "migrate folders from SQL")
if err := m.folderMigrator.Migrate(ctx, namespace, repositoryResources, progress); err != nil {
return fmt.Errorf("migrate folders from SQL: %w", err)
@@ -70,7 +80,17 @@ func (m *legacyResourcesMigrator) Migrate(ctx context.Context, rw repository.Rea
continue
}
reader := NewLegacyResourceMigrator(m.legacyMigrator, parser, repositoryResources, progress, opts, namespace, kind.GroupResource())
reader := NewLegacyResourceMigrator(
m.legacyMigrator,
parser,
repositoryResources,
progress,
opts,
namespace,
kind.GroupResource(),
signer,
)
if err := reader.Migrate(ctx); err != nil {
return fmt.Errorf("migrate resource %s: %w", kind, err)
}
@@ -87,9 +107,19 @@ type legacyResourceResourceMigrator struct {
kind schema.GroupResource
options provisioning.MigrateJobOptions
resources resources.RepositoryResources
signer signature.Signer
}
func NewLegacyResourceMigrator(legacy legacy.LegacyMigrator, parser resources.Parser, resources resources.RepositoryResources, progress jobs.JobProgressRecorder, options provisioning.MigrateJobOptions, namespace string, kind schema.GroupResource) *legacyResourceResourceMigrator {
func NewLegacyResourceMigrator(
legacy legacy.LegacyMigrator,
parser resources.Parser,
resources resources.RepositoryResources,
progress jobs.JobProgressRecorder,
options provisioning.MigrateJobOptions,
namespace string,
kind schema.GroupResource,
signer signature.Signer,
) *legacyResourceResourceMigrator {
return &legacyResourceResourceMigrator{
legacy: legacy,
parser: parser,
@@ -98,6 +128,7 @@ func NewLegacyResourceMigrator(legacy legacy.LegacyMigrator, parser resources.Pa
namespace: namespace,
kind: kind,
resources: resources,
signer: signer,
}
}
@@ -126,6 +157,12 @@ func (r *legacyResourceResourceMigrator) Write(ctx context.Context, key *resourc
parsed.Meta.SetManagerProperties(utils.ManagerProperties{})
parsed.Meta.SetSourceProperties(utils.SourceProperties{})
// Add author signature to the context
ctx, err = r.signer.Sign(ctx, parsed.Meta)
if err != nil {
return fmt.Errorf("add author signature: %w", err)
}
// TODO: this seems to be same logic as the export job
// TODO: we should use a kind safe manager here
fileName, err := r.resources.CreateResourceFileFromObject(ctx, parsed.Obj, resources.WriteOptions{
@@ -3,6 +3,7 @@ package migrate
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,6 +18,7 @@ import (
"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/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -26,11 +28,14 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(nil, errors.New("parser factory error"))
signerFactory := signature.NewMockSignerFactory(t)
migrator := NewLegacyResourcesMigrator(
nil,
mockParserFactory,
nil,
nil,
signerFactory,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, jobs.NewMockJobProgressRecorder(t))
@@ -48,12 +53,14 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything, mock.Anything).
Return(nil, errors.New("repo resources factory error"))
signerFactory := signature.NewMockSignerFactory(t)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
nil,
signerFactory,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, jobs.NewMockJobProgressRecorder(t))
@@ -81,11 +88,17 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
signer := signature.NewMockSigner(t)
signerFactory := signature.NewMockSignerFactory(t)
signerFactory.On("New", mock.Anything, mock.Anything).
Return(signer, nil)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
mockFolderMigrator,
signerFactory,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, progress)
@@ -120,11 +133,17 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
signer := signature.NewMockSigner(t)
signerFactory := signature.NewMockSignerFactory(t)
signerFactory.On("New", mock.Anything, mock.Anything).
Return(signer, nil)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
mockLegacyMigrator,
mockFolderMigrator,
signerFactory,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, progress)
@@ -138,6 +157,45 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
progress.AssertExpectations(t)
})
t.Run("should fail when signer factory fails", func(t *testing.T) {
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(resources.NewMockParser(t), nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything, mock.Anything).
Return(mockRepoResources, nil)
mockFolderMigrator := NewMockLegacyFoldersMigrator(t)
mockSignerFactory := signature.NewMockSignerFactory(t)
mockSignerFactory.On("New", mock.Anything, signature.SignOptions{
Namespace: "test-namespace",
History: true,
}).Return(nil, fmt.Errorf("signer factory error"))
progress := jobs.NewMockJobProgressRecorder(t)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
mockFolderMigrator,
mockSignerFactory,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{
History: true,
}, progress)
require.Error(t, err)
require.EqualError(t, err, "get signer: signer factory error")
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockFolderMigrator.AssertExpectations(t)
mockSignerFactory.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully migrate all resources", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
mockParserFactory := resources.NewMockParserFactory(t)
@@ -153,6 +211,13 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
mockFolderMigrator.On("Migrate", mock.Anything, "test-namespace", mockRepoResources, mock.Anything).
Return(nil)
mockSigner := signature.NewMockSigner(t)
mockSignerFactory := signature.NewMockSignerFactory(t)
mockSignerFactory.On("New", mock.Anything, signature.SignOptions{
Namespace: "test-namespace",
History: true,
}).Return(mockSigner, nil)
mockLegacyMigrator := legacy.NewMockLegacyMigrator(t)
mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
@@ -180,9 +245,12 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
mockParserFactory,
mockLegacyMigrator,
mockFolderMigrator,
mockSignerFactory,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, progress)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{
History: true,
}, progress)
require.NoError(t, err)
mockParserFactory.AssertExpectations(t)
@@ -209,6 +277,7 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err := migrator.Write(context.Background(), &resource.ResourceKey{}, []byte("test"))
@@ -257,6 +326,7 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err = migrator.Write(context.Background(), &resource.ResourceKey{}, []byte("test"))
@@ -267,6 +337,108 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) {
progress.AssertExpectations(t)
})
t.Run("should fail when signer fails", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockSigner := signature.NewMockSigner(t)
mockSigner.On("Sign", mock.Anything, meta).
Return(nil, errors.New("signing error"))
progress := jobs.NewMockJobProgressRecorder(t)
migrator := NewLegacyResourceMigrator(
nil,
mockParser,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
mockSigner,
)
err = migrator.Write(context.Background(), &resource.ResourceKey{}, []byte("test"))
require.Error(t, err)
require.EqualError(t, err, "add author signature: signing error")
mockParser.AssertExpectations(t)
mockSigner.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully add author signature", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockSigner := signature.NewMockSigner(t)
signedCtx := repository.WithAuthorSignature(context.Background(), repository.CommitSignature{
Name: "test-user",
Email: "test@example.com",
})
mockSigner.On("Sign", mock.Anything, meta).
Return(signedCtx, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("CreateResourceFileFromObject", signedCtx, mock.Anything, mock.Anything).
Return("test/path", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Name == "test" &&
result.Error == nil &&
result.Path == "test/path"
})).Return()
progress.On("TooManyErrors").Return(nil)
migrator := NewLegacyResourceMigrator(
nil,
mockParser,
mockRepoResources,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
mockSigner,
)
err = migrator.Write(context.Background(), &resource.ResourceKey{}, []byte("test"))
require.NoError(t, err)
mockParser.AssertExpectations(t)
mockSigner.AssertExpectations(t)
mockRepoResources.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully write resource", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
@@ -338,6 +510,7 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err = migrator.Write(context.Background(), &resource.ResourceKey{}, []byte("test"))
@@ -382,6 +555,7 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err = migrator.Write(context.Background(), &resource.ResourceKey{}, []byte("test"))
@@ -411,6 +585,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
@@ -441,6 +616,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "test-resources"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
@@ -471,6 +647,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
@@ -509,6 +686,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
@@ -538,6 +716,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
progress.On("SetTotal", mock.Anything, 200).Return()
signer := signature.NewMockSigner(t)
migrator := NewLegacyResourceMigrator(
mockLegacyMigrator,
@@ -547,6 +726,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signer,
)
err := migrator.Migrate(context.Background())
@@ -86,7 +86,7 @@ func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, jo
return fmt.Errorf("update repo with job status at start: %w", err)
}
repositoryResources, err := r.repositoryResources.Client(ctx, rw, resources.RepositoryResourcesOptions{})
repositoryResources, err := r.repositoryResources.Client(ctx, rw)
if err != nil {
return fmt.Errorf("create repository resources client: %w", err)
}
@@ -157,7 +157,7 @@ func TestSyncWorker_Process(t *testing.T) {
rpf.On("Execute", mock.Anything, repoConfig, mock.Anything).Return(nil)
// Repository resources creation fails
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(nil, errors.New("failed to create repository resources client"))
rrf.On("Client", mock.Anything, mock.Anything).Return(nil, errors.New("failed to create repository resources client"))
},
expectedError: "create repository resources client: failed to create repository resources client",
},
@@ -189,7 +189,7 @@ func TestSyncWorker_Process(t *testing.T) {
rpf.On("Execute", mock.Anything, repoConfig, mock.Anything).Return(nil)
// Repository resources creation succeeds
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(&resources.MockRepositoryResources{}, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(&resources.MockRepositoryResources{}, nil)
// Getting clients for namespace fails
cf.On("Clients", mock.Anything, "test-namespace").Return(nil, errors.New("failed to get clients"))
@@ -222,7 +222,7 @@ func TestSyncWorker_Process(t *testing.T) {
// Setup resources and clients
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, nil)
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
mockClients := resources.NewMockResourceClients(t)
cf.On("Clients", mock.Anything, "test-namespace").Return(mockClients, nil)
@@ -277,7 +277,7 @@ func TestSyncWorker_Process(t *testing.T) {
// Setup resources and clients
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, nil)
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
mockClients := resources.NewMockResourceClients(t)
cf.On("Clients", mock.Anything, "test-namespace").Return(mockClients, nil)
@@ -321,7 +321,7 @@ func TestSyncWorker_Process(t *testing.T) {
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, errors.New("stats error"))
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
// Simple mocks for other calls
mockClients := resources.NewMockResourceClients(t)
@@ -347,7 +347,7 @@ func TestSyncWorker_Process(t *testing.T) {
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, nil)
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
// Verify only sync status is patched
rpf.On("Execute", mock.Anything, mock.Anything, mock.MatchedBy(func(patch []map[string]interface{}) bool {
@@ -391,7 +391,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
mockRepoResources.On("Stats", mock.Anything).Return(stats, nil)
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
// Verify both sync status and stats are patched
rpf.On("Execute", mock.Anything, mock.Anything, mock.MatchedBy(func(patch []map[string]interface{}) bool {
@@ -463,7 +463,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
mockRepoResources.On("Stats", mock.Anything).Return(stats, nil)
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
// Verify only sync status is patched (multiple stats should be ignored)
rpf.On("Execute", mock.Anything, mock.Anything, mock.MatchedBy(func(patch []map[string]interface{}) bool {
@@ -497,7 +497,7 @@ func TestSyncWorker_Process(t *testing.T) {
// Setup resources and clients
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, nil)
rrf.On("Client", mock.Anything, mock.Anything, resources.RepositoryResourcesOptions{}).Return(mockRepoResources, nil)
rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil)
mockClients := resources.NewMockResourceClients(t)
cf.On("Clients", mock.Anything, mock.Anything).Return(mockClients, nil)
+3 -1
View File
@@ -49,6 +49,7 @@ import (
"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/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/services/apiserver"
@@ -546,13 +547,14 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
statusPatcher.Patch,
syncer,
)
signerFactory := signature.NewSignerFactory(b.clients)
legacyFolders := migrate.NewLegacyFoldersMigrator(b.legacyMigrator)
legacyResources := migrate.NewLegacyResourcesMigrator(
b.repositoryResources,
b.parsers,
b.legacyMigrator,
legacyFolders,
signerFactory,
)
storageSwapper := migrate.NewStorageSwapper(b.unified, b.storageStatus)
legacyMigrator := migrate.NewLegacyMigrator(
@@ -10,15 +10,9 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
)
type RepositoryResourcesOptions struct {
// FIXME: this is a temporary option to preload all user info
// we should remove this once we have a better way to handle user info and commit signatures
PreloadAllUserInfo bool
}
//go:generate mockery --name RepositoryResourcesFactory --structname MockRepositoryResourcesFactory --inpackage --filename repository_resources_factory_mock.go --with-expecter
type RepositoryResourcesFactory interface {
Client(ctx context.Context, repo repository.ReaderWriter, opts RepositoryResourcesOptions) (RepositoryResources, error)
Client(ctx context.Context, repo repository.ReaderWriter) (RepositoryResources, error)
}
//go:generate mockery --name RepositoryResources --structname MockRepositoryResources --inpackage --filename repository_resources_mock.go --with-expecter
@@ -64,7 +58,7 @@ func NewRepositoryResourcesFactory(parsers ParserFactory, clients ClientFactory,
return &repositoryResourcesFactory{parsers, clients, lister}
}
func (r *repositoryResourcesFactory) Client(ctx context.Context, repo repository.ReaderWriter, opts RepositoryResourcesOptions) (RepositoryResources, error) {
func (r *repositoryResourcesFactory) Client(ctx context.Context, repo repository.ReaderWriter) (RepositoryResources, error) {
clients, err := r.clients.Clients(ctx, repo.Config().Namespace)
if err != nil {
return nil, fmt.Errorf("create clients: %w", err)
@@ -79,21 +73,8 @@ func (r *repositoryResourcesFactory) Client(ctx context.Context, repo repository
return nil, fmt.Errorf("create parser: %w", err)
}
signatures := map[string]repository.CommitSignature{}
if opts.PreloadAllUserInfo {
userClient, err := clients.User()
if err != nil {
return nil, fmt.Errorf("create user client: %w", err)
}
signatures, err = loadUsers(ctx, userClient)
if err != nil {
return nil, fmt.Errorf("load users: %w", err)
}
}
folders := NewFolderManager(repo, folderClient, NewEmptyFolderTree())
resources := NewResourcesManager(repo, folders, parser, clients, signatures)
resources := NewResourcesManager(repo, folders, parser, clients)
return &repositoryResources{
FolderManager: folders,
@@ -22,9 +22,9 @@ func (_m *MockRepositoryResourcesFactory) EXPECT() *MockRepositoryResourcesFacto
return &MockRepositoryResourcesFactory_Expecter{mock: &_m.Mock}
}
// Client provides a mock function with given fields: ctx, repo, opts
func (_m *MockRepositoryResourcesFactory) Client(ctx context.Context, repo repository.ReaderWriter, opts RepositoryResourcesOptions) (RepositoryResources, error) {
ret := _m.Called(ctx, repo, opts)
// Client provides a mock function with given fields: ctx, repo
func (_m *MockRepositoryResourcesFactory) Client(ctx context.Context, repo repository.ReaderWriter) (RepositoryResources, error) {
ret := _m.Called(ctx, repo)
if len(ret) == 0 {
panic("no return value specified for Client")
@@ -32,19 +32,19 @@ func (_m *MockRepositoryResourcesFactory) Client(ctx context.Context, repo repos
var r0 RepositoryResources
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, repository.ReaderWriter, RepositoryResourcesOptions) (RepositoryResources, error)); ok {
return rf(ctx, repo, opts)
if rf, ok := ret.Get(0).(func(context.Context, repository.ReaderWriter) (RepositoryResources, error)); ok {
return rf(ctx, repo)
}
if rf, ok := ret.Get(0).(func(context.Context, repository.ReaderWriter, RepositoryResourcesOptions) RepositoryResources); ok {
r0 = rf(ctx, repo, opts)
if rf, ok := ret.Get(0).(func(context.Context, repository.ReaderWriter) RepositoryResources); ok {
r0 = rf(ctx, repo)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(RepositoryResources)
}
}
if rf, ok := ret.Get(1).(func(context.Context, repository.ReaderWriter, RepositoryResourcesOptions) error); ok {
r1 = rf(ctx, repo, opts)
if rf, ok := ret.Get(1).(func(context.Context, repository.ReaderWriter) error); ok {
r1 = rf(ctx, repo)
} else {
r1 = ret.Error(1)
}
@@ -60,14 +60,13 @@ type MockRepositoryResourcesFactory_Client_Call struct {
// Client is a helper method to define mock.On call
// - ctx context.Context
// - repo repository.ReaderWriter
// - opts RepositoryResourcesOptions
func (_e *MockRepositoryResourcesFactory_Expecter) Client(ctx interface{}, repo interface{}, opts interface{}) *MockRepositoryResourcesFactory_Client_Call {
return &MockRepositoryResourcesFactory_Client_Call{Call: _e.mock.On("Client", ctx, repo, opts)}
func (_e *MockRepositoryResourcesFactory_Expecter) Client(ctx interface{}, repo interface{}) *MockRepositoryResourcesFactory_Client_Call {
return &MockRepositoryResourcesFactory_Client_Call{Call: _e.mock.On("Client", ctx, repo)}
}
func (_c *MockRepositoryResourcesFactory_Client_Call) Run(run func(ctx context.Context, repo repository.ReaderWriter, opts RepositoryResourcesOptions)) *MockRepositoryResourcesFactory_Client_Call {
func (_c *MockRepositoryResourcesFactory_Client_Call) Run(run func(ctx context.Context, repo repository.ReaderWriter)) *MockRepositoryResourcesFactory_Client_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.ReaderWriter), args[2].(RepositoryResourcesOptions))
run(args[0].(context.Context), args[1].(repository.ReaderWriter))
})
return _c
}
@@ -77,7 +76,7 @@ func (_c *MockRepositoryResourcesFactory_Client_Call) Return(_a0 RepositoryResou
return _c
}
func (_c *MockRepositoryResourcesFactory_Client_Call) RunAndReturn(run func(context.Context, repository.ReaderWriter, RepositoryResourcesOptions) (RepositoryResources, error)) *MockRepositoryResourcesFactory_Client_Call {
func (_c *MockRepositoryResourcesFactory_Client_Call) RunAndReturn(run func(context.Context, repository.ReaderWriter) (RepositoryResources, error)) *MockRepositoryResourcesFactory_Client_Call {
_c.Call.Return(run)
return _c
}
@@ -41,17 +41,15 @@ type ResourcesManager struct {
folders *FolderManager
parser Parser
clients ResourceClients
userInfo map[string]repository.CommitSignature
resourcesLookup map[resourceID]string // the path with this k8s name
}
func NewResourcesManager(repo repository.ReaderWriter, folders *FolderManager, parser Parser, clients ResourceClients, userInfo map[string]repository.CommitSignature) *ResourcesManager {
func NewResourcesManager(repo repository.ReaderWriter, folders *FolderManager, parser Parser, clients ResourceClients) *ResourcesManager {
return &ResourcesManager{
repo: repo,
folders: folders,
parser: parser,
clients: clients,
userInfo: userInfo,
resourcesLookup: map[resourceID]string{},
}
}
@@ -78,8 +76,6 @@ func (r *ResourcesManager) CreateResourceFileFromObject(ctx context.Context, obj
}
}
ctx = r.withAuthorSignature(ctx, meta)
name := meta.GetName()
if name == "" {
return "", ErrMissingName
@@ -233,26 +229,3 @@ func (r *ResourcesManager) RemoveResourceFromFile(ctx context.Context, path stri
return objName, schema.GroupVersionKind{}, nil
}
func (r *ResourcesManager) withAuthorSignature(ctx context.Context, item utils.GrafanaMetaAccessor) context.Context {
id := item.GetUpdatedBy()
if id == "" {
id = item.GetCreatedBy()
}
if id == "" {
id = "grafana"
}
sig := r.userInfo[id] // lookup
if sig.Name == "" && sig.Email == "" {
sig.Name = id
}
t, err := item.GetUpdatedTimestamp()
if err == nil && t != nil {
sig.When = *t
} else {
sig.When = item.GetCreationTimestamp().Time
}
return repository.WithAuthorSignature(ctx, sig)
}
@@ -0,0 +1,33 @@
package signature
import (
"context"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
type grafanaSigner struct{}
// FIXME: where should we use this default signature?
// NewGrafanaSigner returns a Signer that uses the grafana user as the author
func NewGrafanaSigner() Signer {
return &grafanaSigner{}
}
func (s *grafanaSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) {
sig := repository.CommitSignature{
Name: "grafana",
// TODO: should we add email?
// Email: "grafana@grafana.com",
}
t, err := item.GetUpdatedTimestamp()
if err == nil && t != nil {
sig.When = *t
} else {
sig.When = item.GetCreationTimestamp().Time
}
return repository.WithAuthorSignature(ctx, sig), nil
}
@@ -0,0 +1,90 @@
package signature
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
func TestNewGrafanaSigner(t *testing.T) {
signer := NewGrafanaSigner()
require.NotNil(t, signer, "signer should not be nil")
require.IsType(t, &grafanaSigner{}, signer, "signer should be of type *grafanaSigner")
}
func TestGrafanaSigner_Sign(t *testing.T) {
tests := []struct {
name string
creationTimestamp time.Time
updateTimestampErr error
updatedTimestamp *time.Time
expectedTime time.Time
setupMocks func(meta *utils.MockGrafanaMetaAccessor)
}{
{
name: "should use creation timestamp when no update timestamp",
creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
updatedTimestamp: ptr(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
updateTimestampErr: errors.New("failed"),
expectedTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
setupMocks: func(meta *utils.MockGrafanaMetaAccessor) {
meta.On("GetCreationTimestamp").Return(metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)})
},
},
{
name: "should use creation timestamp when update timestamp is nil",
creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
updatedTimestamp: nil,
expectedTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
setupMocks: func(meta *utils.MockGrafanaMetaAccessor) {
meta.On("GetCreationTimestamp").Return(metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)})
},
},
{
name: "should use update timestamp when available",
creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
updatedTimestamp: ptr(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)),
expectedTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
meta := utils.NewMockGrafanaMetaAccessor(t)
var updatedTime *time.Time
if tt.updatedTimestamp != nil {
updatedTime = tt.updatedTimestamp
}
meta.On("GetUpdatedTimestamp").Return(updatedTime, tt.updateTimestampErr)
if tt.setupMocks != nil {
tt.setupMocks(meta)
}
signer := NewGrafanaSigner()
ctx := context.Background()
signedCtx, err := signer.Sign(ctx, meta)
require.NoError(t, err)
// Verify the signature in the context
sig := repository.GetAuthorSignature(signedCtx)
require.NotNil(t, sig, "signature should be present in context")
require.Equal(t, "grafana", sig.Name)
require.Equal(t, tt.expectedTime, sig.When)
meta.AssertExpectations(t)
})
}
}
func ptr[T any](v T) *T {
return &v
}
@@ -0,0 +1,52 @@
package signature
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
//go:generate mockery --name Signer --structname MockSigner --inpackage --filename signer_mock.go --with-expecter
type Signer interface {
Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error)
}
type SignOptions struct {
Namespace string
History bool
}
// SignerFactory is a factory for creating Signers
//
//go:generate mockery --name SignerFactory --structname MockSignerFactory --inpackage --filename signature_factory_mock.go --with-expecter
type SignerFactory interface {
New(ctx context.Context, opts SignOptions) (Signer, error)
}
type signerFactory struct {
clients resources.ClientFactory
}
func NewSignerFactory(clients resources.ClientFactory) SignerFactory {
return &signerFactory{clients}
}
func (f *signerFactory) New(ctx context.Context, opts SignOptions) (Signer, error) {
if !opts.History {
return NewGrafanaSigner(), nil
}
clients, err := f.clients.Clients(ctx, opts.Namespace)
if err != nil {
return nil, fmt.Errorf("get clients: %w", err)
}
userClient, err := clients.User()
if err != nil {
return nil, fmt.Errorf("get user client: %w", err)
}
return NewLoadUsersOnceSigner(userClient), nil
}
@@ -0,0 +1,95 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package signature
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockSignerFactory is an autogenerated mock type for the SignerFactory type
type MockSignerFactory struct {
mock.Mock
}
type MockSignerFactory_Expecter struct {
mock *mock.Mock
}
func (_m *MockSignerFactory) EXPECT() *MockSignerFactory_Expecter {
return &MockSignerFactory_Expecter{mock: &_m.Mock}
}
// New provides a mock function with given fields: ctx, opts
func (_m *MockSignerFactory) New(ctx context.Context, opts SignOptions) (Signer, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for New")
}
var r0 Signer
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, SignOptions) (Signer, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, SignOptions) Signer); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Signer)
}
}
if rf, ok := ret.Get(1).(func(context.Context, SignOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSignerFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New'
type MockSignerFactory_New_Call struct {
*mock.Call
}
// New is a helper method to define mock.On call
// - ctx context.Context
// - opts SignOptions
func (_e *MockSignerFactory_Expecter) New(ctx interface{}, opts interface{}) *MockSignerFactory_New_Call {
return &MockSignerFactory_New_Call{Call: _e.mock.On("New", ctx, opts)}
}
func (_c *MockSignerFactory_New_Call) Run(run func(ctx context.Context, opts SignOptions)) *MockSignerFactory_New_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(SignOptions))
})
return _c
}
func (_c *MockSignerFactory_New_Call) Return(_a0 Signer, _a1 error) *MockSignerFactory_New_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSignerFactory_New_Call) RunAndReturn(run func(context.Context, SignOptions) (Signer, error)) *MockSignerFactory_New_Call {
_c.Call.Return(run)
return _c
}
// NewMockSignerFactory creates a new instance of MockSignerFactory. 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 NewMockSignerFactory(t interface {
mock.TestingT
Cleanup(func())
}) *MockSignerFactory {
mock := &MockSignerFactory{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,91 @@
package signature
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
func TestSignerFactory_New(t *testing.T) {
tests := []struct {
name string
opts SignOptions
setupMocks func(t *testing.T, clients *resources.MockClientFactory)
expectedType interface{}
expectedError string
}{
{
name: "should return grafana signer when history is false",
opts: SignOptions{
History: false,
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
// No mocks needed as we shouldn't call any clients
},
expectedType: &grafanaSigner{},
},
{
name: "should return load users once signer when history is true",
opts: SignOptions{
History: true,
Namespace: "test-ns",
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
mockResourceClients := resources.NewMockResourceClients(t)
clients.On("Clients", context.Background(), "test-ns").Return(mockResourceClients, nil)
mockResourceClients.On("User").Return(nil, nil)
},
expectedType: &loadUsersOnceSigner{},
},
{
name: "should return error when clients factory fails",
opts: SignOptions{
History: true,
Namespace: "test-ns",
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
clients.On("Clients", context.Background(), "test-ns").Return(nil, fmt.Errorf("clients error"))
},
expectedError: "get clients: clients error",
},
{
name: "should return error when user client fails",
opts: SignOptions{
History: true,
Namespace: "test-ns",
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
mockResourceClients := resources.NewMockResourceClients(t)
clients.On("Clients", context.Background(), "test-ns").Return(mockResourceClients, nil)
mockResourceClients.On("User").Return(nil, fmt.Errorf("user client error"))
},
expectedError: "get user client: user client error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClients := resources.NewMockClientFactory(t)
tt.setupMocks(t, mockClients)
factory := NewSignerFactory(mockClients)
signer, err := factory.New(context.Background(), tt.opts)
if tt.expectedError != "" {
require.Error(t, err)
require.EqualError(t, err, tt.expectedError)
require.Nil(t, signer)
} else {
require.NoError(t, err)
require.NotNil(t, signer)
require.IsType(t, tt.expectedType, signer, "signer should be of expected type")
}
mockClients.AssertExpectations(t)
})
}
}
@@ -0,0 +1,96 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package signature
import (
context "context"
utils "github.com/grafana/grafana/pkg/apimachinery/utils"
mock "github.com/stretchr/testify/mock"
)
// MockSigner is an autogenerated mock type for the Signer type
type MockSigner struct {
mock.Mock
}
type MockSigner_Expecter struct {
mock *mock.Mock
}
func (_m *MockSigner) EXPECT() *MockSigner_Expecter {
return &MockSigner_Expecter{mock: &_m.Mock}
}
// Sign provides a mock function with given fields: ctx, item
func (_m *MockSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) {
ret := _m.Called(ctx, item)
if len(ret) == 0 {
panic("no return value specified for Sign")
}
var r0 context.Context
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, utils.GrafanaMetaAccessor) (context.Context, error)); ok {
return rf(ctx, item)
}
if rf, ok := ret.Get(0).(func(context.Context, utils.GrafanaMetaAccessor) context.Context); ok {
r0 = rf(ctx, item)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(context.Context)
}
}
if rf, ok := ret.Get(1).(func(context.Context, utils.GrafanaMetaAccessor) error); ok {
r1 = rf(ctx, item)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSigner_Sign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sign'
type MockSigner_Sign_Call struct {
*mock.Call
}
// Sign is a helper method to define mock.On call
// - ctx context.Context
// - item utils.GrafanaMetaAccessor
func (_e *MockSigner_Expecter) Sign(ctx interface{}, item interface{}) *MockSigner_Sign_Call {
return &MockSigner_Sign_Call{Call: _e.mock.On("Sign", ctx, item)}
}
func (_c *MockSigner_Sign_Call) Run(run func(ctx context.Context, item utils.GrafanaMetaAccessor)) *MockSigner_Sign_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(utils.GrafanaMetaAccessor))
})
return _c
}
func (_c *MockSigner_Sign_Call) Return(_a0 context.Context, _a1 error) *MockSigner_Sign_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSigner_Sign_Call) RunAndReturn(run func(context.Context, utils.GrafanaMetaAccessor) (context.Context, error)) *MockSigner_Sign_Call {
_c.Call.Return(run)
return _c
}
// NewMockSigner creates a new instance of MockSigner. 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 NewMockSigner(t interface {
mock.TestingT
Cleanup(func())
}) *MockSigner {
mock := &MockSigner{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,115 @@
package signature
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
const maxUsers = 10000
type loadUsersOnceSigner struct {
signatures map[string]repository.CommitSignature
client dynamic.ResourceInterface
once sync.Once
onceErr error
}
// NewLoadUsersOnceSigner returns a Signer that loads the signatures from users
// it will only load the signatures once and cache them
// if the user is not found, it will use the grafana user as the author
func NewLoadUsersOnceSigner(client dynamic.ResourceInterface) Signer {
return &loadUsersOnceSigner{
client: client,
once: sync.Once{},
signatures: map[string]repository.CommitSignature{},
}
}
func (s *loadUsersOnceSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) {
if s.onceErr != nil {
return ctx, fmt.Errorf("load signatures: %w", s.onceErr)
}
var err error
s.once.Do(func() {
s.signatures, err = s.load(ctx, s.client)
s.onceErr = err
})
if err != nil {
return ctx, fmt.Errorf("load signatures: %w", err)
}
id := item.GetUpdatedBy()
if id == "" {
id = item.GetCreatedBy()
}
if id == "" {
id = "grafana"
}
sig := s.signatures[id] // lookup
if sig.Name == "" && sig.Email == "" {
sig.Name = id
}
t, err := item.GetUpdatedTimestamp()
if err == nil && t != nil {
sig.When = *t
} else {
sig.When = item.GetCreationTimestamp().Time
}
return repository.WithAuthorSignature(ctx, sig), nil
}
func (s *loadUsersOnceSigner) load(ctx context.Context, client dynamic.ResourceInterface) (map[string]repository.CommitSignature, error) {
userInfo := make(map[string]repository.CommitSignature)
var count int
err := resources.ForEach(ctx, client, func(item *unstructured.Unstructured) error {
count++
if count > maxUsers {
return errors.New("too many users")
}
sig := repository.CommitSignature{}
// FIXME: should we improve logging here?
var (
ok bool
err error
)
sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login")
if !ok || err != nil {
return nil
}
sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email")
if !ok || err != nil {
return nil
}
if sig.Name == sig.Email {
if sig.Name == "" {
sig.Name = item.GetName()
} else if strings.Contains(sig.Email, "@") {
sig.Email = "" // don't use the same value for name+email
}
}
userInfo["user:"+item.GetName()] = sig
return nil
})
if err != nil {
return nil, err
}
return userInfo, nil
}
@@ -0,0 +1,357 @@
package signature
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
// mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface
type mockDynamicInterface struct {
dynamic.ResourceInterface
items []unstructured.Unstructured
err error
}
func (m *mockDynamicInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
if m.err != nil {
return nil, m.err
}
return &unstructured.UnstructuredList{
Items: m.items,
}, nil
}
type mockGrafanaMetaAccessor struct {
utils.GrafanaMetaAccessor
createdBy string
updatedBy string
creationTimestamp time.Time
updatedTimestamp *time.Time
updatedTimestampErr error
}
func (m *mockGrafanaMetaAccessor) GetCreatedBy() string {
return m.createdBy
}
func (m *mockGrafanaMetaAccessor) GetUpdatedBy() string {
return m.updatedBy
}
func (m *mockGrafanaMetaAccessor) GetCreationTimestamp() metav1.Time {
return metav1.Time{Time: m.creationTimestamp}
}
func (m *mockGrafanaMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) {
if m.updatedTimestampErr != nil {
return nil, m.updatedTimestampErr
}
return m.updatedTimestamp, nil
}
func TestLoadUsersOnceSigner_Sign(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
updateTime := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
items []unstructured.Unstructured
meta *mockGrafanaMetaAccessor
clientErr error
expectedSig repository.CommitSignature
expectedError string
}{
{
name: "should sign with user info when user exists",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "john@example.com",
When: updateTime,
},
},
{
name: "should fallback to created by when updated by is empty",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
createdBy: "user:user1",
creationTimestamp: baseTime,
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "john@example.com",
When: baseTime,
},
},
{
name: "should use grafana when no user info available",
meta: &mockGrafanaMetaAccessor{
creationTimestamp: baseTime,
},
expectedSig: repository.CommitSignature{
Name: "grafana",
When: baseTime,
},
},
{
name: "should handle user with same login and email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "john@example.com",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "john@example.com",
Email: "",
When: updateTime,
},
},
{
name: "should handle empty login and email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "",
"email": "",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "user1",
Email: "",
When: updateTime,
},
},
{
name: "should handle empty email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "",
When: updateTime,
},
},
{
name: "should fail when too many users",
items: func() []unstructured.Unstructured {
items := make([]unstructured.Unstructured, maxUsers+1)
for i := 0; i < maxUsers+1; i++ {
items[i] = unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
}
}
return items
}(),
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
expectedError: "load signatures: too many users",
},
{
name: "should handle missing user fields gracefully",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
// missing login and email
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
expectedSig: repository.CommitSignature{
Name: "user:user1",
When: baseTime,
},
},
{
name: "should use creation timestamp when update timestamp has error",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestampErr: errors.New("update timestamp error"),
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "john@example.com",
When: baseTime,
},
},
{
name: "should fail when listing users fails",
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
clientErr: fmt.Errorf("failed to list users"),
expectedError: "load signatures: error executing list: failed to list users",
},
{
name: "should handle empty user list",
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
items: []unstructured.Unstructured{},
expectedSig: repository.CommitSignature{
Name: "user:user1",
When: baseTime,
},
},
{
name: "should handle multiple calls with error",
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
clientErr: fmt.Errorf("failed to list users"),
expectedError: "load signatures: error executing list: failed to list users",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &mockDynamicInterface{
items: tt.items,
err: tt.clientErr,
}
signer := NewLoadUsersOnceSigner(client)
ctx := context.Background()
signedCtx, err := signer.Sign(ctx, tt.meta)
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
// Test that subsequent calls also fail with the same error
_, err2 := signer.Sign(ctx, tt.meta)
require.Error(t, err2)
require.Contains(t, err2.Error(), tt.expectedError)
return
}
require.NoError(t, err)
sig := repository.GetAuthorSignature(signedCtx)
require.NotNil(t, sig)
require.Equal(t, tt.expectedSig.Name, sig.Name)
require.Equal(t, tt.expectedSig.Email, sig.Email)
require.Equal(t, tt.expectedSig.When, sig.When)
// Test that subsequent calls use cached data
signedCtx2, err := signer.Sign(ctx, tt.meta)
require.NoError(t, err)
sig2 := repository.GetAuthorSignature(signedCtx2)
require.Equal(t, sig, sig2)
})
}
}
@@ -1,57 +0,0 @@
package resources
import (
"context"
"errors"
"strings"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
const maxUsers = 10000
func loadUsers(ctx context.Context, client dynamic.ResourceInterface) (map[string]repository.CommitSignature, error) {
userInfo := make(map[string]repository.CommitSignature)
var count int
err := ForEach(ctx, client, func(item *unstructured.Unstructured) error {
count++
if count > maxUsers {
return errors.New("too many users")
}
sig := repository.CommitSignature{}
// FIXME: should we improve logging here?
var (
ok bool
err error
)
sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login")
if !ok || err != nil {
return nil
}
sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email")
if !ok || err != nil {
return nil
}
if sig.Name == sig.Email {
if sig.Name == "" {
sig.Name = item.GetName()
} else if strings.Contains(sig.Email, "@") {
sig.Email = "" // don't use the same value for name+email
}
}
userInfo["user:"+item.GetName()] = sig
return nil
})
if err != nil {
return nil, err
}
return userInfo, nil
}
@@ -1,195 +0,0 @@
package resources
import (
"context"
"testing"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
// mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface
type mockDynamicInterface struct {
dynamic.ResourceInterface
items []unstructured.Unstructured
}
func (m *mockDynamicInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
return &unstructured.UnstructuredList{
Items: m.items,
}, nil
}
func TestLoadUsers(t *testing.T) {
tests := []struct {
name string
items []unstructured.Unstructured
expectedUsers map[string]repository.CommitSignature
expectedError string
}{
{
name: "should load users successfully",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user2",
},
"spec": map[string]interface{}{
"login": "janedoe",
"email": "jane@example.com",
},
},
},
},
expectedUsers: map[string]repository.CommitSignature{
"user:user1": {
Name: "johndoe",
Email: "john@example.com",
},
"user:user2": {
Name: "janedoe",
Email: "jane@example.com",
},
},
},
{
name: "should handle missing email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
// email missing
},
},
},
},
expectedUsers: map[string]repository.CommitSignature{},
},
{
name: "should handle missing login",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
// login missing
"email": "john@example.com",
},
},
},
},
expectedUsers: map[string]repository.CommitSignature{},
},
{
name: "should handle same login and email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "john@example.com",
"email": "john@example.com",
},
},
},
},
expectedUsers: map[string]repository.CommitSignature{
"user:user1": {
Name: "john@example.com",
Email: "", // Email should be empty when same as login
},
},
},
{
name: "should handle empty login and email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "",
"email": "",
},
},
},
},
expectedUsers: map[string]repository.CommitSignature{
"user:user1": {
Name: "user1", // Should use metadata name when login is empty
Email: "",
},
},
},
{
name: "should fail when too many users",
items: func() []unstructured.Unstructured {
items := make([]unstructured.Unstructured, maxUsers+1)
for i := 0; i < maxUsers+1; i++ {
items[i] = unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
}
}
return items
}(),
expectedError: "too many users",
},
{
name: "should handle empty user list",
items: []unstructured.Unstructured{},
expectedUsers: map[string]repository.CommitSignature{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &mockDynamicInterface{
items: tt.items,
}
userInfo, err := loadUsers(context.Background(), client)
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
return
}
require.NoError(t, err)
require.Equal(t, tt.expectedUsers, userInfo)
})
}
}