Skip to content

Commit 5fce1c9

Browse files
c2ndevpoiana
authored andcommitted
fix(controller): handle explicitly diff errors, some other minor changes
Signed-off-by: cannarelladev <cannarella.dev@gmail.com>
1 parent 49921ce commit 5fce1c9

File tree

3 files changed

+168
-87
lines changed

3 files changed

+168
-87
lines changed

controllers/falco/controller.go

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package falco
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"time"
2324

@@ -269,20 +270,14 @@ func (r *Reconciler) handleDeletion(ctx context.Context, falco *instancev1alpha1
269270
return false, err
270271
}
271272

272-
// Remove the finalizer using patch.
273-
// Track ResourceVersion to detect if the patch was a no-op (due to cache race conditions).
274-
oldRV := falco.ResourceVersion
275273
patch := client.MergeFrom(falco.DeepCopy())
276274
controllerutil.RemoveFinalizer(falco, finalizer)
277275
if err := r.Patch(ctx, falco, patch); err != nil && !apierrors.IsNotFound(err) {
278276
log.FromContext(ctx).Error(err, "unable to remove finalizer from Falco instance")
279277
return false, err
280278
}
281279

282-
// Only log deletion if the patch actually removed the finalizer
283-
if falco.ResourceVersion != oldRV {
284-
log.FromContext(ctx).Info("Falco instance deleted")
285-
}
280+
log.FromContext(ctx).Info("Falco instance deleted")
286281

287282
return true, nil
288283
}
@@ -357,24 +352,34 @@ func (r *Reconciler) ensureDeployment(ctx context.Context, falco *instancev1alph
357352
// Check if update is needed to avoid unnecessary API writes.
358353
// This is important for K8s < 1.31 where SSA may cause spurious resourceVersion bumps.
359354
// See: https://github.com/kubernetes/kubernetes/issues/124605
355+
var changedFields string
360356
if resourceExists {
361357
comparison, err := diff(existingResource, applyConfig)
362358
if err != nil {
363-
logger.Error(err, "unable to compare resources")
364-
// On error, proceed with apply to be safe
365-
} else if comparison.IsSame() {
366-
logger.V(2).Info("Falco resource is up to date, skipping apply", "kind", falco.Spec.Type)
367-
reconcileCondition.Reason = "ResourceUpToDate"
368-
reconcileCondition.Message = "Resource is up to date"
369-
return nil
359+
if !errors.Is(err, ErrNoManagedFields) {
360+
logger.Error(err, "unable to compare existing resource with desired state")
361+
reconcileCondition.Status = metav1.ConditionFalse
362+
reconcileCondition.Reason = "ResourceComparisonError"
363+
reconcileCondition.Message = "Unable to compare existing resource with desired state: " + err.Error()
364+
return err
365+
}
366+
logger.V(2).Info("No managed fields found, proceeding with apply to take ownership", "kind", falco.Spec.Type)
367+
} else {
368+
if comparison.IsSame() {
369+
logger.V(2).Info("Falco resource is up to date, skipping apply", "kind", falco.Spec.Type)
370+
reconcileCondition.Reason = "ResourceUpToDate"
371+
reconcileCondition.Message = "Resource is up to date"
372+
return nil
373+
}
374+
changedFields = formatChangedFields(comparison)
370375
}
371376
}
372377

373378
if !resourceExists {
374379
logger.Info("Creating Falco resource", "kind", falco.Spec.Type)
375380
}
376381

377-
applyOpts := []client.ApplyOption{client.ForceOwnership, client.FieldOwner("falco-controller")}
382+
applyOpts := []client.ApplyOption{client.ForceOwnership, client.FieldOwner(fieldManager)}
378383
if err = r.Apply(ctx, client.ApplyConfigurationFromUnstructured(applyConfig), applyOpts...); err != nil {
379384
logger.Error(err, "unable to apply resource", "kind", falco.Spec.Type)
380385
reconcileCondition.Status = metav1.ConditionFalse
@@ -388,7 +393,7 @@ func (r *Reconciler) ensureDeployment(ctx context.Context, falco *instancev1alph
388393
reconcileCondition.Reason = "ResourceCreated"
389394
reconcileCondition.Message = "Resource created successfully"
390395
} else {
391-
logger.Info("Falco resource updated", "kind", falco.Spec.Type)
396+
logger.Info("Falco resource updated", "kind", falco.Spec.Type, "changedFields", changedFields)
392397
reconcileCondition.Reason = "ResourceUpdated"
393398
reconcileCondition.Message = "Resource updated successfully"
394399
}
@@ -551,14 +556,20 @@ func (r *Reconciler) ensureResource(ctx context.Context, falco *instancev1alpha1
551556
// Check if update is needed to avoid unnecessary API writes.
552557
// This is important for K8s < 1.31 where SSA may cause spurious resourceVersion bumps.
553558
// See: https://github.com/kubernetes/kubernetes/issues/124605
559+
var changedFields string
554560
if resourceExists {
555561
comparison, err := diff(existingResource, desiredResource)
556562
if err != nil {
557-
logger.Error(err, "unable to compare resources", "type", resourceType)
558-
// On error, proceed with apply to be safe
559-
} else if comparison.IsSame() {
560-
logger.V(3).Info(resourceType+" is up to date, skipping apply", "name", desiredResource.GetName())
561-
return nil
563+
if !errors.Is(err, ErrNoManagedFields) {
564+
return fmt.Errorf("unable to compare existing %s with desired state: %w", resourceType, err)
565+
}
566+
logger.V(3).Info("No managed fields found, proceeding with apply to take ownership", "type", resourceType, "name", desiredResource.GetName())
567+
} else {
568+
if comparison.IsSame() {
569+
logger.V(3).Info(resourceType+" is up to date, skipping apply", "name", desiredResource.GetName())
570+
return nil
571+
}
572+
changedFields = formatChangedFields(comparison)
562573
}
563574
}
564575

@@ -570,7 +581,7 @@ func (r *Reconciler) ensureResource(ctx context.Context, falco *instancev1alpha1
570581
if !resourceExists {
571582
logger.V(3).Info(resourceType+" created", "name", desiredResource.GetName())
572583
} else {
573-
logger.V(3).Info(resourceType+" updated", "name", desiredResource.GetName())
584+
logger.V(3).Info(resourceType+" updated", "name", desiredResource.GetName(), "changedFields", changedFields)
574585
}
575586

576587
return nil

controllers/falco/diff.go

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (C) 2025 The Falco Authors
1+
// Copyright (C) 2026 The Falco Authors
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -17,7 +17,9 @@
1717
package falco
1818

1919
import (
20+
"errors"
2021
"fmt"
22+
"strings"
2123

2224
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2325
"k8s.io/apimachinery/pkg/runtime"
@@ -31,35 +33,10 @@ const (
3133
fieldManager = "falco-controller"
3234
)
3335

34-
// needsUpdate checks if the current object needs to be updated to match the desired state.
35-
// It extracts only the fields managed by this controller and compares them with the desired config.
36-
//
37-
// This avoids unnecessary API writes on Kubernetes versions < 1.31 where Server-Side Apply
38-
// may cause spurious resourceVersion bumps on no-op patches.
39-
// See: https://github.com/kubernetes/kubernetes/issues/124605
40-
func needsUpdate(current runtime.Object, desired *unstructured.Unstructured) (bool, error) {
41-
if current == nil || desired == nil {
42-
return true, nil
43-
}
44-
45-
// Extract only the fields managed by our field manager from the current object
46-
extracted, err := managedfields.ExtractAsUnstructured(current, fieldManager)
47-
if err != nil {
48-
return true, fmt.Errorf("failed to extract managed fields: %w", err)
49-
}
50-
51-
// If no managed fields found, we need to apply
52-
if extracted == nil {
53-
return true, nil
54-
}
55-
56-
// Prune empty fields from both objects before comparison
57-
managedfields.PruneEmptyFields(extracted)
58-
managedfields.PruneEmptyFields(desired)
59-
60-
// Compare the extracted managed fields with the desired state
61-
return managedfields.NeedsUpdate(extracted, desired)
62-
}
36+
// ErrNoManagedFields is returned when no managed fields are found for the field manager.
37+
// This is not a fatal error - it indicates the resource was never managed by this controller
38+
// and should be applied.
39+
var ErrNoManagedFields = errors.New("no managed fields found for field manager")
6340

6441
// diff calculates the difference between the current and desired objects.
6542
// Returns a typed.Comparison that contains Added, Modified, and Removed field sets.
@@ -75,7 +52,7 @@ func diff(current runtime.Object, desired *unstructured.Unstructured) (*typed.Co
7552
}
7653

7754
if extracted == nil {
78-
return nil, fmt.Errorf("no managed fields found for field manager %s", fieldManager)
55+
return nil, ErrNoManagedFields
7956
}
8057

8158
// Deep copy desired to avoid modifying the original
@@ -87,3 +64,28 @@ func diff(current runtime.Object, desired *unstructured.Unstructured) (*typed.Co
8764

8865
return managedfields.Compare(extracted, desiredCopy)
8966
}
67+
68+
// formatChangedFields returns a human-readable summary of the changed fields from a comparison.
69+
func formatChangedFields(comparison *typed.Comparison) string {
70+
if comparison == nil {
71+
return ""
72+
}
73+
74+
var parts []string
75+
76+
if !comparison.Added.Empty() {
77+
parts = append(parts, fmt.Sprintf("added: %s", comparison.Added.String()))
78+
}
79+
if !comparison.Modified.Empty() {
80+
parts = append(parts, fmt.Sprintf("modified: %s", comparison.Modified.String()))
81+
}
82+
if !comparison.Removed.Empty() {
83+
parts = append(parts, fmt.Sprintf("removed: %s", comparison.Removed.String()))
84+
}
85+
86+
if len(parts) == 0 {
87+
return "no changes"
88+
}
89+
90+
return strings.Join(parts, "; ")
91+
}

controllers/falco/diff_test.go

Lines changed: 102 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (C) 2025 The Falco Authors
1+
// Copyright (C) 2026 The Falco Authors
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -17,42 +17,45 @@
1717
package falco
1818

1919
import (
20+
"fmt"
2021
"testing"
2122

2223
"github.com/stretchr/testify/assert"
2324
appsv1 "k8s.io/api/apps/v1"
2425
corev1 "k8s.io/api/core/v1"
2526
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2627
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
28+
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
29+
"sigs.k8s.io/structured-merge-diff/v4/typed"
2730
)
2831

29-
func TestNeedsUpdate(t *testing.T) {
30-
t.Run("nil current returns needs update", func(t *testing.T) {
32+
func TestDiff(t *testing.T) {
33+
t.Run("nil current returns error", func(t *testing.T) {
3134
desired := &unstructured.Unstructured{
3235
Object: map[string]interface{}{
3336
"apiVersion": "v1",
3437
"kind": "ConfigMap",
3538
},
3639
}
37-
result, err := needsUpdate(nil, desired)
38-
assert.NoError(t, err)
39-
assert.True(t, result)
40+
result, err := diff(nil, desired)
41+
assert.Error(t, err)
42+
assert.Nil(t, result)
4043
})
4144

42-
t.Run("nil desired returns needs update", func(t *testing.T) {
45+
t.Run("nil desired returns error", func(t *testing.T) {
4346
current := &corev1.ConfigMap{
4447
TypeMeta: metav1.TypeMeta{
4548
APIVersion: "v1",
4649
Kind: "ConfigMap",
4750
},
4851
ObjectMeta: metav1.ObjectMeta{Name: "test"},
4952
}
50-
result, err := needsUpdate(current, nil)
51-
assert.NoError(t, err)
52-
assert.True(t, result)
53+
result, err := diff(current, nil)
54+
assert.Error(t, err)
55+
assert.Nil(t, result)
5356
})
5457

55-
t.Run("no managed fields returns needs update", func(t *testing.T) {
58+
t.Run("no managed fields returns ErrNoManagedFields", func(t *testing.T) {
5659
current := &appsv1.DaemonSet{
5760
TypeMeta: metav1.TypeMeta{
5861
APIVersion: "apps/v1",
@@ -72,35 +75,100 @@ func TestNeedsUpdate(t *testing.T) {
7275
},
7376
},
7477
}
75-
result, err := needsUpdate(current, desired)
76-
assert.NoError(t, err)
77-
assert.True(t, result) // No managed fields means we need to apply
78+
result, err := diff(current, desired)
79+
assert.Error(t, err)
80+
assert.ErrorIs(t, err, ErrNoManagedFields)
81+
assert.Nil(t, result)
7882
})
7983
}
8084

81-
func TestDiff(t *testing.T) {
82-
t.Run("nil current returns error", func(t *testing.T) {
83-
desired := &unstructured.Unstructured{
84-
Object: map[string]interface{}{
85-
"apiVersion": "v1",
86-
"kind": "ConfigMap",
87-
},
85+
func TestErrNoManagedFields(t *testing.T) {
86+
t.Run("error message is descriptive", func(t *testing.T) {
87+
assert.Contains(t, ErrNoManagedFields.Error(), "no managed fields")
88+
})
89+
90+
t.Run("can be wrapped and unwrapped", func(t *testing.T) {
91+
wrapped := fmt.Errorf("failed to compare: %w", ErrNoManagedFields)
92+
assert.ErrorIs(t, wrapped, ErrNoManagedFields)
93+
})
94+
95+
t.Run("is distinguishable from other errors", func(t *testing.T) {
96+
otherErr := fmt.Errorf("some other error")
97+
assert.NotErrorIs(t, otherErr, ErrNoManagedFields)
98+
})
99+
}
100+
101+
func TestFormatChangedFields(t *testing.T) {
102+
t.Run("nil comparison returns empty string", func(t *testing.T) {
103+
result := formatChangedFields(nil)
104+
assert.Equal(t, "", result)
105+
})
106+
107+
t.Run("empty comparison returns no changes", func(t *testing.T) {
108+
comparison := &typed.Comparison{
109+
Added: &fieldpath.Set{},
110+
Modified: &fieldpath.Set{},
111+
Removed: &fieldpath.Set{},
88112
}
89-
result, err := diff(nil, desired)
90-
assert.Error(t, err)
91-
assert.Nil(t, result)
113+
result := formatChangedFields(comparison)
114+
assert.Equal(t, "no changes", result)
92115
})
93116

94-
t.Run("nil desired returns error", func(t *testing.T) {
95-
current := &corev1.ConfigMap{
96-
TypeMeta: metav1.TypeMeta{
97-
APIVersion: "v1",
98-
Kind: "ConfigMap",
99-
},
100-
ObjectMeta: metav1.ObjectMeta{Name: "test"},
117+
t.Run("only added fields", func(t *testing.T) {
118+
added := fieldpath.NewSet(fieldpath.MakePathOrDie("spec", "replicas"))
119+
comparison := &typed.Comparison{
120+
Added: added,
121+
Modified: &fieldpath.Set{},
122+
Removed: &fieldpath.Set{},
101123
}
102-
result, err := diff(current, nil)
103-
assert.Error(t, err)
104-
assert.Nil(t, result)
124+
result := formatChangedFields(comparison)
125+
assert.Contains(t, result, "added:")
126+
assert.Contains(t, result, "spec")
127+
assert.NotContains(t, result, "modified:")
128+
assert.NotContains(t, result, "removed:")
129+
})
130+
131+
t.Run("only modified fields", func(t *testing.T) {
132+
modified := fieldpath.NewSet(fieldpath.MakePathOrDie("spec", "template", "spec", "containers"))
133+
comparison := &typed.Comparison{
134+
Added: &fieldpath.Set{},
135+
Modified: modified,
136+
Removed: &fieldpath.Set{},
137+
}
138+
result := formatChangedFields(comparison)
139+
assert.Contains(t, result, "modified:")
140+
assert.Contains(t, result, "spec")
141+
assert.NotContains(t, result, "added:")
142+
assert.NotContains(t, result, "removed:")
143+
})
144+
145+
t.Run("only removed fields", func(t *testing.T) {
146+
removed := fieldpath.NewSet(fieldpath.MakePathOrDie("metadata", "labels"))
147+
comparison := &typed.Comparison{
148+
Added: &fieldpath.Set{},
149+
Modified: &fieldpath.Set{},
150+
Removed: removed,
151+
}
152+
result := formatChangedFields(comparison)
153+
assert.Contains(t, result, "removed:")
154+
assert.Contains(t, result, "metadata")
155+
assert.NotContains(t, result, "added:")
156+
assert.NotContains(t, result, "modified:")
157+
})
158+
159+
t.Run("multiple change types", func(t *testing.T) {
160+
added := fieldpath.NewSet(fieldpath.MakePathOrDie("spec", "newField"))
161+
modified := fieldpath.NewSet(fieldpath.MakePathOrDie("spec", "replicas"))
162+
removed := fieldpath.NewSet(fieldpath.MakePathOrDie("metadata", "annotations"))
163+
comparison := &typed.Comparison{
164+
Added: added,
165+
Modified: modified,
166+
Removed: removed,
167+
}
168+
result := formatChangedFields(comparison)
169+
assert.Contains(t, result, "added:")
170+
assert.Contains(t, result, "modified:")
171+
assert.Contains(t, result, "removed:")
172+
assert.Contains(t, result, "; ")
105173
})
106174
}

0 commit comments

Comments
 (0)