Provisioning: Refactor to combine validation and test endpoint logic (#111965)

Provisioning: Refactor test endpoint
This commit is contained in:
Stephanie Hingtgen
2025-10-03 01:16:37 -06:00
committed by GitHub
parent add8beefad
commit 3ce9137c19
10 changed files with 437 additions and 292 deletions
@@ -0,0 +1,84 @@
package repository
import (
"context"
"net/http"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// SimpleRepositoryTester will validate the repository configuration, and then proceed to test the connection to the repository
type SimpleRepositoryTester struct {
validator RepositoryValidator
}
func NewSimpleRepositoryTester(validator RepositoryValidator) SimpleRepositoryTester {
return SimpleRepositoryTester{
validator: validator,
}
}
// TestRepository validates the repository and then runs a health check
func (t *SimpleRepositoryTester) TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
errors := t.validator.ValidateRepository(repo)
if len(errors) > 0 {
rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid
Success: false,
Errors: make([]provisioning.ErrorDetails, len(errors)),
}
for i, err := range errors {
rsp.Errors[i] = provisioning.ErrorDetails{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}
}
return rsp, nil
}
return repo.Test(ctx)
}
type VerifyAgainstExistingRepositories func(ctx context.Context, cfg *provisioning.Repository) *field.Error // defined this way to prevent an import cycle
// RepositoryTesterWithExistingChecker will validate the repository configuration, run a health check, and then compare it against existing repositories
type RepositoryTesterWithExistingChecker struct {
tester SimpleRepositoryTester
verify VerifyAgainstExistingRepositories
}
func NewRepositoryTesterWithExistingChecker(tester SimpleRepositoryTester, verify VerifyAgainstExistingRepositories) RepositoryTesterWithExistingChecker {
return RepositoryTesterWithExistingChecker{
tester: tester,
verify: verify,
}
}
// TestRepositoryAndCheckExisting validates the repository, runs a health check, and then compares it against existing repositories
func (c *RepositoryTesterWithExistingChecker) TestRepositoryAndCheckExisting(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
rsp, err := c.tester.TestRepository(ctx, repo)
if err != nil {
return nil, err
}
if rsp.Success {
cfg := repo.Config()
if validationErr := c.verify(ctx, cfg); validationErr != nil {
rsp = &provisioning.TestResults{
Success: false,
Code: http.StatusUnprocessableEntity,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseType(validationErr.Type),
Field: validationErr.Field,
Detail: validationErr.Detail,
}},
}
}
}
return rsp, nil
}
@@ -0,0 +1,204 @@
package repository
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
func TestTestRepository(t *testing.T) {
tests := []struct {
name string
repository *MockRepository
expectedCode int
expectedErrs []provisioning.ErrorDetails
expectedError error
}{
{
name: "validation fails",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
// Missing required title
},
})
m.On("Validate").Return(field.ErrorList{})
return m
}(),
expectedCode: http.StatusUnprocessableEntity,
expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueRequired,
Field: "spec.title",
Detail: "a repository title must be given",
}},
},
{
name: "test passes",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
m.On("Validate").Return(field.ErrorList{})
m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil)
return m
}(),
expectedCode: http.StatusOK,
expectedErrs: nil,
},
{
name: "test fails with error",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
m.On("Validate").Return(field.ErrorList{})
m.On("Test", mock.Anything).Return(nil, fmt.Errorf("test error"))
return m
}(),
expectedError: fmt.Errorf("test error"),
},
{
name: "test fails with results",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
m.On("Validate").Return(field.ErrorList{})
m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
}, nil)
return m
}(),
expectedCode: http.StatusBadRequest,
expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
},
}
tester := NewSimpleRepositoryTester(NewValidator(10*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder, provisioning.SyncTargetTypeInstance}, true))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, err := tester.TestRepository(context.Background(), tt.repository)
if tt.expectedError != nil {
require.Error(t, err)
require.Equal(t, tt.expectedError.Error(), err.Error())
return
}
require.NoError(t, err)
require.NotNil(t, results)
require.Equal(t, tt.expectedCode, results.Code)
if tt.expectedErrs != nil {
require.Equal(t, tt.expectedErrs, results.Errors)
require.False(t, results.Success)
} else {
require.True(t, results.Success)
require.Empty(t, results.Errors)
}
})
}
}
func TestTester_TestRepository(t *testing.T) {
repository := NewMockRepository(t)
repository.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
repository.On("Validate").Return(field.ErrorList{})
repository.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil)
tester := NewSimpleRepositoryTester(NewValidator(10*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder, provisioning.SyncTargetTypeInstance}, true))
results, err := tester.TestRepository(context.Background(), repository)
require.NoError(t, err)
require.NotNil(t, results)
require.Equal(t, http.StatusOK, results.Code)
require.True(t, results.Success)
}
func TestFromFieldError(t *testing.T) {
tests := []struct {
name string
fieldError *field.Error
expectedCode int
expectedField string
expectedType metav1.CauseType
expectedDetail string
}{
{
name: "required field error",
fieldError: &field.Error{
Type: field.ErrorTypeRequired,
Field: "spec.title",
Detail: "a repository title must be given",
},
expectedCode: http.StatusBadRequest,
expectedField: "spec.title",
expectedType: metav1.CauseTypeFieldValueRequired,
expectedDetail: "a repository title must be given",
},
{
name: "not supported field error",
fieldError: &field.Error{
Type: field.ErrorTypeNotSupported,
Field: "spec.workflow",
Detail: "branch is only supported on git repositories",
},
expectedCode: http.StatusBadRequest,
expectedField: "spec.workflow",
expectedType: metav1.CauseTypeFieldValueNotSupported,
expectedDetail: "branch is only supported on git repositories",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FromFieldError(tt.fieldError)
require.NotNil(t, result)
require.Equal(t, tt.expectedCode, result.Code)
require.False(t, result.Success)
require.Len(t, result.Errors, 1)
errorDetail := result.Errors[0]
require.Equal(t, tt.expectedField, errorDetail.Field)
require.Equal(t, tt.expectedType, errorDetail.Type)
require.Equal(t, tt.expectedDetail, errorDetail.Detail)
})
}
}
@@ -1,10 +1,10 @@
package repository
import (
"context"
"fmt"
"net/http"
"slices"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
@@ -12,57 +12,27 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// RepositoryValidator interface for validating repositories against existing ones
type RepositoryValidator interface {
VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error
type RepositoryValidator struct {
allowedTargets []provisioning.SyncTargetType
allowImageRendering bool
minSyncInterval time.Duration
}
func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
return TestRepositoryWithValidator(ctx, repo, nil)
func NewValidator(minSyncInterval time.Duration, allowedTargets []provisioning.SyncTargetType, allowImageRendering bool) RepositoryValidator {
// do not allow minsync interval to be less than 10
if minSyncInterval <= 10*time.Second {
minSyncInterval = 10 * time.Second
}
return RepositoryValidator{
allowedTargets: allowedTargets,
allowImageRendering: allowImageRendering,
minSyncInterval: minSyncInterval,
}
}
func TestRepositoryWithValidator(ctx context.Context, repo Repository, validator RepositoryValidator) (*provisioning.TestResults, error) {
errors := ValidateRepository(repo)
if len(errors) > 0 {
rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid
Success: false,
Errors: make([]provisioning.ErrorDetails, len(errors)),
}
for i, err := range errors {
rsp.Errors[i] = provisioning.ErrorDetails{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}
}
return rsp, nil
}
rsp, err := repo.Test(ctx)
if err != nil {
return nil, err
}
if rsp.Success && validator != nil {
cfg := repo.Config()
if validationErr := validator.VerifyAgainstExistingRepositories(ctx, cfg); validationErr != nil {
rsp = &provisioning.TestResults{
Success: false,
Code: http.StatusUnprocessableEntity,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseType(validationErr.Type),
Field: validationErr.Field,
Detail: validationErr.Detail,
}},
}
}
}
return rsp, nil
}
func ValidateRepository(repo Repository) field.ErrorList {
// ValidateRepository solely does configuration checks on the repository object. It does not run a health check or compare against existing repositories.
func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorList {
list := repo.Validate()
cfg := repo.Config()
@@ -70,9 +40,22 @@ func ValidateRepository(repo Repository) field.ErrorList {
list = append(list, field.Required(field.NewPath("spec", "title"), "a repository title must be given"))
}
if cfg.Spec.Sync.Enabled && cfg.Spec.Sync.Target == "" {
list = append(list, field.Required(field.NewPath("spec", "sync", "target"),
"The target type is required when sync is enabled"))
if cfg.Spec.Sync.Enabled {
if cfg.Spec.Sync.Target == "" {
list = append(list, field.Required(field.NewPath("spec", "sync", "target"),
"The target type is required when sync is enabled"))
} else if !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) {
list = append(list,
field.Invalid(
field.NewPath("spec", "target"),
cfg.Spec.Sync.Target,
"sync target is not supported"))
}
if cfg.Spec.Sync.IntervalSeconds < int64(v.minSyncInterval.Seconds()) {
list = append(list, field.Invalid(field.NewPath("spec", "sync", "intervalSeconds"),
cfg.Spec.Sync.IntervalSeconds, fmt.Sprintf("Interval must be at least %d seconds", int64(v.minSyncInterval.Seconds()))))
}
}
// Reserved names (for now)
@@ -131,6 +114,13 @@ func ValidateRepository(repo Repository) field.ErrorList {
}
}
if !v.allowImageRendering && cfg.Spec.GitHub != nil && cfg.Spec.GitHub.GenerateDashboardPreviews {
list = append(list,
field.Invalid(field.NewPath("spec", "generateDashboardPreviews"),
cfg.Spec.GitHub.GenerateDashboardPreviews,
"image rendering is not enabled"))
}
return list
}
@@ -1,12 +1,9 @@
package repository
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
@@ -74,6 +71,28 @@ func TestValidateRepository(t *testing.T) {
require.Contains(t, errors.ToAggregate().Error(), "spec.sync.target: Required value")
},
},
{
name: "sync interval too low",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
Sync: provisioning.SyncOptions{
Enabled: true,
Target: provisioning.SyncTargetTypeFolder,
IntervalSeconds: 5,
},
},
})
m.On("Validate").Return(field.ErrorList{})
return m
}(),
expectedErrs: 1,
validateError: func(t *testing.T, errors field.ErrorList) {
require.Contains(t, errors.ToAggregate().Error(), "spec.sync.intervalSeconds: Invalid value")
},
},
{
name: "reserved name",
repository: func() *MockRepository {
@@ -132,6 +151,27 @@ func TestValidateRepository(t *testing.T) {
require.Contains(t, errors.ToAggregate().Error(), "spec.github: Invalid value")
},
},
{
name: "github enabled when image rendering is not allowed",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
GenerateDashboardPreviews: true,
},
},
})
m.On("Validate").Return(field.ErrorList{})
return m
}(),
expectedErrs: 1,
validateError: func(t *testing.T, errors field.ErrorList) {
require.Contains(t, errors.ToAggregate().Error(), "spec.generateDashboardPreviews: Invalid value")
},
},
{
name: "mismatched git config",
repository: func() *MockRepository {
@@ -163,16 +203,18 @@ func TestValidateRepository(t *testing.T) {
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 5,
Target: provisioning.SyncTargetTypeInstance,
},
},
})
m.On("Validate").Return(field.ErrorList{})
return m
}(),
expectedErrs: 3,
expectedErrs: 4,
// 1. missing title
// 2. sync target missing
// 3. reserved name
// 4. sync target not supported
},
{
name: "branch workflow for non-github repository",
@@ -258,9 +300,10 @@ func TestValidateRepository(t *testing.T) {
},
}
validator := NewValidator(10*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder}, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errors := ValidateRepository(tt.repository)
errors := validator.ValidateRepository(tt.repository)
require.Len(t, errors, tt.expectedErrs)
if tt.validateError != nil {
tt.validateError(t, errors)
@@ -268,189 +311,3 @@ func TestValidateRepository(t *testing.T) {
})
}
}
func TestTestRepository(t *testing.T) {
tests := []struct {
name string
repository *MockRepository
expectedCode int
expectedErrs []provisioning.ErrorDetails
expectedError error
}{
{
name: "validation fails",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
// Missing required title
},
})
m.On("Validate").Return(field.ErrorList{})
return m
}(),
expectedCode: http.StatusUnprocessableEntity,
expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueRequired,
Field: "spec.title",
Detail: "a repository title must be given",
}},
},
{
name: "test passes",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
m.On("Validate").Return(field.ErrorList{})
m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil)
return m
}(),
expectedCode: http.StatusOK,
expectedErrs: nil,
},
{
name: "test fails with error",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
m.On("Validate").Return(field.ErrorList{})
m.On("Test", mock.Anything).Return(nil, fmt.Errorf("test error"))
return m
}(),
expectedError: fmt.Errorf("test error"),
},
{
name: "test fails with results",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
m.On("Validate").Return(field.ErrorList{})
m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
}, nil)
return m
}(),
expectedCode: http.StatusBadRequest,
expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, err := TestRepository(context.Background(), tt.repository)
if tt.expectedError != nil {
require.Error(t, err)
require.Equal(t, tt.expectedError.Error(), err.Error())
return
}
require.NoError(t, err)
require.NotNil(t, results)
require.Equal(t, tt.expectedCode, results.Code)
if tt.expectedErrs != nil {
require.Equal(t, tt.expectedErrs, results.Errors)
require.False(t, results.Success)
} else {
require.True(t, results.Success)
require.Empty(t, results.Errors)
}
})
}
}
func TestTester_TestRepository(t *testing.T) {
repository := NewMockRepository(t)
repository.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
repository.On("Validate").Return(field.ErrorList{})
repository.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil)
results, err := TestRepository(context.Background(), repository)
require.NoError(t, err)
require.NotNil(t, results)
require.Equal(t, http.StatusOK, results.Code)
require.True(t, results.Success)
}
func TestFromFieldError(t *testing.T) {
tests := []struct {
name string
fieldError *field.Error
expectedCode int
expectedField string
expectedType metav1.CauseType
expectedDetail string
}{
{
name: "required field error",
fieldError: &field.Error{
Type: field.ErrorTypeRequired,
Field: "spec.title",
Detail: "a repository title must be given",
},
expectedCode: http.StatusBadRequest,
expectedField: "spec.title",
expectedType: metav1.CauseTypeFieldValueRequired,
expectedDetail: "a repository title must be given",
},
{
name: "not supported field error",
fieldError: &field.Error{
Type: field.ErrorTypeNotSupported,
Field: "spec.workflow",
Detail: "branch is only supported on git repositories",
},
expectedCode: http.StatusBadRequest,
expectedField: "spec.workflow",
expectedType: metav1.CauseTypeFieldValueNotSupported,
expectedDetail: "branch is only supported on git repositories",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FromFieldError(tt.fieldError)
require.NotNil(t, result)
require.Equal(t, tt.expectedCode, result.Code)
require.False(t, result.Success)
require.Len(t, result.Errors, 1)
errorDetail := result.Errors[0]
require.Equal(t, tt.expectedField, errorDetail.Field)
require.Equal(t, tt.expectedType, errorDetail.Type)
require.Equal(t, tt.expectedDetail, errorDetail.Detail)
})
}
}