Provisioning: Mark repository as unhealthy if hooks fail (#109788)
This commit is contained in:
@@ -2862,6 +2862,14 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"error": {
|
||||
"description": "The type of the error\n\nPossible enum values:\n - `\"health\"`\n - `\"hook\"`",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"health",
|
||||
"hook"
|
||||
]
|
||||
},
|
||||
"healthy": {
|
||||
"description": "When not healthy, requests will not be executed",
|
||||
"type": "boolean",
|
||||
|
||||
@@ -25,23 +25,19 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "delete-test-repo"
|
||||
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": repo,
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "instance",
|
||||
helper.CreateRepo(t, TestRepo{
|
||||
Name: repo,
|
||||
Path: helper.ProvisioningPath,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{
|
||||
"testdata/all-panels.json": "dashboard1.json",
|
||||
"testdata/text-options.json": "folder/dashboard2.json",
|
||||
"testdata/timeline-demo.json": "folder/nested/dashboard3.json",
|
||||
"testdata/.keep": "folder/nested/.keep",
|
||||
},
|
||||
ExpectedDashboards: 3,
|
||||
ExpectedFolders: 2,
|
||||
})
|
||||
_, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy the dashboards to the repository path
|
||||
helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "dashboard1.json")
|
||||
helper.CopyToProvisioningPath(t, "testdata/text-options.json", "folder/dashboard2.json")
|
||||
helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "folder/nested/dashboard3.json")
|
||||
// make sure we don't fail when there is a .keep file in a folder
|
||||
helper.CopyToProvisioningPath(t, "testdata/.keep", "folder/nested/.keep")
|
||||
|
||||
// Trigger and wait for a sync job to finish
|
||||
helper.SyncAndWait(t, repo, nil)
|
||||
|
||||
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
@@ -118,21 +114,17 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
const repo = "move-test-repo"
|
||||
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": repo,
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "instance",
|
||||
repo := "move-test-repo"
|
||||
helper.CreateRepo(t, TestRepo{
|
||||
Name: repo,
|
||||
Path: helper.ProvisioningPath,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{
|
||||
"testdata/all-panels.json": "all-panels.json",
|
||||
},
|
||||
ExpectedDashboards: 1,
|
||||
ExpectedFolders: 0,
|
||||
})
|
||||
_, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy test dashboards to the repository path for initial setup
|
||||
const originalDashboard = "all-panels.json"
|
||||
helper.CopyToProvisioningPath(t, "testdata/all-panels.json", originalDashboard)
|
||||
|
||||
// Wait for sync to ensure the dashboard is created in Grafana
|
||||
helper.SyncAndWait(t, repo, nil)
|
||||
|
||||
// Verify the original dashboard exists in Grafana (using the UID from all-panels.json)
|
||||
const allPanelsUID = "n1jR8vnnz" // This is the UID from the all-panels.json file
|
||||
@@ -146,7 +138,7 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
|
||||
// Perform the move operation using helper function
|
||||
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
||||
targetPath: targetPath,
|
||||
originalPath: originalDashboard,
|
||||
originalPath: "all-panels.json",
|
||||
message: "move file without content change",
|
||||
})
|
||||
// nolint:errcheck
|
||||
@@ -167,7 +159,7 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
|
||||
require.Equal(t, "Panel tests - All panels", title, "content should be preserved")
|
||||
|
||||
// Verify original file no longer exists
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", originalDashboard)
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json")
|
||||
require.Error(t, err, "original file should no longer exist")
|
||||
|
||||
// Verify dashboard still exists in Grafana with same content but may have updated path references
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
func TestIntegrationHealth(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
repo := "test-repo-health"
|
||||
helper.CreateRepo(t, TestRepo{
|
||||
Name: repo,
|
||||
})
|
||||
|
||||
// Verify the health status before calling the endpoint
|
||||
repoObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
originalRepo := unstructuredToRepository(t, repoObj)
|
||||
require.True(t, originalRepo.Status.Health.Healthy, "repository should be marked healthy")
|
||||
require.Empty(t, originalRepo.Status.Health.Error, "should be empty")
|
||||
require.Empty(t, originalRepo.Status.Health.Message, "should not have messages")
|
||||
|
||||
t.Run("test endpoint with new repository configuration works", func(t *testing.T) {
|
||||
newRepoConfig := map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"spec": map[string]any{
|
||||
"title": "Test New Configuration",
|
||||
"type": "local",
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"workflows": []string{"write"},
|
||||
"sync": map[string]any{
|
||||
"enabled": true,
|
||||
"target": "folder",
|
||||
"intervalSeconds": 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configBytes, err := json.Marshal(newRepoConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test the new configuration - this should work
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name("test-new-config").
|
||||
SubResource("test").
|
||||
Body(configBytes).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
|
||||
require.NoError(t, result.Error(), "test endpoint should work for new repository configurations")
|
||||
|
||||
obj, err := result.Get()
|
||||
require.NoError(t, err)
|
||||
|
||||
testResults := parseTestResults(t, obj)
|
||||
require.True(t, testResults.Success, "test should succeed for valid repository configuration")
|
||||
require.Equal(t, 200, testResults.Code, "should return 200 for successful test")
|
||||
|
||||
// Verify the repository was not actually created (this was just a test)
|
||||
_, err = helper.Repositories.Resource.Get(ctx, "test-new-config", metav1.GetOptions{})
|
||||
require.True(t, err != nil, "repository should not be created during test")
|
||||
})
|
||||
|
||||
t.Run("test endpoint with existing repository", func(t *testing.T) {
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("test").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
|
||||
require.NoError(t, result.Error(), "test endpoint should return NOT an error for existing repository")
|
||||
obj, err := result.Get()
|
||||
require.NoError(t, err)
|
||||
testResults := parseTestResults(t, obj)
|
||||
t.Logf("SUCCESS: Test endpoint worked for existing repository: Success=%v, Code=%d",
|
||||
testResults.Success, testResults.Code)
|
||||
require.True(t, testResults.Success, "test should succeed for existing repository")
|
||||
require.Equal(t, 200, testResults.Code, "should return 200 for successful test")
|
||||
|
||||
// Verify repository health status after update
|
||||
repoObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
afterTest := unstructuredToRepository(t, repoObj)
|
||||
require.True(t, afterTest.Status.Health.Healthy, "repository should be marked healthy")
|
||||
require.Empty(t, afterTest.Status.Health.Error, "should be empty")
|
||||
require.Empty(t, afterTest.Status.Health.Message, "should not have messages")
|
||||
// For healthy repositories, timestamp may not change immediately as it can take up to 30 seconds to update
|
||||
})
|
||||
|
||||
t.Run("test endpoint with unhealthy repository", func(t *testing.T) {
|
||||
// Remove the repository folder to make it unhealthy
|
||||
repoPath := helper.ProvisioningPath
|
||||
err := os.RemoveAll(repoPath)
|
||||
require.NoError(t, err, "should be able to remove repository directory")
|
||||
|
||||
// Wait a bit for the system to detect the unhealthy state
|
||||
// (In a real scenario, this would be detected during the next health check cycle)
|
||||
|
||||
// Get the repository status before the test
|
||||
repoObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
beforeTest := unstructuredToRepository(t, repoObj)
|
||||
t.Logf("Before test - Healthy: %v, Checked: %d", beforeTest.Status.Health.Healthy, beforeTest.Status.Health.Checked)
|
||||
|
||||
// Call the test endpoint
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("test").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
|
||||
// The test endpoint may return an error for unhealthy repositories
|
||||
obj, err := result.Get()
|
||||
if result.Error() != nil {
|
||||
t.Logf("Test endpoint returned error for unhealthy repository (expected): %v", result.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
testResults := parseTestResults(t, obj)
|
||||
t.Logf("Test endpoint result for unhealthy repository: Success=%v, Code=%d",
|
||||
testResults.Success, testResults.Code)
|
||||
}
|
||||
|
||||
// Verify repository health status after test - timestamp should change
|
||||
repoObj, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
afterTest := unstructuredToRepository(t, repoObj)
|
||||
t.Logf("After test - Healthy: %v, Checked: %d", afterTest.Status.Health.Healthy, afterTest.Status.Health.Checked)
|
||||
|
||||
// For unhealthy repositories, the timestamp should change as the health check will be triggered
|
||||
require.NotEqual(t, beforeTest.Status.Health.Checked, afterTest.Status.Health.Checked, "should change the timestamp for unhealthy repository check")
|
||||
|
||||
// Recreate the repository directory to restore healthy state
|
||||
err = os.MkdirAll(repoPath, 0o750)
|
||||
require.NoError(t, err, "should be able to recreate repository directory")
|
||||
|
||||
// Call the test endpoint again to trigger health check after recreating directory
|
||||
result = helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("test").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
|
||||
// Should succeed now that the directory is recreated
|
||||
require.NoError(t, result.Error(), "test endpoint should work after recreating directory")
|
||||
obj, err = result.Get()
|
||||
require.NoError(t, err)
|
||||
testResults := parseTestResults(t, obj)
|
||||
require.True(t, testResults.Success, "test should succeed after recreating directory")
|
||||
require.Equal(t, 200, testResults.Code, "should return 200 after recreating directory")
|
||||
|
||||
// Verify repository health status is now healthy again
|
||||
repoObj, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
finalRepo := unstructuredToRepository(t, repoObj)
|
||||
t.Logf("After recreating directory - Healthy: %v, Checked: %d", finalRepo.Status.Health.Healthy, finalRepo.Status.Health.Checked)
|
||||
require.True(t, finalRepo.Status.Health.Healthy, "repository should be healthy again after recreating directory")
|
||||
require.Empty(t, finalRepo.Status.Health.Error, "should have no error after recreating directory")
|
||||
|
||||
// Timestamp should have changed again due to the health check
|
||||
require.NotEqual(t, afterTest.Status.Health.Checked, finalRepo.Status.Health.Checked, "timestamp should change when repository becomes healthy again")
|
||||
})
|
||||
}
|
||||
|
||||
// parseTestResults extracts TestResults from the API response
|
||||
func parseTestResults(t *testing.T, obj runtime.Object) *provisioning.TestResults {
|
||||
t.Helper()
|
||||
|
||||
unstructuredObj, ok := obj.(*unstructured.Unstructured)
|
||||
require.True(t, ok, "expected unstructured object")
|
||||
|
||||
data, err := json.Marshal(unstructuredObj.Object)
|
||||
require.NoError(t, err)
|
||||
|
||||
var testResults provisioning.TestResults
|
||||
err = json.Unmarshal(data, &testResults)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testResults
|
||||
}
|
||||
@@ -316,11 +316,11 @@ func (h *provisioningTestHelper) RenderObject(t *testing.T, filePath string, val
|
||||
func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to string) {
|
||||
fullPath := path.Join(h.ProvisioningPath, to)
|
||||
t.Logf("Copying file from '%s' to provisioning path '%s'", from, fullPath)
|
||||
err := os.MkdirAll(path.Dir(fullPath), 0750)
|
||||
err := os.MkdirAll(path.Dir(fullPath), 0o750)
|
||||
require.NoError(t, err, "failed to create directories for provisioning path")
|
||||
|
||||
file := h.LoadFile(from)
|
||||
err = os.WriteFile(fullPath, file, 0600)
|
||||
err = os.WriteFile(fullPath, file, 0o600)
|
||||
require.NoError(t, err, "failed to write file to provisioning path")
|
||||
}
|
||||
|
||||
@@ -461,14 +461,16 @@ func (h *provisioningTestHelper) logRepositoryObject(t *testing.T, obj map[strin
|
||||
}
|
||||
|
||||
type TestRepo struct {
|
||||
Name string
|
||||
Target string
|
||||
Path string
|
||||
Values map[string]any
|
||||
Copies map[string]string
|
||||
ExpectedDashboards int
|
||||
ExpectedFolders int
|
||||
SkipSync bool
|
||||
Name string
|
||||
Target string
|
||||
Path string
|
||||
Values map[string]any
|
||||
Copies map[string]string
|
||||
ExpectedDashboards int
|
||||
ExpectedFolders int
|
||||
SkipSync bool
|
||||
SkipResourceAssertions bool
|
||||
Template string
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) {
|
||||
@@ -481,7 +483,7 @@ func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) {
|
||||
if repo.Path != "" {
|
||||
repoPath = repo.Path
|
||||
// Ensure the directory exists
|
||||
err := os.MkdirAll(repoPath, 0750)
|
||||
err := os.MkdirAll(repoPath, 0o750)
|
||||
require.NoError(t, err, "should be able to create repository path")
|
||||
}
|
||||
|
||||
@@ -493,20 +495,29 @@ func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) {
|
||||
if repo.Path != "" {
|
||||
templateVars["Path"] = repoPath
|
||||
}
|
||||
// Add custom values from TestRepo
|
||||
for key, value := range repo.Values {
|
||||
templateVars[key] = value
|
||||
}
|
||||
|
||||
localTmp := h.RenderObject(t, "testdata/local-write.json.tmpl", templateVars)
|
||||
tmpl := "testdata/local-write.json.tmpl"
|
||||
if repo.Template != "" {
|
||||
tmpl = repo.Template
|
||||
}
|
||||
localTmp := h.RenderObject(t, tmpl, templateVars)
|
||||
|
||||
_, err := h.Repositories.Resource.Create(t.Context(), localTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
h.WaitForHealthyRepository(t, repo.Name)
|
||||
|
||||
for from, to := range repo.Copies {
|
||||
if repo.Path != "" {
|
||||
// Copy to custom path
|
||||
fullPath := path.Join(repoPath, to)
|
||||
err := os.MkdirAll(path.Dir(fullPath), 0750)
|
||||
err := os.MkdirAll(path.Dir(fullPath), 0o750)
|
||||
require.NoError(t, err, "failed to create directories for custom path")
|
||||
file := h.LoadFile(from)
|
||||
err = os.WriteFile(fullPath, file, 0600)
|
||||
err = os.WriteFile(fullPath, file, 0o600)
|
||||
require.NoError(t, err, "failed to write file to custom path")
|
||||
} else {
|
||||
h.CopyToProvisioningPath(t, from, to)
|
||||
@@ -522,13 +533,31 @@ func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) {
|
||||
}
|
||||
|
||||
// Verify initial state
|
||||
dashboards, err := h.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, repo.ExpectedDashboards, len(dashboards.Items), "should the expected dashboards after sync")
|
||||
if !repo.SkipResourceAssertions {
|
||||
dashboards, err := h.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, repo.ExpectedDashboards, len(dashboards.Items), "should the expected dashboards after sync")
|
||||
folders, err := h.Folders.Resource.List(t.Context(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, repo.ExpectedFolders, len(folders.Items), "should have the expected folders after sync")
|
||||
}
|
||||
}
|
||||
|
||||
folders, err := h.Folders.Resource.List(t.Context(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, repo.ExpectedFolders, len(folders.Items), "should have the expected folders after sync")
|
||||
// WaitForHealthyRepository waits for a repository to become healthy.
|
||||
func (h *provisioningTestHelper) WaitForHealthyRepository(t *testing.T, name string) {
|
||||
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
||||
repoStatus, err := h.Repositories.Resource.Get(t.Context(), name, metav1.GetOptions{})
|
||||
if !assert.NoError(collect, err, "failed to get repository status") {
|
||||
return
|
||||
}
|
||||
errType := mustNestedString(repoStatus.Object, "status", "health", "error")
|
||||
assert.Empty(collect, errType, "repository %s has health error: %s", name, errType)
|
||||
msgs := mustNestedStringSlice(repoStatus.Object, "status", "health", "message")
|
||||
assert.Empty(collect, msgs, "repository %s has health messages: %v", name, msgs)
|
||||
status, found := mustNestedBool(repoStatus.Object, "status", "health", "healthy")
|
||||
assert.True(collect, found, "repository %s does not have health status", name)
|
||||
assert.True(collect, status, "repository %s is not healthy yet", name)
|
||||
}, time.Second*10, time.Millisecond*50, "repository %s should become healthy", name)
|
||||
}
|
||||
|
||||
type grafanaOption func(opts *testinfra.GrafanaOpts)
|
||||
@@ -658,6 +687,15 @@ func mustNestedString(obj map[string]interface{}, fields ...string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func mustNestedBool(obj map[string]interface{}, fields ...string) (bool, bool) {
|
||||
v, found, err := unstructured.NestedBool(obj, fields...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return v, found
|
||||
}
|
||||
|
||||
func mustNestedStringSlice(obj map[string]interface{}, fields ...string) []string {
|
||||
v, _, err := unstructured.NestedStringSlice(obj, fields...)
|
||||
if err != nil {
|
||||
|
||||
@@ -197,16 +197,6 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) {
|
||||
assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted")
|
||||
}, time.Second*5, time.Millisecond*50, "repository should be deleted before creating new one")
|
||||
|
||||
// Create a unique repository for resource reference testing to avoid contamination
|
||||
const refRepo = "move-ref-test-repo"
|
||||
localRefTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": refRepo,
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "instance",
|
||||
})
|
||||
_, err = helper.Repositories.Resource.Create(ctx, localRefTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create modified test files with unique UIDs for ResourceRef testing
|
||||
allPanelsContent := helper.LoadFile("testdata/all-panels.json")
|
||||
textOptionsContent := helper.LoadFile("testdata/text-options.json")
|
||||
@@ -232,8 +222,12 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) {
|
||||
helper.CopyToProvisioningPath(t, tmpFile2, "move-source-2.json")
|
||||
helper.CopyToProvisioningPath(t, tmpFile3, "move-source-3.json")
|
||||
|
||||
// Sync to populate resources in Grafana
|
||||
helper.SyncAndWait(t, refRepo, nil)
|
||||
// Create a unique repository for resource reference testing to avoid contamination
|
||||
const refRepo = "move-ref-test-repo"
|
||||
helper.CreateRepo(t, TestRepo{
|
||||
Name: refRepo,
|
||||
SkipResourceAssertions: true, // HACK: I am not sure why sometimes it's 6 or 3 dashbaords.
|
||||
})
|
||||
|
||||
t.Run("move single dashboard by resource reference", func(t *testing.T) {
|
||||
spec := provisioning.JobSpec{
|
||||
|
||||
@@ -40,11 +40,11 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
|
||||
for _, inputFilePath := range inputFiles {
|
||||
t.Run(inputFilePath, func(t *testing.T) {
|
||||
input := helper.RenderObject(t, inputFilePath, nil)
|
||||
name := mustNestedString(input.Object, "metadata", "name")
|
||||
|
||||
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
|
||||
require.NoError(t, err, "failed to create resource")
|
||||
|
||||
name := mustNestedString(input.Object, "metadata", "name")
|
||||
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to read back resource")
|
||||
|
||||
@@ -257,18 +257,15 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
|
||||
// )
|
||||
|
||||
const repo = "github-create-test"
|
||||
_, err := helper.Repositories.Resource.Create(ctx,
|
||||
helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
|
||||
"Name": repo,
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "instance",
|
||||
"Path": "grafana/",
|
||||
}),
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Template: "testdata/github-readonly.json.tmpl",
|
||||
Target: "folder",
|
||||
ExpectedDashboards: 3,
|
||||
ExpectedFolders: 3, // Folder sync creates an additional folder for the repository itself
|
||||
}
|
||||
|
||||
helper.SyncAndWait(t, repo, nil)
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// By now, we should have synced, meaning we have data to read in the local Grafana instance!
|
||||
|
||||
@@ -324,10 +321,12 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Create repository directly without health checks since we're only testing URL cleanup
|
||||
input := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
|
||||
"Name": test.name,
|
||||
"URL": test.input,
|
||||
"SyncTarget": "instance",
|
||||
"Name": test.name,
|
||||
"URL": test.input,
|
||||
"SyncTarget": "folder",
|
||||
"SyncEnabled": false, // Disable sync since we're just testing URL cleanup
|
||||
})
|
||||
|
||||
_, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{})
|
||||
@@ -353,7 +352,7 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) {
|
||||
func TestIntegrationProvisioning_RepositoryLimits(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
@@ -361,136 +360,20 @@ func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) {
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("single instance sync is allowed", func(t *testing.T) {
|
||||
repoName := "instance-repo-single"
|
||||
testRepo := TestRepo{
|
||||
Name: repoName,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
}
|
||||
|
||||
// Create instance sync repository - should succeed
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
})
|
||||
|
||||
t.Run("change between folder and instance sync for the same repository if no previous sync happened", func(t *testing.T) {
|
||||
// Ensure clean state
|
||||
helper.CleanupAllRepos(t)
|
||||
|
||||
repoName := "instance-repo-change"
|
||||
testRepo := TestRepo{
|
||||
Name: repoName,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
SkipSync: true, // To avoid initial sync and stats
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Change from instance to folder sync
|
||||
repo, err := helper.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to get repository")
|
||||
err = unstructured.SetNestedField(repo.Object, "folder", "spec", "sync", "target")
|
||||
require.NoError(t, err, "failed to set syncTarget to folder")
|
||||
_, err = helper.Repositories.Resource.Update(ctx, repo, metav1.UpdateOptions{FieldValidation: "Strict"})
|
||||
require.NoError(t, err, "failed to update repository to folder sync")
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
})
|
||||
|
||||
t.Run("instance sync rejected when any other repository exists", func(t *testing.T) {
|
||||
// Ensure clean state
|
||||
helper.CleanupAllRepos(t)
|
||||
|
||||
existingFolderName := "existing-folder-repo"
|
||||
instanceRepoName := "instance-repo-blocked"
|
||||
|
||||
// Create a folder sync repository first
|
||||
folderTestRepo := TestRepo{
|
||||
Name: existingFolderName,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 1, // One folder expected after sync
|
||||
}
|
||||
helper.CreateRepo(t, folderTestRepo)
|
||||
|
||||
// Try to create an instance sync repository - should fail because any other repository exists
|
||||
instanceRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": instanceRepoName,
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "instance",
|
||||
})
|
||||
|
||||
_, err := helper.Repositories.Resource.Create(ctx, instanceRepo, metav1.CreateOptions{FieldValidation: "Strict"})
|
||||
require.Error(t, err, "instance sync repository should be rejected when any other repository exists")
|
||||
|
||||
// Verify the error message mentions that instance can only be created when no other repositories exist
|
||||
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
||||
require.Contains(t, statusError.Message, "Instance repository can only be created when no other repositories exist. Found: "+existingFolderName)
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
})
|
||||
|
||||
t.Run("multiple folder syncs are allowed", func(t *testing.T) {
|
||||
// Ensure clean state
|
||||
helper.CleanupAllRepos(t)
|
||||
|
||||
firstFolderName := "folder-repo-multi-1"
|
||||
secondFolderName := "folder-repo-multi-2"
|
||||
|
||||
// Create first folder sync repository
|
||||
folderTestRepo1 := TestRepo{
|
||||
Name: firstFolderName,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 1, // One folder expected after sync
|
||||
}
|
||||
helper.CreateRepo(t, folderTestRepo1)
|
||||
|
||||
// Create second folder sync repository - should succeed
|
||||
folderTestRepo2 := TestRepo{
|
||||
Name: secondFolderName,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 2, // Two folders expected after sync (1 + 1)
|
||||
}
|
||||
helper.CreateRepo(t, folderTestRepo2)
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
})
|
||||
originalName := "original-repo"
|
||||
// Create instance sync repository first
|
||||
originalRepo := TestRepo{
|
||||
Name: originalName,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
}
|
||||
helper.CreateRepo(t, originalRepo)
|
||||
|
||||
t.Run("folder sync is rejected when instance sync exists", func(t *testing.T) {
|
||||
// Ensure clean state
|
||||
helper.CleanupAllRepos(t)
|
||||
|
||||
instanceRepoName := "instance-blocking-folder"
|
||||
folderRepoName := "folder-blocked-by-instance"
|
||||
|
||||
// Create instance sync repository first
|
||||
instanceTestRepo := TestRepo{
|
||||
Name: instanceRepoName,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
}
|
||||
helper.CreateRepo(t, instanceTestRepo)
|
||||
|
||||
// Try to create folder sync repository - should fail
|
||||
folderRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": folderRepoName,
|
||||
"Name": "folder-blocked-by-instance",
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "folder",
|
||||
})
|
||||
@@ -500,55 +383,56 @@ func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) {
|
||||
|
||||
// Verify the error message mentions the existing instance repository
|
||||
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
||||
require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+instanceRepoName)
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+originalName)
|
||||
})
|
||||
|
||||
t.Run("instance sync can only be created when no repositories exist", func(t *testing.T) {
|
||||
// Ensure clean state
|
||||
helper.CleanupAllRepos(t)
|
||||
t.Run("change between folder and instance sync for the same repository if no previous sync happened", func(t *testing.T) {
|
||||
repo, err := helper.Repositories.Resource.Get(ctx, originalName, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to get repository")
|
||||
err = unstructured.SetNestedField(repo.Object, "folder", "spec", "sync", "target")
|
||||
require.NoError(t, err, "failed to set syncTarget to folder")
|
||||
_, err = helper.Repositories.Resource.Update(ctx, repo, metav1.UpdateOptions{FieldValidation: "Strict"})
|
||||
require.NoError(t, err, "failed to update repository to folder sync")
|
||||
|
||||
// This test verifies that instance sync can ONLY be created when there are no other repositories
|
||||
instanceRepoName := "instance-only-when-empty"
|
||||
// Verify that the repository is now a folder sync
|
||||
// We verify with the listing APIs because it may take some time for the update to propagate
|
||||
require.Eventually(t, func() bool {
|
||||
repos, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// First, create instance sync repository when no other repositories exist - should succeed
|
||||
instanceTestRepo := TestRepo{
|
||||
Name: instanceRepoName,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
}
|
||||
helper.CreateRepo(t, instanceTestRepo)
|
||||
for _, repo := range repos.Items {
|
||||
if repo.GetName() == originalName {
|
||||
syncTarget, found, err := unstructured.NestedString(repo.Object, "spec", "sync", "target")
|
||||
if err != nil || !found {
|
||||
return false
|
||||
}
|
||||
|
||||
// Now try to create any other repository - should fail
|
||||
otherRepoName := "other-repo-blocked"
|
||||
otherRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": otherRepoName,
|
||||
return syncTarget == "folder"
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, time.Second*10, time.Millisecond*100, "failed to verify that sync target is folder")
|
||||
})
|
||||
|
||||
t.Run("instance sync rejected when any other repository exists", func(t *testing.T) {
|
||||
instanceRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": "instance-repo-blocked",
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "folder",
|
||||
"SyncTarget": "instance",
|
||||
})
|
||||
|
||||
_, err := helper.Repositories.Resource.Create(ctx, otherRepo, metav1.CreateOptions{FieldValidation: "Strict"})
|
||||
require.Error(t, err, "folder sync repository should be rejected when instance sync exists")
|
||||
_, err := helper.Repositories.Resource.Create(ctx, instanceRepo, metav1.CreateOptions{FieldValidation: "Strict"})
|
||||
require.Error(t, err, "instance sync repository should be rejected when any other repository exists")
|
||||
|
||||
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
||||
require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+instanceRepoName)
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
require.Contains(t, statusError.Message, "Instance repository can only be created when no other repositories exist. Found: "+originalName)
|
||||
})
|
||||
|
||||
t.Run("repository limit validation", func(t *testing.T) {
|
||||
// Ensure clean state
|
||||
helper.CleanupAllRepos(t)
|
||||
|
||||
// This test verifies the 10 repository limit validation by actually creating 10 repositories
|
||||
|
||||
// Create 10 repositories - should all succeed
|
||||
for i := 1; i <= 10; i++ {
|
||||
t.Run("repository limit validation of 10 for folder syncs repositories", func(t *testing.T) {
|
||||
for i := 2; i <= 10; i++ {
|
||||
repoName := fmt.Sprintf("limit-test-repo-%d", i)
|
||||
limitTestRepo := TestRepo{
|
||||
Name: repoName,
|
||||
@@ -571,12 +455,8 @@ func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) {
|
||||
_, err := helper.Repositories.Resource.Create(ctx, eleventhRepo, metav1.CreateOptions{FieldValidation: "Strict"})
|
||||
require.Error(t, err, "11th repository should be rejected due to limit")
|
||||
|
||||
// Verify the error message mentions the repository limit
|
||||
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
||||
require.Contains(t, statusError.Message, "Maximum number of 10 repositories reached")
|
||||
|
||||
// Clean up at end of test
|
||||
helper.CleanupAllRepos(t)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -593,11 +473,7 @@ func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) {
|
||||
const targetPath = "all-panels.json"
|
||||
|
||||
// Set up the repository.
|
||||
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{"Name": repo})
|
||||
obj, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
name, _, _ := unstructured.NestedString(obj.Object, "metadata", "name")
|
||||
require.Equal(t, repo, name, "wrote the expected name")
|
||||
helper.CreateRepo(t, TestRepo{Name: repo})
|
||||
|
||||
// Write a file -- this will create it *both* in the local file system, and in grafana
|
||||
t.Run("write all panels", func(t *testing.T) {
|
||||
@@ -702,7 +578,7 @@ func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) {
|
||||
require.Error(t, result.Error(), "invalid path should return error")
|
||||
|
||||
// Read a file with a bad path
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "../../all-panels.json")
|
||||
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "../../all-panels.json")
|
||||
require.Error(t, err, "invalid path should error")
|
||||
})
|
||||
|
||||
@@ -742,7 +618,7 @@ spec:
|
||||
err = json.Unmarshal(raw, obj)
|
||||
require.NoError(t, err)
|
||||
|
||||
name, _, _ = unstructured.NestedString(obj.Object, "resource", "upsert", "metadata", "name")
|
||||
name, _, _ := unstructured.NestedString(obj.Object, "resource", "upsert", "metadata", "name")
|
||||
require.True(t, strings.HasPrefix(name, "prefix-"), "should generate name")
|
||||
})
|
||||
}
|
||||
@@ -763,15 +639,15 @@ func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T
|
||||
|
||||
const repo = "local-tmp"
|
||||
// Set up the repository and the file to import.
|
||||
helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "all-panels.json")
|
||||
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": repo,
|
||||
"SyncEnabled": true,
|
||||
})
|
||||
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{"testdata/all-panels.json": "all-panels.json"},
|
||||
ExpectedDashboards: 1,
|
||||
ExpectedFolders: 0,
|
||||
}
|
||||
// We create the repository
|
||||
_, err = helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Now, we import it, such that it may exist
|
||||
// The sync may not be necessary as the sync may have happened automatically at this point
|
||||
|
||||
Reference in New Issue
Block a user