Generated banner for Reconciler Logic
Generated logo for Reconciler Logic

Reconciler Logic

Grafana LabsOfficialPublished by Grafana Labs

  • Skill
  • Tool integration
  • Cloud and DevOps
  • Apache-2.0
  • No issues found

From the author

Implement reconcilers and watchers for grafana-app-sdk apps — write `TypedReconciler[*MyKind]` reconcile functions, apply generation-based skip patterns, do conflict-safe status updates via `resource.UpdateObject`, configure `BasicReconcileOptions` (namespace, label/field filters, finalizer management), use `Watcher` for event-style handling, reconcile `UnmanagedKinds` (resources your app doesn't own), and register the whole thing in `app.go`. Use when writing a reconciler, implementing the reconcile loop, adding async business logic, handling create/update/delete events, processing resource state changes, scheduling periodic resyncs with `RequeueAfter`, picking between Watcher and Reconciler, or wiring a controller into `app.go` — even when the user says "process this resource", "handle X events", or "write a controller" without saying "reconciler".
— the description by Grafana Labs

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides the patterns and procedures for implementing the asynchronous business-logic layer in grafana-app-sdk applications. It covers the creation of TypedReconciler functions, managing resource state changes, handling event-driven updates, and configuring informer options. It emphasizes safe status updates using resource.UpdateObject to prevent race conditions and provides guidance on managing finalizers and periodic resyncs to ensure system consistency.

What it does

  • Defines TypedReconciler patterns for strongly-typed resource processing.
  • Implements conflict-safe status updates using resource.UpdateObject.
  • Configures BasicReconcileOptions for namespace, label, and field filtering.
  • Manages resource finalizers for clean deletion of external dependencies.
  • Schedules periodic resyncs and async polling using RequeueAfter.
  • Wires controllers into the application registration process.

When to use it

  • When building the core business logic for a custom Kubernetes operator.
  • When you need to reconcile resources that your application does not own.
  • When handling complex create, update, or delete event cycles.
  • When you need to poll external systems asynchronously and update resource status.
  • When you need to filter which resources trigger reconciliation events.

Advantages

  • Prevents 409 conflict errors during status updates.
  • Reduces unnecessary processing with generation-based skip patterns.
  • Provides a structured, type-safe approach to operator development.
  • Simplifies finalizer management through opinionated defaults.

Usage examples

“How do I implement a reconciler for my custom resource MyKind?”
You define a struct embedding operator.TypedReconciler[*v1alpha1.MyKind], wire a ReconcileFunc to handle the logic, and register it in app.go using AppManagedKind.
“How can I avoid 409 conflicts when updating status?”
Use the resource.UpdateObject function, which fetches the latest object version before applying your update, ensuring atomic status changes.
“How do I make my reconciler poll an external system every 30 seconds?”
Return an operator.ReconcileResult with the RequeueAfter field set to 30 * time.Second within your reconcile function.
“How do I filter events so my reconciler only processes resources in the prod namespace?”
Configure the BasicReconcileOptions with the Namespace field set to 'prod' when registering the reconciler in your app configuration.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/grafana/reconciler-logic/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip reconciler-logic.zip -d .agents/skills

Documentation by Grafana Labs

Reconciler Logic

Reconcilers are the async business-logic layer of a grafana-app-sdk app. The SDK enqueues a reconcile event when a resource is created, updated, or deleted; the reconciler observes the current state and drives the system toward the desired state.

Common Workflows

Implementing a new reconciler end-to-end
# 1. Generate operator stubs for a standalone app
grafana-app-sdk project component add operator

# 2. Implement the ReconcileFunc — see § TypedReconciler below for the pattern

# 3. Register the reconciler in app.go (see references/registration.md)

# 4. Generate, build, and verify it runs
grafana-app-sdk generate
go build ./...
go run ./cmd/operator   # tail logs — reconcile entries should appear when you kubectl-apply a resource

If the operator starts but no reconcile events fire when you create a resource:

  • Check BasicReconcileOptions.Namespace matches the resource's namespace

  • Check BasicReconcileOptions.LabelFilters / FieldSelectors — most "no events" issues are filter mismatches (kubectl get <resource> -o yaml to see labels)

  • Confirm the reconciler was attached to the right (latest) version of the kind

TypedReconciler — preferred pattern

operator.TypedReconciler handles type assertion and provides a strongly-typed ReconcileFunc:

type MyKindReconciler struct {
    operator.TypedReconciler[*v1alpha1.MyKind]
    client resource.Client
}

func NewMyKindReconciler(client resource.Client) *MyKindReconciler {
    r := &MyKindReconciler{client: client}
    r.ReconcileFunc = r.reconcile  // wire the typed func
    return r
}

func (r *MyKindReconciler) reconcile(
    ctx context.Context,
    req operator.TypedReconcileRequest[*v1alpha1.MyKind],
) (operator.ReconcileResult, error) {
    obj := req.Object

    // Skip if already reconciled this generation
    if obj.GetGeneration() == obj.Status.LastObservedGeneration &&
       req.Action != operator.ReconcileActionDeleted {
        return operator.ReconcileResult{}, nil
    }

    log := logging.FromContext(ctx).With("name", obj.GetName(), "namespace", obj.GetNamespace())
    log.Info("reconciling", "action", operator.ResourceActionFromReconcileAction(req.Action))

    if req.Action == operator.ReconcileActionDeleted {
        return operator.ReconcileResult{}, nil
    }

    // ... business logic ...

    // Atomic status update — see § Status updates below
    _, err := resource.UpdateObject(ctx, r.client, obj.GetStaticMetadata().Identifier(),
        func(obj *v1alpha1.MyKind, _ bool) (*v1alpha1.MyKind, error) {
            obj.Status.LastObservedGeneration = obj.GetGeneration()
            obj.Status.State = "Ready"
            return obj, nil
        },
        resource.UpdateOptions{Subresource: "status"},
    )
    return operator.ReconcileResult{}, err
}

ReconcileAction values: ReconcileActionCreated, ReconcileActionUpdated, ReconcileActionDeleted, ReconcileActionResynced.

To requeue after a delay (e.g. polling an external system):

return operator.ReconcileResult{RequeueAfter: 10 * time.Second}, nil

Status updates with resource.UpdateObject

Always use resource.UpdateObject for status writes — it fetches the latest version before applying your update function, avoiding 409 Conflict errors when multiple reconcile events race:

_, err := resource.UpdateObject(ctx, r.client, identifier,
    func(obj *v1alpha1.MyKind, exists bool) (*v1alpha1.MyKind, error) {
        obj.Status.LastObservedGeneration = obj.GetGeneration()
        obj.Status.State = "Ready"
        obj.Status.Message = ""
        return obj, nil
    },
    resource.UpdateOptions{Subresource: "status"},
)

Do not use client.Update for status — it sends the full object and races with spec changes made by users.

Generation-based skip

Check LastObservedGeneration at the top of the reconcile function to avoid re-processing unchanged resources:

if obj.GetGeneration() == obj.Status.LastObservedGeneration {
    return operator.ReconcileResult{}, nil
}

ReconcileOptions

Control informer behavior via BasicReconcileOptions on the AppManagedKind entry:

{
    Kind:       mykindv1alpha1.MyKindKind(),
    Reconciler: reconciler,
    ReconcileOptions: simple.BasicReconcileOptions{
        Namespace:      "my-namespace",          // watch one namespace; default is all
        LabelFilters:   []string{"env=prod"},    // only reconcile matching resources
        FieldSelectors: []string{"status.phase=Running"},
        UsePlain:       false,                   // false = wrap in OpinionatedReconciler (default; manages finalizers)
    },
},

UsePlain: false (the default) wraps your reconciler in OpinionatedReconciler, which manages finalizers automatically so the SDK can guarantee clean deletion.

References

  • references/watchers.md — Watcher alternative (event-style Add/Update/Delete callbacks) + decision matrix for watcher vs reconciler

  • references/unmanaged-kinds.md — UnmanagedKinds for reconciling resources your app doesn't own, with UseOpinionated: false guidance and common failure modes

  • references/registration.md — full app.go wiring (client setup, multi-version registration, ValidateManifest) + common failure modes

External resources

Files

references/reconciler-patterns.md

Reconciler Patterns

Finalizers

Use finalizers to ensure cleanup of external resources before a Kubernetes resource is deleted. Without a finalizer, the resource may be deleted before the reconciler has a chance to clean up. Finalizers are automatically managed by the OpinionatedReconciler and OpinionatedWatcher, but can be manually managed if preferred:

const myKindFinalizer = "my-app.grafana.app/finalizer"

func (r *MyKindReconciler) reconcile(
    ctx context.Context,
    req operator.TypedReconcileRequest[*v1.MyKind],
) (operator.ReconcileResult, error) {
    obj := req.Object

    // Handle deletion
    if obj.DeletionTimestamp != nil {
        if containsString(obj.Finalizers, myKindFinalizer) {
            // Run cleanup
            if err := r.cleanupExternalResources(ctx, obj); err != nil {
                return operator.ReconcileResult{}, fmt.Errorf("cleanup: %w", err)
            }
            // Remove finalizer
            obj.Finalizers = removeString(obj.Finalizers, myKindFinalizer)
            if _, err := r.client.Update(ctx, obj, resource.UpdateOptions{}); err != nil {
                return operator.ReconcileResult{}, fmt.Errorf("removing finalizer: %w", err)
            }
        }
        return operator.ReconcileResult{}, nil
    }

    // Add finalizer on first reconcile
    if !containsString(obj.Finalizers, myKindFinalizer) {
        obj.Finalizers = append(obj.Finalizers, myKindFinalizer)
        if _, err := r.client.Update(ctx, obj, resource.UpdateOptions{}); err != nil {
            return operator.ReconcileResult{}, fmt.Errorf("adding finalizer: %w", err)
        }
        // Return early — the update will trigger another reconcile
        return operator.ReconcileResult{}, nil
    }

    // ... normal reconcile logic ...
}

func containsString(slice []string, s string) bool {
    for _, item := range slice {
        if item == s { return true }
    }
    return false
}

func removeString(slice []string, s string) []string {
    result := make([]string, 0, len(slice))
    for _, item := range slice {
        if item != s { result = append(result, item) }
    }
    return result
}

When managing finalizers manually, be sure to set UsePlain: true for the ManagedKind's ReconcileOptions.

Polling External Systems with RequeueAfter

When an external operation is asynchronous, poll until it completes:

func (r *MyKindReconciler) reconcile(
    ctx context.Context,
    req operator.TypedReconcileRequest[*v1.MyKind],
) (operator.ReconcileResult, error) {
    obj := req.Object

    switch obj.Status.State {
    case "", "Pending":
        // Kick off the async operation
        jobID, err := r.externalClient.StartJob(ctx, buildJobSpec(obj))
        if err != nil {
            return operator.ReconcileResult{}, fmt.Errorf("starting job: %w", err)
        }
        _, err = resource.UpdateObject(ctx, r.client, obj.GetStaticMetadata().Identifier(),
            func(o *v1.MyKind, _ bool) (*v1.MyKind, error) {
                o.Status.State = "Provisioning"
                o.Status.ExternalJobID = jobID
                return o, nil
            },
            resource.UpdateOptions{Subresource: "status"},
        )
        if err != nil {
            return operator.ReconcileResult{}, err
        }
        // Check back in 10 seconds
        return operator.ReconcileResult{RequeueAfter: 10 * time.Second}, nil

    case "Provisioning":
        status, err := r.externalClient.GetJobStatus(ctx, obj.Status.ExternalJobID)
        if err != nil {
            return operator.ReconcileResult{}, fmt.Errorf("checking job status: %w", err)
        }
        var newState, newMessage string
        switch status {
        case "running":
            return operator.ReconcileResult{RequeueAfter: 10 * time.Second}, nil
        case "done":
            newState = "Ready"
        case "failed":
            newState = "Error"
            newMessage = "External job failed"
        default:
            return operator.ReconcileResult{}, fmt.Errorf("unknown provisioning status '%s'", status)
        }
        _, err = resource.UpdateObject(ctx, r.client, obj.GetStaticMetadata().Identifier(),
            func(o *v1.MyKind, _ bool) (*v1.MyKind, error) {
                o.Status.State = newState
                o.Status.Message = newMessage
                return o, nil
            },
            resource.UpdateOptions{Subresource: "status"},
        )
        return operator.ReconcileResult{}, err

    case "Ready":
        // Ensure still in sync
        return r.reconcileReady(ctx, obj)
    }

    return operator.ReconcileResult{}, nil
}

Atomic Status Updates with resource.UpdateObject

Use resource.UpdateObject to update status — it fetches the latest version of the object, applies your update function, then pushes the result. This avoids 409 Conflict errors that occur when a reconcile event races with a user spec change:

_, err := resource.UpdateObject(ctx, r.client, req.Object.GetStaticMetadata().Identifier(),
    func(obj *v1alpha1.MyKind, exists bool) (*v1alpha1.MyKind, error) {
        obj.Status.LastObservedGeneration = req.Object.GetGeneration()
        obj.Status.State = "Ready"
        obj.Status.Message = ""
        return obj, nil
    },
    resource.UpdateOptions{Subresource: "status"},
)

Never use client.Update for status — it sends the full object body and conflicts with concurrent spec writes.

Generation-Based Status Staleness

Set LastObservedGeneration after successfully reconciling a generation. Consumers can check status.lastObservedGeneration == metadata.generation to know if the status is current:

obj.Status.LastObservedGeneration = obj.GetGeneration()

Skip reconciliation entirely if already processed. Note this also skips ReconcileActionResynced events for unchanged resources — this is intentional when you only want to react to spec changes:

if obj.GetGeneration() == obj.Status.LastObservedGeneration {
    return operator.ReconcileResult{}, nil
}

Structured Logging

func (r *MyKindReconciler) reconcile(
    ctx context.Context,
    req operator.TypedReconcileRequest[*v1.MyKind],
) (operator.ReconcileResult, error) {
    log := logging.FromContext(ctx).With(
        "name", req.Object.GetName(),
        "namespace", req.Object.GetNamespace(),
        "generation", req.Object.GetGeneration(),
    )

    log.Info("Starting reconcile")

    if err := r.doWork(ctx, req.Object); err != nil {
        log.Error("Reconcile failed", "error", err)
        return operator.ReconcileResult{}, err
    }

    log.Info("Reconcile complete")
    return operator.ReconcileResult{}, nil
}

Permanent vs Transient Errors

// Mark permanent errors in status and stop retrying
type PermanentError struct{ msg string }
func (e PermanentError) Error() string { return e.msg }

func (r *MyKindReconciler) reconcile(
    ctx context.Context,
    req operator.TypedReconcileRequest[*v1.MyKind],
) (operator.ReconcileResult, error) {
    obj := req.Object
    if err := r.doWork(ctx, obj); err != nil {
        var permErr PermanentError
        if errors.As(err, &permErr) {
            // Update status and don't retry
            msg := permErr.Error()
            _, _ = resource.UpdateObject(ctx, r.client, obj.GetStaticMetadata().Identifier(),
                func(o *v1.MyKind, _ bool) (*v1.MyKind, error) {
                    o.Status.State = "Error"
                    o.Status.Message = msg
                    return o, nil
                },
                resource.UpdateOptions{Subresource: "status"},
            )
            return operator.ReconcileResult{}, nil // nil error = no retry
        }
        // Transient error — SDK will retry with backoff
        return operator.ReconcileResult{}, fmt.Errorf("transient error: %w", err)
    }
    return operator.ReconcileResult{}, nil
}

Testing Reconcilers

The SDK does not ship fake client implementations. Implement the resource.Client interface in your test package as needed. A minimal stub only needs to implement the methods your reconciler actually calls:

type fakeClient struct {
    objects map[string]*v1.MyKind
    updated *v1.MyKind
}

func (f *fakeClient) Get(ctx context.Context, id resource.Identifier) (resource.Object, error) {
    obj, ok := f.objects[id.Name]
    if !ok {
        return nil, fmt.Errorf("not found")
    }
    return obj, nil
}

func (f *fakeClient) Update(ctx context.Context, id resource.Identifier, obj resource.Object, opts resource.UpdateOptions) (resource.Object, error) {
    typed := obj.(*v1.MyKind)
    f.updated = typed
    f.objects[id.Name] = typed
    return typed, nil
}

// Implement remaining resource.Client methods as no-ops or panics.

func TestMyKindReconciler_Reconcile(t *testing.T) {
    obj := &v1.MyKind{
        ObjectMeta: metav1.ObjectMeta{
            Name:      "test",
            Namespace: "default",
        },
        Spec: v1.MyKindSpec{Title: "Test"},
    }

    client := &fakeClient{objects: map[string]*v1.MyKind{"test": obj}}
    fakeExternal := &fakeExternalClient{}

    r := NewMyKindReconciler(client)
    r.externalClient = fakeExternal

    result, err := r.reconcile(context.Background(), operator.TypedReconcileRequest[*v1.MyKind]{
        Object: obj,
        Action: operator.ReconcileActionCreated,
    })

    assert.NoError(t, err)
    assert.Equal(t, operator.ReconcileResult{}, result)
    assert.Equal(t, "Ready", client.updated.Status.State)
}

Context Usage

Always propagate context from Reconcile to all downstream calls. Never create a new background context:

// Good
result, err := r.externalClient.DoWork(ctx, params)

// Bad — loses deadline, cancellation, and trace context
result, err := r.externalClient.DoWork(context.Background(), params)

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Grafana Labs
Publisher
Grafana Labs (Official)
Source
grafana/skills · skills/grafana-app-sdk/reconciler-logic · commit 8fb72a5
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Grafana Labs or Grafana Labs.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download reconciler-logic.zip