-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a higher-level "canonical" reconciler API #2582
Comments
Not sure about the finalizer aspect, because that is optional, reconcilers might or might not need one. The rest generally makes sense |
/kind support feature |
That's a fair point. Reducing boilerplate is my main goal with this proposal so the interface should probably be kept as small as possible. Perhaps finalizers could be supported by implementing an optional interface. That would keep package canonical
// PointerObject is anything that is a pointer AND a client.Object, not a pointer to a client.Object. This type is
// needed to allow us to Get objects using generics. Example:
//
// var obj PO = new(T)
// r.client.Get(ctx, req.NamespacedName, obj)
//
type PointerObject[T any] interface {
*T
client.Object
}
// Reconciler is a type that can be used to reconcile an object in Kubernetes. It behaves similarly to
// reconcile.Reconciler, but it handles some of the boilerplate for you such as handling finalizers and
// updating the object if the reconciler modifies it.
type Reconciler[T any, PO PointerObject[T]] interface {
// Reconcile is called when the object is created or updated. It is passed the object that was created or updated.
// Deleted objects are not passed to this method. Deleted objects can be handled by also implementing the Finalizer
// interface, however that is optional.
Reconcile(ctx context.Context, obj PO) (ctrl.Result, error)
}
// Finalizer is an interface that can be implemented by a Reconciler to add a finalizer to the object. If the object is
// deleted, Finalize will be called. If Finalize returns a nil error, the finalizer will be removed from the object in
// Kubernetes.
type Finalizer[T any, PO PointerObject[T]] interface {
// Finalizer is the name of the finalizer that will be added to the object. If this is empty, no finalizer will be
// added and Finalize will not be called.
Finalizer() string
// Finalize is called when the object is deleted if Finalizer returns a non-empty string. It is passed the object
// that was deleted. If Finalize returns a nil error, the finalizer will be removed from the object in Kubernetes.
Finalize(ctx context.Context, obj PO) error
} |
I guess optional interface is a possibility but I am not a huge fan of this, as it means that we'd be adding even more inversion of control which IMHO makes the code harder to understand, as you need to know things that are not visible in the code of a given reconciler. Other than that, the reconciler interface can be simplified to
There is IMHO no need to complicate things in an attempt to enforce the |
I agree that the visibility of the finalizing behaviour is not great with the use of the optional interface. If the Perhaps it would be beneficial to add a simpler package typed
type Reconciler[T client.Object] interface {
Reconcile(context.Context, T) (reconcile.Result, error)
}
type reconciler[T any, PO PointerObject[T]] struct {
inner Reconciler[PO]
client client.Client
}
func (r *reconciler[T, PO]) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// We need the type parameter T here to initialize obj because we can't initialize a generic type with a literal and
// obj must not be nil. For example `PO{}` is invalid.
var obj PO = new(T)
if err := r.client.Get(ctx, req.NamespacedName, obj); err != nil {
return ctrl.Result{}, err
}
return r.inner.Reconcile(ctx, obj)
}
You're right, the func (r *typedReconciler[T, PO]) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// We need the type parameter T here to initialize obj because we can't initialize a generic type with a literal and
// obj must not be nil. For example `PO{}` is invalid.
var obj PO = new(T)
if err := r.client.Get(ctx, req.NamespacedName, obj); err != nil {
return ctrl.Result{}, err
}
return r.inner.Reconcile(ctx, obj)
} |
What I meant is that the visibility of the finalizer behavior is not great due to the inversion of control, regardless of if the interface is optional or not. I don't think the six lines or what it is to add the finalizer if obj is undeleted and to finalize and then remove the finalizer if not are that big a deal. If they are to you, you ofc have the option to build something downstream.
You can use
|
Right, it's clear that the finalizing behaviour is too much inversion of control. So what do you think about starting off with simply a "typed" reconciler? By this, I mean the code example in this comment but with the use of reflection to simplify the generics. This would seem to me to cover the most common use cases with the least amount of inversion of control. I'd be willing to open a PR for this. |
Are we just trying to abstract away the So that we can just get to the business logic around the Reconciler? From the context of this issue/discussion, it sounds like the boilerplate associated with this is the consensus with how we approach interface we want to export. The interface defined from both of you all seems to get to the object being the thing we would act upon and allow us to not have to provide the type Reconciler[T client.Object] interface {
Reconcile(context.Context, T)(reconcile.Result, error)
} However, if what is underlying has to account for trying to replicate the logic around both reconciler interfaces (controller using reconciler.Reconciler) we would have to account for that unless we redo the same logic around this newly introduced use case to then not return the Aside from the reflection semantics (generic or otherwise), would this be the direction we want to go in? |
yeah I'm in general open to the idea, which is why I opened #2221 for it. The main questions I can think of would be a) How do we name this (we have to keep the current |
Conceptually, I think of a typed reconciler as being something that is slightly higher-level than the // Maybe in sigs.k8s.io/controller-runtime/pkg/reconcile/typed
package typed
type Reconciler[T client.Object] interface {
Reconcile(context.Context, T) (reconcile.Result, error)
}
// New returns an adapter that accepts a typed.Reconciler and returns a reconcile.Reconciler.
func New[T client.Object](client client.Client, rec Reconciler[T]) reconcile.Reconciler {
// …
}
// Users of controller-runtime can then use the existing builder as-is simply by calling `typed.New()`.
func (r *MyTypedReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.Example{}).
Complete(typed.New(r.client, r))
} What do you think? |
I think I'd prefer to keep things on the same package, otherwise it might be confusing (hence the question for the name). Maybe Using a constructor works. An alternative might be to have a |
Personally, I find the name If a subpackage is too confusing, then perhaps simply |
No. If they were, there would be no reason to keep the existing |
Ah okay, apologies. There's a gap in my understand of controller-runtime reconcilers. My understanding was that reconcilers were always tied to Kubernetes objects. If that's not the case, then perhaps |
Either |
I would pick ObjectReconciler |
Correct me if I'm wrong, but it seems like everyone who has commented on this issue likes the idea even if there's still some open debate on the exact naming of the API. I haven't contributed to controller-runtime yet, but I see that the contributing guidelines state that the proposal should be accepted before opening a PR. Is the naming something you'd like to sort out before accepting the proposal, or should that be discussed in a PR? |
Naming and technical design is usually sorted in a proposal PR |
Ok thanks for clarifying. Is there a formal way a proposal is accepted or should I just get started on a PR (after I sign the CLA)? |
Just create a PR, no need to be so hesitant :) We can discuss details on the PR. |
This being merged should close this? Do we want to keep this open to keep the discussion going? @alvaroaleman @sbueringer @vincepri |
The Kubernetes project currently lacks enough contributors to adequately respond to all issues. This bot triages un-triaged issues according to the following rules:
You can:
Please send feedback to sig-contributor-experience at kubernetes/community. /lifecycle stale |
/close |
@JamesOwenHall: Closing this issue. In response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository. |
Controller-runtime strikes a good balance between simplifying the development of Kubernetes controllers and still being very flexible. The flexibility of the
reconcile.Reconciler
interface should be maintained but I believe there would be value in also providing a slightly higher-level abstraction to reduce the duplication of common controller patterns.In my experience, controllers can end up looking like the following before even implementing any business logic.
I propose adding a higher-level interface like the following to complement the existing
reconcile.Reconciler
interface. This builds upon #2221 but extends beyond just the use of generics.Alternative designs could make
Finalizer
andFinalize
part of a separate optional interface such that reconcilers would only need to implement them if they used finalizers.This kind of interface would reduce a reconciler to pretty much just the business logic.
I believe a higher-level API like this could help reduce boilerplate and therefore reduce bugs and code duplication, and make controller-runtime more approachable. This new API would not replace the existing
reconcile.Reconciler
API, but rather it would be an opinionated alternative for controllers that fit the "canonical" model.The text was updated successfully, but these errors were encountered: