internal/controllers/resourceslice/slicecleanup.go (158 lines of code) (raw):
package resourceslice
import (
"context"
"fmt"
"slices"
"time"
apiv1 "github.com/Azure/eno/api/v1"
"github.com/Azure/eno/internal/manager"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/workqueue"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
)
// cleanupController is responsible for deleting resource slices when they are no longer needed by their composition.
// It holds a finalizer on slices so they can't be deleted by the k8s GC controller until the composition has been deleted.
// The controller has very little surface area: it deletes slices and removes their finalizers without modifying the composition.
//
// A non-cached apiserver client is used to be absolutely sure that a slice can be safely deleted.
// But we avoid using it by excluding brand new resource slices and always checking the informer cache first.
type cleanupController struct {
client client.Client
noCacheReader client.Reader
}
func NewCleanupController(mgr ctrl.Manager) error {
c := &cleanupController{
client: mgr.GetClient(),
noCacheReader: mgr.GetAPIReader(),
}
return ctrl.NewControllerManagedBy(mgr).
For(&apiv1.ResourceSlice{}).
WatchesRawSource(source.Kind(mgr.GetCache(), &apiv1.Composition{}, c.newCompEventHandler())).
WithLogConstructor(manager.NewLogConstructor(mgr, "sliceCleanupController")).
Complete(c)
}
func (c *cleanupController) newCompEventHandler() handler.TypedEventHandler[*apiv1.Composition, reconcile.Request] {
fn := func(c *apiv1.Composition, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
for _, syn := range []*apiv1.Synthesis{c.Status.InFlightSynthesis, c.Status.CurrentSynthesis, c.Status.PreviousSynthesis} {
if syn == nil {
continue
}
for _, ref := range syn.ResourceSlices {
q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Name: ref.Name, Namespace: c.Namespace}})
}
}
}
return &handler.TypedFuncs[*apiv1.Composition, reconcile.Request]{
CreateFunc: func(ctx context.Context, e event.TypedCreateEvent[*apiv1.Composition], q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
fn(e.Object, q)
},
UpdateFunc: func(ctx context.Context, e event.TypedUpdateEvent[*apiv1.Composition], q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
fn(e.ObjectNew, q)
fn(e.ObjectOld, q)
},
DeleteFunc: func(ctx context.Context, e event.TypedDeleteEvent[*apiv1.Composition], q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
if !e.DeleteStateUnknown {
fn(e.Object, q)
}
},
}
}
func (c *cleanupController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := logr.FromContextOrDiscard(ctx).WithValues("resourceSliceName", req.Name, "resourceSliceNamespace", req.Namespace)
slice := &apiv1.ResourceSlice{}
err := c.client.Get(ctx, req.NamespacedName, slice)
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
if err != nil {
logger.Error(err, "failed to get resource slice")
return ctrl.Result{}, err
}
logger = logger.WithValues("synthesisUUID", slice.Spec.SynthesisUUID)
owner := metav1.GetControllerOf(slice)
if owner != nil {
logger = logger.WithValues("compositionName", owner.Name, "compositionNamespace", req.Namespace)
}
ctx = logr.NewContext(ctx, logger)
if slice.DeletionTimestamp != nil {
return c.removeFinalizer(ctx, slice, owner)
}
// Don't bother checking on brand new resource slices
if delta := time.Since(slice.CreationTimestamp.Time); delta < 5*time.Second {
return ctrl.Result{RequeueAfter: delta}, nil
}
del, err := c.shouldDelete(ctx, c.client, slice, owner)
if err != nil {
logger.Error(err, "failed to check if resource slice should be deleted (cached)")
return ctrl.Result{}, err
}
if !del {
return ctrl.Result{}, nil // fail safe for stale cache
}
del, err = c.shouldDelete(ctx, c.noCacheReader, slice, owner)
if err != nil {
logger.Error(err, "failed to check if resource slice should be deleted")
return ctrl.Result{}, err
}
if !del {
return ctrl.Result{}, nil
}
if err := c.client.Delete(ctx, slice, &client.Preconditions{UID: &slice.UID}); err != nil {
logger.Error(err, "failed to delete resource slice")
return ctrl.Result{}, err
}
logger.V(0).Info("deleted unused resource slice", "age", time.Since(slice.CreationTimestamp.Time).Milliseconds())
return ctrl.Result{}, nil
}
func (c *cleanupController) shouldDelete(ctx context.Context, reader client.Reader, slice *apiv1.ResourceSlice, ref *metav1.OwnerReference) (bool, error) {
comp := &apiv1.Composition{}
err := reader.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: slice.Namespace}, comp)
if errors.IsNotFound(err) {
return true, nil
}
if err != nil {
return false, fmt.Errorf("getting composition: %w", err)
}
// Don't delete slices that are part of an active synthesis
if comp.Status.InFlightSynthesis != nil && comp.Status.InFlightSynthesis.UUID == slice.Spec.SynthesisUUID {
return false, nil
}
// Check resource slice references
for _, syn := range []*apiv1.Synthesis{comp.Status.CurrentSynthesis, comp.Status.PreviousSynthesis} {
if syn == nil {
continue
}
idx := slices.IndexFunc(syn.ResourceSlices, func(ref *apiv1.ResourceSliceRef) bool {
return ref.Name == slice.Name
})
if idx != -1 {
return false, nil
}
}
return true, nil
}
// removeFinalizer removes the finalizer from the resource slice if the slice is not needed for deletion of the composition.
// The finalizer exists only to handle a case where the k8s GC controller deletes the resource slices before the composition's deletion has been reconciled.
// So we can safely release finalizers in every case _except_ when the slice is referenced by the current synthesis of a deleting composition.
//
// Since order of informer events isn't guaranteed, it's safer to avoid checking the deletion status of the composition.
// Instead, we check if the slice is part of the current synthesis of a composition that is being deleted.
// If the composition is not being deleted, holding the finalizer until reconciliation has completed is not a bad idea anyway.
func (c *cleanupController) removeFinalizer(ctx context.Context, slice *apiv1.ResourceSlice, ref *metav1.OwnerReference) (ctrl.Result, error) {
logger := logr.FromContextOrDiscard(ctx)
if len(slice.Finalizers) == 0 {
return ctrl.Result{}, nil // shouldn't be possible
}
comp := &apiv1.Composition{}
err := c.client.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: slice.Namespace}, comp)
if client.IgnoreNotFound(err) != nil {
return ctrl.Result{}, fmt.Errorf("getting composition: %w", err)
}
syn := comp.Status.CurrentSynthesis
if syn != nil && syn.Reconciled == nil {
idx := slices.IndexFunc(syn.ResourceSlices, func(ref *apiv1.ResourceSliceRef) bool {
return ref.Name == slice.Name
})
if idx != -1 {
return ctrl.Result{}, err // slice is needed for cleanup
}
}
// It's important to not update the whole slice resource, since our informer cached representation is missing fields (to save memory)
patchJSON := []byte(`[{"op": "remove", "path": "/metadata/finalizers"}]`)
if err := c.client.Patch(ctx, slice, client.RawPatch(types.JSONPatchType, patchJSON)); err != nil {
return ctrl.Result{}, fmt.Errorf("removing finalizer: %w", err)
}
logger.V(0).Info("removed resource slice finalizers")
return ctrl.Result{}, nil
}