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