-
Notifications
You must be signed in to change notification settings - Fork 1.2k
🏃 Proposal to extract cluster-specifics out of the Manager #1075
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| Move cluster-specific code out of the manager | ||
| =================== | ||
|
|
||
| ## Motivation | ||
|
|
||
| Today, it is already possible to use controller-runtime to build controllers that act on | ||
| more than one cluster. However, this is undocumented and not straight-forward, requiring | ||
| users to look into the implementation details to figure out how to make this work. | ||
|
|
||
| ## Goals | ||
|
|
||
| * Provide an easy-to-discover way to build controllers that act on multiple clusters | ||
| * Decouple the management of `Runnables` from the construction of "things that require a kubeconfig" | ||
| * Do not introduce changes for users that build controllers that act on one cluster only | ||
|
|
||
| ## Non-Goals | ||
|
|
||
| ## Proposal | ||
|
|
||
| Currently, the `./pkg/manager.Manager` has two purposes: | ||
|
|
||
| * Handle running controllers/other runnables and managing their lifecycle | ||
| * Setting up various things to interact with the Kubernetes cluster, | ||
| for example a `Client` and a `Cache` | ||
|
|
||
| This works very well when building controllers that talk to a single cluster, | ||
| however some use-cases require controllers that interact with more than | ||
| one cluster. This multi-cluster usecase is very awkward today, because it | ||
| requires to construct one manager per cluster and adding all subsequent | ||
| managers to the first one. | ||
|
|
||
| This document proposes to move all cluster-specific code out of the manager | ||
| and into a new package and interface, that then gets embedded into the manager. | ||
| This allows to keep the usage for single-cluster cases the same and introduce | ||
| this change in a backwards-compatible manner. | ||
|
|
||
| Furthermore, the manager gets extended to start all caches before any other | ||
| `runnables` are started. | ||
|
|
||
|
|
||
| The new `Cluster` interface will look like this: | ||
|
|
||
| ```go | ||
| type Cluster interface { | ||
| // SetFields will set cluster-specific dependencies on an object for which the object has implemented the inject | ||
| // interface, specifically inject.Client, inject.Cache, inject.Scheme, inject.Config and inject.APIReader | ||
| SetFields(interface{}) error | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tangentially related aside: I'd like to see if we can maybe refactor the internal DI stuff a bit before 1.0 -- the complete lack of type signature, etc bugs me a bit, but I've yet to figure out a better answer.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Its an unrelated topic but IMHO we should try to get rid of all of it, because it makes changes to it runtime errors and not compile-time errors which is very bad
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agreed. I'd like to see a proposal for how to cleanly do that (this is not to sound dismissive or snarky -- I genuinely would like to see a proposal :-) ) |
||
|
|
||
| // GetConfig returns an initialized Config | ||
| GetConfig() *rest.Config | ||
|
|
||
| // GetClient returns a client configured with the Config. This client may | ||
| // not be a fully "direct" client -- it may read from a cache, for | ||
| // instance. See Options.NewClient for more information on how the default | ||
| // implementation works. | ||
| GetClient() client.Client | ||
|
|
||
| // GetFieldIndexer returns a client.FieldIndexer configured with the client | ||
| GetFieldIndexer() client.FieldIndexer | ||
|
|
||
| // GetCache returns a cache.Cache | ||
| GetCache() cache.Cache | ||
|
|
||
| // GetEventRecorderFor returns a new EventRecorder for the provided name | ||
| GetEventRecorderFor(name string) record.EventRecorder | ||
|
|
||
| // GetRESTMapper returns a RESTMapper | ||
| GetRESTMapper() meta.RESTMapper | ||
|
|
||
| // GetAPIReader returns a reader that will be configured to use the API server. | ||
| // This should be used sparingly and only when the client does not fit your | ||
| // use case. | ||
| GetAPIReader() client.Reader | ||
|
|
||
| // GetScheme returns an initialized Scheme | ||
| GetScheme() *runtime.Scheme | ||
|
|
||
| // Start starts the connection tothe Cluster | ||
| Start(<-chan struct{}) error | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are we going to integrate the context work here?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, this proposal is orthogonal to the context work, the |
||
| } | ||
| ``` | ||
|
|
||
| And the current `Manager` interface will change to look like this: | ||
|
|
||
| ```go | ||
| type Manager interface { | ||
| // Cluster holds objects to connect to a cluster | ||
| cluser.Cluster | ||
|
|
||
| // Add will set requested dependencies on the component, and cause the component to be | ||
| // started when Start is called. Add will inject any dependencies for which the argument | ||
| // implements the inject interface - e.g. inject.Client. | ||
| // Depending on if a Runnable implements LeaderElectionRunnable interface, a Runnable can be run in either | ||
| // non-leaderelection mode (always running) or leader election mode (managed by leader election if enabled). | ||
| Add(Runnable) error | ||
|
|
||
| // Elected is closed when this manager is elected leader of a group of | ||
| // managers, either because it won a leader election or because no leader | ||
| // election was configured. | ||
| Elected() <-chan struct{} | ||
|
|
||
| // SetFields will set any dependencies on an object for which the object has implemented the inject | ||
| // interface - e.g. inject.Client. | ||
| SetFields(interface{}) error | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure what will happen when the embeded
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AFAIK this is fine as long as they have the same signature |
||
|
|
||
| // AddMetricsExtraHandler adds an extra handler served on path to the http server that serves metrics. | ||
| // Might be useful to register some diagnostic endpoints e.g. pprof. Note that these endpoints meant to be | ||
| // sensitive and shouldn't be exposed publicly. | ||
| // If the simple path -> handler mapping offered here is not enough, a new http server/listener should be added as | ||
| // Runnable to the manager via Add method. | ||
| AddMetricsExtraHandler(path string, handler http.Handler) error | ||
|
|
||
| // AddHealthzCheck allows you to add Healthz checker | ||
| AddHealthzCheck(name string, check healthz.Checker) error | ||
|
|
||
| // AddReadyzCheck allows you to add Readyz checker | ||
| AddReadyzCheck(name string, check healthz.Checker) error | ||
|
|
||
| // Start starts all registered Controllers and blocks until the Stop channel is closed. | ||
| // Returns an error if there is an error starting any controller. | ||
| // If LeaderElection is used, the binary must be exited immediately after this returns, | ||
| // otherwise components that need leader election might continue to run after the leader | ||
| // lock was lost. | ||
| Start(<-chan struct{}) error | ||
|
|
||
| // GetWebhookServer returns a webhook.Server | ||
| GetWebhookServer() *webhook.Server | ||
| } | ||
| ``` | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be possible to separate this into 3 interfaces? In my point of view, this will make the API clearer. In multicluster environments, you have to call type cluserconnector.ClusterConnector{
// same as above
...
}}
type MultiClusterManager interface {
// Create a new connection to a cluster. Creates a new instance of ClusterConnector and adds it to this manager
Connect(config *rest.Config, name string, opts ...Option) cluserconnector.ClusterConnector
// same as Manager from proposal except cluserconnector.ClusterConnector
Add(Runnable) error
Elected() <-chan struct{}
SetFields(interface{}) error
AddMetricsExtraHandler(path string, handler http.Handler) error
AddHealthzCheck(name string, check healthz.Checker) error
AddReadyzCheck(name string, check healthz.Checker) error
Start(<-chan struct{}) error
GetWebhookServer() *webhook.Server
}
type Manager interface {
cluserconnector.ClusterConnector
MultiClusterManager
}Introducing a factory method
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kramerul Generally I like the idea (although I would probably embedd the Right now LeaderElection is the reason why it IMHO makes sense to define one
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @alvaroaleman, I haven't thought about this aspect. But it's absolutely correct, that you need to have a primary connection for leader election. In this case I would agree to have only one The
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like the idea of having an explicit interface for MultiCluster manager.
+1 A possible alternative is:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added it like this: In all the multi-cluster controllers I've built so far, the actual controller just wants to watch in all clusters but doesn't necessarily know the number or names of them ahead of time which is why a method that returns a map of all clusters is IMHO more useful. If there is one that gets a special treatment, this is still possible.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A number of the patterns I've seen for multicluster end up having clusters come & go over time. Even if we're not going to tackle that now, we should keep that in mind while designing. I'm also not certain about the whole "primary" / "secondary" cluster thing. Perhaps modeling this as
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
IMHO this proposal should do nothing to prevent that but it does not need to do anything yet to simplify that. Once we want such a functionality, we would probably add a
I guess we could probably change
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On second thought, after trying to update the sample, I would like to not make a
And probably more. IMHO, extracting the clusterspecifics out of the manager won't block any of the work mentioned above but allows us to start working on the topic without requiring us to already anticipate all use cases (And is already a big improvement over the status quo).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, fine with doing that in a follow-up |
||
| Furthermore, during startup, the `Manager` will use type assertion to find `Cluster`s | ||
| to be able to start their caches before anything else: | ||
|
|
||
| ```go | ||
| type HasCaches interface { | ||
| GetCache() | ||
| } | ||
| if getter, hasCaches := runnable.(HasCaches); hasCaches { | ||
| m.caches = append(m.caches, getter()) | ||
| } | ||
| ``` | ||
|
|
||
| ```go | ||
| for idx := range cm.caches { | ||
| go func(idx int) {cm.caches[idx].Start(cm.internalStop)} | ||
| } | ||
|
|
||
| for _, cache := range cm.caches { | ||
| cache.WaitForCacheSync(cm.internalStop) | ||
| } | ||
|
|
||
| // Start all other runnables | ||
| ``` | ||
|
|
||
| ## Example | ||
|
|
||
| Below is a sample `reconciler` that will create a secret in a `mirrorCluster` for each | ||
| secret found in `referenceCluster` if none of that name already exists. To keep the sample | ||
| short, it won't compare the contents of the secrets. | ||
|
|
||
| ```go | ||
| type secretMirrorReconciler struct { | ||
| referenceClusterClient, mirrorClusterClient client.Client | ||
| } | ||
|
|
||
| func (r *secretMirrorReconciler) Reconcile(r reconcile.Request)(reconcile.Result, error){ | ||
| s := &corev1.Secret{} | ||
| if err := r.referenceClusterClient.Get(context.TODO(), r.NamespacedName, s); err != nil { | ||
| if kerrors.IsNotFound{ return reconcile.Result{}, nil } | ||
| return reconcile.Result, err | ||
| } | ||
|
|
||
| if err := r.mirrorClusterClient.Get(context.TODO(), r.NamespacedName, &corev1.Secret); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tangent: Not that we need to tackle it here, but we could even create a multicluster client that (ab)used the unused
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sounds like a great idea (but I would prefer to keep that as an orthogonal work item) |
||
| if !kerrors.IsNotFound(err) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand how that is of use, we have to return on NotFound so its handled differently from
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, the pattern just becomes |
||
| return reconcile.Result{}, err | ||
| } | ||
|
|
||
| mirrorSecret := &corev1.Secret{ | ||
| ObjectMeta: metav1.ObjectMeta{Namespace: s.Namespace, Name: s.Name}, | ||
| Data: s.Data, | ||
| } | ||
| return reconcile.Result{}, r.mirrorClusterClient.Create(context.TODO(), mirrorSecret) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func NewSecretMirrorReconciler(mgr manager.Manager, mirrorCluster cluster.Cluster) error { | ||
| return ctrl.NewControllerManagedBy(mgr). | ||
| // Watch Secrets in the reference cluster | ||
| For(&corev1.Secret{}). | ||
| // Watch Secrets in the mirror cluster | ||
| Watches( | ||
| source.NewKindWithCache(&corev1.Secret{}, mirrorCluster.GetCache()), | ||
| &handler.EnqueueRequestForObject{}, | ||
| ). | ||
DirectXMan12 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Complete(&secretMirrorReconciler{ | ||
| referenceClusterClient: mgr.GetClient(), | ||
| mirrorClusterClient: mirrorCluster.GetClient(), | ||
| }) | ||
| } | ||
| } | ||
alvaroaleman marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| func main(){ | ||
|
|
||
| mgr, err := manager.New(cfg1, manager.Options{}) | ||
| if err != nil { | ||
| panic(err) | ||
| } | ||
|
|
||
| mirrorCluster, err := cluster.New(cfg2) | ||
| if err != nil { | ||
| panic(err) | ||
| } | ||
|
|
||
| if err := mgr.Add(mirrorCluster); err != nil { | ||
| panic(err) | ||
| } | ||
|
|
||
| if err := NewSecretMirrorReconciler(mgr, mirrorCluster); err != nil { | ||
| panic(err) | ||
| } | ||
|
|
||
| if err := mgr.Start(signals.SetupSignalHandler()); err != nil { | ||
| panic(err) | ||
| } | ||
| } | ||
| ``` | ||
Uh oh!
There was an error while loading. Please reload this page.