Provisioning: Improve logging and tracing in job processing (#113454)

* Provisioning: Improve logging and tracing in job processing

- Add comprehensive tracing with OpenTelemetry spans across all job operations
- Enhance logging with consistent style: lowercase, concise messages, appropriate log levels
- Use past tense for completed lifecycle events (e.g., 'stopped' vs 'stop')
- Add structured logging with contextual attributes for better searchability
- Handle graceful shutdowns without throwing errors on context cancellation
- Refactor Cleanup method into listExpiredJobs and cleanUpExpiredJob for better code quality
- Avoid double logging by only logging errors when handled locally
- Add tracing and logging to historyjob controller cleanup operations

Files modified:
- pkg/registry/apis/provisioning/jobs/driver.go: Add tracing spans and improve error handling for graceful shutdown
- pkg/registry/apis/provisioning/jobs/concurrent_driver.go: Add tracing and consistent logging
- pkg/registry/apis/provisioning/jobs/persistentstore.go: Add comprehensive tracing and logging to all public methods, refactor cleanup
- apps/provisioning/pkg/controller/historyjob.go: Add tracing and improve logging consistency

* Update pkg/registry/apis/provisioning/jobs/persistentstore.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor logging in persistentstore.go

- Remove debug log statements at the start of job operations for cleaner output
- Maintain structured logging with contextual attributes for improved traceability

Files modified:
- pkg/registry/apis/provisioning/jobs/persistentstore.go: Clean up logging for job operations

* Enhance logging and tracing in provisioning job operations

- Introduce OpenTelemetry spans for better observability in job processing and webhook handling
- Improve structured logging with contextual attributes for key operations
- Remove unnecessary tracing spans in long-running functions to streamline performance
- Update error handling to record errors in spans for better traceability

Files modified:
- pkg/registry/apis/provisioning/controller/repository.go: Add tracing and structured logging to sync job operations
- pkg/registry/apis/provisioning/jobs/concurrent_driver.go: Remove tracing span from long-running function
- pkg/registry/apis/provisioning/jobs/driver.go: Enhance logging and tracing in job processing
- pkg/registry/apis/provisioning/webhooks/webhook.go: Implement tracing and structured logging for webhook connections

* Update pkg/registry/apis/provisioning/jobs/driver.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Improve error handling in ConcurrentJobDriver to differentiate between graceful shutdown and unexpected stops

* Remove unused import in driver.go to clean up code

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Roberto Jiménez Sánchez
2025-11-12 14:59:27 +01:00
committed by GitHub
co-authored by Copilot
parent 6d64c373ce
commit cdc6a6114c
6 changed files with 444 additions and 94 deletions
+31 -21
View File
@@ -58,32 +58,42 @@ func NewHistoryJobController(
func (c *HistoryJobController) cleanupJob(obj interface{}) {
job, ok := obj.(*provisioning.HistoricJob)
if !ok {
c.logger.Error("Expected HistoricJob but got", "type", obj)
c.logger.Error("unexpected object type - expected HistoricJob", "type", obj)
return
}
age := time.Since(job.CreationTimestamp.Time)
if age > c.expirationTime {
namespace := job.Namespace
ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace)
if err != nil {
c.logger.Error("Failed to set provisioning identity for cleanup", "error", err)
// Only cleanup jobs older than expiration time
if age <= c.expirationTime {
return
}
logger := c.logger.With(
"job", job.Name,
"namespace", job.Namespace,
"age", age,
)
logger.Debug("start cleanup expired historic job")
namespace := job.Namespace
ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace)
if err != nil {
logger.Error("failed to set provisioning identity", "error", err)
return
}
ctx = request.WithNamespace(ctx, namespace)
err = c.client.HistoricJobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
logger.Debug("historic job already deleted")
return
}
ctx = request.WithNamespace(ctx, namespace)
err = c.client.HistoricJobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
c.logger.Error("Failed to delete expired HistoryJob",
"namespace", job.Namespace,
"name", job.Name,
"age", age,
"error", err)
} else {
c.logger.Info("Deleted expired HistoryJob",
"namespace", job.Namespace,
"name", job.Name,
"age", age)
}
logger.Error("failed to delete expired historic job", "error", err)
return
}
logger.Info("deleted expired historic job")
}
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -140,6 +141,9 @@ func repoKeyFunc(obj any) (string, error) {
}
// Run starts the RepositoryController.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual processing operations already have their own spans.
func (rc *RepositoryController) Run(ctx context.Context, workerCount int) {
defer utilruntime.HandleCrash()
defer rc.queue.ShutDown()
@@ -386,21 +390,31 @@ func shouldUseIncrementalSync(ctx context.Context, versioned repository.Versione
}
func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) error {
ctx, span := rc.tracer.Start(ctx, "provisioning.controller.add_sync_job")
defer span.End()
span.SetAttributes(
attribute.String("repository", obj.GetName()),
attribute.String("namespace", obj.Namespace),
attribute.Bool("incremental", syncOptions != nil && syncOptions.Incremental),
)
job, err := rc.jobs.Insert(ctx, obj.Namespace, provisioning.JobSpec{
Repository: obj.GetName(),
Action: provisioning.JobActionPull,
Pull: syncOptions,
})
if apierrors.IsAlreadyExists(err) {
logging.FromContext(ctx).Info("sync job already exists, nothing triggered")
logging.FromContext(ctx).Info("sync job already exists")
return nil
}
if err != nil {
span.RecordError(err)
// FIXME: should we update the status of the repository if we fail to add the job?
return fmt.Errorf("error adding sync job: %w", err)
}
logging.FromContext(ctx).Info("sync job triggered", "job", job.Name)
span.SetAttributes(attribute.String("job.name", job.Name))
return nil
}
@@ -75,9 +75,12 @@ func NewConcurrentJobDriver(
// Run starts multiple job drivers concurrently and handles cleanup coordination.
// This is a blocking function that will run until the context is canceled or an error occurs.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual job processing and cleanup operations already have their own spans.
func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers)
logger.Info("starting concurrent job driver with lease-based cleanup", "cleanup_interval", c.cleanupInterval)
logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval)
// Set up cleanup ticker - runs more frequently with lease-based approach
cleanupTicker := time.NewTicker(c.cleanupInterval)
@@ -85,7 +88,7 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
// Initial cleanup
if err := c.store.Cleanup(ctx); err != nil {
logger.Error("failed to clean up old jobs at start", "error", err)
logger.Error("failed initial cleanup", "error", err)
}
var wg sync.WaitGroup
@@ -99,10 +102,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
select {
case <-cleanupTicker.C:
if err := c.store.Cleanup(ctx); err != nil {
logger.Error("failed to cleanup jobs", "error", err)
logger.Error("failed cleanup", "error", err)
}
case <-ctx.Done():
logger.Debug("cleanup goroutine stopping")
logger.Debug("cleanup routine stopped")
return
}
}
@@ -133,13 +136,13 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
return
}
driverLogger.Debug("starting job driver")
driverLogger.Info("start job driver")
if err := driver.Run(driverCtx); err != nil {
driverLogger.Error("job driver failed", "error", err)
errChan <- err
return
}
driverLogger.Debug("job driver stopped")
driverLogger.Info("job driver stopped")
}(i)
}
@@ -157,6 +160,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
}
}
logger.Info("all job driver workers stopped")
return ctx.Err()
if ctx.Err() != nil {
logger.Info("all job drivers gracefully stopped")
return nil
}
return fmt.Errorf("concurrent job driver stopped unexpectedly")
}
+82 -16
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apiserver/pkg/endpoints/request"
@@ -14,6 +15,7 @@ import (
"github.com/grafana/grafana/apps/provisioning/pkg/apifmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
)
// Store is an abstraction for the storage API.
@@ -105,6 +107,9 @@ func NewJobDriver(
// Run drives jobs to completion. This is a blocking function.
// It will run until the context is canceled or an error occurs.
// This is a thread-safe function; it may be called from multiple goroutines.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual job processing operations already have their own spans.
func (d *jobDriver) Run(ctx context.Context) error {
jobTicker := time.NewTicker(d.jobInterval)
defer jobTicker.Stop()
@@ -122,7 +127,8 @@ func (d *jobDriver) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
logger.Info("job driver stopped")
return nil // Context cancellation is expected during shutdown
case <-jobTicker.C:
d.processJobsUntilDoneOrError(ctx)
case <-d.notifications:
@@ -134,6 +140,11 @@ func (d *jobDriver) Run(ctx context.Context) error {
// This will keep processing jobs until there are none left (or we hit an error)
func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) {
for {
// Check if context is cancelled before attempting to claim jobs
if ctx.Err() != nil {
return
}
err := d.claimAndProcessOneJob(ctx)
if err != nil {
if !errors.Is(err, ErrNoJobs) {
@@ -145,11 +156,17 @@ func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) {
}
func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.claim_and_process_one_job")
defer span.End()
logger := logging.FromContext(ctx)
// Claim a job to work on.
claimedJob, rollback, err := d.store.Claim(ctx)
if err != nil {
if !errors.Is(err, ErrNoJobs) {
span.RecordError(err)
}
return apifmt.Errorf("failed to claim job: %w", err)
}
// Ensure that the job is cleaned up if we fail to complete it.
@@ -159,9 +176,15 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
namespace := claimedJob.GetNamespace()
logger = logger.With("job", claimedJob.GetName(), "namespace", namespace)
ctx = logging.Context(ctx, logger)
logger.Debug("claimed a job")
d.currentJob = claimedJob
span.SetAttributes(
attribute.String("job.name", claimedJob.GetName()),
attribute.String("job.namespace", namespace),
attribute.String("job.repository", claimedJob.Spec.Repository),
attribute.String("job.action", string(claimedJob.Spec.Action)),
)
// Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it.
// Incidentally, this also limits our permissions to only the namespace of the job.
ctx = request.WithNamespace(ctx, namespace)
@@ -188,11 +211,26 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
end := time.Now()
logger.Debug("job processed", "duration", end.Sub(recorder.Started()), "error", err)
// Capture job timeout
if jobctx.Err() != nil && err == nil {
// Check if parent context was cancelled (graceful shutdown)
if ctx.Err() != nil {
logger.Debug("context cancel - job will retry")
// Don't complete the job - let it be retried by another worker
d.mu.Lock()
d.currentJob = nil
d.mu.Unlock()
return nil
}
// Capture job timeout (but not parent context cancellation)
if jobctx.Err() != nil && err == nil && ctx.Err() == nil {
err = jobctx.Err()
}
// Record job processing error on span
if err != nil {
span.RecordError(err)
}
// Complete the job
d.mu.Lock()
d.currentJob.Status = recorder.Complete(ctx, err)
@@ -205,27 +243,29 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
err = d.historicJobs.WriteJob(ctx, d.currentJob.DeepCopy())
if err != nil {
// We're not going to return this as it is not critical. Not ideal, but not critical.
logger.Warn("failed to create historic job", "historic_job", *d.currentJob, "error", err)
} else {
logger.Debug("created historic job", "historic_job", *d.currentJob)
logger.Warn("failed to write historic job", "error", err)
}
// Mark the job as completed.
if err := d.store.Complete(ctx, d.currentJob); err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", d.currentJob.GetName(), d.currentJob.GetNamespace(), err)
}
logger.Debug("job completed")
logger.Info("job complete")
return nil
}
// leaseRenewalLoop continuously renews the lease for a job until the context is cancelled.
// If lease renewal fails persistently, it signals via the leaseExpired channel.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// for the lifetime of a job. Individual RenewLease calls already have their own spans.
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, leaseExpired chan struct{}) {
ticker := time.NewTicker(d.leaseRenewalInterval)
defer ticker.Stop()
logger.Debug("starting lease renewal loop", "renewal_interval", d.leaseRenewalInterval)
logger.Debug("start lease renewal loop", "renewal_interval", d.leaseRenewalInterval)
consecutiveFailures := 0
maxFailures := 3 // Allow a few failures before giving up
@@ -233,7 +273,7 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger,
for {
select {
case <-ctx.Done():
logger.Debug("lease renewal loop stopping")
logger.Debug("lease renewal loop stopped")
return
case <-ticker.C:
d.mu.Lock()
@@ -267,13 +307,12 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger,
logger.Debug("lease renewal recovered", "previous_failures", consecutiveFailures)
}
consecutiveFailures = 0
logger.Debug("lease renewed successfully")
}
}
}
}
// processJobWithLeaseCheck processes a job but aborts if the lease expires.
// processJobWithLeaseCheck processes a job but aborts if the lease expires or context is cancelled.
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
// Run the job processing in a goroutine so we can monitor lease expiry
resultChan := make(chan error, 1)
@@ -287,11 +326,16 @@ func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobPr
case <-leaseExpired:
return apifmt.Errorf("job aborted due to lease expiry")
case <-ctx.Done():
// Return context error directly - caller will determine if this is due to graceful shutdown
// or job timeout based on which context was cancelled
return ctx.Err()
}
}
func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.process_job")
defer span.End()
logger := logging.FromContext(ctx)
d.mu.Lock()
if d.currentJob == nil {
@@ -305,6 +349,11 @@ func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder
namespace := d.currentJob.Namespace
d.mu.Unlock()
span.SetAttributes(
attribute.String("job.repository", repoName),
attribute.String("job.action", string(job.Spec.Action)),
)
for _, worker := range d.workers {
if !worker.IsSupported(ctx, *job) {
continue
@@ -312,12 +361,13 @@ func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder
repo, err := d.repoGetter.GetRepository(ctx, namespace, repoName)
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to get repository '%s': %w", repoName, err)
}
r := repo.Config()
if r.DeletionTimestamp != nil && !r.DeletionTimestamp.IsZero() {
logger.Info("repository is marked for deletion, skipping processing job",
logger.Info("repository marked for deletion - skip job",
"name", r.Name,
"namespace", r.Namespace,
"deletionTimestamp", r.DeletionTimestamp,
@@ -325,14 +375,23 @@ func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder
return nil
}
return worker.Process(ctx, repo, *job, recorder)
err = worker.Process(ctx, repo, *job, recorder)
if err != nil {
span.RecordError(err)
}
return err
}
return apifmt.Errorf("no workers were registered to handle the job")
err := apifmt.Errorf("no workers were registered to handle the job")
span.RecordError(err)
return err
}
func (d *jobDriver) onProgress() ProgressFn {
return func(ctx context.Context, status provisioning.JobStatus) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.update_progress")
defer span.End()
logging.FromContext(ctx).Debug("job progress", "status", status)
const maxRetries = 3
@@ -376,9 +435,16 @@ func (d *jobDriver) onProgress() ProgressFn {
// Update succeeded, update our local copy
*d.currentJob = *updated
d.mu.Unlock()
span.SetAttributes(
attribute.String("job.state", string(status.State)),
attribute.Int("attempt", attempt+1),
)
return nil
}
return apifmt.Errorf("failed to update job progress after %d attempts", maxRetries)
err := apifmt.Errorf("failed to update job progress after %d attempts", maxRetries)
span.RecordError(err)
return err
}
}
@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
@@ -18,6 +19,7 @@ import (
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/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/prometheus/client_golang/prometheus"
)
@@ -100,6 +102,16 @@ func NewJobStore(provisioningClient client.ProvisioningV0alpha1Interface, expiry
// If err is not nil, the job and rollback values are always nil.
// The err may be ErrNoJobs if there are no jobs to claim.
func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.claim")
defer func() {
if err != nil && !errors.Is(err, ErrNoJobs) {
span.RecordError(err)
}
span.End()
}()
logger := logging.FromContext(ctx).With("operation", "claim")
requirement, err := labels.NewRequirement(LabelJobClaim, selection.DoesNotExist, nil)
if err != nil {
return nil, nil, apifmt.Errorf("could not create requirement: %w", err)
@@ -114,9 +126,12 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
}
if len(jobs.Items) == 0 {
logger.Debug("no jobs available to claim")
return nil, nil, ErrNoJobs
}
logger.Debug("found jobs available", "count", len(jobs.Items))
for _, job := range jobs.Items {
if job.Labels == nil {
job.Labels = make(map[string]string)
@@ -145,6 +160,20 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
return nil, nil, apifmt.Errorf("failed to claim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Info("job claim complete",
"job", updatedJob.GetName(),
"namespace", updatedJob.GetNamespace(),
"repository", updatedJob.Spec.Repository,
"action", updatedJob.Spec.Action,
)
span.SetAttributes(
attribute.String("job.name", updatedJob.GetName()),
attribute.String("job.namespace", updatedJob.GetNamespace()),
attribute.String("job.repository", updatedJob.Spec.Repository),
attribute.String("job.action", string(updatedJob.Spec.Action)),
)
return updatedJob.DeepCopy(), func() {
// Rolling back does not need to care about the parent's cancellation state.
// This will also use the parent context (i.e. from the for loop!), ensuring we have permissions to do this.
@@ -181,50 +210,99 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
}
// We failed to claim any jobs.
logger.Debug("no jobs claimed - all already claimed by others")
return nil, nil, ErrNoJobs
}
// Update saves the job back to the store.
func (s *persistentStore) Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.update")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "update",
"job", job.GetName(),
"namespace", job.GetNamespace(),
)
span.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
)
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
updatedJob, err := s.client.Jobs(job.GetNamespace()).Update(ctx, job, metav1.UpdateOptions{})
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Debug("update job complete")
return updatedJob, nil
}
// Get retrieves a job by name for conflict resolution.
func (s *persistentStore) Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.get")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "get",
"job", name,
"namespace", namespace,
)
span.SetAttributes(
attribute.String("job.name", name),
attribute.String("job.namespace", namespace),
)
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to grant provisioning identity for job lookup: %w", err)
}
// Use Get to directly fetch the job by name
job, err := s.client.Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to get job by name '%s': %w", name, err)
}
logger.Debug("get job complete")
return job, nil
}
// Complete marks a job as completed and moves it to the historic job store.
// When in the historic store, there is no more claim on the job.
func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) error {
logger := logging.FromContext(ctx).With("namespace", job.GetNamespace(), "job", job.GetName())
ctx, span := tracing.Start(ctx, "provisioning.jobs.complete")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "complete",
"namespace", job.GetNamespace(),
"job", job.GetName(),
)
span.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
attribute.String("job.action", string(job.Spec.Action)),
)
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
@@ -235,6 +313,7 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
// This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store.
err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{})
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Debug("deleted job from job store")
@@ -246,26 +325,44 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
delete(job.Labels, LabelJobClaim)
s.queueMetrics.DecreaseQueueSize(string(job.Spec.Action))
logger.Debug("job completion done")
logger.Debug("complete job complete")
return nil
}
// RenewLease renews the lease for a claimed job, extending its expiry time.
// Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired).
func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.renew_lease")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "renew_lease",
"job", job.GetName(),
"namespace", job.GetNamespace(),
)
span.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
)
if job.Labels == nil || job.Labels[LabelJobClaim] == "" {
return apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Fetch the latest version to avoid conflicts
latestJob, err := s.client.Jobs(job.GetNamespace()).Get(ctx, job.GetName(), metav1.GetOptions{})
if err != nil {
span.RecordError(err)
if apierrors.IsNotFound(err) {
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
}
@@ -274,7 +371,9 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
// Verify we still own the lease
if latestJob.Labels == nil || latestJob.Labels[LabelJobClaim] == "" {
return apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
// Update the claim timestamp to current time
@@ -284,85 +383,213 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
// Update the job in storage with the latest resource version
_, err = s.client.Jobs(job.GetNamespace()).Update(ctx, updatedJob, metav1.UpdateOptions{})
if apierrors.IsConflict(err) {
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
if apierrors.IsNotFound(err) {
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
// Update the job's claim timestamp and resource version in memory
job.Labels[LabelJobClaim] = updatedJob.Labels[LabelJobClaim]
job.ResourceVersion = updatedJob.ResourceVersion
logger.Debug("renew lease complete")
return nil
}
// Cleanup finds jobs with expired leases and marks them as failed.
// This replaces the old cleanup mechanism and should be called more frequently.
func (s *persistentStore) Cleanup(ctx context.Context) error {
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
if err != nil {
return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
}
ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup")
defer span.End()
// Find jobs with expired leases (older than expiry time)
expiry := s.clock().Add(-s.expiry).UnixMilli()
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
if err != nil {
return apifmt.Errorf("could not create requirement: %w", err)
}
startTime := s.clock()
logger := logging.FromContext(ctx).With("operation", "cleanup")
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
jobs, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
LabelSelector: labels.NewSelector().Add(*requirement).String(),
Limit: 100, // Process in batches
})
cancel()
// List expired jobs
jobs, err := s.listExpiredJobs(ctx)
if err != nil {
return apifmt.Errorf("failed to list jobs with expired leases: %w", err)
span.RecordError(err)
return err
}
// If no jobs found, cleanup is complete
if len(jobs.Items) == 0 {
if len(jobs) == 0 {
duration := s.clock().Sub(startTime)
logger.Info("cleanup complete - no expired jobs found", "duration", duration)
span.SetAttributes(
attribute.Int("count", 0),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
for _, job := range jobs.Items {
// Mark job as failed due to lease expiry and archive it
job := job.DeepCopy()
job.Status.State = provisioning.JobStateError
job.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
logger.Info("found expired jobs", "count", len(jobs))
// Set namespace context for the completion
ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Use Complete to properly archive the failed job
if err := s.Complete(ctx, job); err != nil {
if apierrors.IsNotFound(err) {
// Job was already completed/deleted by another process
continue
}
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
// Clean up each expired job
for _, job := range jobs {
if err := s.cleanUpExpiredJob(ctx, job); err != nil {
span.RecordError(err)
return err
}
}
duration := s.clock().Sub(startTime)
logger.Info("cleanup complete",
"duration", duration,
"count", len(jobs),
)
span.SetAttributes(
attribute.Int("count", len(jobs)),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
// listExpiredJobs returns jobs with expired leases.
func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) {
logger := logging.FromContext(ctx)
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
if err != nil {
return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
}
// Find jobs with expired leases (older than expiry time)
expiry := s.clock().Add(-s.expiry).UnixMilli()
expiryTime := time.UnixMilli(expiry)
logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339))
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
if err != nil {
return nil, apifmt.Errorf("could not create requirement: %w", err)
}
listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs")
defer listSpan.End()
listSpan.SetAttributes(
attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)),
attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())),
)
timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second)
defer cancel()
jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
LabelSelector: labels.NewSelector().Add(*requirement).String(),
Limit: 100, // Process in batches
})
if err != nil {
listSpan.RecordError(err)
return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err)
}
listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items)))
return jobList.Items, nil
}
// cleanUpExpiredJob marks a single expired job as failed and archives it.
func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error {
// Calculate how long the job has been expired
var expiredFor time.Duration
var claimTimestamp time.Time
if claimTime, exists := job.Labels[LabelJobClaim]; exists {
claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64)
if parseErr == nil {
claimTimestamp = time.UnixMilli(claimMillis)
expiredFor = s.clock().Sub(claimTimestamp)
}
}
logger := logging.FromContext(ctx).With(
"job", job.GetName(),
"namespace", job.GetNamespace(),
"repository", job.Spec.Repository,
"action", job.Spec.Action,
"expired_for", expiredFor,
)
if !claimTimestamp.IsZero() {
logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339))
}
jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job")
defer jobSpan.End()
jobSpan.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
attribute.String("job.repository", job.Spec.Repository),
attribute.String("job.action", string(job.Spec.Action)),
attribute.String("job.expired_for", expiredFor.String()),
)
// Mark job as failed due to lease expiry and archive it
jobCopy := job.DeepCopy()
jobCopy.Status.State = provisioning.JobStateError
jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
// Set namespace context for the completion
jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace())
if err != nil {
jobSpan.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Use Complete to properly archive the failed job
if err := s.Complete(jobCtx, jobCopy); err != nil {
if apierrors.IsNotFound(err) {
// Job was already completed/deleted by another process - this is expected
logger.Warn("job already completed or deleted by another process")
return nil
}
jobSpan.RecordError(err)
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Info("clean up expired job complete")
return nil
}
func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.insert")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "insert",
"namespace", namespace,
"repository", spec.Repository,
"action", spec.Action,
)
span.SetAttributes(
attribute.String("job.namespace", namespace),
attribute.String("job.repository", spec.Repository),
attribute.String("job.action", string(spec.Action)),
)
if spec.Repository == "" {
return nil, errors.New("missing repository in job")
err := errors.New("missing repository in job")
span.RecordError(err)
return nil, err
}
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", namespace, err)
}
@@ -376,19 +603,27 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro
Spec: spec,
}
if err := mutateJobAction(job); err != nil {
span.RecordError(err)
return nil, err
}
generateJobName(job) // Side-effect: updates the job's name.
logger = logger.With("job", job.GetName())
span.SetAttributes(attribute.String("job.name", job.GetName()))
created, err := s.client.Jobs(namespace).Create(ctx, job, metav1.CreateOptions{})
if apierrors.IsAlreadyExists(err) {
span.RecordError(err)
return nil, apifmt.Errorf("job '%s' in '%s' already exists: %w", job.GetName(), job.GetNamespace(), err)
}
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to create job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
s.queueMetrics.IncreaseQueueSize(string(job.Spec.Action))
logger.Info("insert job complete")
return created, nil
}
@@ -6,6 +6,7 @@ import (
"net/http"
"time"
"go.opentelemetry.io/otel/attribute"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authorization/authorizer"
@@ -17,6 +18,7 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest"
"github.com/prometheus/client_golang/prometheus"
@@ -122,8 +124,16 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim
}
return provisioningapis.WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := logging.FromContext(r.Context()).With("logger", "webhook-connector", "repo", name)
ctx := logging.Context(r.Context(), logger)
ctx, span := tracing.Start(r.Context(), "provisioning.webhook.handle")
defer span.End()
span.SetAttributes(
attribute.String("repository", name),
attribute.String("namespace", namespace),
)
logger := logging.FromContext(ctx).With("logger", "webhook-connector", "repo", name)
ctx = logging.Context(ctx, logger)
if !s.webhooksEnabled {
responder.Error(errors.NewBadRequest("webhooks are not enabled"))
return
@@ -140,12 +150,15 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim
rsp, err := hooks.Webhook(ctx, r)
if err != nil {
span.RecordError(err)
responder.Error(err)
return
}
if rsp == nil {
responder.Error(fmt.Errorf("expecting a response"))
err := fmt.Errorf("expecting a response")
span.RecordError(err)
responder.Error(err)
return
}
@@ -162,12 +175,17 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim
if rsp.Job != nil {
rsp.Job.Repository = name
actionTaken = string(rsp.Job.Action)
span.SetAttributes(attribute.String("job.action", actionTaken))
job, err := s.core.GetJobQueue().Insert(ctx, namespace, *rsp.Job)
if err != nil {
span.RecordError(err)
logger.Error("failed to insert job", "error", err)
responder.Error(err)
return
}
span.SetAttributes(attribute.String("job.name", job.Name))
logger.Info("webhook job created", "job", job.Name, "action", actionTaken)
responder.Object(rsp.Code, job)
return
}