Skip to content

Commit 7cb44bc

Browse files
committed
add finalizer package to help with finalizing objects
we've been using this package internally for a while, it's been stable and is better shared here
1 parent 5da3415 commit 7cb44bc

File tree

4 files changed

+604
-0
lines changed

4 files changed

+604
-0
lines changed

finalizer/finalizer.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Package finalizer provides utilities for managing Kubernetes finalizers in
2+
// controller applications.
3+
//
4+
// The core functions GetAddFunc and GetRemoveFunc return functions that can
5+
// add or remove finalizers from Kubernetes objects. These use get-modify-write
6+
// instead of apply/patch operations to ensure they cannot recreate objects
7+
// that have been marked for deletion.
8+
//
9+
// The SetFinalizerHandler provides a handler that automatically adds
10+
// finalizers to objects that don't already have them, excluding objects
11+
// with deletion timestamps.
12+
package finalizer
13+
14+
import (
15+
"context"
16+
17+
"golang.org/x/exp/slices"
18+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
20+
"k8s.io/apimachinery/pkg/runtime"
21+
"k8s.io/apimachinery/pkg/runtime/schema"
22+
"k8s.io/apimachinery/pkg/types"
23+
"k8s.io/client-go/dynamic"
24+
25+
"github.com/authzed/controller-idioms/component"
26+
"github.com/authzed/controller-idioms/typed"
27+
)
28+
29+
type (
30+
AddFunc[K component.KubeObject] func(ctx context.Context, nn types.NamespacedName) (K, error)
31+
RemoveFunc[K component.KubeObject] func(ctx context.Context, nn types.NamespacedName) (K, error)
32+
)
33+
34+
// GetAddFunc returns a function that does a get-modify-write instead of an apply, so that it can't accidentally
35+
// re-create a deleted object (apply does an upsert).
36+
func GetAddFunc[K component.KubeObject](client dynamic.Interface, gvr schema.GroupVersionResource, finalizer, fieldManager string) AddFunc[K] {
37+
return func(ctx context.Context, nn types.NamespacedName) (K, error) {
38+
// get latest object
39+
u, err := client.Resource(gvr).Namespace(nn.Namespace).Get(ctx, nn.Name, metav1.GetOptions{})
40+
if err != nil {
41+
var nilObj K
42+
return nilObj, err
43+
}
44+
obj, err := typed.UnstructuredObjToTypedObj[K](u)
45+
if err != nil {
46+
var nilObj K
47+
return nilObj, err
48+
}
49+
50+
if slices.Contains(obj.GetFinalizers(), finalizer) || obj.GetDeletionTimestamp() != nil {
51+
return obj, nil
52+
}
53+
54+
obj.SetFinalizers(append(obj.GetFinalizers(), finalizer))
55+
56+
finalized, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&obj)
57+
if err != nil {
58+
var nilObj K
59+
return nilObj, err
60+
}
61+
62+
updated, err := client.Resource(gvr).Namespace(obj.GetNamespace()).Update(ctx, &unstructured.Unstructured{Object: finalized}, metav1.UpdateOptions{FieldManager: fieldManager})
63+
if err != nil {
64+
var nilObj K
65+
return nilObj, err
66+
}
67+
68+
return typed.UnstructuredObjToTypedObj[K](updated)
69+
}
70+
}
71+
72+
// GetRemoveFunc returns a function that does a get-modify-write instead of an apply. Unlike when adding, this
73+
// won't be called in cases that could potentially re-create a deleted object, but we do it this way so that the field
74+
// manager operation matches what created the finalizer (`Update`).
75+
func GetRemoveFunc[K component.KubeObject](client dynamic.Interface, gvr schema.GroupVersionResource, finalizer, fieldManager string) RemoveFunc[K] {
76+
return func(ctx context.Context, nn types.NamespacedName) (K, error) {
77+
// get latest object
78+
u, err := client.Resource(gvr).Namespace(nn.Namespace).Get(ctx, nn.Name, metav1.GetOptions{})
79+
if err != nil {
80+
var nilObj K
81+
return nilObj, err
82+
}
83+
obj, err := typed.UnstructuredObjToTypedObj[K](u)
84+
if err != nil {
85+
var nilObj K
86+
return nilObj, err
87+
}
88+
89+
finalizerIdx := slices.Index(obj.GetFinalizers(), finalizer)
90+
91+
if finalizerIdx == -1 || obj.GetDeletionTimestamp() == nil {
92+
return obj, nil
93+
}
94+
obj.SetFinalizers(slices.Delete(obj.GetFinalizers(), finalizerIdx, finalizerIdx+1))
95+
96+
definalized, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&obj)
97+
if err != nil {
98+
var nilObj K
99+
return nilObj, err
100+
}
101+
102+
updated, err := client.Resource(gvr).Namespace(obj.GetNamespace()).Update(ctx, &unstructured.Unstructured{Object: definalized}, metav1.UpdateOptions{FieldManager: fieldManager})
103+
if err != nil {
104+
var nilObj K
105+
return nilObj, err
106+
}
107+
108+
return typed.UnstructuredObjToTypedObj[K](updated)
109+
}
110+
}

finalizer/finalizer_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package finalizer_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
9+
"k8s.io/apimachinery/pkg/runtime"
10+
"k8s.io/apimachinery/pkg/runtime/schema"
11+
"k8s.io/apimachinery/pkg/types"
12+
"k8s.io/client-go/dynamic/fake"
13+
14+
"github.com/authzed/controller-idioms/finalizer"
15+
)
16+
17+
func ExampleGetAddFunc() {
18+
ctx, cancel := context.WithCancel(context.Background())
19+
defer cancel()
20+
21+
// Create a test object without a finalizer
22+
testObj := &unstructured.Unstructured{
23+
Object: map[string]interface{}{
24+
"apiVersion": "example.com/v1",
25+
"kind": "MyObject",
26+
"metadata": map[string]interface{}{
27+
"name": "test-object",
28+
"namespace": "default",
29+
},
30+
},
31+
}
32+
33+
// Create a fake dynamic client with the test object
34+
scheme := runtime.NewScheme()
35+
dynamicClient := fake.NewSimpleDynamicClient(scheme, testObj)
36+
37+
// Define the GVR for our resource
38+
gvr := schema.GroupVersionResource{
39+
Group: "example.com",
40+
Version: "v1",
41+
Resource: "myobjects",
42+
}
43+
44+
// Create an add function for finalizers
45+
addFunc := finalizer.GetAddFunc[*metav1.PartialObjectMetadata](
46+
dynamicClient,
47+
gvr,
48+
"my-controller.example.com/finalizer",
49+
"my-controller",
50+
)
51+
52+
// Use the function to add a finalizer
53+
result, err := addFunc(ctx, types.NamespacedName{
54+
Name: "test-object",
55+
Namespace: "default",
56+
})
57+
if err != nil {
58+
fmt.Printf("Error adding finalizer: %v\n", err)
59+
return
60+
}
61+
62+
fmt.Printf("Finalizer added: %v\n", len(result.GetFinalizers()) > 0)
63+
fmt.Printf("Finalizer name: %s\n", result.GetFinalizers()[0])
64+
65+
// Output: Finalizer added: true
66+
// Finalizer name: my-controller.example.com/finalizer
67+
}
68+
69+
func ExampleGetRemoveFunc() {
70+
ctx, cancel := context.WithCancel(context.Background())
71+
defer cancel()
72+
73+
now := metav1.Now()
74+
75+
// Create a test object with a finalizer and deletion timestamp
76+
testObj := &unstructured.Unstructured{
77+
Object: map[string]interface{}{
78+
"apiVersion": "example.com/v1",
79+
"kind": "MyObject",
80+
"metadata": map[string]interface{}{
81+
"name": "test-object",
82+
"namespace": "default",
83+
"finalizers": []interface{}{
84+
"my-controller.example.com/finalizer",
85+
},
86+
"deletionTimestamp": now.Format("2006-01-02T15:04:05Z"),
87+
},
88+
},
89+
}
90+
91+
// Create a fake dynamic client with the test object
92+
scheme := runtime.NewScheme()
93+
dynamicClient := fake.NewSimpleDynamicClient(scheme, testObj)
94+
95+
// Define the GVR for our resource
96+
gvr := schema.GroupVersionResource{
97+
Group: "example.com",
98+
Version: "v1",
99+
Resource: "myobjects",
100+
}
101+
102+
// Create a remove function for finalizers
103+
removeFunc := finalizer.GetRemoveFunc[*metav1.PartialObjectMetadata](
104+
dynamicClient,
105+
gvr,
106+
"my-controller.example.com/finalizer",
107+
"my-controller",
108+
)
109+
110+
// Use the function to remove a finalizer
111+
result, err := removeFunc(ctx, types.NamespacedName{
112+
Name: "test-object",
113+
Namespace: "default",
114+
})
115+
if err != nil {
116+
fmt.Printf("Error removing finalizer: %v\n", err)
117+
return
118+
}
119+
120+
fmt.Printf("Finalizer removed: %v\n", len(result.GetFinalizers()) == 0)
121+
fmt.Printf("Has deletion timestamp: %v\n", result.GetDeletionTimestamp() != nil)
122+
123+
// Output: Finalizer removed: true
124+
// Has deletion timestamp: true
125+
}
126+
127+
func ExampleGetAddFunc_alreadyHasFinalizer() {
128+
ctx, cancel := context.WithCancel(context.Background())
129+
defer cancel()
130+
131+
// Create a test object that already has the finalizer
132+
testObj := &unstructured.Unstructured{
133+
Object: map[string]interface{}{
134+
"apiVersion": "example.com/v1",
135+
"kind": "MyObject",
136+
"metadata": map[string]interface{}{
137+
"name": "test-object",
138+
"namespace": "default",
139+
"finalizers": []interface{}{
140+
"my-controller.example.com/finalizer",
141+
},
142+
},
143+
},
144+
}
145+
146+
// Create a fake dynamic client with the test object
147+
scheme := runtime.NewScheme()
148+
dynamicClient := fake.NewSimpleDynamicClient(scheme, testObj)
149+
150+
// Define the GVR for our resource
151+
gvr := schema.GroupVersionResource{
152+
Group: "example.com",
153+
Version: "v1",
154+
Resource: "myobjects",
155+
}
156+
157+
// Create an add function for finalizers
158+
addFunc := finalizer.GetAddFunc[*metav1.PartialObjectMetadata](
159+
dynamicClient,
160+
gvr,
161+
"my-controller.example.com/finalizer",
162+
"my-controller",
163+
)
164+
165+
// Use the function - it should be a no-op since finalizer already exists
166+
result, err := addFunc(ctx, types.NamespacedName{
167+
Name: "test-object",
168+
Namespace: "default",
169+
})
170+
if err != nil {
171+
fmt.Printf("Error: %v\n", err)
172+
return
173+
}
174+
175+
fmt.Printf("Finalizer count: %d\n", len(result.GetFinalizers()))
176+
fmt.Printf("Still has finalizer: %v\n", result.GetFinalizers()[0] == "my-controller.example.com/finalizer")
177+
178+
// Output: Finalizer count: 1
179+
// Still has finalizer: true
180+
}

finalizer/set_finalizer.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package finalizer
2+
3+
import (
4+
"context"
5+
6+
"golang.org/x/exp/slices"
7+
"k8s.io/apimachinery/pkg/types"
8+
9+
"github.com/authzed/controller-idioms/component"
10+
"github.com/authzed/controller-idioms/handler"
11+
"github.com/authzed/controller-idioms/typedctx"
12+
)
13+
14+
type ctxKey[K component.KubeObject] interface {
15+
typedctx.SettableContext[K]
16+
typedctx.MustValueContext[K]
17+
}
18+
19+
type SetFinalizerHandler[K component.KubeObject] struct {
20+
FinalizeableObjectCtxKey ctxKey[K]
21+
Finalizer string
22+
AddFinalizer AddFunc[K]
23+
RequeueAPIErr func(context.Context, error)
24+
25+
Next handler.Handler
26+
}
27+
28+
func (s *SetFinalizerHandler[K]) Handle(ctx context.Context) {
29+
obj := s.FinalizeableObjectCtxKey.MustValue(ctx)
30+
if !slices.Contains(obj.GetFinalizers(), s.Finalizer) && obj.GetDeletionTimestamp() == nil {
31+
db, err := s.AddFinalizer(ctx, types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()})
32+
if err != nil {
33+
s.RequeueAPIErr(ctx, err)
34+
return
35+
}
36+
ctx = s.FinalizeableObjectCtxKey.WithValue(ctx, db)
37+
}
38+
s.Next.Handle(ctx)
39+
}

0 commit comments

Comments
 (0)