Reconciler Logic
Grafana LabsOfficialPublished by Grafana Labs
- Skill
- Tool integration
- Cloud and DevOps
- Apache-2.0
- No issues found
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
Download the skill as a ZIP:
https://autogineer.com/agent-skills/grafana/reconciler-logic/downloadUnzip 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 resourceIf the operator starts but no reconcile events fire when you create a resource:
Check
BasicReconcileOptions.Namespacematches the resource's namespaceCheck
BasicReconcileOptions.LabelFilters/FieldSelectors— most "no events" issues are filter mismatches (kubectl get <resource> -o yamlto 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}, nilStatus 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—Watcheralternative (event-style Add/Update/Delete callbacks) + decision matrix for watcher vs reconcilerreferences/unmanaged-kinds.md—UnmanagedKindsfor reconciling resources your app doesn't own, withUseOpinionated: falseguidance and common failure modesreferences/registration.md— fullapp.gowiring (client setup, multi-version registration,ValidateManifest) + common failure modes
External resources
Files
- ATTRIBUTION.mdAdded by AutoGineer855 B
- LICENSELicence file added by AutoGineer11.1 KiB
- SKILL.md6.4 KiB
- references/informer-configuration.md12.0 KiB
- references/reconciler-patterns.md9.7 KiB
- references/registration.md2.5 KiB
- references/unmanaged-kinds.md1.7 KiB
- references/watchers.md1.5 KiB
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