Files
grafana/pkg/tests/apis/provisioning/helper_test.go
Stephanie Hingtgen 163b9007a7 Provisioning: Fix flaky tests and race condition in folder existing check (#111209)
Provisioning: Fix some of the flakiness
2025-09-17 08:37:10 -05:00

960 lines
32 KiB
Go

package provisioning
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"testing"
"text/template"
"time"
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/rest"
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardsV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
const (
waitTimeoutDefault = 30 * time.Second
waitIntervalDefault = 100 * time.Millisecond
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
type provisioningTestHelper struct {
*apis.K8sTestHelper
ProvisioningPath string
Repositories *apis.K8sResourceClient
Jobs *apis.K8sResourceClient
Folders *apis.K8sResourceClient
DashboardsV0 *apis.K8sResourceClient
DashboardsV1 *apis.K8sResourceClient
DashboardsV2alpha1 *apis.K8sResourceClient
DashboardsV2beta1 *apis.K8sResourceClient
AdminREST *rest.RESTClient
EditorREST *rest.RESTClient
ViewerREST *rest.RESTClient
}
func (h *provisioningTestHelper) SyncAndWait(t *testing.T, repo string, options *provisioning.SyncJobOptions) {
t.Helper()
if options == nil {
options = &provisioning.SyncJobOptions{}
}
body := asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionPull,
Pull: options,
})
result := h.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(t.Context())
if apierrors.IsAlreadyExists(result.Error()) {
// Wait for all jobs to finish as we don't have the name.
h.AwaitJobs(t, repo)
return
}
obj, err := result.Get()
require.NoError(t, err, "expecting to be able to sync repository")
unstruct, ok := obj.(*unstructured.Unstructured)
require.True(t, ok, "expecting unstructured object, but got %T", obj)
name := unstruct.GetName()
require.NotEmpty(t, name, "expecting name to be set")
h.AwaitJobs(t, repo)
}
func (h *provisioningTestHelper) TriggerJobAndWaitForSuccess(t *testing.T, repo string, spec provisioning.JobSpec) {
t.Helper()
body := asJSON(spec)
result := h.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(t.Context())
if apierrors.IsAlreadyExists(result.Error()) {
// Wait for all jobs to finish as we don't have the name.
h.AwaitJobs(t, repo)
return
}
obj, err := result.Get()
require.NoError(t, err, "expecting to be able to sync repository")
unstruct, ok := obj.(*unstructured.Unstructured)
require.True(t, ok, "expecting unstructured object, but got %T", obj)
name := unstruct.GetName()
require.NotEmpty(t, name, "expecting name to be set")
h.AwaitJobSuccess(t, t.Context(), unstruct)
}
func (h *provisioningTestHelper) TriggerJobAndWaitForComplete(t *testing.T, repo string, spec provisioning.JobSpec) *unstructured.Unstructured {
t.Helper()
body := asJSON(spec)
result := h.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(t.Context())
if apierrors.IsAlreadyExists(result.Error()) {
// A job is already in-flight. Wait and return the latest historic job.
t.Logf("job already running for repo %q; waiting for it to complete", repo)
return h.AwaitLatestHistoricJob(t, repo)
}
obj, err := result.Get()
require.NoError(t, err, "expecting to be able to sync repository")
unstruct, ok := obj.(*unstructured.Unstructured)
require.True(t, ok, "expecting unstructured object, but got %T", obj)
name := unstruct.GetName()
require.NotEmpty(t, name, "expecting name to be set")
return h.AwaitJob(t, t.Context(), unstruct)
}
// AwaitLatestHistoricJob waits for the repo's queue to empty and returns the most recent historic job.
func (h *provisioningTestHelper) AwaitLatestHistoricJob(t *testing.T, repo string) *unstructured.Unstructured {
t.Helper()
// Wait until no active jobs for this repo
require.EventuallyWithT(t, func(collect *assert.CollectT) {
list, err := h.Jobs.Resource.List(context.Background(), metav1.ListOptions{})
if !assert.NoError(collect, err, "failed to list active jobs") {
return
}
for _, elem := range list.Items {
r, _, err := unstructured.NestedString(elem.Object, "spec", "repository")
if !assert.NoError(collect, err) {
return
}
if r == repo {
collect.Errorf("still have active job %q for repo %q", elem.GetName(), repo)
return
}
}
}, waitTimeoutDefault, waitIntervalDefault, "job queue must be empty before reading historic jobs")
// Fetch historic jobs and pick the newest by creationTimestamp
result, err := h.Repositories.Resource.Get(context.Background(), repo, metav1.GetOptions{}, "jobs")
require.NoError(t, err, "failed to list historic jobs")
list, err := result.ToList()
require.NoError(t, err, "results should be a list")
require.NotEmpty(t, list.Items, "expect at least one historic job")
latest := list.Items[0]
for i := 1; i < len(list.Items); i++ {
if list.Items[i].GetCreationTimestamp().After(latest.GetCreationTimestamp().Time) {
latest = list.Items[i]
}
}
return latest.DeepCopy()
}
func (h *provisioningTestHelper) AwaitJobSuccess(t *testing.T, ctx context.Context, job *unstructured.Unstructured) {
t.Helper()
job = h.AwaitJob(t, ctx, job)
lastErrors := mustNestedStringSlice(job.Object, "status", "errors")
lastState := mustNestedString(job.Object, "status", "state")
repo := job.GetLabels()[jobs.LabelRepository]
// Debug state if job failed
if len(lastErrors) > 0 || lastState != string(provisioning.JobStateSuccess) {
h.DebugState(t, repo, fmt.Sprintf("JOB FAILED: %s", job.GetName()))
}
require.Empty(t, lastErrors, "historic job '%s' has errors: %v", job.GetName(), lastErrors)
require.Equal(t, string(provisioning.JobStateSuccess), lastState,
"historic job '%s' was not successful", job.GetName())
}
func (h *provisioningTestHelper) AwaitJob(t *testing.T, ctx context.Context, job *unstructured.Unstructured) *unstructured.Unstructured {
t.Helper()
repo := job.GetLabels()[jobs.LabelRepository]
require.NotEmpty(t, repo)
var lastResult *unstructured.Unstructured
require.EventuallyWithT(t, func(collect *assert.CollectT) {
result, err := h.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{},
"jobs", string(job.GetUID()))
if !assert.False(collect, apierrors.IsNotFound(err)) {
collect.Errorf("job '%s' not found, still waiting for it to complete", job.GetName())
return
}
assert.NoError(collect, err, "failed to get job '%s' to be found", job.GetName())
if err != nil {
return
}
lastResult = result
}, waitTimeoutDefault, waitIntervalDefault)
require.NotNil(t, lastResult, "expected job result to be non-nil")
return lastResult
}
func (h *provisioningTestHelper) AwaitJobs(t *testing.T, repoName string) {
t.Helper()
// First, we wait for all current jobs for the repository to disappear (i.e. complete/fail).
j, err := h.Jobs.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err, "failed to list active jobs")
waitUntilComplete := map[string]bool{}
for _, item := range j.Items {
annotations := item.GetLabels()
if annotations[jobs.LabelRepository] == repoName {
waitUntilComplete[item.GetName()] = false
}
}
// if no active jobs for this repo, queue a pull job as a failsafe to try to ensure we are up to date as much as possible
if len(waitUntilComplete) == 0 {
body := asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionPull,
Pull: &provisioning.SyncJobOptions{},
})
h.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repoName).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(t.Context())
j, err = h.Jobs.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err, "failed to list active jobs")
for _, item := range j.Items {
annotations := item.GetLabels()
if annotations[jobs.LabelRepository] == repoName {
waitUntilComplete[item.GetName()] = false
}
}
}
require.EventuallyWithT(t, func(collect *assert.CollectT) {
for elem := range waitUntilComplete {
_, err := h.Jobs.Resource.Get(context.Background(), elem, metav1.GetOptions{})
switch {
case err == nil:
collect.Errorf("job(%s) for repo %s still exists", elem, repoName)
return
case apierrors.IsNotFound(err):
// yay
waitUntilComplete[elem] = true
default:
collect.Errorf("get(%s) for repo %s: %v", elem, repoName, err)
return
}
}
for elem, isComplete := range waitUntilComplete {
if !isComplete {
collect.Errorf("job(%s) for repo %s still exists", elem, repoName)
return
}
}
}, waitTimeoutDefault, waitIntervalDefault, "jobs for %s should finish. status: %v", repoName, waitUntilComplete)
// Then wait for them to be listed as historic jobs
var list *unstructured.UnstructuredList
require.EventuallyWithT(t, func(collect *assert.CollectT) {
result, err := h.Repositories.Resource.Get(context.Background(), repoName, metav1.GetOptions{}, "jobs")
if !assert.NoError(collect, err, "failed to list historic jobs") {
return
}
list, err = result.ToList()
if !assert.NoError(collect, err, "results should be a list") {
return
}
if !assert.NotEmpty(collect, list.Items, "expect at least one job") {
return
}
}, waitTimeoutDefault, waitIntervalDefault, "failed to list historic jobs")
// finally check that all the jobs are successful
successCount := 0
for _, elem := range list.Items {
require.Equal(t, repoName, elem.GetLabels()[jobs.LabelRepository], "should have repo label")
// historic jobs will have a suffix of -<hash>, trim that to see if the job is one we were waiting on
if _, ok := waitUntilComplete[getNameBeforeLastDash(elem.GetName())]; ok && (mustNestedString(elem.Object, "status", "state") != string(provisioning.JobStateError)) {
successCount++
}
}
// can be greater if a pull job was queued by a background task
require.GreaterOrEqual(t, successCount, len(waitUntilComplete), "should have all original jobs we were waiting on successful. got: %v. expected: %v", list.Items, waitUntilComplete)
}
func getNameBeforeLastDash(name string) string {
lastDashIndex := strings.LastIndex(name, "-")
if lastDashIndex == -1 {
return name
}
return name[:lastDashIndex]
}
// RenderObject reads the filePath and renders it as a template with the given values.
// The template is expected to be a YAML or JSON file.
//
// The values object is mutated to also include the helper property as `h`.
func (h *provisioningTestHelper) RenderObject(t *testing.T, filePath string, values map[string]any) *unstructured.Unstructured {
t.Helper()
file := h.LoadFile(filePath)
if values == nil {
values = make(map[string]any)
}
values["h"] = h
tmpl, err := template.New(filePath).Parse(string(file))
require.NoError(t, err, "failed to parse template")
var buf strings.Builder
err = tmpl.Execute(&buf, values)
require.NoError(t, err, "failed to execute template")
return h.LoadYAMLOrJSON(buf.String())
}
// CopyToProvisioningPath copies a file to the provisioning path.
// The from path is relative to test file's directory.
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), 0o750)
require.NoError(t, err, "failed to create directories for provisioning path")
file := h.LoadFile(from)
err = os.WriteFile(fullPath, file, 0o600)
require.NoError(t, err, "failed to write file to provisioning path")
}
// DebugState logs the current state of filesystem, repository, and Grafana resources for debugging
func (h *provisioningTestHelper) DebugState(t *testing.T, repo string, label string) {
t.Helper()
t.Logf("=== DEBUG STATE: %s ===", label)
ctx := context.Background()
// Log filesystem contents using existing tree function
printFileTree(t, h.ProvisioningPath)
// Log all repositories first
t.Logf("All repositories:")
repos, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
if err != nil {
t.Logf(" ERROR listing repositories: %v", err)
} else {
t.Logf(" Total repositories: %d", len(repos.Items))
for i, repository := range repos.Items {
t.Logf(" Repository %d: name=%s", i+1, repository.GetName())
}
}
// Log repository files for the specific repo
t.Logf("Repository '%s' files:", repo)
h.logRepositoryFiles(t, ctx, repo, " ")
// Log files for all other repositories too
if repos != nil && len(repos.Items) > 1 {
t.Logf("Files in other repositories:")
for _, repository := range repos.Items {
if repository.GetName() != repo {
t.Logf(" Repository '%s' files:", repository.GetName())
h.logRepositoryFiles(t, ctx, repository.GetName(), " ")
}
}
}
// Log Grafana dashboards
t.Logf("Grafana dashboards:")
dashboards, err := h.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
if err != nil {
t.Logf(" ERROR listing dashboards: %v", err)
} else {
t.Logf(" Total dashboards: %d", len(dashboards.Items))
for i, dashboard := range dashboards.Items {
t.Logf(" Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID())
}
}
// Log Grafana folders
t.Logf("Grafana folders:")
folders, err := h.Folders.Resource.List(ctx, metav1.ListOptions{})
if err != nil {
t.Logf(" ERROR listing folders: %v", err)
} else {
t.Logf(" Total folders: %d", len(folders.Items))
for i, folder := range folders.Items {
t.Logf(" Folder %d: name=%s", i+1, folder.GetName())
}
}
t.Logf("=== END DEBUG STATE ===")
}
// logRepositoryFiles logs repository file structure using the files API
func (h *provisioningTestHelper) logRepositoryFiles(t *testing.T, ctx context.Context, repoName string, prefix string) {
t.Helper()
// Try to list files at root level
files, err := h.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{}, "files")
if err != nil {
t.Logf("%sERROR getting repository files: %v", prefix, err)
return
}
// The API returns a structured response, we need to extract the actual file data
if files.Object != nil {
h.logRepositoryObject(t, files.Object, prefix, "")
} else {
t.Logf("%s(empty repository)", prefix)
}
}
// logRepositoryObject recursively logs repository file structure from API response
func (h *provisioningTestHelper) logRepositoryObject(t *testing.T, obj map[string]interface{}, prefix string, path string) {
t.Helper()
if obj == nil {
return
}
// Skip metadata fields and focus on actual content
for key, value := range obj {
// Skip Kubernetes metadata fields
if key == "kind" || key == "apiVersion" || key == "metadata" {
continue
}
// Calculate new path for nested objects
var newPath string
if path != "" {
newPath = path + "/" + key
} else {
newPath = key
}
switch v := value.(type) {
case map[string]interface{}:
t.Logf("%s├── %s/", prefix, key)
h.logRepositoryObject(t, v, prefix+" ", newPath)
case []interface{}:
// Handle lists (like items array)
if key == "items" && len(v) > 0 {
t.Logf("%s%d items:", prefix, len(v))
for i, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
// Try to get the actual file path from the item
if pathVal, exists := itemMap["path"]; exists {
t.Logf("%s├── %v", prefix, pathVal)
} else {
t.Logf("%s├── item %d:", prefix, i+1)
}
h.logRepositoryObject(t, itemMap, prefix+" ", newPath)
}
}
}
default:
// This could be file content or metadata
// Skip common metadata fields that are not useful for debugging
if key != "kind" && key != "apiVersion" && key != "path" && key != "size" && key != "hash" {
t.Logf("%s├── %s: %v", prefix, key, value)
}
}
}
}
// validateManagedDashboardsFolderMetadata validates the folder metadata
// of the managed dashboards.
// If folder is nested, folder annotations should not be empty.
// Also checks that the managerId property exists.
func (h *provisioningTestHelper) validateManagedDashboardsFolderMetadata(t *testing.T,
ctx context.Context, repoName string, dashboards []unstructured.Unstructured) {
t.Helper()
// Check if folder is nested or not.
// If not, folder annotations should be empty as we have an "instance" sync target
for _, d := range dashboards {
sourcePath, _, _ := unstructured.NestedString(d.Object, "metadata", "annotations", "grafana.app/sourcePath")
isNested := strings.Contains(sourcePath, "/")
folder, found, _ := unstructured.NestedString(d.Object, "metadata", "annotations", "grafana.app/folder")
if isNested {
require.True(t, found, "dashboard should have a folder annotation")
require.NotEmpty(t, folder, "dashboard should be in a non-empty folder")
} else {
require.False(t, found, "dashboard should not have a folder annotation")
}
managerID, _, _ := unstructured.NestedString(d.Object, "metadata", "annotations", "grafana.app/managerId")
// require.Equal(t, repoName, managerID, "dashboard should be managed by gitsync repo")
require.Equal(t, repoName, managerID, "dashboard should be managed by gitsync repo")
}
}
type TestRepo struct {
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) {
if repo.Target == "" {
repo.Target = "instance"
}
// Use custom path if provided, otherwise use default provisioning path
repoPath := h.ProvisioningPath
if repo.Path != "" {
repoPath = repo.Path
// Ensure the directory exists
err := os.MkdirAll(repoPath, 0o750)
require.NoError(t, err, "should be able to create repository path")
}
templateVars := map[string]any{
"Name": repo.Name,
"SyncEnabled": !repo.SkipSync,
"SyncTarget": repo.Target,
}
if repo.Path != "" {
templateVars["Path"] = repoPath
}
// Add custom values from TestRepo
for key, value := range repo.Values {
templateVars[key] = value
}
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), 0o750)
require.NoError(t, err, "failed to create directories for custom path")
file := h.LoadFile(from)
err = os.WriteFile(fullPath, file, 0o600)
require.NoError(t, err, "failed to write file to custom path")
} else {
h.CopyToProvisioningPath(t, from, to)
}
}
if !repo.SkipSync {
// Trigger and wait for initial sync to populate resources
h.SyncAndWait(t, repo.Name, nil)
h.DebugState(t, repo.Name, "AFTER INITIAL SYNC")
} else {
h.DebugState(t, repo.Name, "AFTER REPO CREATION")
}
// Verify initial state
if !repo.SkipResourceAssertions {
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dashboards, err := h.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
if err != nil {
collect.Errorf("could not list dashboards error: %s", err.Error())
return
}
if len(dashboards.Items) != repo.ExpectedDashboards {
collect.Errorf("should have the expected dashboards after sync. got: %d. expected: %d", len(dashboards.Items), repo.ExpectedDashboards)
return
}
folders, err := h.Folders.Resource.List(t.Context(), metav1.ListOptions{})
if err != nil {
collect.Errorf("could not list folders: error: %s", err.Error())
return
}
if len(folders.Items) != repo.ExpectedFolders {
collect.Errorf("should have the expected folders after sync. got: %d. expected: %d", len(folders.Items), repo.ExpectedFolders)
return
}
assert.Len(collect, dashboards.Items, repo.ExpectedDashboards)
assert.Len(collect, folders.Items, repo.ExpectedFolders)
}, waitTimeoutDefault, waitIntervalDefault, "should have the expected dashboards and 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)
}, waitTimeoutDefault, waitIntervalDefault, "repository %s should become healthy", name)
}
type grafanaOption func(opts *testinfra.GrafanaOpts)
// Useful for debugging a test in development.
//
//lint:ignore U1000 This is used when needed while debugging.
//nolint:golint,unused
func withLogs(opts *testinfra.GrafanaOpts) {
opts.EnableLog = true
}
func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper {
provisioningPath := t.TempDir()
opts := testinfra.GrafanaOpts{
EnableFeatureToggles: []string{
featuremgmt.FlagProvisioning,
},
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: grafanarest.Mode5,
},
"folders.folder.grafana.app": {
DualWriterMode: grafanarest.Mode5,
},
},
PermittedProvisioningPaths: ".|" + provisioningPath,
}
for _, o := range options {
o(&opts)
}
helper := apis.NewK8sTestHelper(t, opts)
// FIXME: keeping this line here to keep the dependency around until we have tests which use this again.
helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient()
repositories := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: provisioning.RepositoryResourceInfo.GroupVersionResource(),
})
jobs := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: provisioning.JobResourceInfo.GroupVersionResource(),
})
folders := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: folder.FolderResourceInfo.GroupVersionResource(),
})
dashboardsV0 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: dashboardV0.DashboardResourceInfo.GroupVersionResource(),
})
dashboardsV1 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
})
dashboardsV2alpha1 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: dashboardsV2alpha1.DashboardResourceInfo.GroupVersionResource(),
})
dashboardsV2beta1 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: dashboardsV2beta1.DashboardResourceInfo.GroupVersionResource(),
})
// Repo client, but less guard rails. Useful for subresources. We'll need this later...
gv := &schema.GroupVersion{Group: "provisioning.grafana.app", Version: "v0alpha1"}
adminClient := helper.Org1.Admin.RESTClient(t, gv)
editorClient := helper.Org1.Editor.RESTClient(t, gv)
viewerClient := helper.Org1.Viewer.RESTClient(t, gv)
deleteAll := func(client *apis.K8sResourceClient) error {
ctx := context.Background()
list, err := client.Resource.List(ctx, metav1.ListOptions{})
if err != nil {
return err
}
for _, resource := range list.Items {
if err := client.Resource.Delete(ctx, resource.GetName(), metav1.DeleteOptions{}); err != nil {
return err
}
}
return nil
}
require.NoError(t, deleteAll(dashboardsV1), "deleting all dashboards") // v0+v1+v2
require.NoError(t, deleteAll(folders), "deleting all folders")
require.NoError(t, deleteAll(repositories), "deleting all repositories")
return &provisioningTestHelper{
ProvisioningPath: provisioningPath,
K8sTestHelper: helper,
Repositories: repositories,
AdminREST: adminClient,
EditorREST: editorClient,
ViewerREST: viewerClient,
Jobs: jobs,
Folders: folders,
DashboardsV0: dashboardsV0,
DashboardsV1: dashboardsV1,
DashboardsV2alpha1: dashboardsV2alpha1,
DashboardsV2beta1: dashboardsV2beta1,
}
}
func mustNestedString(obj map[string]interface{}, fields ...string) string {
v, _, err := unstructured.NestedString(obj, fields...)
if err != nil {
panic(err)
}
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 {
panic(err)
}
return v
}
func asJSON(obj any) []byte {
jj, _ := json.Marshal(obj)
return jj
}
func unstructuredToRepository(t *testing.T, obj *unstructured.Unstructured) *provisioning.Repository {
bytes, err := obj.MarshalJSON()
require.NoError(t, err)
repo := &provisioning.Repository{}
err = json.Unmarshal(bytes, repo)
require.NoError(t, err)
return repo
}
// postFilesRequest performs a direct HTTP POST request to the files API.
// This bypasses Kubernetes REST client limitations with '/' characters in subresource names.
type filesPostOptions struct {
targetPath string // The target file/directory path
originalPath string // Source path for move operations (optional)
message string // Commit message (optional)
body string // Request body content (optional)
ref string // Git ref/branch (optional)
}
func (h *provisioningTestHelper) postFilesRequest(t *testing.T, repo string, opts filesPostOptions) *http.Response {
addr := h.GetEnv().Server.HTTPServer.Listener.Addr().String()
baseUrl := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/%s",
addr, repo, opts.targetPath)
// Build the URL with proper query parameter encoding
parsedUrl, err := url.Parse(baseUrl)
require.NoError(t, err)
params := parsedUrl.Query()
if opts.originalPath != "" {
params.Set("originalPath", opts.originalPath)
}
if opts.message != "" {
params.Set("message", opts.message)
}
if opts.ref != "" {
params.Set("ref", opts.ref)
}
parsedUrl.RawQuery = params.Encode()
req, err := http.NewRequest(http.MethodPost, parsedUrl.String(), strings.NewReader(opts.body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
// printFileTree prints the directory structure as a tree for debugging purposes
func printFileTree(t *testing.T, rootPath string) {
t.Helper()
t.Logf("File tree for %s:", rootPath)
err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
depth := strings.Count(relPath, string(filepath.Separator))
indent := strings.Repeat(" ", depth)
if d.IsDir() {
t.Logf("%s├── %s/", indent, d.Name())
} else {
info, err := d.Info()
if err != nil {
t.Logf("%s├── %s (error reading info)", indent, d.Name())
} else {
t.Logf("%s├── %s (%d bytes)", indent, d.Name(), info.Size())
}
}
return nil
})
if err != nil {
t.Logf("Error walking directory: %v", err)
}
}
// Helper function to count files in a directory recursively
func countFilesInDir(rootPath string) (int, error) {
count := 0
err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
count++
}
return nil
})
return count, err
}
// CleanupAllRepos deletes all repositories and waits for them to be fully removed
func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) {
t.Helper()
ctx := context.Background()
// First, get all repositories that exist
list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
if err != nil || len(list.Items) == 0 {
return // Nothing to clean up
}
// Wait for any active jobs to complete before deleting repositories
require.EventuallyWithT(t, func(collect *assert.CollectT) {
activeJobs, err := h.Jobs.Resource.List(ctx, metav1.ListOptions{})
if !assert.NoError(collect, err, "failed to list active jobs") {
return
}
assert.Equal(collect, 0, len(activeJobs.Items), "all active jobs should complete before cleanup")
}, waitTimeoutDefault, waitIntervalDefault, "active jobs should complete before cleanup")
// Now delete all repositories with retries
require.EventuallyWithT(t, func(collect *assert.CollectT) {
list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
if !assert.NoError(collect, err) {
return
}
for _, repo := range list.Items {
err := h.Repositories.Resource.Delete(ctx, repo.GetName(), metav1.DeleteOptions{})
// Don't fail if already deleted (404 is OK)
if err != nil {
assert.True(collect, apierrors.IsNotFound(err), "Should be able to delete repository %s (or it should already be deleted)", repo.GetName())
}
}
}, waitTimeoutDefault, waitIntervalDefault, "should be able to delete all repositories")
// Then wait for repositories to be fully deleted to ensure clean state
require.EventuallyWithT(t, func(collect *assert.CollectT) {
list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
if !assert.NoError(collect, err) {
return
}
assert.Equal(collect, 0, len(list.Items), "repositories should be cleaned up")
}, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests")
}