From 61d137992b84759732e5aaa7f20bb6a172e09ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 21 Aug 2025 10:32:23 +0200 Subject: [PATCH] Provisioning: Mark repository as unhealthy if hooks fail (#109788) --- .../pkg/apis/provisioning/v0alpha1/types.go | 12 + .../v0alpha1/zz_generated.openapi.go | 8 + .../provisioning/v0alpha1/healthstatus.go | 19 +- .../apis/provisioning/controller/health.go | 217 +++++++ .../provisioning/controller/health_test.go | 592 ++++++++++++++++++ .../controller/mocks/RepositoryTester.go | 62 ++ .../controller/mocks/StatusPatcher.go | 55 ++ .../provisioning/controller/repository.go | 155 ++--- pkg/registry/apis/provisioning/files.go | 69 +- pkg/registry/apis/provisioning/history.go | 2 +- pkg/registry/apis/provisioning/jobs.go | 23 +- pkg/registry/apis/provisioning/refs.go | 2 +- pkg/registry/apis/provisioning/register.go | 64 +- pkg/registry/apis/provisioning/test.go | 115 ++-- .../apis/provisioning/webhooks/register.go | 29 +- .../provisioning.grafana.app-v0alpha1.json | 8 + pkg/tests/apis/provisioning/files_test.go | 56 +- pkg/tests/apis/provisioning/health_test.go | 204 ++++++ pkg/tests/apis/provisioning/helper_test.go | 78 ++- pkg/tests/apis/provisioning/movejob_test.go | 18 +- .../apis/provisioning/repository_test.go | 274 +++----- .../provisioning/v0alpha1/endpoints.gen.ts | 6 + 22 files changed, 1578 insertions(+), 490 deletions(-) create mode 100644 pkg/registry/apis/provisioning/controller/health.go create mode 100644 pkg/registry/apis/provisioning/controller/health_test.go create mode 100644 pkg/registry/apis/provisioning/controller/mocks/RepositoryTester.go create mode 100644 pkg/registry/apis/provisioning/controller/mocks/StatusPatcher.go create mode 100644 pkg/tests/apis/provisioning/health_test.go diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go index dd678117d9a..bc673959164 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go @@ -242,10 +242,22 @@ type RepositoryStatus struct { Webhook *WebhookStatus `json:"webhook"` } +// HealthFailureType represents different types of repository failures +// +enum +type HealthFailureType string + +const ( + HealthFailureHook HealthFailureType = "hook" + HealthFailureHealth HealthFailureType = "health" +) + type HealthStatus struct { // When not healthy, requests will not be executed Healthy bool `json:"healthy"` + // The type of the error + Error HealthFailureType `json:"error,omitempty"` + // When the health was checked last time Checked int64 `json:"checked,omitempty"` diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index a1835c624ce..99244e9d5fc 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -571,6 +571,14 @@ func schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref common.ReferenceCall Format: "", }, }, + "error": { + SchemaProps: spec.SchemaProps{ + Description: "The type of the error\n\nPossible enum values:\n - `\"health\"`\n - `\"hook\"`", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"health", "hook"}, + }, + }, "checked": { SchemaProps: spec.SchemaProps{ Description: "When the health was checked last time", diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/healthstatus.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/healthstatus.go index 6d3b33cb318..813d6649458 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/healthstatus.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/healthstatus.go @@ -4,12 +4,17 @@ package v0alpha1 +import ( + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + // HealthStatusApplyConfiguration represents a declarative configuration of the HealthStatus type for use // with apply. type HealthStatusApplyConfiguration struct { - Healthy *bool `json:"healthy,omitempty"` - Checked *int64 `json:"checked,omitempty"` - Message []string `json:"message,omitempty"` + Healthy *bool `json:"healthy,omitempty"` + Error *provisioningv0alpha1.HealthFailureType `json:"error,omitempty"` + Checked *int64 `json:"checked,omitempty"` + Message []string `json:"message,omitempty"` } // HealthStatusApplyConfiguration constructs a declarative configuration of the HealthStatus type for use with @@ -26,6 +31,14 @@ func (b *HealthStatusApplyConfiguration) WithHealthy(value bool) *HealthStatusAp return b } +// WithError sets the Error field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Error field is set to the value of the last call. +func (b *HealthStatusApplyConfiguration) WithError(value provisioningv0alpha1.HealthFailureType) *HealthStatusApplyConfiguration { + b.Error = &value + return b +} + // WithChecked sets the Checked field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Checked field is set to the value of the last call. diff --git a/pkg/registry/apis/provisioning/controller/health.go b/pkg/registry/apis/provisioning/controller/health.go new file mode 100644 index 00000000000..d52faece56b --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/health.go @@ -0,0 +1,217 @@ +package controller + +import ( + "context" + "fmt" + "time" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" +) + +// StatusPatcher defines the interface for updating repository status +// +//go:generate mockery --name=StatusPatcher +type StatusPatcher interface { + Patch(ctx context.Context, repo *provisioning.Repository, patchOperations ...map[string]interface{}) error +} + +// HealthChecker provides unified health checking for repositories +type HealthChecker struct { + tester RepositoryTester + statusPatcher StatusPatcher +} + +// RepositoryTester defines the interface for testing repository connectivity +// +//go:generate mockery --name=RepositoryTester +type RepositoryTester interface { + TestRepository(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, error) +} + +// NewHealthChecker creates a new health checker +func NewHealthChecker(tester RepositoryTester, statusPatcher StatusPatcher) *HealthChecker { + return &HealthChecker{ + tester: tester, + statusPatcher: statusPatcher, + } +} + +// ShouldCheckHealth determines if a repository health check should be performed +func (hc *HealthChecker) ShouldCheckHealth(repo *provisioning.Repository) bool { + // If the repository has been updated, run the health check + if repo.Generation != repo.Status.ObservedGeneration { + return true + } + + // If the repository has a hook error, don't run the health check + if repo.Status.Health.Error == provisioning.HealthFailureHook { + return false + } + + // Check general timing for health checks + return !hc.hasRecentHealthCheck(repo.Status.Health) +} + +// hasRecentHealthCheck checks if a health check was performed recently (for timing purposes) +func (hc *HealthChecker) hasRecentHealthCheck(healthStatus provisioning.HealthStatus) bool { + if healthStatus.Checked == 0 { + return false // Never checked + } + + age := time.Since(time.UnixMilli(healthStatus.Checked)) + if healthStatus.Healthy { + return age <= time.Minute*5 // Recent if checked within 5 minutes when healthy + } + return age <= time.Minute // Recent if checked within 1 minute when unhealthy +} + +// HasRecentFailure checks if there's a recent failure of a specific type +func (hc *HealthChecker) HasRecentFailure(healthStatus provisioning.HealthStatus, failureType provisioning.HealthFailureType) bool { + if healthStatus.Checked == 0 || healthStatus.Healthy || healthStatus.Error != failureType { + return false // No failure of this type + } + + age := time.Since(time.UnixMilli(healthStatus.Checked)) + return age <= time.Minute // Recent if within 1 minute +} + +// RecordFailureAndUpdate records a failure and updates the repository status +func (hc *HealthChecker) RecordFailure(ctx context.Context, failureType provisioning.HealthFailureType, err error, repo *provisioning.Repository) error { + // Create the health status with the failure + healthStatus := hc.recordFailure(failureType, err) + + // Create patch operation + patchOp := map[string]interface{}{ + "op": "replace", + "path": "/status/health", + "value": healthStatus, + } + + // Apply the patch + return hc.statusPatcher.Patch(ctx, repo, patchOp) +} + +// recordFailure creates a health status with a specific failure +func (hc *HealthChecker) recordFailure(failureType provisioning.HealthFailureType, err error) provisioning.HealthStatus { + return provisioning.HealthStatus{ + Healthy: false, + Error: failureType, + Checked: time.Now().UnixMilli(), + Message: []string{err.Error()}, + } +} + +// hasHealthStatusChanged checks if the health status has meaningfully changed +func (hc *HealthChecker) hasHealthStatusChanged(old, new provisioning.HealthStatus) bool { + if old.Healthy != new.Healthy { + return true + } + + if len(old.Message) != len(new.Message) { + return true + } + + if old.Checked != new.Checked { + return true + } + + for i, oldMsg := range old.Message { + if i >= len(new.Message) || oldMsg != new.Message[i] { + return true + } + } + + return false +} + +// RefreshHealth performs a health check on an existing repository, +// updates its status if needed, and returns the test results +func (hc *HealthChecker) RefreshHealth(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, provisioning.HealthStatus, error) { + cfg := repo.Config() + + // Use health checker to perform comprehensive health check with existing status + testResults, newHealthStatus, err := hc.refreshHealth(ctx, repo, cfg.Status.Health) + if err != nil { + return nil, provisioning.HealthStatus{}, fmt.Errorf("health check failed: %w", err) + } + + // Only update if health status actually changed + if hc.hasHealthStatusChanged(cfg.Status.Health, newHealthStatus) { + patchOp := map[string]interface{}{ + "op": "replace", + "path": "/status/health", + "value": newHealthStatus, + } + + if err := hc.statusPatcher.Patch(ctx, cfg, patchOp); err != nil { + return testResults, newHealthStatus, fmt.Errorf("update health status: %w", err) + } + } + + return testResults, newHealthStatus, nil +} + +// RefreshTimestamp updates the health status timestamp without changing other fields +func (hc *HealthChecker) RefreshTimestamp(ctx context.Context, repo *provisioning.Repository) error { + // Update the timestamp on the existing health status + healthStatus := repo.Status.Health + healthStatus.Checked = time.Now().UnixMilli() + + // Create patch operation + patchOp := map[string]interface{}{ + "op": "replace", + "path": "/status/health", + "value": healthStatus, + } + + // Apply the patch + return hc.statusPatcher.Patch(ctx, repo, patchOp) +} + +// refreshHealth performs a comprehensive health check +// Returns test results, health status, and any error +func (hc *HealthChecker) refreshHealth(ctx context.Context, repo repository.Repository, existingStatus provisioning.HealthStatus) (*provisioning.TestResults, provisioning.HealthStatus, error) { + res, err := hc.tester.TestRepository(ctx, repo) + if err != nil { + return nil, existingStatus, fmt.Errorf("failed to test repository: %w", err) + } + + if !res.Success { + // Build error messages + var errorMsgs []string + for _, testErr := range res.Errors { + if testErr.Detail != "" { + errorMsgs = append(errorMsgs, testErr.Detail) + } + } + + healthStatus := provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHealth, + Checked: time.Now().UnixMilli(), + Message: errorMsgs, + } + + return res, healthStatus, nil + } + + // Health check succeeded + now := time.Now() + healthStatus := provisioning.HealthStatus{ + Healthy: true, + Checked: now.UnixMilli(), + } + + // If the existing status is already healthy with no error messages and + // the last check was recent (within 30 seconds), preserve the existing timestamp + // to avoid unnecessary updates + if existingStatus.Healthy && existingStatus.Error == "" && len(existingStatus.Message) == 0 { + lastCheckedTime := time.UnixMilli(existingStatus.Checked) + if now.Sub(lastCheckedTime) < 30*time.Second { + healthStatus.Checked = existingStatus.Checked + } + } + + return res, healthStatus, nil +} diff --git a/pkg/registry/apis/provisioning/controller/health_test.go b/pkg/registry/apis/provisioning/controller/health_test.go new file mode 100644 index 00000000000..a82cfea9076 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/health_test.go @@ -0,0 +1,592 @@ +package controller + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + 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" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks" +) + +func TestNewHealthChecker(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + + hc := NewHealthChecker(mockTester, mockPatcher) + + assert.NotNil(t, hc) + assert.Equal(t, mockTester, hc.tester) + assert.Equal(t, mockPatcher, hc.statusPatcher) +} + +func TestShouldCheckHealth(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expected bool + }{ + { + name: "should check when generation differs", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should not check when hook error exists", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHook, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expected: false, + }, + { + name: "should not check when health check is recent and healthy", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Minute * 3).UnixMilli(), // 3 minutes ago + }, + }, + }, + expected: false, + }, + { + name: "should check when health check is old and healthy", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Minute * 6).UnixMilli(), // 6 minutes ago + }, + }, + }, + expected: true, + }, + { + name: "should not check when health error is recent", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHealth, + Checked: time.Now().Add(-time.Second * 30).UnixMilli(), // 30 seconds ago + }, + }, + }, + expected: false, + }, + { + name: "should check when health error is old", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHealth, + Checked: time.Now().Add(-time.Minute * 2).UnixMilli(), // 2 minutes ago + }, + }, + }, + expected: true, + }, + { + name: "should check when never checked", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: provisioning.RepositoryStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Checked: 0, // Never checked + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + hc := NewHealthChecker(mockTester, mockPatcher) + + result := hc.ShouldCheckHealth(tt.repo) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasRecentFailure(t *testing.T) { + tests := []struct { + name string + healthStatus provisioning.HealthStatus + failureType provisioning.HealthFailureType + expected bool + }{ + { + name: "no recent failure when never checked", + healthStatus: provisioning.HealthStatus{ + Checked: 0, + }, + failureType: provisioning.HealthFailureHook, + expected: false, + }, + { + name: "recent hook failure", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHook, + Checked: time.Now().Add(-time.Second * 30).UnixMilli(), + }, + failureType: provisioning.HealthFailureHook, + expected: true, + }, + { + name: "old hook failure", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHook, + Checked: time.Now().Add(-time.Minute * 2).UnixMilli(), + }, + failureType: provisioning.HealthFailureHook, + expected: false, + }, + { + name: "recent health failure", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHealth, + Checked: time.Now().Add(-time.Second * 30).UnixMilli(), + }, + failureType: provisioning.HealthFailureHealth, + expected: true, + }, + { + name: "old health failure", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHealth, + Checked: time.Now().Add(-time.Minute * 2).UnixMilli(), + }, + failureType: provisioning.HealthFailureHealth, + expected: false, + }, + { + name: "wrong failure type", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHook, + Checked: time.Now().Add(-time.Second * 30).UnixMilli(), + }, + failureType: provisioning.HealthFailureHealth, + expected: false, + }, + { + name: "healthy status with wrong failure type", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Second * 30).UnixMilli(), + }, + failureType: provisioning.HealthFailureHook, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + hc := NewHealthChecker(mockTester, mockPatcher) + + result := hc.HasRecentFailure(tt.healthStatus, tt.failureType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRecordFailure(t *testing.T) { + tests := []struct { + name string + failureType provisioning.HealthFailureType + err error + patchError error + expectError bool + }{ + { + name: "successful hook failure record", + failureType: provisioning.HealthFailureHook, + err: errors.New("hook failed"), + patchError: nil, + expectError: false, + }, + { + name: "successful health failure record", + failureType: provisioning.HealthFailureHealth, + err: errors.New("health check failed"), + patchError: nil, + expectError: false, + }, + { + name: "patch failure", + failureType: provisioning.HealthFailureHook, + err: errors.New("hook failed"), + patchError: errors.New("patch failed"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + hc := NewHealthChecker(mockTester, mockPatcher) + + repo := &provisioning.Repository{ + Status: provisioning.RepositoryStatus{ + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Hour).UnixMilli(), + }, + }, + } + + if tt.patchError != nil { + mockPatcher.On("Patch", mock.Anything, repo, mock.AnythingOfType("map[string]interface {}")). + Return(tt.patchError) + } else { + mockPatcher.On("Patch", mock.Anything, repo, mock.AnythingOfType("map[string]interface {}")). + Return(nil). + Run(func(args mock.Arguments) { + patchOp := args[2].(map[string]interface{}) + assert.Equal(t, "replace", patchOp["op"]) + assert.Equal(t, "/status/health", patchOp["path"]) + + healthStatus := patchOp["value"].(provisioning.HealthStatus) + assert.False(t, healthStatus.Healthy) + assert.Equal(t, tt.failureType, healthStatus.Error) + assert.Contains(t, healthStatus.Message, tt.err.Error()) + assert.Greater(t, healthStatus.Checked, int64(0)) + }) + } + + err := hc.RecordFailure(context.Background(), tt.failureType, tt.err, repo) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + mockPatcher.AssertExpectations(t) + }) + } +} + +func TestRecordFailureFunction(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + hc := NewHealthChecker(mockTester, mockPatcher) + + testErr := errors.New("test error") + result := hc.recordFailure(provisioning.HealthFailureHook, testErr) + + assert.False(t, result.Healthy) + assert.Equal(t, provisioning.HealthFailureHook, result.Error) + assert.Equal(t, []string{"test error"}, result.Message) + assert.Greater(t, result.Checked, int64(0)) +} + +func TestRefreshHealth(t *testing.T) { + tests := []struct { + name string + testResult *provisioning.TestResults + testError error + patchError error + existingStatus provisioning.HealthStatus + expectError bool + expectedHealth bool + expectPatch bool + }{ + { + name: "successful health check", + testResult: &provisioning.TestResults{ + Success: true, + Code: 200, + }, + testError: nil, + existingStatus: provisioning.HealthStatus{ + Healthy: false, + Error: provisioning.HealthFailureHealth, + Checked: time.Now().Add(-time.Hour).UnixMilli(), + }, + expectError: false, + expectedHealth: true, + expectPatch: true, + }, + { + name: "failed health check", + testResult: &provisioning.TestResults{ + Success: false, + Code: 500, + Errors: []provisioning.ErrorDetails{ + {Detail: "connection failed"}, + {Detail: "timeout"}, + }, + }, + testError: nil, + existingStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Hour).UnixMilli(), + }, + expectError: false, + expectedHealth: false, + expectPatch: true, + }, + { + name: "test repository error", + testResult: nil, + testError: errors.New("repository test failed"), + existingStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Hour).UnixMilli(), + }, + expectError: true, + expectedHealth: false, + expectPatch: false, + }, + { + name: "no status change - no patch needed (recent check)", + testResult: &provisioning.TestResults{ + Success: true, + Code: 200, + }, + testError: nil, + existingStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-15 * time.Second).UnixMilli(), + }, + expectError: false, + expectedHealth: true, + expectPatch: false, + }, + { + name: "status unchanged but timestamp needs update (old check)", + testResult: &provisioning.TestResults{ + Success: true, + Code: 200, + }, + testError: nil, + existingStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Hour).UnixMilli(), + }, + expectError: false, + expectedHealth: true, + expectPatch: true, + }, + { + name: "patch error", + testResult: &provisioning.TestResults{ + Success: false, + Code: 500, + Errors: []provisioning.ErrorDetails{ + {Detail: "connection failed"}, + }, + }, + testError: nil, + patchError: errors.New("patch failed"), + existingStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-time.Hour).UnixMilli(), + }, + expectError: true, + expectedHealth: false, + expectPatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + mockRepo := &mockRepository{ + config: &provisioning.Repository{ + Status: provisioning.RepositoryStatus{ + Health: tt.existingStatus, + }, + }, + } + + hc := NewHealthChecker(mockTester, mockPatcher) + + if tt.testError != nil { + mockTester.On("TestRepository", mock.Anything, mockRepo).Return(tt.testResult, tt.testError) + } else { + mockTester.On("TestRepository", mock.Anything, mockRepo).Return(tt.testResult, nil) + } + + if tt.expectPatch { + if tt.patchError != nil { + mockPatcher.On("Patch", mock.Anything, mockRepo.config, mock.AnythingOfType("map[string]interface {}")). + Return(tt.patchError) + } else { + mockPatcher.On("Patch", mock.Anything, mockRepo.config, mock.AnythingOfType("map[string]interface {}")). + Return(nil). + Run(func(args mock.Arguments) { + patchOp := args[2].(map[string]interface{}) + assert.Equal(t, "replace", patchOp["op"]) + assert.Equal(t, "/status/health", patchOp["path"]) + + healthStatus := patchOp["value"].(provisioning.HealthStatus) + assert.Equal(t, tt.expectedHealth, healthStatus.Healthy) + }) + } + } + + testResult, healthStatus, err := hc.RefreshHealth(context.Background(), mockRepo) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedHealth, healthStatus.Healthy) + if tt.testResult != nil { + assert.Equal(t, tt.testResult, testResult) + } + } + + mockTester.AssertExpectations(t) + if tt.expectPatch { + mockPatcher.AssertExpectations(t) + } + }) + } +} + +func TestHasHealthStatusChanged(t *testing.T) { + tests := []struct { + name string + old provisioning.HealthStatus + new provisioning.HealthStatus + expected bool + }{ + { + name: "healthy status changed", + old: provisioning.HealthStatus{ + Healthy: true, + Message: []string{}, + }, + new: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error"}, + }, + expected: true, + }, + { + name: "different message count", + old: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error1"}, + }, + new: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error1", "error2"}, + }, + expected: true, + }, + { + name: "different messages", + old: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error1"}, + }, + new: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error2"}, + }, + expected: true, + }, + { + name: "no change", + old: provisioning.HealthStatus{ + Healthy: true, + Message: []string{}, + }, + new: provisioning.HealthStatus{ + Healthy: true, + Message: []string{}, + }, + expected: false, + }, + { + name: "same messages", + old: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error1", "error2"}, + }, + new: provisioning.HealthStatus{ + Healthy: false, + Message: []string{"error1", "error2"}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTester := mocks.NewRepositoryTester(t) + mockPatcher := mocks.NewStatusPatcher(t) + hc := NewHealthChecker(mockTester, mockPatcher) + + result := hc.hasHealthStatusChanged(tt.old, tt.new) + assert.Equal(t, tt.expected, result) + }) + } +} + +// mockRepository implements repository.Repository interface for testing +type mockRepository struct { + config *provisioning.Repository +} + +func (m *mockRepository) Config() *provisioning.Repository { + return m.config +} + +func (m *mockRepository) Validate() field.ErrorList { + return nil +} + +func (m *mockRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { + return &provisioning.TestResults{Success: true}, nil +} diff --git a/pkg/registry/apis/provisioning/controller/mocks/RepositoryTester.go b/pkg/registry/apis/provisioning/controller/mocks/RepositoryTester.go new file mode 100644 index 00000000000..99b52c7e16f --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/mocks/RepositoryTester.go @@ -0,0 +1,62 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// RepositoryTester is an autogenerated mock type for the RepositoryTester type +type RepositoryTester struct { + mock.Mock +} + +// TestRepository provides a mock function with given fields: ctx, repo +func (_m *RepositoryTester) TestRepository(ctx context.Context, repo repository.Repository) (*v0alpha1.TestResults, error) { + ret := _m.Called(ctx, repo) + + if len(ret) == 0 { + panic("no return value specified for TestRepository") + } + + var r0 *v0alpha1.TestResults + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, repository.Repository) (*v0alpha1.TestResults, error)); ok { + return rf(ctx, repo) + } + if rf, ok := ret.Get(0).(func(context.Context, repository.Repository) *v0alpha1.TestResults); ok { + r0 = rf(ctx, repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.TestResults) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, repository.Repository) error); ok { + r1 = rf(ctx, repo) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewRepositoryTester creates a new instance of RepositoryTester. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRepositoryTester(t interface { + mock.TestingT + Cleanup(func()) +}) *RepositoryTester { + mock := &RepositoryTester{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/controller/mocks/StatusPatcher.go b/pkg/registry/apis/provisioning/controller/mocks/StatusPatcher.go new file mode 100644 index 00000000000..7cfe0b737bf --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/mocks/StatusPatcher.go @@ -0,0 +1,55 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// StatusPatcher is an autogenerated mock type for the StatusPatcher type +type StatusPatcher struct { + mock.Mock +} + +// Patch provides a mock function with given fields: ctx, repo, patchOperations +func (_m *StatusPatcher) Patch(ctx context.Context, repo *v0alpha1.Repository, patchOperations ...map[string]interface{}) error { + _va := make([]interface{}, len(patchOperations)) + for _i := range patchOperations { + _va[_i] = patchOperations[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, repo) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Patch") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, ...map[string]interface{}) error); ok { + r0 = rf(ctx, repo, patchOperations...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewStatusPatcher creates a new instance of StatusPatcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewStatusPatcher(t interface { + mock.TestingT + Cleanup(func()) +}) *StatusPatcher { + mock := &StatusPatcher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 15107e781d7..cf775a08972 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -2,7 +2,6 @@ package controller import ( "context" - "encoding/json" "errors" "fmt" "time" @@ -35,10 +34,6 @@ type RepoGetter interface { AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error) } -type RepositoryTester interface { - TestRepository(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, error) -} - const loggerName = "provisioning-repository-controller" const ( @@ -61,12 +56,13 @@ type RepositoryController struct { logger logging.Logger dualwrite dualwrite.Service - jobs jobs.Queue - finalizer *finalizer + jobs jobs.Queue + finalizer *finalizer + statusPatcher StatusPatcher // Converts config to instance - repoGetter RepoGetter - tester RepositoryTester + repoGetter RepoGetter + healthChecker *HealthChecker // To allow injection for testing. processFn func(item *queueItem) error enqueueRepository func(obj any) @@ -86,6 +82,8 @@ func NewRepositoryController( tester RepositoryTester, jobs jobs.Queue, dualwrite dualwrite.Service, + healthChecker *HealthChecker, + statusPatcher StatusPatcher, ) (*RepositoryController, error) { rc := &RepositoryController{ client: provisioningClient, @@ -98,13 +96,14 @@ func NewRepositoryController( Name: "provisioningRepositoryController", }, ), - repoGetter: repoGetter, - parsers: parsers, + repoGetter: repoGetter, + healthChecker: healthChecker, + statusPatcher: statusPatcher, + parsers: parsers, finalizer: &finalizer{ lister: resourceLister, clientFactory: clients, }, - tester: tester, jobs: jobs, logger: logging.DefaultLogger.With("logger", loggerName), dualwrite: dualwrite, @@ -247,47 +246,6 @@ func (rc *RepositoryController) handleDelete(ctx context.Context, obj *provision return nil } -func (rc *RepositoryController) shouldCheckHealth(obj *provisioning.Repository) bool { - if obj.Status.Health.Checked == 0 || obj.Generation != obj.Status.ObservedGeneration { - return true - } - - healthAge := time.Since(time.UnixMilli(obj.Status.Health.Checked)) - if obj.Status.Health.Healthy { - return healthAge > time.Minute*5 // when healthy, check every 5 mins - } - - return healthAge > time.Minute // otherwise within a minute -} - -func (rc *RepositoryController) runHealthCheck(ctx context.Context, repo repository.Repository) provisioning.HealthStatus { - logger := logging.FromContext(ctx) - logger.Info("running health check") - res, err := rc.tester.TestRepository(ctx, repo) - if err != nil { - res = &provisioning.TestResults{ - Success: false, - Errors: []provisioning.ErrorDetails{{ - Detail: fmt.Sprintf("error running test repository: %s", err.Error()), - }}, - } - } - - healthStatus := provisioning.HealthStatus{ - Healthy: res.Success, - Checked: time.Now().UnixMilli(), - } - for _, err := range res.Errors { - if err.Detail != "" { - healthStatus.Message = append(healthStatus.Message, err.Detail) - } - } - - logger.Info("health check completed", "status", healthStatus) - - return healthStatus -} - func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool { // don't trigger resync if a sync was never started if obj.Status.Sync.Finished == 0 && obj.Status.Sync.State == "" { @@ -309,7 +267,7 @@ func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool func (rc *RepositoryController) runHooks(ctx context.Context, repo repository.Repository, obj *provisioning.Repository) ([]map[string]interface{}, error) { logger := logging.FromContext(ctx) hooks, _ := repo.(repository.Hooks) - if hooks == nil || obj.Generation == obj.Status.ObservedGeneration { + if hooks == nil { return nil, nil } @@ -400,26 +358,7 @@ func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisionin return nil } -func (rc *RepositoryController) patchStatus(ctx context.Context, obj *provisioning.Repository, patchOperations []map[string]interface{}) error { - if len(patchOperations) == 0 { - return nil - } - - patch, err := json.Marshal(patchOperations) - if err != nil { - return fmt.Errorf("error encoding status patch: %w", err) - } - - _, err = rc.client.Repositories(obj.GetNamespace()). - Patch(ctx, obj.Name, types.JSONPatchType, patch, v1.PatchOptions{}, "status") - if err != nil { - return fmt.Errorf("error applying status patch: %w", err) - } - - return nil -} - -func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) *provisioning.SyncStatus { +func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) *provisioning.SyncStatus { const unhealthyMessage = "Repository is unhealthy" hasUnhealthyMessage := len(obj.Status.Sync.Message) > 0 && obj.Status.Sync.Message[0] == unhealthyMessage @@ -430,13 +369,13 @@ func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository LastRef: obj.Status.Sync.LastRef, Started: time.Now().UnixMilli(), } - case obj.Status.Health.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it + case healthStatus.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it // FIXME: is this the clearest way to do this? Should we introduce another status or way of way of handling more // specific errors? return &provisioning.SyncStatus{ LastRef: obj.Status.Sync.LastRef, } - case !obj.Status.Health.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it + case !healthStatus.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it return &provisioning.SyncStatus{ State: provisioning.JobStateError, Message: []string{unhealthyMessage}, @@ -450,6 +389,7 @@ func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository //nolint:gocyclo func (rc *RepositoryController) process(item *queueItem) error { logger := rc.logger.With("key", item.key) + ctx := logging.Context(context.Background(), logger) namespace, name, err := cache.SplitMetaNamespaceKey(item.key) if err != nil { @@ -464,7 +404,7 @@ func (rc *RepositoryController) process(item *queueItem) error { return err } - ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace) + ctx, _, err = identity.WithProvisioningIdentity(ctx, namespace) if err != nil { return err } @@ -476,7 +416,7 @@ func (rc *RepositoryController) process(item *queueItem) error { } shouldResync := rc.shouldResync(obj) - shouldCheckHealth := rc.shouldCheckHealth(obj) + shouldCheckHealth := rc.healthChecker.ShouldCheckHealth(obj) hasSpecChanged := obj.Generation != obj.Status.ObservedGeneration patchOperations := []map[string]interface{}{} @@ -503,28 +443,27 @@ func (rc *RepositoryController) process(item *queueItem) error { return fmt.Errorf("unable to create repository from configuration: %w", err) } - healthStatus := obj.Status.Health - if shouldCheckHealth { - healthStatus = rc.runHealthCheck(ctx, repo) - patchOperations = append(patchOperations, map[string]interface{}{ - "op": "replace", - "path": "/status/health", - "value": healthStatus, - }) + // Handle hooks - may return early if hooks fail + hookOps, shouldContinue, err := rc.processHooks(ctx, repo, obj) + if err != nil { + return fmt.Errorf("process hooks: %w", err) + } + if !shouldContinue { + return nil // Hook handling already updated status and returned early + } + if len(hookOps) > 0 { + patchOperations = append(patchOperations, hookOps...) } - // Run hooks - hookOps, err := rc.runHooks(ctx, repo, obj) - switch { - case err != nil: - return err - case len(hookOps) > 0: - patchOperations = append(patchOperations, hookOps...) + // Handle health checks using the health checker + _, healthStatus, err := rc.healthChecker.RefreshHealth(ctx, repo) + if err != nil { + return fmt.Errorf("update health status: %w", err) } // determine the sync strategy and sync status to apply syncOptions := rc.determineSyncStrategy(ctx, obj, repo, shouldResync, healthStatus) - if syncStatus := rc.determineSyncStatus(obj, syncOptions); syncStatus != nil { + if syncStatus := rc.determineSyncStatus(obj, syncOptions, healthStatus); syncStatus != nil { patchOperations = append(patchOperations, map[string]interface{}{ "op": "replace", "path": "/status/sync", @@ -533,7 +472,7 @@ func (rc *RepositoryController) process(item *queueItem) error { } // Apply all patch operations - if err := rc.patchStatus(ctx, obj, patchOperations); err != nil { + if err := rc.statusPatcher.Patch(ctx, obj, patchOperations...); err != nil { return err } @@ -546,3 +485,29 @@ func (rc *RepositoryController) process(item *queueItem) error { return nil } + +// processHooks handles hook execution with intelligent retry logic +// Returns hook operations, whether processing should continue, and any error +func (rc *RepositoryController) processHooks(ctx context.Context, repo repository.Repository, obj *provisioning.Repository) ([]map[string]interface{}, bool, error) { + shouldRunHooks := obj.Generation != obj.Status.ObservedGeneration + + // Skip hooks if status already indicates recent hook failure to avoid infinite retry + if shouldRunHooks && rc.healthChecker.HasRecentFailure(obj.Status.Health, provisioning.HealthFailureHook) { + shouldRunHooks = false + } + + if !shouldRunHooks { + return nil, true, nil + } + + hookOps, err := rc.runHooks(ctx, repo, obj) + if err != nil { + if err := rc.healthChecker.RecordFailure(ctx, provisioning.HealthFailureHook, err, obj); err != nil { + return nil, false, fmt.Errorf("update status after hook failure: %w", err) + } + + return nil, false, err + } + + return hookOps, true, nil +} diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go index 8fe1a933e37..4ae0c3eaf2d 100644 --- a/pkg/registry/apis/provisioning/files.go +++ b/pkg/registry/apis/provisioning/files.go @@ -58,39 +58,54 @@ func (*filesConnector) NewConnectOptions() (runtime.Object, bool, string) { return nil, true, "" // true adds the {path} component } +// For GET operations, allow even unhealthy repositories +// For write operations (POST, PUT, DELETE), require healthy repository +func (c *filesConnector) getRepo(ctx context.Context, method, name string) (repository.Repository, error) { + if method == http.MethodGet { + return c.getter.GetRepository(ctx, name) + } else { + return c.getter.GetHealthyRepository(ctx, name) + } +} + // TODO: document the synchronous write and delete on the API Spec func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { logger := logging.FromContext(ctx).With("logger", "files-connector", "repository_name", name) ctx = logging.Context(ctx, logger) - repo, err := c.getter.GetHealthyRepository(ctx, name) - if err != nil { - logger.Debug("failed to find repository", "error", err) - return nil, err - } - - readWriter, ok := repo.(repository.ReaderWriter) - if !ok { - return nil, apierrors.NewBadRequest("repository does not support read-writing") - } - - parser, err := c.parsers.GetParser(ctx, readWriter) - if err != nil { - return nil, fmt.Errorf("failed to get parser: %w", err) - } - - clients, err := c.clients.Clients(ctx, repo.Config().Namespace) - if err != nil { - return nil, fmt.Errorf("failed to get clients: %w", err) - } - - folderClient, err := clients.Folder() - if err != nil { - return nil, fmt.Errorf("failed to get folder client: %w", err) - } - folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree()) - dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access) return WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + repo, err := c.getRepo(ctx, r.Method, name) + if err != nil { + logger.Debug("failed to find repository", "error", err) + responder.Error(err) + return + } + + readWriter, ok := repo.(repository.ReaderWriter) + if !ok { + responder.Error(apierrors.NewBadRequest("repository does not support read-writing")) + return + } + + parser, err := c.parsers.GetParser(ctx, readWriter) + if err != nil { + responder.Error(fmt.Errorf("failed to get parser: %w", err)) + return + } + + clients, err := c.clients.Clients(ctx, repo.Config().Namespace) + if err != nil { + responder.Error(fmt.Errorf("failed to get clients: %w", err)) + return + } + + folderClient, err := clients.Folder() + if err != nil { + responder.Error(fmt.Errorf("failed to get folder client: %w", err)) + return + } + folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree()) + dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access) query := r.URL.Query() opts := resources.DualWriteOptions{ Ref: query.Get("ref"), diff --git a/pkg/registry/apis/provisioning/history.go b/pkg/registry/apis/provisioning/history.go index 7770e44fe6d..bf0bb5b35dc 100644 --- a/pkg/registry/apis/provisioning/history.go +++ b/pkg/registry/apis/provisioning/history.go @@ -54,7 +54,7 @@ func (h *historySubresource) NewConnectOptions() (runtime.Object, bool, string) func (h *historySubresource) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { logger := logging.FromContext(ctx).With("logger", "history-subresource") ctx = logging.Context(ctx, logger) - repo, err := h.repoGetter.GetHealthyRepository(ctx, name) + repo, err := h.repoGetter.GetRepository(ctx, name) if err != nil { logger.Debug("failed to find repository", "error", err) return nil, err diff --git a/pkg/registry/apis/provisioning/jobs.go b/pkg/registry/apis/provisioning/jobs.go index e563cc94fa9..8c352bbeee1 100644 --- a/pkg/registry/apis/provisioning/jobs.go +++ b/pkg/registry/apis/provisioning/jobs.go @@ -49,17 +49,17 @@ func (c *jobsConnector) Connect( opts runtime.Object, responder rest.Responder, ) (http.Handler, error) { - repo, err := c.repoGetter.GetHealthyRepository(ctx, name) - if err != nil { - return nil, err - } - cfg := repo.Config() - return WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx = r.Context() prefix := fmt.Sprintf("/%s/jobs/", name) idx := strings.Index(r.URL.Path, prefix) if r.Method == http.MethodGet { + // GET operations: allow even for unhealthy repositories + repo, err := c.repoGetter.GetRepository(ctx, name) + if err != nil { + responder.Error(err) + return + } + cfg := repo.Config() if idx > 0 { jobUID := r.URL.Path[idx+len(prefix):] if !ValidUUID(jobUID) { @@ -82,6 +82,15 @@ func (c *jobsConnector) Connect( responder.Object(http.StatusOK, recent) return } + + // POST operations: require healthy repository + repo, err := c.repoGetter.GetHealthyRepository(ctx, name) + if err != nil { + responder.Error(err) + return + } + cfg := repo.Config() + if idx > 0 { responder.Error(apierrors.NewBadRequest("can not post to a job UID")) return diff --git a/pkg/registry/apis/provisioning/refs.go b/pkg/registry/apis/provisioning/refs.go index 8eed640fdd1..1c37e35fd1e 100644 --- a/pkg/registry/apis/provisioning/refs.go +++ b/pkg/registry/apis/provisioning/refs.go @@ -46,7 +46,7 @@ func (*refsConnector) NewConnectOptions() (runtime.Object, bool, string) { func (c *refsConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { logger := logging.FromContext(ctx).With("logger", "refs-connector", "repository_name", name) ctx = logging.Context(ctx, logger) - repo, err := c.getter.GetHealthyRepository(ctx, name) + repo, err := c.getter.GetRepository(ctx, name) if err != nil { logger.Debug("failed to find repository", "error", err) return nil, err diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index e34599c586b..fd9521d18d1 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -102,7 +102,6 @@ type APIBuilder struct { } jobHistoryConfig *JobHistoryConfig jobHistory jobs.History - tester *RepositoryTester resourceLister resources.ResourceLister repositoryLister listers.RepositoryLister legacyMigrator legacy.LegacyMigrator @@ -114,6 +113,7 @@ type APIBuilder struct { access authlib.AccessChecker mutators []controller.Mutator statusPatcher *controller.RepositoryStatusPatcher + healthChecker *controller.HealthChecker // Extras provides additional functionality to the API. extras []Extra availableRepositoryTypes map[provisioning.RepositoryType]bool @@ -406,6 +406,10 @@ func (b *APIBuilder) GetStatusPatcher() *controller.RepositoryStatusPatcher { return b.statusPatcher } +func (b *APIBuilder) GetHealthChecker() *controller.HealthChecker { + return b.healthChecker +} + func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { err := provisioning.AddToScheme(scheme) if err != nil { @@ -473,10 +477,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place - // TODO: Do not set private fields directly, use factory methods. - storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = &testConnector{ - getter: b, - } + storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, &repository.Tester{}, b) storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access) storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{ @@ -657,14 +658,11 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH jobInformer := sharedInformerFactory.Provisioning().V0alpha1().Jobs() b.client = c.ProvisioningV0alpha1() - - // We do not have a local client until *GetPostStartHooks*, so we can delay init for some - b.tester = &RepositoryTester{ - client: b.GetClient(), - } - b.repositoryLister = repoInformer.Lister() + b.statusPatcher = controller.NewRepositoryStatusPatcher(b.GetClient()) + b.healthChecker = controller.NewHealthChecker(&repository.Tester{}, b.statusPatcher) + // if running solely CRUD, skip the rest of the setup if b.localFileResolver == nil { return nil @@ -691,7 +689,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH stageIfPossible, ) - b.statusPatcher = controller.NewRepositoryStatusPatcher(b.GetClient()) syncer := sync.NewSyncer(sync.Compare, sync.FullSync, sync.IncrementalSync) syncWorker := sync.NewSyncWorker( b.clients, @@ -782,6 +779,8 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH &repository.Tester{}, b.jobs, b.storageStatus, + b.GetHealthChecker(), + b.statusPatcher, ) if err != nil { return err @@ -1254,49 +1253,20 @@ func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository return b.asRepository(ctx, obj) } -func timeSince(when int64) time.Duration { - return time.Duration(time.Now().UnixMilli()-when) * time.Millisecond -} - func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) { repo, err := b.GetRepository(ctx, name) if err != nil { return nil, err } + status := repo.Config().Status.Health if !status.Healthy { - if timeSince(status.Checked) > time.Second*25 { - ctx, _, err = identity.WithProvisioningIdentity(ctx, repo.Config().Namespace) - if err != nil { - return nil, err // The status - } - - // Check health again - s, err := repository.TestRepository(ctx, repo) - if err != nil { - return nil, err // The status - } - - // Write and return the repo with current status - cfg, _ := b.tester.UpdateHealthStatus(ctx, repo.Config(), s) - if cfg != nil { - status = cfg.Status.Health - if cfg.Status.Health.Healthy { - status = cfg.Status.Health - repo, err = b.AsRepository(ctx, cfg) - if err != nil { - return nil, err - } - } - } - } - if !status.Healthy { - return nil, &apierrors.StatusError{ErrStatus: metav1.Status{ - Code: http.StatusFailedDependency, - Message: "The repository configuration is not healthy", - }} - } + return nil, &apierrors.StatusError{ErrStatus: metav1.Status{ + Code: http.StatusFailedDependency, + Message: "The repository configuration is not healthy", + }} } + return repo, err } diff --git a/pkg/registry/apis/provisioning/test.go b/pkg/registry/apis/provisioning/test.go index 9abb1d38a7f..c180d70c7c5 100644 --- a/pkg/registry/apis/provisioning/test.go +++ b/pkg/registry/apis/provisioning/test.go @@ -8,18 +8,37 @@ import ( "reflect" "time" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" ) +type StatusPatcherProvider interface { + GetStatusPatcher() *controller.RepositoryStatusPatcher +} + +type HealthCheckerProvider interface { + GetHealthChecker() *controller.HealthChecker +} + type testConnector struct { - getter RepoGetter + getter RepoGetter + tester controller.RepositoryTester + healthProvider HealthCheckerProvider +} + +func NewTestConnector(getter RepoGetter, tester controller.RepositoryTester, healthProvider HealthCheckerProvider) *testConnector { + return &testConnector{ + getter: getter, + tester: tester, + healthProvider: healthProvider, + } } func (*testConnector) New() runtime.Object { @@ -94,57 +113,71 @@ func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.O } } + var rsp *provisioning.TestResults if repo == nil { + healthChecker := s.healthProvider.GetHealthChecker() + if healthChecker == nil { + // Use precondition failed for when health checker is not ready yet + responder.Error(&errors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusPreconditionFailed, + Reason: metav1.StatusReason("PreconditionFailed"), + Message: "health checker not initialized yet, please try again", + }, + }) + return + } + + // Testing existing repository - get it and update health repo, err = s.getter.GetRepository(ctx, name) if err != nil { responder.Error(err) return } + + // If the last error was not a health check error or empty, return precondition failed + health := repo.Config().Status.Health + if health.Error != provisioning.HealthFailureHealth && health.Error != "" { + rsp = &provisioning.TestResults{ + Success: false, + Code: http.StatusPreconditionFailed, + Errors: func() []provisioning.ErrorDetails { + var errs []provisioning.ErrorDetails + for _, msg := range health.Message { + errs = append(errs, provisioning.ErrorDetails{Detail: msg}) + } + return errs + }(), + } + + if err := healthChecker.RefreshTimestamp(ctx, repo.Config()); err != nil { + responder.Error(err) + return + } + + responder.Object(rsp.Code, rsp) + return + } + + rsp, _, err = healthChecker.RefreshHealth(ctx, repo) + if err != nil { + responder.Error(err) + return + } + } else { + // Testing temporary repository - just run test without status update + rsp, err = s.tester.TestRepository(ctx, repo) + if err != nil { + responder.Error(err) + return + } } - // Only call test if field validation passes - rsp, err := repository.TestRepository(ctx, repo) - if err != nil { - responder.Error(err) - return - } responder.Object(rsp.Code, rsp) }), 30*time.Second), nil } -// TODO: Move tester to a more suitable location out of the connector. -type RepositoryTester struct { - // Repository+Jobs - client client.ProvisioningV0alpha1Interface -} - -// This function will check if the repository is configured and functioning as expected -func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisioning.Repository, res *provisioning.TestResults) (*provisioning.Repository, error) { - if res == nil { - res = &provisioning.TestResults{ - Success: false, - Errors: []provisioning.ErrorDetails{{ - Detail: "missing health status", - }}, - } - } - - repo := cfg.DeepCopy() - repo.Status.Health = provisioning.HealthStatus{ - Healthy: res.Success, - Checked: time.Now().UnixMilli(), - } - for _, err := range res.Errors { - if err.Detail != "" { - repo.Status.Health.Message = append(repo.Status.Health.Message, err.Detail) - } - } - - _, err := t.client.Repositories(repo.GetNamespace()). - UpdateStatus(ctx, repo, metav1.UpdateOptions{}) - return repo, err -} - var ( _ rest.Storage = (*testConnector)(nil) _ rest.Connecter = (*testConnector)(nil) diff --git a/pkg/registry/apis/provisioning/webhooks/register.go b/pkg/registry/apis/provisioning/webhooks/register.go index 7de867fed9a..59ce4949386 100644 --- a/pkg/registry/apis/provisioning/webhooks/register.go +++ b/pkg/registry/apis/provisioning/webhooks/register.go @@ -34,6 +34,16 @@ type WebhookExtraBuilder struct { provisioningapis.ExtraBuilder } +// HACK: assume that the URL is public if it starts with "https://" and does not contain any local IP ranges +func isPublicURL(url string) bool { + return strings.HasPrefix(url, "https://") && + !strings.Contains(url, "localhost") && + !strings.HasPrefix(url, "https://127.") && + !strings.HasPrefix(url, "https://192.") && + !strings.HasPrefix(url, "https://10.") && + !strings.HasPrefix(url, "https://172.16.") +} + func ProvideWebhooks( cfg *setting.Cfg, features featuremgmt.FeatureToggles, @@ -48,8 +58,8 @@ func ProvideWebhooks( urlProvider := func(_ string) string { return cfg.AppURL } - // HACK: Assume is only public if it is HTTPS - isPublic := strings.HasPrefix(urlProvider(""), "https://") + + isPublic := isPublicURL(urlProvider("")) clients := resources.NewClientFactory(configProvider) parsers := resources.NewParserFactory(clients) @@ -74,6 +84,7 @@ func ProvideWebhooks( filepath.Join(cfg.DataPath, "clone"), parsers, []jobs.Worker{pullRequestWorker}, + isPublic, // Pass the public URL flag ) }, } @@ -90,6 +101,7 @@ type WebhookExtra struct { clonedir string parsers resources.ParserFactory workers []jobs.Worker + isPublic bool // Flag to determine if webhook-enhanced repositories should be created } func NewWebhookExtra( @@ -101,6 +113,7 @@ func NewWebhookExtra( clonedir string, parsers resources.ParserFactory, workers []jobs.Worker, + isPublic bool, ) *WebhookExtra { return &WebhookExtra{ render: render, @@ -111,6 +124,7 @@ func NewWebhookExtra( clonedir: clonedir, parsers: parsers, workers: workers, + isPublic: isPublic, } } @@ -155,7 +169,8 @@ func (e *WebhookExtra) GetJobWorkers() []jobs.Worker { // AsRepository delegates repository creation to the webhook connector func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) { - if r.Spec.Type == provisioning.GitHubRepositoryType { + // Only handle GitHub repositories with webhooks if URL is public + if r.Spec.Type == provisioning.GitHubRepositoryType && e.isPublic { gvr := provisioning.RepositoryResourceInfo.GroupVersionResource() webhookURL := fmt.Sprintf( "%sapis/%s/%s/namespaces/%s/%s/%s/webhook", @@ -209,7 +224,11 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit } func (e *WebhookExtra) RepositoryTypes() []provisioning.RepositoryType { - return []provisioning.RepositoryType{ - provisioning.GitHubRepositoryType, + // Only claim to handle GitHub repositories if URL is public + if e.isPublic { + return []provisioning.RepositoryType{ + provisioning.GitHubRepositoryType, + } } + return []provisioning.RepositoryType{} } diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index df2e3eddf0f..39bf07a014c 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -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", diff --git a/pkg/tests/apis/provisioning/files_test.go b/pkg/tests/apis/provisioning/files_test.go index afe7bfd62b9..7fa121e00f5 100644 --- a/pkg/tests/apis/provisioning/files_test.go +++ b/pkg/tests/apis/provisioning/files_test.go @@ -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 diff --git a/pkg/tests/apis/provisioning/health_test.go b/pkg/tests/apis/provisioning/health_test.go new file mode 100644 index 00000000000..17f145047dc --- /dev/null +++ b/pkg/tests/apis/provisioning/health_test.go @@ -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 +} diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 48956371779..0fb50148e8b 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -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 { diff --git a/pkg/tests/apis/provisioning/movejob_test.go b/pkg/tests/apis/provisioning/movejob_test.go index 770f0f9bce9..166d6653a7a 100644 --- a/pkg/tests/apis/provisioning/movejob_test.go +++ b/pkg/tests/apis/provisioning/movejob_test.go @@ -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{ diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 95ebf922dd1..ad6a254059e 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -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 diff --git a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts index b8295bc8802..b20424ce1ad 100644 --- a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts @@ -1050,6 +1050,12 @@ export type RepositorySpec = { export type HealthStatus = { /** When the health was checked last time */ checked?: number; + /** The type of the error + + Possible enum values: + - `"health"` + - `"hook"` */ + error?: 'health' | 'hook'; /** When not healthy, requests will not be executed */ healthy: boolean; /** Summary messages (can be shown to users) Will only be populated when not healthy */