Repository: kubernetes-sigs/cluster-api
Pull request #9805 (2023-12-04 23:34:16)

🌱 Release v1.4.9
/area release

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
**Once this PR has been reviewed and has the lgtm label**, please assign [furkatgofurov7](https://github.com/furkatgofurov7) for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api).

<details open>
Needs approval from an approver in each of these files:

- **[CHANGELOG/OWNERS](https://github.com/kubernetes-sigs/cluster-api/blob/main/CHANGELOG/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["furkatgofurov7"]} -->
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/cluster-api/pull/9805#pullrequestreview-1763663704" title="Approved">CecileRobertMichon</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[CHANGELOG/OWNERS](https://github.com/kubernetes-sigs/cluster-api/blob/main/CHANGELOG/OWNERS)~~ [CecileRobertMichon]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
sbueringer: /lgtm
k8s-ci-robot: LGTM label has been added.  <details>Git tree hash: 5c8baa479d493f926902bd305e3b49403f1aa13e</details>
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/cluster-api/pull/9805#pullrequestreview-1763663704" title="Approved">CecileRobertMichon</a>*, *<a href="https://github.com/kubernetes-sigs/cluster-api/pull/9805#pullrequestreview-1764510984" title="Approved">furkatgofurov7</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[CHANGELOG/OWNERS](https://github.com/kubernetes-sigs/cluster-api/blob/main/CHANGELOG/OWNERS)~~ [CecileRobertMichon,furkatgofurov7]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 57768ed76..226025f12 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -22,7 +22,7 @@ jobs:
           fetch-depth: 0
       - name: Get changed files
         id: changed-files
-        uses: tj-actions/changed-files@1c938490c880156b746568a518594309cfb3f66b # tag=v40.2.1
+        uses: tj-actions/changed-files@da093c1609db0edd0a037ce9664e135f74bf30d9 # tag=v40.2.0
       - name: Get release version
         id: release-version
         run: |
diff --git a/CHANGELOG/v1.4.9.md b/CHANGELOG/v1.4.9.md
new file mode 100644
index 000000000..3390b4de9
--- /dev/null
+++ b/CHANGELOG/v1.4.9.md
@@ -0,0 +1,33 @@
+## 👌 Kubernetes version support
+
+- Management Cluster: v1.23.x -> v1.27.x
+- Workload Cluster: v1.21.x -> v1.27.x
+
+[More information about version support can be found here](https://cluster-api.sigs.k8s.io/reference/versions.html)
+
+## Changes since v1.4.8
+## :chart_with_upwards_trend: Overview
+- 15 new commits merged
+- 5 bugs fixed 🐛
+
+## :bug: Bug Fixes
+- CAPD: Fix DockerMachine panic (#9690)
+- clusterctl: Fix provider namespace secret not included in clusterctl move (#9749)
+- ClusterResourceSet: Requeue after 1 minute if ErrClusterLocked got hit (#9809)
+- KCP: Allow dropping patches KubeadmControlPlane KubeadmConfig (#9700)
+- Runtime SDK: Set User Agent for test extension correctly (#9751)
+
+## :seedling: Others
+- CAPD: Set Condition, if creating external LB failed. (#9712)
+- Dependency: Bump cert-manager to v1.13.2 (#9657)
+- Dependency: Bump controller runtime to v1.14.7 (#9625)
+- Dependency: Bump github.com/docker/docker from 24.0.5 to 24.0.7 (#9674)
+- Dependency: Bump Go version to v1.20.11 (#9687)
+- Dependency: Bump golang.org/grpc to v1.59.0 (#9628)
+- Dependency: Fix delve version in Tiltfile (#9701)
+- e2e: Improve output of exec.KubectlApply (#9766)
+- KCP: Support admin config for Kubeadm v1.29 (#9685)
+- Release: Revert "Improve release staging build speed" (#9763)
+
+
+_Thanks to all our contributors!_ 😊
diff --git a/Tiltfile b/Tiltfile
index 8dcce208a..1ee422acd 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -541,11 +541,11 @@ def deploy_templates(filename, label, substitutions):
     basename = os.path.basename(filename)
     if basename.endswith(".yaml"):
         if basename.startswith("clusterclass-"):
-            clusterclass_name = basename.replace("clusterclass-", "").replace(".yaml", "")
-            deploy_clusterclass(clusterclass_name, label, filename, substitutions)
+            template_name = basename.replace("clusterclass-", "").replace(".yaml", "")
+            deploy_clusterclass(template_name, label, filename, substitutions)
         elif basename.startswith("cluster-template-"):
-            template_name = basename.replace("cluster-template-", "").replace(".yaml", "")
-            deploy_cluster_template(template_name, label, filename, substitutions)
+            clusterclass_name = basename.replace("cluster-template-", "").replace(".yaml", "")
+            deploy_cluster_template(clusterclass_name, label, filename, substitutions)
 
 def deploy_clusterclass(clusterclass_name, label, filename, substitutions):
     apply_clusterclass_cmd = "cat " + filename + " | " + envsubst_cmd + " | " + kubectl_cmd + " apply --namespace=$NAMESPACE -f - && echo \"ClusterClass created from\'" + filename + "\', don't forget to delete\n\""
diff --git a/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go b/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go
index 80a4da147..e7c6a0274 100644
--- a/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go
+++ b/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go
@@ -239,7 +239,7 @@ func (r *KubeadmConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 		// Requeue if the reconcile failed because the ClusterCacheTracker was locked for
 		// the current cluster because of concurrent access.
 		log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-		return ctrl.Result{RequeueAfter: time.Minute}, nil
+		return ctrl.Result{Requeue: true}, nil
 	}
 	return res, err
 }
diff --git a/cmd/clusterctl/client/alpha/kubeadmcontrolplane.go b/cmd/clusterctl/client/alpha/kubeadmcontrolplane.go
index 0d8c80e85..8f77d19fd 100644
--- a/cmd/clusterctl/client/alpha/kubeadmcontrolplane.go
+++ b/cmd/clusterctl/client/alpha/kubeadmcontrolplane.go
@@ -32,7 +32,7 @@ import (
 // getKubeadmControlPlane retrieves the KubeadmControlPlane object corresponding to the name and namespace specified.
 func getKubeadmControlPlane(ctx context.Context, proxy cluster.Proxy, name, namespace string) (*controlplanev1.KubeadmControlPlane, error) {
 	kcpObj := &controlplanev1.KubeadmControlPlane{}
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return nil, err
 	}
@@ -55,7 +55,7 @@ func setRolloutAfterOnKCP(ctx context.Context, proxy cluster.Proxy, name, namesp
 
 // patchKubeadmControlPlane applies a patch to a KubeadmControlPlane.
 func patchKubeadmControlPlane(ctx context.Context, proxy cluster.Proxy, name, namespace string, patch client.Patch) error {
-	cFrom, err := proxy.NewClient(ctx)
+	cFrom, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/alpha/machinedeployment.go b/cmd/clusterctl/client/alpha/machinedeployment.go
index 301eee10b..ec9fcc78b 100644
--- a/cmd/clusterctl/client/alpha/machinedeployment.go
+++ b/cmd/clusterctl/client/alpha/machinedeployment.go
@@ -38,7 +38,7 @@ import (
 // getMachineDeployment retrieves the MachineDeployment object corresponding to the name and namespace specified.
 func getMachineDeployment(ctx context.Context, proxy cluster.Proxy, name, namespace string) (*clusterv1.MachineDeployment, error) {
 	mdObj := &clusterv1.MachineDeployment{}
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return nil, err
 	}
@@ -61,7 +61,7 @@ func setRolloutAfterOnMachineDeployment(ctx context.Context, proxy cluster.Proxy
 
 // patchMachineDeployment applies a patch to a machinedeployment.
 func patchMachineDeployment(ctx context.Context, proxy cluster.Proxy, name, namespace string, patch client.Patch) error {
-	cFrom, err := proxy.NewClient(ctx)
+	cFrom, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -121,7 +121,7 @@ func findMachineDeploymentRevision(toRevision int64, allMSs []*clusterv1.Machine
 // getMachineSetsForDeployment returns a list of MachineSets associated with a MachineDeployment.
 func getMachineSetsForDeployment(ctx context.Context, proxy cluster.Proxy, md *clusterv1.MachineDeployment) ([]*clusterv1.MachineSet, error) {
 	log := logf.Log
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/clusterctl/client/alpha/rollout_rollbacker.go b/cmd/clusterctl/client/alpha/rollout_rollbacker.go
index 046a8df4a..d7b90ec3b 100644
--- a/cmd/clusterctl/client/alpha/rollout_rollbacker.go
+++ b/cmd/clusterctl/client/alpha/rollout_rollbacker.go
@@ -51,7 +51,7 @@ func (r *rollout) ObjectRollbacker(ctx context.Context, proxy cluster.Proxy, ref
 // rollbackMachineDeployment will rollback to a previous MachineSet revision used by this MachineDeployment.
 func rollbackMachineDeployment(ctx context.Context, proxy cluster.Proxy, md *clusterv1.MachineDeployment, toRevision int64) error {
 	log := logf.Log
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/cert_manager.go b/cmd/clusterctl/client/cluster/cert_manager.go
index a679e3a5b..982f17106 100644
--- a/cmd/clusterctl/client/cluster/cert_manager.go
+++ b/cmd/clusterctl/client/cluster/cert_manager.go
@@ -134,7 +134,7 @@ func (cm *certManagerClient) Images(ctx context.Context) ([]string, error) {
 func (cm *certManagerClient) certManagerNamespaceExists(ctx context.Context) (bool, error) {
 	ns := &corev1.Namespace{}
 	key := client.ObjectKey{Name: certManagerNamespace}
-	c, err := cm.proxy.NewClient(ctx)
+	c, err := cm.proxy.NewClient()
 	if err != nil {
 		return false, err
 	}
@@ -284,7 +284,7 @@ func (cm *certManagerClient) migrateCRDs(ctx context.Context) error {
 		return err
 	}
 
-	c, err := cm.proxy.NewClient(ctx)
+	c, err := cm.proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -471,7 +471,7 @@ func getTestResourcesManifestObjs() ([]unstructured.Unstructured, error) {
 func (cm *certManagerClient) createObj(ctx context.Context, obj unstructured.Unstructured) error {
 	log := logf.Log
 
-	c, err := cm.proxy.NewClient(ctx)
+	c, err := cm.proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -512,7 +512,7 @@ func (cm *certManagerClient) deleteObj(ctx context.Context, obj unstructured.Uns
 	log := logf.Log
 	log.V(5).Info("Deleting", logf.UnstructuredToValues(obj)...)
 
-	cl, err := cm.proxy.NewClient(ctx)
+	cl, err := cm.proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/components.go b/cmd/clusterctl/client/cluster/components.go
index 8386f1b35..3bc6a00e0 100644
--- a/cmd/clusterctl/client/cluster/components.go
+++ b/cmd/clusterctl/client/cluster/components.go
@@ -92,7 +92,7 @@ func (p *providerComponents) Create(ctx context.Context, objs []unstructured.Uns
 
 func (p *providerComponents) createObj(ctx context.Context, obj unstructured.Unstructured) error {
 	log := logf.Log
-	c, err := p.proxy.NewClient(ctx)
+	c, err := p.proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -199,7 +199,7 @@ func (p *providerComponents) Delete(ctx context.Context, options DeleteOptions)
 	}
 
 	// Delete all the provider components.
-	cs, err := p.proxy.NewClient(ctx)
+	cs, err := p.proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -241,7 +241,7 @@ func (p *providerComponents) DeleteWebhookNamespace(ctx context.Context) error {
 	log := logf.Log
 	log.V(5).Info("Deleting", "namespace", webhookNamespaceName)
 
-	c, err := p.proxy.NewClient(ctx)
+	c, err := p.proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/installer.go b/cmd/clusterctl/client/cluster/installer.go
index 80cb0ce6b..018f5cc10 100644
--- a/cmd/clusterctl/client/cluster/installer.go
+++ b/cmd/clusterctl/client/cluster/installer.go
@@ -151,7 +151,7 @@ func waitManagerDeploymentsReady(ctx context.Context, opts InstallOptions, insta
 
 func waitDeploymentReady(ctx context.Context, deployment unstructured.Unstructured, timeout time.Duration, proxy Proxy) error {
 	return wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, timeout, false, func(ctx context.Context) (bool, error) {
-		c, err := proxy.NewClient(ctx)
+		c, err := proxy.NewClient()
 		if err != nil {
 			return false, err
 		}
diff --git a/cmd/clusterctl/client/cluster/inventory.go b/cmd/clusterctl/client/cluster/inventory.go
index a318a2f27..4bf8df498 100644
--- a/cmd/clusterctl/client/cluster/inventory.go
+++ b/cmd/clusterctl/client/cluster/inventory.go
@@ -153,7 +153,7 @@ func (p *inventoryClient) EnsureCustomResourceDefinitions(ctx context.Context) e
 	// NB. NewClient has an internal retry loop that should mitigate temporary connection glitch; here we are
 	// trying to detect persistent connection problems (>10s) before entering in longer retry loops while executing
 	// clusterctl operations.
-	_, err := p.proxy.NewClient(ctx)
+	_, err := p.proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -199,7 +199,7 @@ func (p *inventoryClient) EnsureCustomResourceDefinitions(ctx context.Context) e
 		if apiextensionsv1.SchemeGroupVersion.WithKind("CustomResourceDefinition").GroupKind() == o.GroupVersionKind().GroupKind() {
 			crdKey := client.ObjectKeyFromObject(&o)
 			if err := p.pollImmediateWaiter(ctx, waitInventoryCRDInterval, waitInventoryCRDTimeout, func(ctx context.Context) (bool, error) {
-				c, err := p.proxy.NewClient(ctx)
+				c, err := p.proxy.NewClient()
 				if err != nil {
 					return false, err
 				}
@@ -226,7 +226,7 @@ func (p *inventoryClient) EnsureCustomResourceDefinitions(ctx context.Context) e
 
 // checkInventoryCRDs checks if the inventory CRDs are installed in the cluster.
 func checkInventoryCRDs(ctx context.Context, proxy Proxy) (bool, error) {
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return false, err
 	}
@@ -248,7 +248,7 @@ func checkInventoryCRDs(ctx context.Context, proxy Proxy) (bool, error) {
 }
 
 func (p *inventoryClient) createObj(ctx context.Context, o unstructured.Unstructured) error {
-	c, err := p.proxy.NewClient(ctx)
+	c, err := p.proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -273,7 +273,7 @@ func (p *inventoryClient) Create(ctx context.Context, m clusterctlv1.Provider) e
 	// Create the Kubernetes object.
 	createInventoryObjectBackoff := newWriteBackoff()
 	return retryWithExponentialBackoff(ctx, createInventoryObjectBackoff, func(ctx context.Context) error {
-		cl, err := p.proxy.NewClient(ctx)
+		cl, err := p.proxy.NewClient()
 		if err != nil {
 			return err
 		}
@@ -321,7 +321,7 @@ func (p *inventoryClient) List(ctx context.Context) (*clusterctlv1.ProviderList,
 
 // listProviders retrieves the list of provider inventory objects.
 func listProviders(ctx context.Context, proxy Proxy, providerList *clusterctlv1.ProviderList) error {
-	cl, err := proxy.NewClient(ctx)
+	cl, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -399,7 +399,7 @@ func (p *inventoryClient) CheckCAPIContract(ctx context.Context, options ...Chec
 		o.Apply(opt)
 	}
 
-	c, err := p.proxy.NewClient(ctx)
+	c, err := p.proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/mover.go b/cmd/clusterctl/client/cluster/mover.go
index a93bf2f86..ea9c742ab 100644
--- a/cmd/clusterctl/client/cluster/mover.go
+++ b/cmd/clusterctl/client/cluster/mover.go
@@ -280,7 +280,7 @@ func (o *objectMover) checkProvisioningCompleted(ctx context.Context, graph *obj
 
 // getClusterObj retrieves the clusterObj corresponding to a node with type Cluster.
 func getClusterObj(ctx context.Context, proxy Proxy, cluster *node, clusterObj *clusterv1.Cluster) error {
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -298,7 +298,7 @@ func getClusterObj(ctx context.Context, proxy Proxy, cluster *node, clusterObj *
 
 // getMachineObj retrieves the machineObj corresponding to a node with type Machine.
 func getMachineObj(ctx context.Context, proxy Proxy, machine *node, machineObj *clusterv1.Machine) error {
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -617,7 +617,7 @@ func waitReadyForMove(ctx context.Context, proxy Proxy, nodes []*node, dryRun bo
 
 	log := logf.Log
 
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return errors.Wrap(err, "error creating client")
 	}
@@ -672,7 +672,7 @@ func waitReadyForMove(ctx context.Context, proxy Proxy, nodes []*node, dryRun bo
 
 // patchCluster applies a patch to a node referring to a Cluster object.
 func patchCluster(ctx context.Context, proxy Proxy, n *node, patch client.Patch, mutators ...ResourceMutatorFunc) error {
-	cFrom, err := proxy.NewClient(ctx)
+	cFrom, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -707,7 +707,7 @@ func patchCluster(ctx context.Context, proxy Proxy, n *node, patch client.Patch,
 }
 
 func pauseClusterClass(ctx context.Context, proxy Proxy, n *node, pause bool, mutators ...ResourceMutatorFunc) error {
-	cFrom, err := proxy.NewClient(ctx)
+	cFrom, err := proxy.NewClient()
 	if err != nil {
 		return errors.Wrap(err, "error creating client")
 	}
@@ -802,7 +802,7 @@ func (o *objectMover) ensureNamespaces(ctx context.Context, graph *objectGraph,
 func (o *objectMover) ensureNamespace(ctx context.Context, toProxy Proxy, namespace string) error {
 	log := logf.Log
 
-	cs, err := toProxy.NewClient(ctx)
+	cs, err := toProxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -940,7 +940,7 @@ func (o *objectMover) createTargetObject(ctx context.Context, nodeToCreate *node
 		return nil
 	}
 
-	cFrom, err := o.fromProxy.NewClient(ctx)
+	cFrom, err := o.fromProxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -975,7 +975,7 @@ func (o *objectMover) createTargetObject(ctx context.Context, nodeToCreate *node
 	}
 
 	// Creates the targetObj into the target management cluster.
-	cTo, err := toProxy.NewClient(ctx)
+	cTo, err := toProxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -1037,7 +1037,7 @@ func (o *objectMover) backupTargetObject(ctx context.Context, nodeToCreate *node
 	log := logf.Log
 	log.V(1).Info("Saving", nodeToCreate.identity.Kind, nodeToCreate.identity.Name, "Namespace", nodeToCreate.identity.Namespace)
 
-	cFrom, err := o.fromProxy.NewClient(ctx)
+	cFrom, err := o.fromProxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -1089,7 +1089,7 @@ func (o *objectMover) restoreTargetObject(ctx context.Context, nodeToCreate *nod
 	log.V(1).Info("Restoring", nodeToCreate.identity.Kind, nodeToCreate.identity.Name, "Namespace", nodeToCreate.identity.Namespace)
 
 	// Creates the targetObj into the target management cluster.
-	cTo, err := toProxy.NewClient(ctx)
+	cTo, err := toProxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -1206,7 +1206,7 @@ func (o *objectMover) deleteSourceObject(ctx context.Context, nodeToDelete *node
 		return nil
 	}
 
-	cFrom, err := o.fromProxy.NewClient(ctx)
+	cFrom, err := o.fromProxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/objectgraph.go b/cmd/clusterctl/client/cluster/objectgraph.go
index 942d1fc09..454ec27a8 100644
--- a/cmd/clusterctl/client/cluster/objectgraph.go
+++ b/cmd/clusterctl/client/cluster/objectgraph.go
@@ -405,7 +405,7 @@ func getKindAPIString(typeMeta metav1.TypeMeta) string {
 }
 
 func getCRDList(ctx context.Context, proxy Proxy, crdList *apiextensionsv1.CustomResourceDefinitionList) error {
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
@@ -484,7 +484,7 @@ func (o *objectGraph) Discovery(ctx context.Context, namespace string) error {
 }
 
 func getObjList(ctx context.Context, proxy Proxy, typeMeta metav1.TypeMeta, selectors []client.ListOption, objList *unstructured.UnstructuredList) error {
-	c, err := proxy.NewClient(ctx)
+	c, err := proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/proxy.go b/cmd/clusterctl/client/cluster/proxy.go
index 3a27c4596..c370eb270 100644
--- a/cmd/clusterctl/client/cluster/proxy.go
+++ b/cmd/clusterctl/client/cluster/proxy.go
@@ -58,10 +58,10 @@ type Proxy interface {
 	ValidateKubernetesVersion() error
 
 	// NewClient returns a new controller runtime Client object for working on the management cluster.
-	NewClient(ctx context.Context) (client.Client, error)
+	NewClient() (client.Client, error)
 
 	// CheckClusterAvailable checks if a cluster is available and reachable.
-	CheckClusterAvailable(ctx context.Context) error
+	CheckClusterAvailable() error
 
 	// ListResources lists namespaced and cluster-wide resources for a component matching the labels. Namespaced resources are only listed
 	// in the given namespaces.
@@ -156,7 +156,7 @@ func (k *proxy) GetConfig() (*rest.Config, error) {
 	return restConfig, nil
 }
 
-func (k *proxy) NewClient(ctx context.Context) (client.Client, error) {
+func (k *proxy) NewClient() (client.Client, error) {
 	config, err := k.GetConfig()
 	if err != nil {
 		return nil, err
@@ -165,7 +165,7 @@ func (k *proxy) NewClient(ctx context.Context) (client.Client, error) {
 	var c client.Client
 	// Nb. The operation is wrapped in a retry loop to make newClientSet more resilient to temporary connection problems.
 	connectBackoff := newConnectBackoff()
-	if err := retryWithExponentialBackoff(ctx, connectBackoff, func(_ context.Context) error {
+	if err := retryWithExponentialBackoff(context.TODO(), connectBackoff, func(_ context.Context) error {
 		var err error
 		c, err = client.New(config, client.Options{Scheme: localScheme})
 		if err != nil {
@@ -179,7 +179,7 @@ func (k *proxy) NewClient(ctx context.Context) (client.Client, error) {
 	return c, nil
 }
 
-func (k *proxy) CheckClusterAvailable(ctx context.Context) error {
+func (k *proxy) CheckClusterAvailable() error {
 	// Check if the cluster is available by creating a client to the cluster.
 	// If creating the client times out and never established we assume that
 	// the cluster does not exist or is not reachable.
@@ -191,7 +191,7 @@ func (k *proxy) CheckClusterAvailable(ctx context.Context) error {
 	}
 
 	connectBackoff := newShortConnectBackoff()
-	return retryWithExponentialBackoff(ctx, connectBackoff, func(_ context.Context) error {
+	return retryWithExponentialBackoff(context.TODO(), connectBackoff, func(_ context.Context) error {
 		_, err := client.New(config, client.Options{Scheme: localScheme})
 		return err
 	})
@@ -211,12 +211,12 @@ func (k *proxy) CheckClusterAvailable(ctx context.Context) error {
 //     as the conversion would fail, because the AWS controller hosting the conversion webhook has already been deleted.
 //   - Thus we exclude resources of other providers if we detect that ListResources is called to list resources of a provider.
 func (k *proxy) ListResources(ctx context.Context, labels map[string]string, namespaces ...string) ([]unstructured.Unstructured, error) {
-	cs, err := k.newClientSet(ctx)
+	cs, err := k.newClientSet()
 	if err != nil {
 		return nil, err
 	}
 
-	c, err := k.NewClient(ctx)
+	c, err := k.NewClient()
 	if err != nil {
 		return nil, err
 	}
@@ -320,7 +320,7 @@ func (k *proxy) GetContexts(prefix string) ([]string, error) {
 
 // GetResourceNames returns the list of resource names which begin with prefix.
 func (k *proxy) GetResourceNames(ctx context.Context, groupVersion, kind string, options []client.ListOption, prefix string) ([]string, error) {
-	client, err := k.NewClient(ctx)
+	client, err := k.NewClient()
 	if err != nil {
 		return nil, err
 	}
@@ -393,7 +393,7 @@ func newProxy(kubeconfig Kubeconfig, opts ...ProxyOption) Proxy {
 	return p
 }
 
-func (k *proxy) newClientSet(ctx context.Context) (*kubernetes.Clientset, error) {
+func (k *proxy) newClientSet() (*kubernetes.Clientset, error) {
 	config, err := k.GetConfig()
 	if err != nil {
 		return nil, err
@@ -402,7 +402,7 @@ func (k *proxy) newClientSet(ctx context.Context) (*kubernetes.Clientset, error)
 	var cs *kubernetes.Clientset
 	// Nb. The operation is wrapped in a retry loop to make newClientSet more resilient to temporary connection problems.
 	connectBackoff := newConnectBackoff()
-	if err := retryWithExponentialBackoff(ctx, connectBackoff, func(_ context.Context) error {
+	if err := retryWithExponentialBackoff(context.TODO(), connectBackoff, func(_ context.Context) error {
 		var err error
 		cs, err = kubernetes.NewForConfig(config)
 		if err != nil {
diff --git a/cmd/clusterctl/client/cluster/template.go b/cmd/clusterctl/client/cluster/template.go
index 872fd4eed..a07821489 100644
--- a/cmd/clusterctl/client/cluster/template.go
+++ b/cmd/clusterctl/client/cluster/template.go
@@ -84,7 +84,7 @@ func (t *templateClient) GetFromConfigMap(ctx context.Context, configMapNamespac
 		return nil, errors.New("invalid GetFromConfigMap operation: missing configMapName value")
 	}
 
-	c, err := t.proxy.NewClient(ctx)
+	c, err := t.proxy.NewClient()
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/clusterctl/client/cluster/topology.go b/cmd/clusterctl/client/cluster/topology.go
index 7c377c679..1c8180a5b 100644
--- a/cmd/clusterctl/client/cluster/topology.go
+++ b/cmd/clusterctl/client/cluster/topology.go
@@ -120,9 +120,9 @@ func (t *topologyClient) Plan(ctx context.Context, in *TopologyPlanInput) (*Topo
 	// Example: This client will be used to fetch the underlying ClusterClass when the input
 	// only has a Cluster object.
 	var c client.Client
-	if err := t.proxy.CheckClusterAvailable(ctx); err == nil {
+	if err := t.proxy.CheckClusterAvailable(); err == nil {
 		if initialized, err := t.inventoryClient.CheckCAPIInstalled(ctx); err == nil && initialized {
-			c, err = t.proxy.NewClient(ctx)
+			c, err = t.proxy.NewClient()
 			if err != nil {
 				return nil, errors.Wrap(err, "failed to create a client to the cluster")
 			}
@@ -263,7 +263,7 @@ func (t *topologyClient) validateInput(in *TopologyPlanInput) error {
 //   - Prepare cluster objects so that the state of the cluster, if modified, correctly represents
 //     the expected changes.
 func (t *topologyClient) prepareInput(ctx context.Context, in *TopologyPlanInput, apiReader client.Reader) error {
-	if err := t.setMissingNamespaces(ctx, in.TargetNamespace, in.Objs); err != nil {
+	if err := t.setMissingNamespaces(in.TargetNamespace, in.Objs); err != nil {
 		return errors.Wrap(err, "failed to set missing namespaces")
 	}
 
@@ -275,12 +275,12 @@ func (t *topologyClient) prepareInput(ctx context.Context, in *TopologyPlanInput
 
 // setMissingNamespaces sets the object to the current namespace on objects
 // that are missing the namespace field.
-func (t *topologyClient) setMissingNamespaces(ctx context.Context, currentNamespace string, objs []*unstructured.Unstructured) error {
+func (t *topologyClient) setMissingNamespaces(currentNamespace string, objs []*unstructured.Unstructured) error {
 	if currentNamespace == "" {
 		// If TargetNamespace is not provided use "default" namespace.
 		currentNamespace = metav1.NamespaceDefault
 		// If a cluster is available use the current namespace as defined in its kubeconfig.
-		if err := t.proxy.CheckClusterAvailable(ctx); err == nil {
+		if err := t.proxy.CheckClusterAvailable(); err == nil {
 			currentNamespace, err = t.proxy.CurrentNamespace()
 			if err != nil {
 				return errors.Wrap(err, "failed to get current namespace")
diff --git a/cmd/clusterctl/client/cluster/upgrader.go b/cmd/clusterctl/client/cluster/upgrader.go
index 8aaf2cde6..f640dfb24 100644
--- a/cmd/clusterctl/client/cluster/upgrader.go
+++ b/cmd/clusterctl/client/cluster/upgrader.go
@@ -377,7 +377,7 @@ func (u *providerUpgrader) doUpgrade(ctx context.Context, upgradePlan *UpgradePl
 			return err
 		}
 
-		c, err := u.proxy.NewClient(ctx)
+		c, err := u.proxy.NewClient()
 		if err != nil {
 			return err
 		}
@@ -454,7 +454,7 @@ func (u *providerUpgrader) scaleDownProvider(ctx context.Context, provider clust
 	log := logf.Log
 	log.Info("Scaling down", "Provider", provider.Name, "Version", provider.Version, "Namespace", provider.Namespace)
 
-	cs, err := u.proxy.NewClient(ctx)
+	cs, err := u.proxy.NewClient()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/clusterctl/client/cluster/workload_cluster.go b/cmd/clusterctl/client/cluster/workload_cluster.go
index 7cb52ba24..0e92d9d50 100644
--- a/cmd/clusterctl/client/cluster/workload_cluster.go
+++ b/cmd/clusterctl/client/cluster/workload_cluster.go
@@ -44,7 +44,7 @@ func newWorkloadCluster(proxy Proxy) *workloadCluster {
 }
 
 func (p *workloadCluster) GetKubeconfig(ctx context.Context, workloadClusterName string, namespace string) (string, error) {
-	cs, err := p.proxy.NewClient(ctx)
+	cs, err := p.proxy.NewClient()
 	if err != nil {
 		return "", err
 	}
diff --git a/cmd/clusterctl/client/clusterclass.go b/cmd/clusterctl/client/clusterclass.go
index ea0b8d965..2b0b59b39 100644
--- a/cmd/clusterctl/client/clusterclass.go
+++ b/cmd/clusterctl/client/clusterclass.go
@@ -99,7 +99,7 @@ func fetchMissingClusterClassTemplates(ctx context.Context, clusterClassClient r
 	// Check if the cluster is initialized
 	clusterInitialized := false
 	var err error
-	if err := clusterClient.Proxy().CheckClusterAvailable(ctx); err == nil {
+	if err := clusterClient.Proxy().CheckClusterAvailable(); err == nil {
 		clusterInitialized, err = clusterClient.ProviderInventory().CheckCAPIInstalled(ctx)
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to check if the cluster is initialized")
@@ -107,7 +107,7 @@ func fetchMissingClusterClassTemplates(ctx context.Context, clusterClassClient r
 	}
 	var c client.Client
 	if clusterInitialized {
-		c, err = clusterClient.Proxy().NewClient(ctx)
+		c, err = clusterClient.Proxy().NewClient()
 		if err != nil {
 			return nil, err
 		}
diff --git a/cmd/clusterctl/client/config.go b/cmd/clusterctl/client/config.go
index 03e3354bf..8e1b9eb68 100644
--- a/cmd/clusterctl/client/config.go
+++ b/cmd/clusterctl/client/config.go
@@ -225,7 +225,7 @@ func (c *clusterctlClient) GetClusterTemplate(ctx context.Context, options GetCl
 
 	// If the option specifying the targetNamespace is empty, try to detect it.
 	if options.TargetNamespace == "" {
-		if err := clusterClient.Proxy().CheckClusterAvailable(ctx); err != nil {
+		if err := clusterClient.Proxy().CheckClusterAvailable(); err != nil {
 			return nil, errors.Wrap(err, "management cluster not available. Cannot auto-discover target namespace. Please specify a target namespace")
 		}
 		currentNamespace, err := clusterClient.Proxy().CurrentNamespace()
@@ -277,7 +277,7 @@ func (c *clusterctlClient) getTemplateFromRepository(ctx context.Context, cluste
 	provider := source.InfrastructureProvider
 	ensureCustomResourceDefinitions := false
 	if provider == "" {
-		if err := cluster.Proxy().CheckClusterAvailable(ctx); err != nil {
+		if err := cluster.Proxy().CheckClusterAvailable(); err != nil {
 			return nil, errors.Wrap(err, "management cluster not available. Cannot auto-discover default infrastructure provider. Please specify an infrastructure provider")
 		}
 		// ensure the custom resource definitions required by clusterctl are in place
@@ -305,7 +305,7 @@ func (c *clusterctlClient) getTemplateFromRepository(ctx context.Context, cluste
 
 	// If the version of the infrastructure provider to get templates from is empty, try to detect it.
 	if version == "" {
-		if err := cluster.Proxy().CheckClusterAvailable(ctx); err != nil {
+		if err := cluster.Proxy().CheckClusterAvailable(); err != nil {
 			return nil, errors.Wrapf(err, "management cluster not available. Cannot auto-discover version for the provider %q automatically. Please specify a version", name)
 		}
 		// ensure the custom resource definitions required by clusterctl are in place (if not already done)
diff --git a/cmd/clusterctl/client/config/providers_client.go b/cmd/clusterctl/client/config/providers_client.go
index 94fa74bc6..f74a58e4f 100644
--- a/cmd/clusterctl/client/config/providers_client.go
+++ b/cmd/clusterctl/client/config/providers_client.go
@@ -63,7 +63,6 @@ const (
 	VclusterProviderName       = "vcluster"
 	VirtinkProviderName        = "virtink"
 	CoxEdgeProviderName        = "coxedge"
-	ProxmoxProviderName        = "proxmox"
 )
 
 // Bootstrap providers.
@@ -271,11 +270,6 @@ func (p *providersClient) defaults() []Provider {
 			url:          "https://github.com/smartxworks/cluster-api-provider-virtink/releases/latest/infrastructure-components.yaml",
 			providerType: clusterctlv1.InfrastructureProviderType,
 		},
-		&provider{
-			name:         ProxmoxProviderName,
-			url:          "https://github.com/ionos-cloud/cluster-api-provider-proxmox/releases/latest/infrastructure-components.yaml",
-			providerType: clusterctlv1.InfrastructureProviderType,
-		},
 
 		// Bootstrap providers
 		&provider{
diff --git a/cmd/clusterctl/client/describe.go b/cmd/clusterctl/client/describe.go
index 2e98ef032..4ebd75dc5 100644
--- a/cmd/clusterctl/client/describe.go
+++ b/cmd/clusterctl/client/describe.go
@@ -82,7 +82,7 @@ func (c *clusterctlClient) DescribeCluster(ctx context.Context, options Describe
 	}
 
 	// Fetch the Cluster client.
-	client, err := cluster.Proxy().NewClient(ctx)
+	client, err := cluster.Proxy().NewClient()
 	if err != nil {
 		return nil, err
 	}
diff --git a/controlplane/kubeadm/internal/controllers/controller.go b/controlplane/kubeadm/internal/controllers/controller.go
index 1bf2625f6..c6cb714b6 100644
--- a/controlplane/kubeadm/internal/controllers/controller.go
+++ b/controlplane/kubeadm/internal/controllers/controller.go
@@ -243,7 +243,7 @@ func (r *KubeadmControlPlaneReconciler) Reconcile(ctx context.Context, req ctrl.
 		// the current cluster because of concurrent access.
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 		return res, err
 	}
@@ -254,7 +254,7 @@ func (r *KubeadmControlPlaneReconciler) Reconcile(ctx context.Context, req ctrl.
 	// the current cluster because of concurrent access.
 	if errors.Is(err, remote.ErrClusterLocked) {
 		log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-		return ctrl.Result{RequeueAfter: time.Minute}, nil
+		return ctrl.Result{Requeue: true}, nil
 	}
 	return res, err
 }
diff --git a/docs/book/src/user/quick-start.md b/docs/book/src/user/quick-start.md
index 3baca3b8d..56d519e68 100644
--- a/docs/book/src/user/quick-start.md
+++ b/docs/book/src/user/quick-start.md
@@ -740,35 +740,6 @@ clusterctl init --infrastructure vsphere
 For more information about prerequisites, credentials management, or permissions for vSphere, see the [vSphere
 project][vSphere getting started guide].
 
-{{#/tab }}
-{{#tab Proxmox}}
-
-First, we need to add the IPAM provider to your clusterctl config file `~/.cluster-api/clusterctl.yaml`:
-```yaml
-providers:
-  - name: in-cluster
-    url: https://github.com/kubernetes-sigs/cluster-api-ipam-provider-in-cluster/releases/latest/ipam-components.yaml
-    type: IPAMProvider
-```
-
-```bash
-# The host for the Proxmox cluster
-export PROXMOX_URL="https://pve.example:8006"
-# The Proxmox token ID to access the remote Proxmox endpoint
-export PROXMOX_TOKEN='root@pam!capi'
-# The secret associated with the token ID
-# You may want to set this in `$XDG_CONFIG_HOME/cluster-api/clusterctl.yaml` so your password is not in
-# bash history
-export PROXMOX_SECRET="1234-1234-1234-1234"
-
-
-# Finally, initialize the management cluster
-clusterctl init --infrastructure proxmox --ipam in-cluster
-```
-
-For more information about the CAPI provider for Proxmox, see the [Proxmox
-project][Proxmox getting started guide].
-
 {{#/tab }}
 {{#/tabs }}
 
@@ -1236,37 +1207,6 @@ export CONTROL_PLANE_ENDPOINT_IP="1.2.3.4"
 
 For more information about prerequisites, credentials management, or permissions for vSphere, see the [vSphere getting started guide].
 
-{{#/tab }}
-{{#tab Proxmox}}
-
-A ClusterAPI compatible image must be available in your Proxmox cluster. For instructions on how to build a compatible VM template
-see [image-builder](https://image-builder.sigs.k8s.io/capi/capi.html).
-
-```bash
-# The node that hosts the VM template to be used to provision VMs
-export PROXMOX_SOURCENODE="pve"
-# The template VM ID used for cloning VMs
-export TEMPLATE_VMID=100
-# The ssh authorized keys used to ssh to the machines.
-export VM_SSH_KEYS="ssh-ed25519 ..., ssh-ed25519 ..."
-# The IP address used for the control plane endpoint
-export CONTROL_PLANE_ENDPOINT_IP=10.10.10.4
-# The IP ranges for Cluster nodes
-export NODE_IP_RANGES="[10.10.10.5-10.10.10.50, 10.10.10.55-10.10.10.70]"
-# The gateway for the machines network-config.
-export GATEWAY="10.10.10.1"
-# Subnet Mask in CIDR notation for your node IP ranges
-export IP_PREFIX=24
-# The Proxmox network device for VMs
-export BRIDGE="vmbr1"
-# The dns nameservers for the machines network-config.
-export DNS_SERVERS="[8.8.8.8,8.8.4.4]"
-# The Proxmox nodes used for VM deployments
-export ALLOWED_NODES="[pve1,pve2,pve3]"
-```
-
-For more information about prerequisites and advanced setups for Proxmox, see the [Proxmox getting started guide].
-
 {{#/tab }}
 {{#/tabs }}
 
@@ -1683,5 +1623,4 @@ kind delete cluster
 [provider components]: ../reference/glossary.md#provider-components
 [vSphere getting started guide]: https://github.com/kubernetes-sigs/cluster-api-provider-vsphere/blob/master/docs/getting_started.md
 [workload cluster]: ../reference/glossary.md#workload-cluster
-[CAPI Operator quickstart]: ./quick-start-operator.md
-[Proxmox getting started guide]: https://github.com/ionos-cloud/cluster-api-provider-proxmox/blob/main/docs/Usage.md
+[CAPI Operator quickstart]: ./quick-start-operator.md
\ No newline at end of file
diff --git a/exp/internal/controllers/machinepool_controller.go b/exp/internal/controllers/machinepool_controller.go
index fc89c4970..2a4f7a120 100644
--- a/exp/internal/controllers/machinepool_controller.go
+++ b/exp/internal/controllers/machinepool_controller.go
@@ -19,7 +19,6 @@ package controllers
 import (
 	"context"
 	"fmt"
-	"time"
 
 	"github.com/pkg/errors"
 	corev1 "k8s.io/api/core/v1"
@@ -198,7 +197,7 @@ func (r *MachinePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request)
 		// the current cluster because of concurrent access.
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 		return ctrl.Result{}, err
 	}
@@ -216,7 +215,7 @@ func (r *MachinePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request)
 	// the current cluster because of concurrent access.
 	if errors.Is(err, remote.ErrClusterLocked) {
 		log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-		return ctrl.Result{RequeueAfter: time.Minute}, nil
+		return ctrl.Result{Requeue: true}, nil
 	}
 	return res, err
 }
diff --git a/go.mod b/go.mod
index f35458455..17c13279d 100644
--- a/go.mod
+++ b/go.mod
@@ -21,7 +21,7 @@ require (
 	github.com/google/go-github/v53 v53.2.0
 	github.com/google/gofuzz v1.2.0
 	github.com/olekukonko/tablewriter v0.0.5
-	github.com/onsi/ginkgo/v2 v2.13.2
+	github.com/onsi/ginkgo/v2 v2.13.1
 	github.com/onsi/gomega v1.30.0
 	github.com/pkg/errors v0.9.1
 	github.com/prometheus/client_golang v1.17.0
diff --git a/go.sum b/go.sum
index 821e76533..5b7751ce3 100644
--- a/go.sum
+++ b/go.sum
@@ -433,8 +433,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs=
-github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
+github.com/onsi/ginkgo/v2 v2.13.1 h1:LNGfMbR2OVGBfXjvRZIZ2YCTQdGKtPLvuI1rMCCj3OU=
+github.com/onsi/ginkgo/v2 v2.13.1/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
 github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
 github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
diff --git a/hack/tools/go.mod b/hack/tools/go.mod
index 24ff4c13e..798df7145 100644
--- a/hack/tools/go.mod
+++ b/hack/tools/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/pkg/errors v0.9.1
 	github.com/spf13/pflag v1.0.5
 	github.com/valyala/fastjson v1.6.4
-	google.golang.org/api v0.152.0
+	google.golang.org/api v0.151.0
 	k8s.io/api v0.28.4
 	k8s.io/apiextensions-apiserver v0.28.4
 	k8s.io/apimachinery v0.28.4
@@ -32,10 +32,10 @@ require (
 )
 
 require (
-	cloud.google.com/go v0.110.10 // indirect
-	cloud.google.com/go/compute v1.23.3 // indirect
+	cloud.google.com/go v0.110.8 // indirect
+	cloud.google.com/go/compute v1.23.1 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
-	cloud.google.com/go/iam v1.1.5 // indirect
+	cloud.google.com/go/iam v1.1.3 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
 	github.com/MakeNowJust/heredoc v1.0.0 // indirect
 	github.com/Masterminds/goutils v1.1.1 // indirect
@@ -142,14 +142,14 @@ require (
 	golang.org/x/sys v0.15.0 // indirect
 	golang.org/x/term v0.15.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
-	golang.org/x/time v0.5.0 // indirect
+	golang.org/x/time v0.3.0 // indirect
 	golang.org/x/tools v0.14.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
+	google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect
 	google.golang.org/grpc v1.59.0 // indirect
 	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
diff --git a/hack/tools/go.sum b/hack/tools/go.sum
index ee24b4f23..0825721fe 100644
--- a/hack/tools/go.sum
+++ b/hack/tools/go.sum
@@ -17,22 +17,22 @@ cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHOb
 cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
 cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
 cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
-cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
-cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
+cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME=
+cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
 cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
-cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
+cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0=
+cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
-cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
+cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc=
+cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -321,7 +321,7 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
-github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs=
+github.com/onsi/ginkgo/v2 v2.13.1 h1:LNGfMbR2OVGBfXjvRZIZ2YCTQdGKtPLvuI1rMCCj3OU=
 github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
 github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -590,8 +590,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -671,8 +671,8 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513
 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
 google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.152.0 h1:t0r1vPnfMc260S2Ci+en7kfCZaLOPs5KI0sVV/6jZrY=
-google.golang.org/api v0.152.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
+google.golang.org/api v0.151.0 h1:FhfXLO/NFdJIzQtCqjpysWwqKk8AzGWBUhMIx67cVDU=
+google.golang.org/api v0.151.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -717,12 +717,12 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
-google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
-google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
-google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
+google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
+google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI=
+google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k=
+google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
diff --git a/internal/controllers/machine/machine_controller.go b/internal/controllers/machine/machine_controller.go
index e35ac8d39..31663a5de 100644
--- a/internal/controllers/machine/machine_controller.go
+++ b/internal/controllers/machine/machine_controller.go
@@ -206,7 +206,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re
 		// the current cluster because of concurrent access.
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 		return res, err
 	}
@@ -224,7 +224,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re
 	// the current cluster because of concurrent access.
 	if errors.Is(err, remote.ErrClusterLocked) {
 		log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-		return ctrl.Result{RequeueAfter: time.Minute}, nil
+		return ctrl.Result{Requeue: true}, nil
 	}
 	return res, err
 }
@@ -606,7 +606,7 @@ func (r *Reconciler) drainNode(ctx context.Context, cluster *clusterv1.Cluster,
 	if err != nil {
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing drain Node because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 		log.Error(err, "Error creating a remote client for cluster while draining Node, won't retry")
 		return ctrl.Result{}, nil
diff --git a/internal/controllers/machinehealthcheck/machinehealthcheck_controller.go b/internal/controllers/machinehealthcheck/machinehealthcheck_controller.go
index c62411a07..f485b7e5c 100644
--- a/internal/controllers/machinehealthcheck/machinehealthcheck_controller.go
+++ b/internal/controllers/machinehealthcheck/machinehealthcheck_controller.go
@@ -176,7 +176,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re
 		// the current cluster because of concurrent access.
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 		log.Error(err, "Failed to reconcile MachineHealthCheck")
 		r.recorder.Eventf(m, corev1.EventTypeWarning, "ReconcileError", "%v", err)
diff --git a/internal/controllers/machineset/machineset_controller.go b/internal/controllers/machineset/machineset_controller.go
index 1b93130f7..a72cb00f0 100644
--- a/internal/controllers/machineset/machineset_controller.go
+++ b/internal/controllers/machineset/machineset_controller.go
@@ -184,7 +184,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re
 		// the current cluster because of concurrent access.
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 		r.recorder.Eventf(machineSet, corev1.EventTypeWarning, "ReconcileError", "%v", err)
 	}
diff --git a/internal/controllers/topology/cluster/cluster_controller.go b/internal/controllers/topology/cluster/cluster_controller.go
index 933ddc481..a8ec867f3 100644
--- a/internal/controllers/topology/cluster/cluster_controller.go
+++ b/internal/controllers/topology/cluster/cluster_controller.go
@@ -209,7 +209,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re
 		// the current cluster because of concurrent access.
 		if errors.Is(err, remote.ErrClusterLocked) {
 			log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-			return ctrl.Result{RequeueAfter: time.Minute}, nil
+			return ctrl.Result{Requeue: true}, nil
 		}
 	}
 	return result, err


Test patch:
diff --git a/cmd/clusterctl/client/alpha/rollout_pauser_test.go b/cmd/clusterctl/client/alpha/rollout_pauser_test.go
index ae5d0b939..10b952bb7 100644
--- a/cmd/clusterctl/client/alpha/rollout_pauser_test.go
+++ b/cmd/clusterctl/client/alpha/rollout_pauser_test.go
@@ -153,7 +153,7 @@ func Test_ObjectPauser(t *testing.T) {
 			}
 			g.Expect(err).ToNot(HaveOccurred())
 			for _, obj := range tt.fields.objs {
-				cl, err := proxy.NewClient(context.Background())
+				cl, err := proxy.NewClient()
 				g.Expect(err).ToNot(HaveOccurred())
 				key := client.ObjectKeyFromObject(obj)
 				switch obj.(type) {
diff --git a/cmd/clusterctl/client/alpha/rollout_restarter_test.go b/cmd/clusterctl/client/alpha/rollout_restarter_test.go
index 14969e194..f0a3b181c 100644
--- a/cmd/clusterctl/client/alpha/rollout_restarter_test.go
+++ b/cmd/clusterctl/client/alpha/rollout_restarter_test.go
@@ -211,7 +211,7 @@ func Test_ObjectRestarter(t *testing.T) {
 			}
 			g.Expect(err).ToNot(HaveOccurred())
 			for _, obj := range tt.fields.objs {
-				cl, err := proxy.NewClient(context.Background())
+				cl, err := proxy.NewClient()
 				g.Expect(err).ToNot(HaveOccurred())
 				key := client.ObjectKeyFromObject(obj)
 				switch obj.(type) {
diff --git a/cmd/clusterctl/client/alpha/rollout_resumer_test.go b/cmd/clusterctl/client/alpha/rollout_resumer_test.go
index da85fb930..727a9574b 100644
--- a/cmd/clusterctl/client/alpha/rollout_resumer_test.go
+++ b/cmd/clusterctl/client/alpha/rollout_resumer_test.go
@@ -156,7 +156,7 @@ func Test_ObjectResumer(t *testing.T) {
 			}
 			g.Expect(err).ToNot(HaveOccurred())
 			for _, obj := range tt.fields.objs {
-				cl, err := proxy.NewClient(context.Background())
+				cl, err := proxy.NewClient()
 				g.Expect(err).ToNot(HaveOccurred())
 				key := client.ObjectKeyFromObject(obj)
 				switch obj.(type) {
diff --git a/cmd/clusterctl/client/alpha/rollout_rollbacker_test.go b/cmd/clusterctl/client/alpha/rollout_rollbacker_test.go
index 9ddf27de0..374b5c040 100644
--- a/cmd/clusterctl/client/alpha/rollout_rollbacker_test.go
+++ b/cmd/clusterctl/client/alpha/rollout_rollbacker_test.go
@@ -247,7 +247,7 @@ func Test_ObjectRollbacker(t *testing.T) {
 				return
 			}
 			g.Expect(err).ToNot(HaveOccurred())
-			cl, err := proxy.NewClient(context.Background())
+			cl, err := proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 			key := client.ObjectKeyFromObject(deployment)
 			md := &clusterv1.MachineDeployment{}
diff --git a/cmd/clusterctl/client/cluster/cert_manager_test.go b/cmd/clusterctl/client/cluster/cert_manager_test.go
index 4ed9582fb..fda43d62e 100644
--- a/cmd/clusterctl/client/cluster/cert_manager_test.go
+++ b/cmd/clusterctl/client/cluster/cert_manager_test.go
@@ -594,7 +594,7 @@ func Test_certManagerClient_deleteObjs(t *testing.T) {
 					}
 				}
 
-				cl, err := proxy.NewClient(ctx)
+				cl, err := proxy.NewClient()
 				g.Expect(err).ToNot(HaveOccurred())
 
 				err = cl.Get(context.Background(), client.ObjectKeyFromObject(obj), obj)
diff --git a/cmd/clusterctl/client/cluster/components_test.go b/cmd/clusterctl/client/cluster/components_test.go
index e67692d5d..d2affd1dd 100644
--- a/cmd/clusterctl/client/cluster/components_test.go
+++ b/cmd/clusterctl/client/cluster/components_test.go
@@ -270,7 +270,7 @@ func Test_providerComponents_Delete(t *testing.T) {
 
 			g.Expect(err).ToNot(HaveOccurred())
 
-			cs, err := proxy.NewClient(context.Background())
+			cs, err := proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			for _, want := range tt.wantDiff {
@@ -322,7 +322,7 @@ func Test_providerComponents_DeleteCoreProviderWebhookNamespace(t *testing.T) {
 		}
 
 		proxy := test.NewFakeProxy().WithObjs(initObjs...)
-		proxyClient, _ := proxy.NewClient(context.Background())
+		proxyClient, _ := proxy.NewClient()
 		var nsList corev1.NamespaceList
 
 		// assert length before deleting
@@ -457,7 +457,7 @@ func Test_providerComponents_Create(t *testing.T) {
 
 			g.Expect(err).ToNot(HaveOccurred())
 
-			cs, err := proxy.NewClient(context.Background())
+			cs, err := proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			for _, item := range tt.want {
diff --git a/cmd/clusterctl/client/cluster/crd_migration_test.go b/cmd/clusterctl/client/cluster/crd_migration_test.go
index 950e5150b..b3013c298 100644
--- a/cmd/clusterctl/client/cluster/crd_migration_test.go
+++ b/cmd/clusterctl/client/cluster/crd_migration_test.go
@@ -264,7 +264,7 @@ func Test_CRDMigrator(t *testing.T) {
 				objs = append(objs, &tt.CRs[i])
 			}
 
-			c, err := test.NewFakeProxy().WithObjs(objs...).NewClient(context.Background())
+			c, err := test.NewFakeProxy().WithObjs(objs...).NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 			countingClient := newUpgradeCountingClient(c)
 
diff --git a/cmd/clusterctl/client/cluster/mover_test.go b/cmd/clusterctl/client/cluster/mover_test.go
index 96fe02ee6..b4100b007 100644
--- a/cmd/clusterctl/client/cluster/mover_test.go
+++ b/cmd/clusterctl/client/cluster/mover_test.go
@@ -866,7 +866,7 @@ func Test_objectMover_restoreTargetObject(t *testing.T) {
 				g.Expect(err).ToNot(HaveOccurred())
 
 				// Check objects are in new restored cluster
-				csTo, err := toProxy.NewClient(ctx)
+				csTo, err := toProxy.NewClient()
 				g.Expect(err).ToNot(HaveOccurred())
 
 				key := client.ObjectKey{
@@ -894,7 +894,7 @@ func Test_objectMover_restoreTargetObject(t *testing.T) {
 				g.Expect(err).ToNot(HaveOccurred())
 
 				// Check objects are in new restored cluster
-				csAfter, err := toProxy.NewClient(ctx)
+				csAfter, err := toProxy.NewClient()
 				g.Expect(err).ToNot(HaveOccurred())
 
 				keyAfter := client.ObjectKey{
@@ -959,7 +959,7 @@ func Test_objectMover_toDirectory(t *testing.T) {
 			g.Expect(err).ToNot(HaveOccurred())
 
 			// check that the objects are stored in the temporary directory but not deleted from the source cluster
-			csFrom, err := graph.proxy.NewClient(ctx)
+			csFrom, err := graph.proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			missingFiles := []string{}
@@ -1120,7 +1120,7 @@ func Test_objectMover_fromDirectory(t *testing.T) {
 			g.Expect(err).ToNot(HaveOccurred())
 
 			// Check objects are in new restored cluster
-			csTo, err := toProxy.NewClient(ctx)
+			csTo, err := toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			for _, node := range graph.uidToNode {
@@ -1211,10 +1211,10 @@ func Test_objectMover_move_dryRun(t *testing.T) {
 			g.Expect(err).ToNot(HaveOccurred())
 
 			// check that the objects are kept in the source cluster and are not created in the target cluster
-			csFrom, err := graph.proxy.NewClient(ctx)
+			csFrom, err := graph.proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
-			csTo, err := toProxy.NewClient(ctx)
+			csTo, err := toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 			for _, node := range graph.uidToNode {
 				key := client.ObjectKey{
@@ -1285,10 +1285,10 @@ func Test_objectMover_move(t *testing.T) {
 			g.Expect(err).ToNot(HaveOccurred())
 
 			// check that the objects are removed from the source cluster and are created in the target cluster
-			csFrom, err := graph.proxy.NewClient(ctx)
+			csFrom, err := graph.proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
-			csTo, err := toProxy.NewClient(ctx)
+			csTo, err := toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			for _, node := range graph.uidToNode {
@@ -1397,10 +1397,10 @@ func Test_objectMover_move_with_Mutator(t *testing.T) {
 			g.Expect(err).ToNot(HaveOccurred())
 
 			// check that the objects are removed from the source cluster and are created in the target cluster
-			csFrom, err := graph.proxy.NewClient(ctx)
+			csFrom, err := graph.proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
-			csTo, err := toProxy.NewClient(ctx)
+			csTo, err := toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			for _, node := range graph.uidToNode {
@@ -1813,7 +1813,7 @@ func Test_objectMoverService_ensureNamespace(t *testing.T) {
 
 			// Check that the namespaces either existed or were created in the
 			// target.
-			csTo, err := tt.args.toProxy.NewClient(ctx)
+			csTo, err := tt.args.toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			ns := &corev1.Namespace{}
@@ -1920,7 +1920,7 @@ func Test_objectMoverService_ensureNamespaces(t *testing.T) {
 
 			// Check that the namespaces either existed or were created in the
 			// target.
-			csTo, err := tt.args.toProxy.NewClient(ctx)
+			csTo, err := tt.args.toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			namespaces := &corev1.NamespaceList{}
@@ -2170,7 +2170,7 @@ func Test_createTargetObject(t *testing.T) {
 			}
 			g.Expect(err).ToNot(HaveOccurred())
 
-			toClient, err := tt.args.toProxy.NewClient(ctx)
+			toClient, err := tt.args.toProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			tt.want(g, toClient)
@@ -2313,7 +2313,7 @@ func Test_deleteSourceObject(t *testing.T) {
 			err := mover.deleteSourceObject(ctx, tt.args.node)
 			g.Expect(err).ToNot(HaveOccurred())
 
-			fromClient, err := tt.args.fromProxy.NewClient(ctx)
+			fromClient, err := tt.args.fromProxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			tt.want(g, fromClient)
@@ -2360,7 +2360,7 @@ func TestWaitReadyForMove(t *testing.T) {
 			graph := getObjectGraphWithObjs(objs)
 
 			if tt.moveBlocked {
-				c, err := graph.proxy.NewClient(ctx)
+				c, err := graph.proxy.NewClient()
 				g.Expect(err).NotTo(HaveOccurred())
 
 				cluster := &clusterv1.Cluster{}
diff --git a/cmd/clusterctl/client/clusterclass_test.go b/cmd/clusterctl/client/clusterclass_test.go
index 912df08a9..581de70b6 100644
--- a/cmd/clusterctl/client/clusterclass_test.go
+++ b/cmd/clusterctl/client/clusterclass_test.go
@@ -72,7 +72,7 @@ func TestClusterClassExists(t *testing.T) {
 
 			config := newFakeConfig(ctx)
 			client := newFakeCluster(cluster.Kubeconfig{Path: "kubeconfig", Context: "mgmt-context"}, config).WithObjs(tt.objs...)
-			c, _ := client.Proxy().NewClient(ctx)
+			c, _ := client.Proxy().NewClient()
 
 			actual, err := clusterClassExists(ctx, c, tt.clusterClass, metav1.NamespaceDefault)
 			g.Expect(err).ToNot(HaveOccurred())
diff --git a/cmd/clusterctl/client/config_test.go b/cmd/clusterctl/client/config_test.go
index 599cfa1c4..f8beeee2b 100644
--- a/cmd/clusterctl/client/config_test.go
+++ b/cmd/clusterctl/client/config_test.go
@@ -92,7 +92,6 @@ func Test_clusterctlClient_GetProvidersConfig(t *testing.T) {
 				config.OpenStackProviderName,
 				config.OutscaleProviderName,
 				config.PacketProviderName,
-				config.ProxmoxProviderName,
 				config.SideroProviderName,
 				config.VCloudDirectorProviderName,
 				config.VclusterProviderName,
@@ -146,7 +145,6 @@ func Test_clusterctlClient_GetProvidersConfig(t *testing.T) {
 				config.OpenStackProviderName,
 				config.OutscaleProviderName,
 				config.PacketProviderName,
-				config.ProxmoxProviderName,
 				config.SideroProviderName,
 				config.VCloudDirectorProviderName,
 				config.VclusterProviderName,
diff --git a/cmd/clusterctl/client/delete_test.go b/cmd/clusterctl/client/delete_test.go
index cfeaee91c..52e9d4b03 100644
--- a/cmd/clusterctl/client/delete_test.go
+++ b/cmd/clusterctl/client/delete_test.go
@@ -181,7 +181,7 @@ func Test_clusterctlClient_Delete(t *testing.T) {
 			proxy := tt.fields.client.clusters[input].Proxy()
 			gotProviders := &clusterctlv1.ProviderList{}
 
-			c, err := proxy.NewClient(ctx)
+			c, err := proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 			g.Expect(c.List(ctx, gotProviders)).To(Succeed())
 
diff --git a/cmd/clusterctl/client/tree/discovery_test.go b/cmd/clusterctl/client/tree/discovery_test.go
index ed8bb4284..e0c2088f8 100644
--- a/cmd/clusterctl/client/tree/discovery_test.go
+++ b/cmd/clusterctl/client/tree/discovery_test.go
@@ -841,7 +841,7 @@ func Test_Discovery(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := NewWithT(t)
 
-			client, err := test.NewFakeProxy().WithObjs(tt.args.objs...).NewClient(context.Background())
+			client, err := test.NewFakeProxy().WithObjs(tt.args.objs...).NewClient()
 			g.Expect(client).ToNot(BeNil())
 			g.Expect(err).ToNot(HaveOccurred())
 
diff --git a/cmd/clusterctl/client/upgrade_test.go b/cmd/clusterctl/client/upgrade_test.go
index 9b8ac7579..7d0ddbe44 100644
--- a/cmd/clusterctl/client/upgrade_test.go
+++ b/cmd/clusterctl/client/upgrade_test.go
@@ -296,7 +296,7 @@ func Test_clusterctlClient_ApplyUpgrade(t *testing.T) {
 			proxy := tt.fields.client.clusters[input].Proxy()
 			gotProviders := &clusterctlv1.ProviderList{}
 
-			c, err := proxy.NewClient(ctx)
+			c, err := proxy.NewClient()
 			g.Expect(err).ToNot(HaveOccurred())
 
 			g.Expect(c.List(ctx, gotProviders)).To(Succeed())
diff --git a/cmd/clusterctl/cmd/config_repositories_test.go b/cmd/clusterctl/cmd/config_repositories_test.go
index df4b77bbd..2b419abc8 100644
--- a/cmd/clusterctl/cmd/config_repositories_test.go
+++ b/cmd/clusterctl/cmd/config_repositories_test.go
@@ -141,7 +141,6 @@ oci                 InfrastructureProvider   https://github.com/oracle/cluster-a
 openstack           InfrastructureProvider   https://github.com/kubernetes-sigs/cluster-api-provider-openstack/releases/latest/          infrastructure-components.yaml
 outscale            InfrastructureProvider   https://github.com/outscale/cluster-api-provider-outscale/releases/latest/                  infrastructure-components.yaml
 packet              InfrastructureProvider   https://github.com/kubernetes-sigs/cluster-api-provider-packet/releases/latest/             infrastructure-components.yaml
-proxmox             InfrastructureProvider   https://github.com/ionos-cloud/cluster-api-provider-proxmox/releases/latest/                infrastructure-components.yaml
 sidero              InfrastructureProvider   https://github.com/siderolabs/sidero/releases/latest/                                       infrastructure-components.yaml
 vcd                 InfrastructureProvider   https://github.com/vmware/cluster-api-provider-cloud-director/releases/latest/              infrastructure-components.yaml
 vcluster            InfrastructureProvider   https://github.com/loft-sh/cluster-api-provider-vcluster/releases/latest/                   infrastructure-components.yaml
@@ -302,10 +301,6 @@ var expectedOutputYaml = `- File: core_components.yaml
   Name: packet
   ProviderType: InfrastructureProvider
   URL: https://github.com/kubernetes-sigs/cluster-api-provider-packet/releases/latest/
-- File: infrastructure-components.yaml
-  Name: proxmox
-  ProviderType: InfrastructureProvider
-  URL: https://github.com/ionos-cloud/cluster-api-provider-proxmox/releases/latest/
 - File: infrastructure-components.yaml
   Name: sidero
   ProviderType: InfrastructureProvider
diff --git a/cmd/clusterctl/internal/test/fake_proxy.go b/cmd/clusterctl/internal/test/fake_proxy.go
index c710d0ff5..3fb9864f6 100644
--- a/cmd/clusterctl/internal/test/fake_proxy.go
+++ b/cmd/clusterctl/internal/test/fake_proxy.go
@@ -79,7 +79,7 @@ func (f *FakeProxy) GetConfig() (*rest.Config, error) {
 	return nil, nil
 }
 
-func (f *FakeProxy) NewClient(_ context.Context) (client.Client, error) {
+func (f *FakeProxy) NewClient() (client.Client, error) {
 	if f.cs != nil {
 		return f.cs, nil
 	}
@@ -87,7 +87,7 @@ func (f *FakeProxy) NewClient(_ context.Context) (client.Client, error) {
 	return f.cs, nil
 }
 
-func (f *FakeProxy) CheckClusterAvailable(_ context.Context) error {
+func (f *FakeProxy) CheckClusterAvailable() error {
 	// default to considering the cluster as available unless explicitly set to be
 	// unavailable.
 	if f.available == nil || *f.available {
diff --git a/test/go.mod b/test/go.mod
index a95a49294..4f1d51f66 100644
--- a/test/go.mod
+++ b/test/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/flatcar/ignition v0.36.2
 	github.com/go-logr/logr v1.3.0
 	github.com/google/go-cmp v0.6.0
-	github.com/onsi/ginkgo/v2 v2.13.2
+	github.com/onsi/ginkgo/v2 v2.13.1
 	github.com/onsi/gomega v1.30.0
 	github.com/pkg/errors v0.9.1
 	github.com/prometheus/client_golang v1.17.0
diff --git a/test/go.sum b/test/go.sum
index b5f076062..16babaacc 100644
--- a/test/go.sum
+++ b/test/go.sum
@@ -327,8 +327,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs=
-github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
+github.com/onsi/ginkgo/v2 v2.13.1 h1:LNGfMbR2OVGBfXjvRZIZ2YCTQdGKtPLvuI1rMCCj3OU=
+github.com/onsi/ginkgo/v2 v2.13.1/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
 github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
 github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
diff --git a/test/infrastructure/docker/exp/internal/controllers/dockermachinepool_controller.go b/test/infrastructure/docker/exp/internal/controllers/dockermachinepool_controller.go
index 3d72b64fe..d50281f49 100644
--- a/test/infrastructure/docker/exp/internal/controllers/dockermachinepool_controller.go
+++ b/test/infrastructure/docker/exp/internal/controllers/dockermachinepool_controller.go
@@ -20,7 +20,6 @@ package controllers
 import (
 	"context"
 	"fmt"
-	"time"
 
 	"github.com/pkg/errors"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -159,7 +158,7 @@ func (r *DockerMachinePoolReconciler) Reconcile(ctx context.Context, req ctrl.Re
 	// the current cluster because of concurrent access.
 	if errors.Is(err, remote.ErrClusterLocked) {
 		log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-		return ctrl.Result{RequeueAfter: time.Minute}, nil
+		return ctrl.Result{Requeue: true}, nil
 	}
 	return res, err
 }
diff --git a/test/infrastructure/docker/internal/controllers/dockermachine_controller.go b/test/infrastructure/docker/internal/controllers/dockermachine_controller.go
index f2bfad8ab..68b3553ff 100644
--- a/test/infrastructure/docker/internal/controllers/dockermachine_controller.go
+++ b/test/infrastructure/docker/internal/controllers/dockermachine_controller.go
@@ -195,7 +195,7 @@ func (r *DockerMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 	// the current cluster because of concurrent access.
 	if errors.Is(err, remote.ErrClusterLocked) {
 		log.V(5).Info("Requeuing because another worker has the lock on the ClusterCacheTracker")
-		return ctrl.Result{RequeueAfter: time.Minute}, nil
+		return ctrl.Result{Requeue: true}, nil
 	}
 	return res, err
 }
diff --git a/test/infrastructure/inmemory/internal/cloud/alias.go b/test/infrastructure/inmemory/internal/cloud/alias.go
index 51daceae1..3fc7b0d0d 100644
--- a/test/infrastructure/inmemory/internal/cloud/alias.go
+++ b/test/infrastructure/inmemory/internal/cloud/alias.go
@@ -19,6 +19,7 @@ package cloud
 import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
+	cbuilder "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/builder"
 	cclient "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/client"
 	cmanager "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/manager"
 )
@@ -36,4 +37,7 @@ type Manager cmanager.Manager
 var (
 	// NewManager returns a new Manager for creating Controllers.
 	NewManager = cmanager.New
+
+	// NewControllerManagedBy returns a new controller builder that will be started by the provided Manager.
+	NewControllerManagedBy = cbuilder.ControllerManagedBy
 )
diff --git a/test/infrastructure/inmemory/internal/cloud/doc.go b/test/infrastructure/inmemory/internal/cloud/doc.go
index a88deec31..ac60dfe74 100644
--- a/test/infrastructure/inmemory/internal/cloud/doc.go
+++ b/test/infrastructure/inmemory/internal/cloud/doc.go
@@ -27,6 +27,7 @@ We can't use controller-runtime directly for the following reasons:
 * data should be stored in-memory
 * we would like that objects in memory behave like Kubernetes objects (garbage collection)
 
-The Manager, is the object responsible for the lifecycle of objects.
+The Manager, is the object responsible for the lifecycle of objects; it also allows
+defining controllers.
 */
 package cloud
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/builder/builder.go b/test/infrastructure/inmemory/internal/cloud/runtime/builder/builder.go
new file mode 100644
index 000000000..9fd02e846
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/builder/builder.go
@@ -0,0 +1,229 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package builder
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+
+	ccontroller "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/controller"
+	"sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/handler"
+	cmanager "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/manager"
+	cpredicate "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/predicate"
+	creconciler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/reconcile"
+	csource "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/source"
+)
+
+// Builder builds a Controller.
+type Builder struct {
+	forInput         ForInput
+	watchesInput     []WatchesInput
+	mgr              cmanager.Manager
+	globalPredicates []cpredicate.Predicate
+	ctrl             ccontroller.Controller
+	ctrlOptions      ccontroller.Options
+	name             string
+}
+
+// ControllerManagedBy returns a new controller builder that will be started by the provided Manager.
+func ControllerManagedBy(m cmanager.Manager) *Builder {
+	return &Builder{mgr: m}
+}
+
+// ForInput represents the information set by For method.
+type ForInput struct {
+	object     client.Object
+	predicates []cpredicate.Predicate
+	err        error
+}
+
+// For defines the type of Object being *reconciled*, and configures the ControllerManagedBy to respond to create / delete /
+// update events by *reconciling the object*.
+func (blder *Builder) For(object client.Object, opts ...ForOption) *Builder {
+	if blder.forInput.object != nil {
+		blder.forInput.err = fmt.Errorf("method For(...) should only be called once, could not assign multiple objects for reconciliation")
+		return blder
+	}
+	input := ForInput{object: object}
+	for _, opt := range opts {
+		opt.ApplyToFor(&input)
+	}
+
+	blder.forInput = input
+	return blder
+}
+
+// WatchesInput represents the information set by Watches method.
+type WatchesInput struct {
+	src          csource.Source
+	eventhandler handler.EventHandler
+	predicates   []cpredicate.Predicate
+}
+
+// Watches defines the type of Object to watch, and configures the ControllerManagedBy to respond to create / delete /
+// update events by *reconciling the object* with the given EventHandler.
+func (blder *Builder) Watches(src csource.Source, eventhandler handler.EventHandler, opts ...WatchesOption) *Builder {
+	input := WatchesInput{src: src, eventhandler: eventhandler}
+	for _, opt := range opts {
+		opt.ApplyToWatches(&input)
+	}
+
+	blder.watchesInput = append(blder.watchesInput, input)
+	return blder
+}
+
+// WithEventFilter sets the event filters, to filter which create/update/delete/generic events eventually
+// trigger reconciliations.  For example, filtering on whether the resource version has changed.
+// Given predicate is added for all watched objects.
+// Defaults to the empty list.
+func (blder *Builder) WithEventFilter(p cpredicate.Predicate) *Builder {
+	blder.globalPredicates = append(blder.globalPredicates, p)
+	return blder
+}
+
+// WithOptions overrides the controller options use in doController. Defaults to empty.
+func (blder *Builder) WithOptions(options ccontroller.Options) *Builder {
+	blder.ctrlOptions = options
+	return blder
+}
+
+// Named sets the name of the controller to the given name.  The name shows up
+// in metrics, among other things, and thus should be a prometheus compatible name
+// (underscores and alphanumeric characters only).
+//
+// By default, controllers are named using the lowercase version of their kind.
+func (blder *Builder) Named(name string) *Builder {
+	blder.name = name
+	return blder
+}
+
+// Complete builds the Application Controller.
+func (blder *Builder) Complete(r creconciler.Reconciler) error {
+	_, err := blder.Build(r)
+	return err
+}
+
+// Build builds the Application Controller and returns the Controller it created.
+func (blder *Builder) Build(r creconciler.Reconciler) (ccontroller.Controller, error) {
+	if r == nil {
+		return nil, fmt.Errorf("must provide a non-nil Reconciler")
+	}
+	if blder.mgr == nil {
+		return nil, fmt.Errorf("must provide a non-nil Manager")
+	}
+	if blder.forInput.err != nil {
+		return nil, blder.forInput.err
+	}
+
+	// Set the ControllerManagedBy
+	if err := blder.doController(r); err != nil {
+		return nil, err
+	}
+
+	// Set the Watch
+	if err := blder.doWatch(); err != nil {
+		return nil, err
+	}
+
+	return blder.ctrl, nil
+}
+
+func (blder *Builder) doWatch() error {
+	// Reconcile type
+	if blder.forInput.object != nil {
+		i, err := blder.mgr.GetCache().GetInformer(context.TODO(), blder.forInput.object)
+		if err != nil {
+			return err
+		}
+
+		src := &csource.Informer{Informer: i}
+		hdler := &handler.EnqueueRequestForObject{}
+		allPredicates := append(blder.globalPredicates, blder.forInput.predicates...)
+		if err := blder.ctrl.Watch(src, hdler, allPredicates...); err != nil {
+			return err
+		}
+	}
+
+	// Do the watch requests
+	if len(blder.watchesInput) == 0 && blder.forInput.object == nil {
+		return errors.New("there are no watches configured, controller will never get triggered. Use For(), Owns() or Watches() to set them up")
+	}
+	for _, w := range blder.watchesInput {
+		allPredicates := append([]cpredicate.Predicate(nil), blder.globalPredicates...)
+		allPredicates = append(allPredicates, w.predicates...)
+
+		if err := blder.ctrl.Watch(w.src, w.eventhandler, allPredicates...); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (blder *Builder) getControllerName(gvk schema.GroupVersionKind, hasGVK bool) (string, error) {
+	if blder.name != "" {
+		return blder.name, nil
+	}
+	if !hasGVK {
+		return "", errors.New("one of For() or Named() must be called")
+	}
+	return strings.ToLower(gvk.Kind), nil
+}
+
+func (blder *Builder) doController(r creconciler.Reconciler) error {
+	ctrlOptions := blder.ctrlOptions
+	if ctrlOptions.Reconciler == nil {
+		ctrlOptions.Reconciler = r
+	}
+
+	var gvk schema.GroupVersionKind
+	hasGVK := blder.forInput.object != nil
+	if hasGVK {
+		var err error
+		gvk, err = apiutil.GVKForObject(blder.forInput.object, blder.mgr.GetScheme())
+		if err != nil {
+			return err
+		}
+	}
+
+	// Setup concurrency.
+	if ctrlOptions.Concurrency <= 0 {
+		ctrlOptions.Concurrency = 1
+	}
+
+	controllerName, err := blder.getControllerName(gvk, hasGVK)
+	if err != nil {
+		return err
+	}
+
+	// Build the controller.
+	blder.ctrl, err = ccontroller.New(controllerName, ctrlOptions)
+	if err != nil {
+		return err
+	}
+
+	// Add the controller to the Manager.
+	if err := blder.mgr.AddController(blder.ctrl); err != nil {
+		return err
+	}
+	return err
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/builder/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/builder/doc.go
new file mode 100644
index 000000000..e6015d6ee
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/builder/doc.go
@@ -0,0 +1,18 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package builder provides a builder to build controllers.
+package builder
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/builder/options.go b/test/infrastructure/inmemory/internal/cloud/runtime/builder/options.go
new file mode 100644
index 000000000..a8c4545a8
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/builder/options.go
@@ -0,0 +1,58 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package builder
+
+import (
+	cpredicate "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/predicate"
+)
+
+// ForOption is some configuration that modifies options for a For request.
+type ForOption interface {
+	// ApplyToFor applies this configuration to the given for input.
+	ApplyToFor(*ForInput)
+}
+
+// WatchesOption is some configuration that modifies options for a watches request.
+type WatchesOption interface {
+	// ApplyToWatches applies this configuration to the given watches options.
+	ApplyToWatches(*WatchesInput)
+}
+
+// WithPredicates sets the given predicates list.
+func WithPredicates(predicates ...cpredicate.Predicate) Predicates {
+	return Predicates{
+		predicates: predicates,
+	}
+}
+
+// Predicates filters events before enqueuing the keys.
+type Predicates struct {
+	predicates []cpredicate.Predicate
+}
+
+// ApplyToFor applies this configuration to the given ForInput options.
+func (w Predicates) ApplyToFor(opts *ForInput) {
+	opts.predicates = w.predicates
+}
+
+// ApplyToWatches applies this configuration to the given WatchesInput options.
+func (w Predicates) ApplyToWatches(opts *WatchesInput) {
+	opts.predicates = w.predicates
+}
+
+var _ ForOption = &Predicates{}
+var _ WatchesOption = &Predicates{}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/cache/cache.go b/test/infrastructure/inmemory/internal/cloud/runtime/cache/cache.go
index 78508a878..b78e08534 100644
--- a/test/infrastructure/inmemory/internal/cloud/runtime/cache/cache.go
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/cache/cache.go
@@ -35,6 +35,7 @@ import (
 )
 
 // Cache implements an in-memory cache.
+// TODO: consider if to move to internal.
 type Cache interface {
 	Start(ctx context.Context) error
 
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/cache/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/cache/doc.go
index 7b4b73aa1..fe1f5ad8c 100644
--- a/test/infrastructure/inmemory/internal/cloud/runtime/cache/doc.go
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/cache/doc.go
@@ -18,8 +18,10 @@ limitations under the License.
 Package cache defines resource group aware Cache.
 
 The Cache implements sync loop and garbage collector inspired from the ones existing
-in Kubernetes.
+in Kubernetes, but in this case only finalizers are respected while currently there is not
+a concept of ownerReferences.
 
-Note: The cloud runtime is using a Cache for all the resource groups.
+Note: The cloud runtime is using a Cache for all the resource groups, so it will be possible
+to implement controllers processing request from all the resources groups.
 */
 package cache
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/controller/controller.go b/test/infrastructure/inmemory/internal/cloud/runtime/controller/controller.go
new file mode 100644
index 000000000..1e3690bfd
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/controller/controller.go
@@ -0,0 +1,203 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/util/workqueue"
+	ctrl "sigs.k8s.io/controller-runtime"
+
+	chandler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/handler"
+	cpredicate "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/predicate"
+	creconciler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/reconcile"
+	csource "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/source"
+)
+
+// Options are the arguments for creating a new Controller.
+type Options struct {
+	Concurrency int
+	Reconciler  creconciler.Reconciler
+}
+
+// Controller represents a controller.
+type Controller interface {
+	Watch(src csource.Source, evthdler chandler.EventHandler, prct ...cpredicate.Predicate) error
+	Start(ctx context.Context) error
+}
+
+// New returns a new Controller.
+func New(name string, options Options) (Controller, error) {
+	if name == "" {
+		return nil, fmt.Errorf("name cannot be empty")
+	}
+
+	if options.Reconciler == nil {
+		return nil, fmt.Errorf("reconciler cannot be nil")
+	}
+
+	if options.Concurrency < 1 {
+		return nil, fmt.Errorf("concurrency cannot be less then 1")
+	}
+
+	return &controller{
+		name:             name,
+		reconciler:       options.Reconciler,
+		concurrency:      options.Concurrency,
+		cacheSyncTimeout: 2 * time.Minute,
+	}, nil
+}
+
+type controller struct {
+	name             string
+	reconciler       creconciler.Reconciler
+	concurrency      int
+	cacheSyncTimeout time.Duration // TODO: make this an option
+
+	queue workqueue.RateLimitingInterface
+
+	lock         sync.Mutex
+	startWatches []watchDescription
+	started      bool
+
+	//nolint:containedctx
+	ctx context.Context
+}
+
+type watchDescription struct {
+	src        csource.Source
+	handler    chandler.EventHandler
+	predicates []cpredicate.Predicate
+}
+
+// Watch implements Controller.
+func (c *controller) Watch(src csource.Source, evthdler chandler.EventHandler, prct ...cpredicate.Predicate) error {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	if !c.started {
+		c.startWatches = append(c.startWatches, watchDescription{src: src, handler: evthdler, predicates: prct})
+		return nil
+	}
+
+	log := ctrl.LoggerFrom(c.ctx)
+	log.Info("Starting EventSource", "source", src)
+	return src.Start(c.ctx, evthdler, c.queue, prct...)
+}
+
+// Start implements Controller.
+func (c *controller) Start(ctx context.Context) error {
+	if ctx == nil {
+		return fmt.Errorf("context cannot be nil")
+	}
+
+	if c.started {
+		return fmt.Errorf("controller started more than once")
+	}
+
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	log := ctrl.LoggerFrom(ctx).WithValues("controller", c.name)
+	ctx = ctrl.LoggerInto(ctx, log)
+	c.ctx = ctx
+
+	log.Info("Starting controller queue")
+	c.queue = workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter())
+	go func() {
+		<-ctx.Done()
+		c.queue.ShutDown()
+	}()
+
+	for _, watch := range c.startWatches {
+		log.Info("Starting EventSource", "source", watch.src)
+		if err := watch.src.Start(ctx, watch.handler, c.queue, watch.predicates...); err != nil {
+			return err
+		}
+	}
+
+	c.startWatches = nil
+
+	var workers int64
+	go func() {
+		log.Info("Starting reconcile workers", "count", c.concurrency)
+		wg := &sync.WaitGroup{}
+		wg.Add(c.concurrency)
+		for i := 0; i < c.concurrency; i++ {
+			go func() {
+				atomic.AddInt64(&workers, 1)
+				defer wg.Done()
+				for c.processNextWorkItem(ctx) {
+				}
+			}()
+		}
+
+		<-ctx.Done()
+		wg.Wait()
+	}()
+
+	if err := wait.PollUntilContextTimeout(ctx, 50*time.Millisecond, 5*time.Second, false, func(ctx context.Context) (done bool, err error) {
+		if atomic.LoadInt64(&workers) < int64(c.concurrency) {
+			return false, nil
+		}
+		return true, nil
+	}); err != nil {
+		return fmt.Errorf("failed to start reconcile workers: %v", err)
+	}
+	c.started = true
+	return nil
+}
+
+func (c *controller) processNextWorkItem(ctx context.Context) bool {
+	obj, shutdown := c.queue.Get()
+	if shutdown {
+		return false
+	}
+
+	defer c.queue.Done(obj)
+
+	c.reconcileHandler(ctx, obj)
+	return true
+}
+
+func (c *controller) reconcileHandler(ctx context.Context, obj interface{}) {
+	req, ok := obj.(creconciler.Request)
+	if !ok {
+		c.queue.Forget(obj)
+		return
+	}
+
+	// TODO: Inject logger + add log on error
+
+	result, err := c.reconciler.Reconcile(ctx, req)
+	switch {
+	case err != nil:
+		c.queue.AddRateLimited(req)
+	case result.RequeueAfter > 0:
+		c.queue.Forget(obj)
+		c.queue.AddAfter(req, result.RequeueAfter)
+	case result.Requeue:
+		c.queue.AddRateLimited(req)
+	default:
+		c.queue.Forget(obj)
+	}
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/controller/controller_test.go b/test/infrastructure/inmemory/internal/cloud/runtime/controller/controller_test.go
new file mode 100644
index 000000000..6d6068415
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/controller/controller_test.go
@@ -0,0 +1,170 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	cloudv1 "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/api/v1alpha1"
+	ccache "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/cache"
+	chandler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/handler"
+	creconciler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/reconcile"
+	csource "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/source"
+)
+
+var scheme = runtime.NewScheme()
+
+func init() {
+	_ = cloudv1.AddToScheme(scheme)
+}
+
+type simpleReconciler struct {
+	processed chan creconciler.Request
+}
+
+func (r *simpleReconciler) Reconcile(_ context.Context, req creconciler.Request) (_ creconciler.Result, err error) {
+	r.processed <- req
+	return creconciler.Result{}, nil
+}
+
+func TestController_Queue(t *testing.T) {
+	g := NewWithT(t)
+	ctx, cancelFn := context.WithCancel(context.TODO())
+
+	r := &simpleReconciler{
+		processed: make(chan creconciler.Request),
+	}
+
+	c, err := New("foo", Options{
+		Concurrency: 1,
+		Reconciler:  r,
+	})
+	g.Expect(err).ToNot(HaveOccurred())
+
+	i := &fakeInformer{}
+
+	err = c.Watch(&csource.Informer{
+		Informer: i,
+	}, &chandler.EnqueueRequestForObject{})
+	g.Expect(err).ToNot(HaveOccurred())
+
+	err = c.Start(ctx)
+	g.Expect(err).ToNot(HaveOccurred())
+
+	i.InformCreate("foo", &cloudv1.CloudMachine{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "bar-create",
+		},
+	})
+
+	g.Eventually(func() bool {
+		r := <-r.processed
+		return r == creconciler.Request{
+			ResourceGroup:  "foo",
+			NamespacedName: types.NamespacedName{Name: "bar-create"},
+		}
+	}, 5*time.Second, 100*time.Millisecond).Should(BeTrue())
+
+	i.InformUpdate("foo", &cloudv1.CloudMachine{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "bar-update",
+		},
+	}, &cloudv1.CloudMachine{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "bar-update",
+		},
+	})
+
+	g.Eventually(func() bool {
+		r := <-r.processed
+		return r == creconciler.Request{
+			ResourceGroup:  "foo",
+			NamespacedName: types.NamespacedName{Name: "bar-update"},
+		}
+	}, 5*time.Second, 100*time.Millisecond).Should(BeTrue())
+
+	i.InformDelete("foo", &cloudv1.CloudMachine{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "bar-delete",
+		},
+	})
+
+	g.Eventually(func() bool {
+		r := <-r.processed
+		return r == creconciler.Request{
+			ResourceGroup:  "foo",
+			NamespacedName: types.NamespacedName{Name: "bar-delete"},
+		}
+	}, 5*time.Second, 100*time.Millisecond).Should(BeTrue())
+
+	i.InformGeneric("foo", &cloudv1.CloudMachine{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "bar-generic",
+		},
+	})
+
+	g.Eventually(func() bool {
+		r := <-r.processed
+		return r == creconciler.Request{
+			ResourceGroup:  "foo",
+			NamespacedName: types.NamespacedName{Name: "bar-generic"},
+		}
+	}, 5*time.Second, 100*time.Millisecond).Should(BeTrue())
+
+	close(r.processed)
+	cancelFn()
+}
+
+var _ ccache.Informer = &fakeInformer{}
+
+type fakeInformer struct {
+	handler ccache.InformEventHandler
+}
+
+func (i *fakeInformer) AddEventHandler(handler ccache.InformEventHandler) error {
+	i.handler = handler
+	return nil
+}
+
+func (i *fakeInformer) RemoveEventHandler(_ ccache.InformEventHandler) error {
+	i.handler = nil
+	return nil
+}
+
+func (i *fakeInformer) InformCreate(resourceGroup string, obj client.Object) {
+	i.handler.OnCreate(resourceGroup, obj)
+}
+
+func (i *fakeInformer) InformUpdate(resourceGroup string, oldObj, newObj client.Object) {
+	i.handler.OnUpdate(resourceGroup, oldObj, newObj)
+}
+
+func (i *fakeInformer) InformDelete(resourceGroup string, res client.Object) {
+	i.handler.OnDelete(resourceGroup, res)
+}
+
+func (i *fakeInformer) InformGeneric(resourceGroup string, res client.Object) {
+	i.handler.OnGeneric(resourceGroup, res)
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/controller/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/controller/doc.go
new file mode 100644
index 000000000..720567847
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/controller/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package controller provides types and functions for building Controllers.
+*/
+package controller
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/event/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/event/doc.go
new file mode 100644
index 000000000..b02d5d495
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/event/doc.go
@@ -0,0 +1,24 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package event contains the definitions for the Event types produced by source.Sources and transformed into
+reconcile.Requests by handler.EventHandler.
+
+The implementation is derived from sigs.k8s.io/controller-runtime/pkg/event and the main difference is that
+events are resourceGroup aware.
+*/
+package event
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/event/event.go b/test/infrastructure/inmemory/internal/cloud/runtime/event/event.go
new file mode 100644
index 000000000..7570551fa
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/event/event.go
@@ -0,0 +1,46 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package event
+
+import (
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// CreateEvent is an event where a cloud object was created.
+type CreateEvent struct {
+	ResourceGroup string
+	Object        client.Object
+}
+
+// UpdateEvent is an event where a cloud object was updated.
+type UpdateEvent struct {
+	ResourceGroup string
+	ObjectOld     client.Object
+	ObjectNew     client.Object
+}
+
+// DeleteEvent is an event where a cloud object was deleted.
+type DeleteEvent struct {
+	ResourceGroup string
+	Object        client.Object
+}
+
+// GenericEvent is an event where the operation type is unknown (e.g. periodic sync).
+type GenericEvent struct {
+	ResourceGroup string
+	Object        client.Object
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/handler/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/handler/doc.go
new file mode 100644
index 000000000..98079f23b
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/handler/doc.go
@@ -0,0 +1,25 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package handler defines EventHandlers that enqueue reconcile.Requests in response to Create, Update, Deletion Events
+observed from Watching Kubernetes APIs.
+
+The implementation is derived from sigs.k8s.io/controller-runtime/pkg/handler and the main difference are:
+- event handlers are resourceGroup aware.
+- the package provide only one event handler implementation, EnqueueRequestForObject.
+*/
+package handler
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/handler/enqueue_object.go b/test/infrastructure/inmemory/internal/cloud/runtime/handler/enqueue_object.go
new file mode 100644
index 000000000..077a8dcb2
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/handler/enqueue_object.go
@@ -0,0 +1,95 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package handler
+
+import (
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/util/workqueue"
+
+	cevent "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/event"
+	creconciler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/reconcile"
+)
+
+var _ EventHandler = &EnqueueRequestForObject{}
+
+// EnqueueRequestForObject enqueues a Request containing the ResourceGroup, Name and Namespace of the object that is the source of the Event.
+// handler.EnqueueRequestForObject is used by almost all Controllers that have associated Resources to reconcile.
+type EnqueueRequestForObject struct{}
+
+// Create implements EventHandler.
+func (e *EnqueueRequestForObject) Create(evt cevent.CreateEvent, q workqueue.RateLimitingInterface) {
+	if evt.Object == nil {
+		return
+	}
+	q.Add(creconciler.Request{
+		ResourceGroup: evt.ResourceGroup,
+		NamespacedName: types.NamespacedName{
+			Namespace: evt.Object.GetNamespace(),
+			Name:      evt.Object.GetName(),
+		},
+	})
+}
+
+// Update implements EventHandler.
+func (e *EnqueueRequestForObject) Update(evt cevent.UpdateEvent, q workqueue.RateLimitingInterface) {
+	switch {
+	case evt.ObjectNew != nil:
+		q.Add(creconciler.Request{
+			ResourceGroup: evt.ResourceGroup,
+			NamespacedName: types.NamespacedName{
+				Namespace: evt.ObjectNew.GetNamespace(),
+				Name:      evt.ObjectNew.GetName(),
+			},
+		})
+	case evt.ObjectOld != nil:
+		q.Add(creconciler.Request{
+			ResourceGroup: evt.ResourceGroup,
+			NamespacedName: types.NamespacedName{
+				Namespace: evt.ObjectOld.GetNamespace(),
+				Name:      evt.ObjectOld.GetName(),
+			},
+		})
+	}
+}
+
+// Delete implements EventHandler.
+func (e *EnqueueRequestForObject) Delete(evt cevent.DeleteEvent, q workqueue.RateLimitingInterface) {
+	if evt.Object == nil {
+		return
+	}
+	q.Add(creconciler.Request{
+		ResourceGroup: evt.ResourceGroup,
+		NamespacedName: types.NamespacedName{
+			Namespace: evt.Object.GetNamespace(),
+			Name:      evt.Object.GetName(),
+		},
+	})
+}
+
+// Generic implements EventHandler.
+func (e *EnqueueRequestForObject) Generic(evt cevent.GenericEvent, q workqueue.RateLimitingInterface) {
+	if evt.Object == nil {
+		return
+	}
+	q.Add(creconciler.Request{
+		ResourceGroup: evt.ResourceGroup,
+		NamespacedName: types.NamespacedName{
+			Namespace: evt.Object.GetNamespace(),
+			Name:      evt.Object.GetName(),
+		},
+	})
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/handler/handler.go b/test/infrastructure/inmemory/internal/cloud/runtime/handler/handler.go
new file mode 100644
index 000000000..c655e0b00
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/handler/handler.go
@@ -0,0 +1,41 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package handler
+
+import (
+	"k8s.io/client-go/util/workqueue"
+
+	cevent "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/event"
+)
+
+// EventHandler enqueues reconcile.Requests in response to events (e.g. object Create). EventHandlers map an Event
+// for one object to trigger Reconciles for either the same object or different objects - e.g. if there is an
+// Event for object with type Foo then reconcile one or more object(s) with type Bar.
+type EventHandler interface {
+	// Create is called in response to an create event - e.g. VM Creation.
+	Create(cevent.CreateEvent, workqueue.RateLimitingInterface)
+
+	// Update is called in response to an update event -  e.g. VM Updated.
+	Update(cevent.UpdateEvent, workqueue.RateLimitingInterface)
+
+	// Delete is called in response to a delete event - e.g. VM Deleted.
+	Delete(cevent.DeleteEvent, workqueue.RateLimitingInterface)
+
+	// Generic is called in response to an event of an unknown type or a synthetic event triggered as a cron or
+	// external trigger request - e.g. reconcile re-sync.
+	Generic(cevent.GenericEvent, workqueue.RateLimitingInterface)
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/manager/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/manager/doc.go
index 401228e7c..a4d5bb448 100644
--- a/test/infrastructure/inmemory/internal/cloud/runtime/manager/doc.go
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/manager/doc.go
@@ -18,5 +18,6 @@ limitations under the License.
 Package manager defines a Manager for resource groups.
 
 Resource groups are stored into an in-memory Cache, and objects via a client.
+The Manager also operate controllers automating objects lifecycle.
 */
 package manager
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/manager/manager.go b/test/infrastructure/inmemory/internal/cloud/runtime/manager/manager.go
index 07dd04614..19c584c49 100644
--- a/test/infrastructure/inmemory/internal/cloud/runtime/manager/manager.go
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/manager/manager.go
@@ -24,10 +24,12 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 
 	ccache "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/cache"
+	ccontroller "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/controller"
 	cresourcegroup "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/resourcegroup"
 )
 
-// Manager initializes shared dependencies such as Caches and Clients.
+// Manager initializes shared dependencies such as Caches and Clients, and provides them to Runnables.
+// A Manager is required to create Controllers.
 type Manager interface {
 	// TODO: refactor in resoucegroup.add/delete/get; make delete fail if rs does not exist
 	AddResourceGroup(name string)
@@ -39,6 +41,8 @@ type Manager interface {
 	// TODO: expose less (only get informers)
 	GetCache() ccache.Cache
 
+	AddController(ccontroller.Controller) error
+
 	Start(ctx context.Context) error
 }
 
@@ -47,14 +51,17 @@ var _ Manager = &manager{}
 type manager struct {
 	scheme *runtime.Scheme
 
-	cache   ccache.Cache
-	started bool
+	cache ccache.Cache
+
+	controllers []ccontroller.Controller
+	started     bool
 }
 
 // New creates a new manager.
 func New(scheme *runtime.Scheme) Manager {
 	m := &manager{
-		scheme: scheme,
+		scheme:      scheme,
+		controllers: make([]ccontroller.Controller, 0),
 	}
 	m.cache = ccache.NewCache(scheme)
 	return m
@@ -81,6 +88,15 @@ func (m *manager) GetCache() ccache.Cache {
 	return m.cache
 }
 
+func (m *manager) AddController(controller ccontroller.Controller) error {
+	if m.started {
+		return fmt.Errorf("cannot add controller to a manager already started")
+	}
+
+	m.controllers = append(m.controllers, controller)
+	return nil
+}
+
 func (m *manager) Start(ctx context.Context) error {
 	log := ctrl.LoggerFrom(ctx)
 
@@ -96,6 +112,13 @@ func (m *manager) Start(ctx context.Context) error {
 		return fmt.Errorf("failed to start cache: %v", err)
 	}
 
+	log.Info("Starting manager")
+	for _, c := range m.controllers {
+		if err := c.Start(ctx); err != nil {
+			return fmt.Errorf("failed to start controllers: %v", err)
+		}
+	}
+
 	m.started = true
 	log.Info("Manager successfully started!")
 	return nil
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/predicate/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/predicate/doc.go
new file mode 100644
index 000000000..9774ec873
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/predicate/doc.go
@@ -0,0 +1,24 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package predicate defines Predicates used by Controllers to filter Events before they are provided to EventHandlers.
+
+The implementation is derived from sigs.k8s.io/controller-runtime/pkg/predicate and the main difference are:
+- predicates are resourceGroup aware.
+- the package doesn't provide predicates implementation out of the box.
+*/
+package predicate
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/predicate/predicate.go b/test/infrastructure/inmemory/internal/cloud/runtime/predicate/predicate.go
new file mode 100644
index 000000000..4afea8eef
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/predicate/predicate.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package predicate
+
+import (
+	cevent "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/event"
+)
+
+// Predicate filters events before enqueuing the keys.
+type Predicate interface {
+	// Create returns true if the Create event should be processed
+	Create(cevent.CreateEvent) bool
+
+	// Delete returns true if the Delete event should be processed
+	Delete(cevent.DeleteEvent) bool
+
+	// Update returns true if the Update event should be processed
+	Update(cevent.UpdateEvent) bool
+
+	// Generic returns true if the Generic event should be processed
+	Generic(cevent.GenericEvent) bool
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/reconcile/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/reconcile/doc.go
new file mode 100644
index 000000000..9d36e7beb
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/reconcile/doc.go
@@ -0,0 +1,24 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package reconcile defines the Reconciler interface; Reconciler is provided
+to Controllers at creation time as the API implementation.
+
+The implementation is derived from sigs.k8s.io/controller-runtime/pkg/reconcile and the main difference is that
+Reconciler and Request are resourceGroup aware.
+*/
+package reconcile
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/reconcile/reconcile.go b/test/infrastructure/inmemory/internal/cloud/runtime/reconcile/reconcile.go
new file mode 100644
index 000000000..ad35dd90e
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/reconcile/reconcile.go
@@ -0,0 +1,49 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package reconcile
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+)
+
+// Reconciler is provided to Controllers at creation time as the API implementation.
+type Reconciler interface {
+	// Reconcile performs a full reconciliation for the resource referred to by the Request.
+	// The Controller will requeue the Request to be processed again if an error is non-nil or
+	// Result.Requeue is true, otherwise upon completion it will remove the work from the queue.
+	Reconcile(context.Context, Request) (Result, error)
+}
+
+// Request contains the information necessary to reconcile a resource. This includes the
+// information to uniquely identify the resource - the resourceGroup it belongs to, its Name and Namespace.
+type Request struct {
+	ResourceGroup string
+	types.NamespacedName
+}
+
+func (r Request) String() string {
+	if r.ResourceGroup == "" {
+		return r.NamespacedName.String()
+	}
+	return r.ResourceGroup + string(types.Separator) + r.NamespacedName.String()
+}
+
+// Result contains the result of a Reconciler invocation.
+type Result = reconcile.Result
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/source/doc.go b/test/infrastructure/inmemory/internal/cloud/runtime/source/doc.go
new file mode 100644
index 000000000..80d41ef47
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/source/doc.go
@@ -0,0 +1,25 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package source provides event streams to hook up to Controllers with Controller.Watch. Events are
+used with handler.EventHandlers to enqueue reconcile.Requests and trigger Reconciles for resources.
+
+The implementation is derived from sigs.k8s.io/controller-runtime/pkg/source  and the main difference are:
+- sources are resourceGroup aware.
+- the package provide only one sources implementation, Informer.
+*/
+package source
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/source/informer_source.go b/test/infrastructure/inmemory/internal/cloud/runtime/source/informer_source.go
new file mode 100644
index 000000000..68c7f87ca
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/source/informer_source.go
@@ -0,0 +1,117 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package source
+
+import (
+	"context"
+	"fmt"
+
+	"k8s.io/client-go/util/workqueue"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	ccache "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/cache"
+	cevent "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/event"
+	chandler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/handler"
+	cpredicate "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/predicate"
+)
+
+// Informer is used to provide a source of events originating from CRUD operations inside the resourceGroup (e.g. Resource Create).
+type Informer struct {
+	Informer ccache.Informer
+}
+
+var _ Source = &Informer{}
+
+// Start implements Source.
+func (is *Informer) Start(_ context.Context, handler chandler.EventHandler, queue workqueue.RateLimitingInterface, prct ...cpredicate.Predicate) error {
+	// Informer should have been specified by the user.
+	if is.Informer == nil {
+		return fmt.Errorf("must specify Informer.Informer")
+	}
+
+	return is.Informer.AddEventHandler(informerEventHandler{Queue: queue, EventHandler: handler, Predicates: prct})
+}
+
+func (is *Informer) String() string {
+	return fmt.Sprintf("informer source: %p", is.Informer)
+}
+
+var _ ccache.InformEventHandler = informerEventHandler{}
+
+// informerEventHandler handle events originated by a source.
+type informerEventHandler struct {
+	EventHandler chandler.EventHandler
+	Queue        workqueue.RateLimitingInterface
+	Predicates   []cpredicate.Predicate
+}
+
+// OnCreate creates CreateEvent and calls Create on EventHandler.
+func (e informerEventHandler) OnCreate(resourceGroup string, obj client.Object) {
+	c := cevent.CreateEvent{
+		ResourceGroup: resourceGroup,
+		Object:        obj,
+	}
+	for _, p := range e.Predicates {
+		if !p.Create(c) {
+			return
+		}
+	}
+	e.EventHandler.Create(c, e.Queue)
+}
+
+// OnUpdate creates UpdateEvent and calls Update on EventHandler.
+func (e informerEventHandler) OnUpdate(resourceGroup string, oldObj, newObj client.Object) {
+	u := cevent.UpdateEvent{
+		ResourceGroup: resourceGroup,
+		ObjectOld:     oldObj,
+		ObjectNew:     newObj,
+	}
+	for _, p := range e.Predicates {
+		if !p.Update(u) {
+			return
+		}
+	}
+	e.EventHandler.Update(u, e.Queue)
+}
+
+// OnDelete creates DeleteEvent and calls Delete on EventHandler.
+func (e informerEventHandler) OnDelete(resourceGroup string, obj client.Object) {
+	d := cevent.DeleteEvent{
+		ResourceGroup: resourceGroup,
+		Object:        obj,
+	}
+	for _, p := range e.Predicates {
+		if !p.Delete(d) {
+			return
+		}
+	}
+	e.EventHandler.Delete(d, e.Queue)
+}
+
+// OnGeneric creates GenericEvent and calls Generic on EventHandler.
+func (e informerEventHandler) OnGeneric(resourceGroup string, obj client.Object) {
+	d := cevent.GenericEvent{
+		ResourceGroup: resourceGroup,
+		Object:        obj,
+	}
+	for _, p := range e.Predicates {
+		if !p.Generic(d) {
+			return
+		}
+	}
+	e.EventHandler.Generic(d, e.Queue)
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime/source/source.go b/test/infrastructure/inmemory/internal/cloud/runtime/source/source.go
new file mode 100644
index 000000000..09b87289e
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime/source/source.go
@@ -0,0 +1,34 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package source
+
+import (
+	"context"
+
+	"k8s.io/client-go/util/workqueue"
+
+	chandler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/handler"
+	cpredicate "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/predicate"
+)
+
+// Source is a source of events (e.g. Create, Update, Delete operations on resources)
+// which should be processed by event.EventHandlers to enqueue reconcile.Requests.
+type Source interface {
+	// Start is internal and should be called only by the Controller to register an EventHandler with the Informer
+	// to enqueue reconcile.Requests.
+	Start(context.Context, chandler.EventHandler, workqueue.RateLimitingInterface, ...cpredicate.Predicate) error
+}
diff --git a/test/infrastructure/inmemory/internal/cloud/runtime_test.go b/test/infrastructure/inmemory/internal/cloud/runtime_test.go
new file mode 100644
index 000000000..cf4cc0c41
--- /dev/null
+++ b/test/infrastructure/inmemory/internal/cloud/runtime_test.go
@@ -0,0 +1,98 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cloud
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	cloudv1 "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/api/v1alpha1"
+	creconciler "sigs.k8s.io/cluster-api/test/infrastructure/inmemory/internal/cloud/runtime/reconcile"
+)
+
+var scheme = runtime.NewScheme()
+
+func init() {
+	_ = cloudv1.AddToScheme(scheme)
+}
+
+type simpleReconciler struct {
+	mgr Manager
+}
+
+func (r *simpleReconciler) SetupWithManager(_ context.Context, mgr Manager) error {
+	return NewControllerManagedBy(mgr).
+		For(&cloudv1.CloudMachine{}).
+		Complete(r)
+}
+
+func (r *simpleReconciler) Reconcile(ctx context.Context, req creconciler.Request) (creconciler.Result, error) {
+	client := r.mgr.GetResourceGroup(req.ResourceGroup).GetClient()
+
+	obj := &cloudv1.CloudMachine{}
+	if err := client.Get(ctx, req.NamespacedName, obj); err != nil {
+		return creconciler.Result{}, err
+	}
+
+	obj.Annotations = map[string]string{"reconciled": "true"}
+	if err := client.Update(ctx, obj); err != nil {
+		return creconciler.Result{}, err
+	}
+	return creconciler.Result{}, nil
+}
+
+func TestController_Queue(t *testing.T) {
+	g := NewWithT(t)
+	ctx, cancelFn := context.WithCancel(context.TODO())
+
+	mgr := NewManager(scheme)
+	mgr.AddResourceGroup("foo")
+
+	c := mgr.GetResourceGroup("foo").GetClient()
+
+	err := (&simpleReconciler{
+		mgr: mgr,
+	}).SetupWithManager(ctx, mgr)
+	g.Expect(err).ToNot(HaveOccurred())
+
+	err = mgr.Start(ctx)
+	g.Expect(err).ToNot(HaveOccurred())
+
+	obj := &cloudv1.CloudMachine{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "bar-reconcile",
+		},
+	}
+	err = c.Create(ctx, obj)
+	g.Expect(err).ToNot(HaveOccurred())
+
+	g.Eventually(func() bool {
+		obj2 := &cloudv1.CloudMachine{}
+		if err := c.Get(ctx, client.ObjectKeyFromObject(obj), obj2); err != nil {
+			return false
+		}
+		return obj2.Annotations["reconciled"] == "true"
+	}, 5*time.Second, 100*time.Millisecond).Should(BeTrue())
+
+	cancelFn()
+}