Files
grafana/pkg/registry/apis/provisioning/controller/repository_test.go
T
Daniele Stefano Ferru 5ecfc79e14 Provisioning: Add Connection resource (#115272)
* Provisioning: Add Connection resource

* adding some more integration tests

* updating openapi snapshot, linting

* generating FE code, fixing issue in unit tests

* addressing comments

* addressing comments

* adding more integration tests

* fixing rebase issues

* removing linting exception

* addressing comments: improving validation and tests

* adding Connection URL at mutation time, updating tests accordingly

* linting
2025-12-16 14:37:07 +01:00

706 lines
21 KiB
Go

package controller
import (
"context"
"testing"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
)
type mockProvisioningV0alpha1Interface struct {
repositoriesFunc func(namespace string) client.RepositoryInterface
}
func (m mockProvisioningV0alpha1Interface) RESTClient() rest.Interface {
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) HistoricJobs(namespace string) client.HistoricJobInterface {
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Jobs(namespace string) client.JobInterface {
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Connections(namespace string) client.ConnectionInterface {
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Repositories(namespace string) client.RepositoryInterface {
if m.repositoriesFunc != nil {
return m.repositoriesFunc(namespace)
}
return nil
}
type mockRepoInterface struct {
patchFunc func(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *provisioning.Repository, err error)
}
func (m mockRepoInterface) Create(ctx context.Context, repository *provisioning.Repository, opts metav1.CreateOptions) (*provisioning.Repository, error) {
panic("not needed for testing")
}
func (m mockRepoInterface) Update(ctx context.Context, repository *provisioning.Repository, opts metav1.UpdateOptions) (*provisioning.Repository, error) {
panic("not needed for testing")
}
func (m mockRepoInterface) UpdateStatus(ctx context.Context, repository *provisioning.Repository, opts metav1.UpdateOptions) (*provisioning.Repository, error) {
panic("not needed for testing")
}
func (m mockRepoInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
panic("not needed for testing")
}
func (m mockRepoInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error {
panic("not needed for testing")
}
func (m mockRepoInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*provisioning.Repository, error) {
panic("not needed for testing")
}
func (m mockRepoInterface) List(ctx context.Context, opts metav1.ListOptions) (*provisioning.RepositoryList, error) {
panic("not needed for testing")
}
func (m mockRepoInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
panic("not needed for testing")
}
func (m mockRepoInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *provisioning.Repository, err error) {
if m.patchFunc != nil {
return m.patchFunc(ctx, name, pt, data, opts, subresources...)
}
return nil, nil
}
func (m mockRepoInterface) Apply(ctx context.Context, repository *provisioningv0alpha1.RepositoryApplyConfiguration, opts metav1.ApplyOptions) (result *provisioning.Repository, err error) {
panic("not needed for testing")
}
func (m mockRepoInterface) ApplyStatus(ctx context.Context, repository *provisioningv0alpha1.RepositoryApplyConfiguration, opts metav1.ApplyOptions) (result *provisioning.Repository, err error) {
panic("not needed for testing")
}
var (
_ client.ProvisioningV0alpha1Interface = (*mockProvisioningV0alpha1Interface)(nil)
_ client.RepositoryInterface = (*mockRepoInterface)(nil)
)
func TestRepositoryController_handleDelete(t *testing.T) {
testCases := []struct {
name string
repoFactory repository.Factory
finalizer finalizerProcessor
client client.ProvisioningV0alpha1Interface
statusPatcher StatusPatcher
repo *provisioning.Repository
expectedErr string
}{
{
name: "No finalizers",
repoFactory: nil,
finalizer: nil,
client: nil,
statusPatcher: nil,
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Finalizers: []string{},
},
},
},
{
name: "Finalizers deleted successfully",
repoFactory: func() repository.Factory {
f := repository.NewMockFactory(t)
f.
On("Build", context.Background(), mock.Anything).
Once().
Return(nil, nil)
return f
}(),
finalizer: func() finalizerProcessor {
f := NewMockFinalizerProcessor(t)
f.
On("process", context.Background(), nil, []string{
repository.RemoveOrphanResourcesFinalizer,
}).
Once().
Return(nil)
return f
}(),
client: func() client.ProvisioningV0alpha1Interface {
repo := &mockRepoInterface{
patchFunc: func(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *provisioning.Repository, err error) {
return &provisioning.Repository{}, nil
},
}
c := &mockProvisioningV0alpha1Interface{
repositoriesFunc: func(namespace string) client.RepositoryInterface {
return repo
},
}
return c
}(),
statusPatcher: nil,
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Finalizers: []string{
repository.RemoveOrphanResourcesFinalizer,
},
},
},
},
{
name: "Error when building repository",
repoFactory: func() repository.Factory {
f := repository.NewMockFactory(t)
f.
On("Build", context.Background(), mock.Anything).
Once().
Return(nil, assert.AnError)
return f
}(),
finalizer: nil,
client: nil,
statusPatcher: nil,
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Finalizers: []string{
repository.RemoveOrphanResourcesFinalizer,
},
},
},
expectedErr: "create repository from configuration: " + assert.AnError.Error(),
},
{
name: "Error when processing finalizer",
repoFactory: func() repository.Factory {
f := repository.NewMockFactory(t)
f.
On("Build", context.Background(), mock.Anything).
Once().
Return(nil, nil)
return f
}(),
finalizer: func() finalizerProcessor {
f := NewMockFinalizerProcessor(t)
f.
On("process", context.Background(), nil, []string{
repository.RemoveOrphanResourcesFinalizer,
}).
Once().
Return(assert.AnError)
return f
}(),
statusPatcher: func() StatusPatcher {
s := mocks.NewStatusPatcher(t)
s.
On("Patch", context.Background(), mock.AnythingOfType("*v0alpha1.Repository"), mock.AnythingOfType("map[string]interface {}")).
Once().
Return(nil) // Return nil error for the status patch
return s
}(),
client: nil,
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Finalizers: []string{
repository.RemoveOrphanResourcesFinalizer,
},
},
},
expectedErr: "process finalizers: " + assert.AnError.Error(),
},
{
name: "Error when patching finalizers",
repoFactory: func() repository.Factory {
f := repository.NewMockFactory(t)
f.
On("Build", context.Background(), mock.Anything).
Once().
Return(nil, nil)
return f
}(),
finalizer: func() finalizerProcessor {
f := NewMockFinalizerProcessor(t)
f.
On("process", context.Background(), nil, []string{
repository.RemoveOrphanResourcesFinalizer,
}).
Once().
Return(nil)
return f
}(),
client: func() client.ProvisioningV0alpha1Interface {
repo := &mockRepoInterface{
patchFunc: func(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *provisioning.Repository, err error) {
return &provisioning.Repository{}, assert.AnError
},
}
c := &mockProvisioningV0alpha1Interface{
repositoriesFunc: func(namespace string) client.RepositoryInterface {
return repo
},
}
return c
}(),
statusPatcher: nil,
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Finalizers: []string{
repository.RemoveOrphanResourcesFinalizer,
},
},
},
expectedErr: "remove finalizers: " + assert.AnError.Error(),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := &RepositoryController{
repoFactory: tc.repoFactory,
finalizer: tc.finalizer,
client: tc.client,
statusPatcher: tc.statusPatcher,
}
err := c.handleDelete(context.Background(), tc.repo)
if tc.expectedErr != "" {
assert.Error(t, err)
assert.ErrorContains(t, err, tc.expectedErr)
} else {
assert.NoError(t, err)
}
})
}
}
func TestShouldUseIncrementalSync(t *testing.T) {
versioned := repository.NewMockVersioned(t)
obj := &provisioning.Repository{
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
LastRef: "123",
},
},
}
latestRef := "456"
t.Run("should use incremental sync", func(t *testing.T) {
versioned.On("CompareFiles", context.Background(), obj.Status.Sync.LastRef, latestRef).Return([]repository.VersionedFileChange{
{
Action: repository.FileActionDeleted,
Path: "test.json",
},
}, nil).Once()
got, err := shouldUseIncrementalSync(context.Background(), versioned, obj, latestRef)
assert.NoError(t, err)
assert.True(t, got)
})
t.Run("should not use incremental sync", func(t *testing.T) {
versioned.On("CompareFiles", context.Background(), obj.Status.Sync.LastRef, latestRef).Return([]repository.VersionedFileChange{
{
Action: repository.FileActionDeleted,
Path: "test/.keep",
},
}, nil).Once()
got, err := shouldUseIncrementalSync(context.Background(), versioned, obj, latestRef)
assert.NoError(t, err)
assert.False(t, got)
})
}
// mockJobsQueueStore implements both jobs.Queue and jobs.Store for testing
type mockJobsQueueStore struct {
*jobs.MockQueue
*jobs.MockStore
}
func TestRepositoryController_process_UnhealthyRepositoryStatusUpdate(t *testing.T) {
testCases := []struct {
name string
repo *provisioning.Repository
healthStatus provisioning.HealthStatus
hasHealthStatusChanged bool
expectedUnhealthyMessage bool
description string
}{
{
name: "unhealthy repository should set unhealthy message in sync status",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
Generation: 1,
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-10 * time.Minute).UnixMilli(),
},
Sync: provisioning.SyncStatus{
State: provisioning.JobStateSuccess,
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
Message: []string{},
},
},
},
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().UnixMilli(),
Message: []string{"connection failed"},
},
hasHealthStatusChanged: true,
expectedUnhealthyMessage: true,
description: "should set unhealthy message when repository becomes unhealthy",
},
{
name: "unhealthy repository should not duplicate unhealthy message",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
Generation: 1,
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: false,
Checked: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
Sync: provisioning.SyncStatus{
State: provisioning.JobStateError,
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
Message: []string{"Repository is unhealthy"},
},
},
},
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().UnixMilli(),
Message: []string{"connection failed"},
},
hasHealthStatusChanged: false,
expectedUnhealthyMessage: false,
description: "should not set unhealthy message when it already exists",
},
{
name: "healthy repository should clear unhealthy message",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
Generation: 1,
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: false,
Checked: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
Sync: provisioning.SyncStatus{
State: provisioning.JobStateError,
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
Message: []string{"Repository is unhealthy"},
},
},
},
healthStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().UnixMilli(),
Message: []string{},
},
hasHealthStatusChanged: true,
expectedUnhealthyMessage: false,
description: "should clear unhealthy message when repository becomes healthy",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create controller
rc := &RepositoryController{}
// Determine sync status ops (this is a pure function, no mocks needed)
syncOps := rc.determineSyncStatusOps(tc.repo, nil, tc.healthStatus)
// Verify expectations
hasUnhealthyOp := false
hasClearUnhealthyOp := false
for _, op := range syncOps {
if path, ok := op["path"].(string); ok {
if path == "/status/sync/message" {
if messages, ok := op["value"].([]string); ok {
if len(messages) > 0 && messages[0] == "Repository is unhealthy" {
hasUnhealthyOp = true
} else if len(messages) == 0 {
hasClearUnhealthyOp = true
}
}
}
}
}
if tc.expectedUnhealthyMessage {
assert.True(t, hasUnhealthyOp, tc.description+": expected unhealthy message operation")
} else if len(tc.repo.Status.Sync.Message) > 0 && tc.healthStatus.Healthy {
assert.True(t, hasClearUnhealthyOp, tc.description+": expected clear unhealthy message operation")
}
})
}
}
func TestRepositoryController_shouldResync_StaleSyncStatus(t *testing.T) {
testCases := []struct {
name string
repo *provisioning.Repository
jobGetError error
expectedResync bool
description string
}{
{
name: "stale sync status with Pending state - job not found",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "test-job-123",
Started: time.Now().Add(-10 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-10 * time.Minute).UnixMilli(),
},
},
},
jobGetError: apierrors.NewNotFound(schema.GroupResource{Resource: "jobs"}, "test-job-123"),
expectedResync: true,
description: "should return true to trigger resync when job is not found",
},
{
name: "stale sync status with Working state - job not found",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStateWorking,
JobID: "test-job-456",
Started: time.Now().Add(-5 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-5 * time.Minute).UnixMilli(),
},
},
},
jobGetError: apierrors.NewNotFound(schema.GroupResource{Resource: "jobs"}, "test-job-456"),
expectedResync: true,
description: "should return true to trigger resync when working job is not found",
},
{
name: "non-stale sync status - job exists",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "test-job-789",
Started: time.Now().Add(-2 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
},
},
jobGetError: nil, // Job exists
expectedResync: false, // Should continue with normal logic
description: "should continue with normal logic when job exists",
},
{
name: "non-stale sync status - no JobID",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "",
Started: time.Now().Add(-2 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
},
},
jobGetError: nil,
expectedResync: false,
description: "should not check when JobID is empty",
},
{
name: "non-stale sync status - already finished",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStateSuccess,
JobID: "test-job-999",
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
},
},
},
jobGetError: nil,
expectedResync: false,
description: "should not check when sync status is already finished",
},
{
name: "stale sync status - job lookup error (non-NotFound)",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "test-job-error",
Started: time.Now().Add(-2 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
},
},
jobGetError: assert.AnError, // Non-NotFound error
expectedResync: false, // Should continue with normal logic
description: "should handle non-NotFound errors gracefully and continue with normal logic",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mocks
mockQueue := jobs.NewMockQueue(t)
mockStore := jobs.NewMockStore(t)
mockJobs := &mockJobsQueueStore{
MockQueue: mockQueue,
MockStore: mockStore,
}
// Set up job Get mock
if tc.repo.Status.Sync.JobID != "" && (tc.repo.Status.Sync.State == provisioning.JobStatePending || tc.repo.Status.Sync.State == provisioning.JobStateWorking) {
mockStore.On("Get", mock.Anything, tc.repo.Namespace, tc.repo.Status.Sync.JobID).Return(nil, tc.jobGetError).Once()
}
// Create controller
rc := &RepositoryController{
jobs: mockJobs,
}
// Test shouldResync
ctx := context.Background()
result := rc.shouldResync(ctx, tc.repo)
// Verify
assert.Equal(t, tc.expectedResync, result, tc.description)
})
}
}