Files
Alex KhomenkoandRoberto Jimenez Sanchez 8cb5f5646a Provisioning: Fix miscellaneous issues with setting and displaying sync status (#113529)
* Provisioning: Preserve in progress job data

* Refactor code and cover more situations

* Fix linting

* Fix issue with remove path operation for started time

* Cleanup

* prettier

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
2025-11-07 12:27:25 +01:00

236 lines
7.9 KiB
Go

package sync
import (
"context"
"fmt"
"time"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/utils"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
//go:generate mockery --name RepositoryPatchFn --structname MockRepositoryPatchFn --inpackage --filename repository_patch_fn_mock.go --with-expecter
type RepositoryPatchFn func(ctx context.Context, repo *provisioning.Repository, patchOperations ...map[string]interface{}) error
// SyncWorker synchronizes the external repo with grafana database
// this function updates the status for both the job and the referenced repository
type SyncWorker struct {
// Clients for the repository
clients resources.ClientFactory
// ResourceClients for the repository
repositoryResources resources.RepositoryResourcesFactory
// Check if the system is using unified storage
storageStatus dualwrite.Service
// Patch status for the repository
patchStatus RepositoryPatchFn
// Sync functions
syncer Syncer
metrics jobs.JobMetrics
tracer tracing.Tracer
maxSyncWorkers int
}
func NewSyncWorker(
clients resources.ClientFactory,
repositoryResources resources.RepositoryResourcesFactory,
storageStatus dualwrite.Service,
patchStatus RepositoryPatchFn,
syncer Syncer,
metrics jobs.JobMetrics,
tracer tracing.Tracer,
maxSyncWorkers int,
) *SyncWorker {
return &SyncWorker{
clients: clients,
repositoryResources: repositoryResources,
patchStatus: patchStatus,
storageStatus: storageStatus,
syncer: syncer,
metrics: metrics,
tracer: tracer,
maxSyncWorkers: maxSyncWorkers,
}
}
func (r *SyncWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
return job.Spec.Action == provisioning.JobActionPull
}
func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder) error {
cfg := repo.Config()
logger := logging.FromContext(ctx).With("job", job.GetName(), "namespace", job.GetNamespace())
ctx, span := r.tracer.Start(ctx, "provisioning.sync.process",
trace.WithAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
attribute.String("job.action", string(job.Spec.Action)),
attribute.String("repository.name", cfg.Name),
attribute.String("repository.namespace", cfg.Namespace),
),
)
defer span.End()
start := time.Now()
outcome := utils.ErrorOutcome
totalChangesMade := 0
defer func() {
r.metrics.RecordJob(string(provisioning.JobActionPull), outcome, totalChangesMade, time.Since(start).Seconds())
span.SetAttributes(
attribute.String("outcome", outcome),
attribute.Int("changes_made", totalChangesMade),
)
}()
// Check if we are onboarding from legacy storage
// HACK -- this should be handled outside of this worker
if r.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) {
err := fmt.Errorf("sync not supported until storage has migrated")
return tracing.Error(span, err)
}
rw, ok := repo.(repository.ReaderWriter)
if !ok {
err := fmt.Errorf("sync job submitted for repository that does not support read-write")
return tracing.Error(span, err)
}
syncStatus := job.Status.ToSyncStatus(job.Name)
// Preserve last ref
lastRef := repo.Config().Status.Sync.LastRef
syncStatus.LastRef = lastRef
// Ensure the sync state is set to 'working' if not already set or still pending.
// FIXME: This should not be needed as the progress recorder should have set it to 'working' by now.
syncStatus.State = provisioning.JobStateWorking
// Update sync status at start using granular JSON patch operations
// Only patch fields that are actually being set to avoid overwriting with zero values
patchOperations := []map[string]interface{}{
{
"op": "replace",
"path": "/status/sync/state",
"value": syncStatus.State,
},
{
"op": "replace",
"path": "/status/sync/job",
"value": syncStatus.JobID,
},
{
"op": "replace",
"path": "/status/sync/started",
"value": syncStatus.Started,
},
}
progress.SetMessage(ctx, "update sync status at start")
statusCtx, statusSpan := r.tracer.Start(ctx, "provisioning.sync.update_start_status")
if err := r.patchStatus(statusCtx, cfg, patchOperations...); err != nil {
statusSpan.End()
logger.Error("failed to update the repository status at the start of the sync job", "error", err)
err = fmt.Errorf("update repo with job status at start: %w", err)
return tracing.Error(span, err)
}
statusSpan.End()
setupCtx, setupSpan := r.tracer.Start(ctx, "provisioning.sync.setup_clients")
repositoryResources, err := r.repositoryResources.Client(setupCtx, rw)
if err != nil {
setupSpan.End()
logger.Error("failed to create repository resources client", "error", err)
setupError := fmt.Errorf("create repository resources client: %w", err)
progress.Complete(ctx, setupError)
return tracing.Error(span, setupError)
}
clients, err := r.clients.Clients(setupCtx, cfg.Namespace)
if err != nil {
setupSpan.End()
logger.Error("failed to get clients for the repository", "error", err)
setupError := fmt.Errorf("get clients for %s: %w", cfg.Name, err)
progress.Complete(ctx, setupError)
return tracing.Error(span, setupError)
}
setupSpan.End()
syncCtx, syncSpan := r.tracer.Start(ctx, "provisioning.sync.execute")
progress.SetMessage(ctx, "execute sync job")
progress.StrictMaxErrors(20) // make it stop after 20 errors
currentRef, syncError := r.syncer.Sync(syncCtx, rw, *job.Spec.Pull, repositoryResources, clients, progress)
jobStatus := progress.Complete(ctx, syncError)
syncStatus = jobStatus.ToSyncStatus(job.Name)
if syncError != nil {
logger.Debug("failed to sync the repository", "error", syncError)
_ = tracing.Error(syncSpan, syncError)
} else {
outcome = utils.SuccessOutcome
for _, summary := range jobStatus.Summary {
totalChangesMade += int(summary.Create + summary.Update + summary.Delete)
}
}
syncSpan.End()
if syncStatus.State != provisioning.JobStateError {
syncStatus.LastRef = currentRef
} else {
// Preserve the original lastRef on error
syncStatus.LastRef = lastRef
}
progress.SetMessage(ctx, "update status and stats")
patchOperations = []map[string]interface{}{
{
"op": "replace",
"path": "/status/sync",
"value": syncStatus,
},
}
finalCtx, finalSpan := r.tracer.Start(ctx, "provisioning.sync.update_final_status")
// Only add stats patch if stats are not nil
stats, err := repositoryResources.Stats(finalCtx)
switch {
case err != nil:
logger.Error("unable to read stats", "error", err)
finalSpan.SetAttributes(attribute.String("stats.error", err.Error()))
case stats == nil:
logger.Error("stats are nil")
finalSpan.SetAttributes(attribute.Bool("stats.nil", true))
case len(stats.Managed) == 1:
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/stats",
"value": stats.Managed[0].Stats,
})
default:
logger.Warn("unexpected number of managed stats", "count", len(stats.Managed))
finalSpan.SetAttributes(attribute.Int("stats.unexpected_count", len(stats.Managed)))
}
// Only patch the specific fields we want to update, not the entire status
if err := r.patchStatus(finalCtx, cfg, patchOperations...); err != nil {
finalSpan.End()
logger.Error("failed to update the repository status at the end of the sync job", "error", err)
err = fmt.Errorf("update repo with job final status: %w", err)
return tracing.Error(span, err)
}
finalSpan.End()
return syncError
}