Repository: kubernetes-sigs/cluster-api-provider-vsphere
Pull request #2879 (2024-04-04 12:16:57)

🌱 Add more supervisor tests
**What this PR does / why we need it**:
Adding more supervisor tests.

At this stage it is just an experiment to figure out how to proceed


Patch:
diff --git a/Makefile b/Makefile
index 6f918517..5964260f 100644
--- a/Makefile
+++ b/Makefile
@@ -377,6 +377,8 @@ generate-e2e-templates-main: $(KUSTOMIZE) ## Generate test templates for the mai
 	"$(KUSTOMIZE)" --load-restrictor LoadRestrictionsNone build "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/clusterclass" > "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/clusterclass-quick-start-supervisor.yaml"
 	cp "$(RELEASE_DIR)/main/cluster-template-topology-supervisor.yaml" "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/topology/cluster-template-topology-supervisor.yaml"
 	"$(KUSTOMIZE)" --load-restrictor LoadRestrictionsNone build "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/topology" > "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/cluster-template-topology-supervisor.yaml"
+	"$(KUSTOMIZE)" --load-restrictor LoadRestrictionsNone build "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/conformance" > "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/cluster-template-conformance-supervisor.yaml"
+	"$(KUSTOMIZE)" --load-restrictor LoadRestrictionsNone build "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/install-on-bootstrap" > "$(E2E_SUPERVISOR_TEMPLATE_DIR)/main/cluster-template-install-on-bootstrap-supervisor.yaml"
 
 .PHONY: generate-e2e-templates-v1.9
 generate-e2e-templates-v1.9: $(KUSTOMIZE)
@@ -868,8 +870,10 @@ clean-ci: ## Cleanup orphaned objects in CI
 		--max-age=12h \
 		--ipam-namespace=default \
 		--folder=/SDDC-Datacenter/vm/Workloads/cluster-api-provider-vsphere \
-		--folder=/SDDC-Datacenter/vm/Workloads/cloud-provider-vsphere \
-		--folder=/SDDC-Datacenter/vm/Workloads/image-builder
+		--resource-pool=/SDDC-Datacenter/host/Cluster-1/Resources/Compute-ResourcePool/cluster-api-provider-vsphere \
+		--vm-folder=/SDDC-Datacenter/vm/Workloads/cluster-api-provider-vsphere \
+		--vm-folder=/SDDC-Datacenter/vm/Workloads/cloud-provider-vsphere \
+		--vm-folder=/SDDC-Datacenter/vm/Workloads/image-builder
 
 .PHONY: clean-temporary
 clean-temporary: ## Remove all temporary files and folders
diff --git a/apis/v1beta1/types.go b/apis/v1beta1/types.go
index 8eebfd2d..1a12b8de 100644
--- a/apis/v1beta1/types.go
+++ b/apis/v1beta1/types.go
@@ -258,6 +258,11 @@ type PCIDeviceSpec struct {
 	// virtual machine is cloned.
 	// +kubebuilder:validation:Required
 	VendorID *int32 `json:"vendorId,omitempty"`
+	// CustomLabel is the hardware label of a virtual machine's PCI device.
+	// Defaults to the eponymous property value in the template from which the
+	// virtual machine is cloned.
+	// +optional
+	CustomLabel string `json:"customLabel,omitempty"`
 }
 
 // NetworkSpec defines the virtual machine's network configuration.
diff --git a/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachines.yaml b/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachines.yaml
index eed1212b..3177a526 100644
--- a/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachines.yaml
+++ b/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachines.yaml
@@ -1212,6 +1212,11 @@ spec:
                 items:
                   description: PCIDeviceSpec defines virtual machine's PCI configuration.
                   properties:
+                    customLabel:
+                      description: CustomLabel is the hardware label of a virtual
+                        machine's PCI device. Defaults to the eponymous property value
+                        in the template from which the virtual machine is cloned.
+                      type: string
                     deviceId:
                       description: DeviceID is the device ID of a virtual machine's
                         PCI, in integer. Defaults to the eponymous property value
diff --git a/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachinetemplates.yaml b/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachinetemplates.yaml
index c6e8b69f..9f4aca65 100644
--- a/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachinetemplates.yaml
+++ b/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspheremachinetemplates.yaml
@@ -1123,6 +1123,12 @@ spec:
                           description: PCIDeviceSpec defines virtual machine's PCI
                             configuration.
                           properties:
+                            customLabel:
+                              description: CustomLabel is the hardware label of a
+                                virtual machine's PCI device. Defaults to the eponymous
+                                property value in the template from which the virtual
+                                machine is cloned.
+                              type: string
                             deviceId:
                               description: DeviceID is the device ID of a virtual
                                 machine's PCI, in integer. Defaults to the eponymous
diff --git a/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspherevms.yaml b/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspherevms.yaml
index 9d251b22..67b91833 100644
--- a/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspherevms.yaml
+++ b/config/default/crd/bases/infrastructure.cluster.x-k8s.io_vspherevms.yaml
@@ -1258,6 +1258,11 @@ spec:
                 items:
                   description: PCIDeviceSpec defines virtual machine's PCI configuration.
                   properties:
+                    customLabel:
+                      description: CustomLabel is the hardware label of a virtual
+                        machine's PCI device. Defaults to the eponymous property value
+                        in the template from which the virtual machine is cloned.
+                      type: string
                     deviceId:
                       description: DeviceID is the device ID of a virtual machine's
                         PCI, in integer. Defaults to the eponymous property value
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index c9e22f18..dc535fab 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -334,6 +334,19 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - nsx.vmware.com
+  resources:
+  - subnetsets
+  - subnetsets/status
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
 - apiGroups:
   - rbac.authorization.k8s.io
   resources:
diff --git a/controllers/vmware/vspherecluster_reconciler.go b/controllers/vmware/vspherecluster_reconciler.go
index 26513c17..1330e03b 100644
--- a/controllers/vmware/vspherecluster_reconciler.go
+++ b/controllers/vmware/vspherecluster_reconciler.go
@@ -62,6 +62,7 @@ type ClusterReconciler struct {
 // +kubebuilder:rbac:groups=vmware.infrastructure.cluster.x-k8s.io,resources=vsphereclusters,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=vmware.infrastructure.cluster.x-k8s.io,resources=vsphereclusters/status,verbs=get;update;patch
 // +kubebuilder:rbac:groups=vmware.infrastructure.cluster.x-k8s.io,resources=vsphereclustertemplates,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=nsx.vmware.com,resources=subnetsets;subnetsets/status,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=vmware.com,resources=virtualnetworks;virtualnetworks/status,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=vmoperator.vmware.com,resources=virtualmachinesetresourcepolicies;virtualmachinesetresourcepolicies/status,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=vmoperator.vmware.com,resources=virtualmachineservices;virtualmachineservices/status,verbs=get;list;watch;create;update;patch;delete
diff --git a/controllers/vspheremachine_controller.go b/controllers/vspheremachine_controller.go
index 9eaa1241..8ad2da4b 100644
--- a/controllers/vspheremachine_controller.go
+++ b/controllers/vspheremachine_controller.go
@@ -90,12 +90,12 @@ func AddMachineControllerToManager(ctx context.Context, controllerManagerContext
 	}
 
 	if supervisorBased {
-		r.VMService = &vmoperator.VmopMachineService{Client: controllerManagerContext.Client}
 		networkProvider, err := inframanager.GetNetworkProvider(ctx, controllerManagerContext.Client, controllerManagerContext.NetworkProvider)
 		if err != nil {
 			return errors.Wrap(err, "failed to create a network provider")
 		}
 		r.networkProvider = networkProvider
+		r.VMService = &vmoperator.VmopMachineService{Client: controllerManagerContext.Client, ConfigureControlPlaneVMReadinessProbe: r.networkProvider.SupportsVMReadinessProbe()}
 
 		return ctrl.NewControllerManagedBy(mgr).
 			// Watch the controlled, infrastructure resource.
diff --git a/go.mod b/go.mod
index a310eac1..3e88810b 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
 	github.com/spf13/cobra v1.8.0
 	github.com/stretchr/testify v1.9.0
 	github.com/vmware-tanzu/net-operator-api v0.0.0-20231019160108-42131d6e8360
+	github.com/vmware-tanzu/nsx-operator/pkg/apis v0.1.0
 	github.com/vmware-tanzu/vm-operator/api v1.8.5
 	github.com/vmware-tanzu/vm-operator/external/ncp v0.0.0-20231214185006-5477585eebfd
 	github.com/vmware-tanzu/vm-operator/external/tanzu-topology v0.0.0-20231214185006-5477585eebfd
diff --git a/go.sum b/go.sum
index 3bec1265..d5b4619b 100644
--- a/go.sum
+++ b/go.sum
@@ -551,6 +551,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1
 github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
 github.com/vmware-tanzu/net-operator-api v0.0.0-20231019160108-42131d6e8360 h1:yG158jviUd3wRqCTJcSDzp+prUZWtSA9dhfm/Rf8m9M=
 github.com/vmware-tanzu/net-operator-api v0.0.0-20231019160108-42131d6e8360/go.mod h1:dtVG693FvGuOSxJvTaKRVGU0EJR8yvLG3E2VaDDHILM=
+github.com/vmware-tanzu/nsx-operator/pkg/apis v0.1.0 h1:HdnQb/X9vJ8a5WQ03g/0nDr9igIIK1fF6wO5wOtkJT4=
+github.com/vmware-tanzu/nsx-operator/pkg/apis v0.1.0/go.mod h1:Q4JzNkNMvjo7pXtlB5/R3oME4Nhah7fAObWgghVmtxk=
 github.com/vmware-tanzu/vm-operator/api v1.8.5 h1:E8rpRdV8+cNp/eNZ/QUHvlrbpPh8uk6bKqwEEmGWe64=
 github.com/vmware-tanzu/vm-operator/api v1.8.5/go.mod h1:SXaSFtnw2502Tzy0bfQVHrvbFDijR96r1ihUYQWPOK8=
 github.com/vmware-tanzu/vm-operator/external/ncp v0.0.0-20231214185006-5477585eebfd h1:qdfVf7KFW+XX7+D4xC/mlBpRA9+B+opdDPxGdqjxO+4=
diff --git a/hack/tools/janitor/janitor.go b/hack/tools/janitor/janitor.go
index 444dfb1e..3427590c 100644
--- a/hack/tools/janitor/janitor.go
+++ b/hack/tools/janitor/janitor.go
@@ -19,10 +19,12 @@ package main
 import (
 	"context"
 	"fmt"
+	"slices"
+	"sort"
+	"strings"
 	"time"
 
 	"github.com/pkg/errors"
-	"github.com/vmware/govmomi/find"
 	"github.com/vmware/govmomi/object"
 	govmomicluster "github.com/vmware/govmomi/vapi/cluster"
 	"github.com/vmware/govmomi/vim25/mo"
@@ -57,6 +59,47 @@ type virtualMachine struct {
 	object        *object.VirtualMachine
 }
 
+func (s *janitor) cleanupVSphere(ctx context.Context, folders, resourcePools, vmFolders []string) error {
+	errList := []error{}
+
+	// Delete vms to cleanup folders and resource pools.
+	for _, folder := range vmFolders {
+		if err := s.deleteVSphereVMs(ctx, folder); err != nil {
+			errList = append(errList, errors.Wrapf(err, "cleaning up vSphereVMs for folder %q", folder))
+		}
+	}
+	if err := kerrors.NewAggregate(errList); err != nil {
+		return errors.Wrap(err, "cleaning up vSphereVMs")
+	}
+
+	// Delete empty resource pools.
+	for _, resourcePool := range resourcePools {
+		if err := s.deleteObjectChildren(ctx, resourcePool, "ResourcePool"); err != nil {
+			errList = append(errList, errors.Wrapf(err, "cleaning up empty resource pool children for resource pool %q", resourcePool))
+		}
+	}
+	if err := kerrors.NewAggregate(errList); err != nil {
+		return errors.Wrap(err, "cleaning up resource pools")
+	}
+
+	// Delete empty folders.
+	for _, folder := range folders {
+		if err := s.deleteObjectChildren(ctx, folder, "Folder"); err != nil {
+			errList = append(errList, errors.Wrapf(err, "cleaning up empty folder children for folder %q", folder))
+		}
+	}
+	if err := kerrors.NewAggregate(errList); err != nil {
+		return errors.Wrap(err, "cleaning up folders")
+	}
+
+	// Delete empty cluster modules.
+	if err := s.deleteVSphereClusterModules(ctx); err != nil {
+		return errors.Wrap(err, "cleaning up vSphere cluster modules")
+	}
+
+	return nil
+}
+
 // deleteVSphereVMs deletes all VSphereVMs in a given folder in vSphere if their creation
 // timestamp is before the janitor's configured maxCreationDate.
 func (s *janitor) deleteVSphereVMs(ctx context.Context, folder string) error {
@@ -70,8 +113,7 @@ func (s *janitor) deleteVSphereVMs(ctx context.Context, folder string) error {
 	log.Info("Deleting vSphere VMs in folder")
 
 	// List all virtual machines inside the folder.
-	finder := find.NewFinder(s.vSphereClients.Vim, false)
-	managedObjects, err := finder.ManagedObjectListChildren(ctx, folder+"/...", "VirtualMachine")
+	managedObjects, err := s.vSphereClients.Finder.ManagedObjectListChildren(ctx, folder+"/...", "VirtualMachine")
 	if err != nil {
 		return err
 	}
@@ -143,7 +185,7 @@ func (s *janitor) deleteVSphereVMs(ctx context.Context, folder string) error {
 	destroyTasks := []*object.Task{}
 	for _, vm := range append(vmsToDeleteAndPoweroff, vmsToDelete...) {
 		log.Info("Destroying vm in vSphere", "vm", vm.managedObject.Config.Name)
-		if dryRun {
+		if s.dryRun {
 			// Skipping actual destroy on dryRun.
 			continue
 		}
@@ -162,12 +204,149 @@ func (s *janitor) deleteVSphereVMs(ctx context.Context, folder string) error {
 	return nil
 }
 
-func waitForTasksFinished(ctx context.Context, tasks []*object.Task, ignoreErrors bool) error {
-	for _, t := range tasks {
-		if err := t.Wait(ctx); !ignoreErrors && err != nil {
+// deleteObjectChildren deletes all child objects in a given object in vSphere if they don't
+// contain any virtual machine.
+// An object only gets deleted if:
+// * it does not have any children of a different type
+// * the timestamp field's value is before s.maxCreationDate
+// If an object does not yet have a field, the janitor will add the field to it with the current timestamp as value.
+func (s *janitor) deleteObjectChildren(ctx context.Context, inventoryPath string, objectType string) error {
+	if !slices.Contains([]string{"ResourcePool", "Folder"}, objectType) {
+		return fmt.Errorf("deleteObjectChildren is not implemented for objectType %s", objectType)
+	}
+
+	if inventoryPath == "" {
+		return fmt.Errorf("cannot use empty string to delete children of type %s", objectType)
+	}
+
+	log := ctrl.LoggerFrom(ctx).WithName(fmt.Sprintf("%sChildren", objectType)).WithValues(objectType, inventoryPath)
+	ctx = ctrl.LoggerInto(ctx, log)
+
+	log.Info("Deleting empty children")
+
+	// Recursively list all objects of the given objectType below the inventoryPath.
+	managedEntities, err := recursiveList(ctx, inventoryPath, s.vSphereClients.Govmomi, s.vSphereClients.Finder, s.vSphereClients.ViewManager, objectType)
+	if err != nil {
+		return err
+	}
+
+	// Build a map which notes if an object has children of a different type.
+	// Later on we will use that information to not delete objects which have children.
+	hasChildren := map[string]bool{}
+	for _, e := range managedEntities {
+		// Check if the object has children, because we only want to delete objects which have children of a different type.
+		children, err := recursiveList(ctx, e.element.Path, s.vSphereClients.Govmomi, s.vSphereClients.Finder, s.vSphereClients.ViewManager)
+		if err != nil {
+			return err
+		}
+		// Mark e to have children, if there are children which are of a different type.
+		for _, child := range children {
+			if child.entity.Reference().Type == objectType {
+				continue
+			}
+			hasChildren[e.element.Path] = true
+			break
+		}
+	}
+
+	// Get key for the deletion marker.
+	deletionMarkerKey, err := s.vSphereClients.FieldsManager.FindKey(ctx, vSphereDeletionMarkerName)
+	if err != nil {
+		if !errors.Is(err, object.ErrKeyNameNotFound) {
+			return errors.Wrapf(err, "finding custom field %q", vSphereDeletionMarkerName)
+		}
+
+		// In case of ErrKeyNameNotFound we will create the deletionMarker but only if
+		// we are not on dryRun.
+		log.Info("Creating the deletion field")
+
+		if !s.dryRun {
+			field, err := s.vSphereClients.FieldsManager.Add(ctx, vSphereDeletionMarkerName, "ManagedEntity", nil, nil)
+			if err != nil {
+				return errors.Wrapf(err, "creating custom field %q", vSphereDeletionMarkerName)
+			}
+			deletionMarkerKey = field.Key
+		}
+	}
+
+	objectsToMark := []*managedElement{}
+	objectsToDelete := []*managedElement{}
+
+	// Filter elements and collect two groups:
+	// * objects to add the timestamp field
+	// * objects to destroy
+	for i := range managedEntities {
+		managedEntity := managedEntities[i]
+
+		// We mark any object we find with a timestamp to determine the first time we did see this item.
+		// This is used as replacement for the non-existing CreationTimestamp on objects.
+		timestamp, err := getDeletionMarkerTimestamp(deletionMarkerKey, managedEntity.entity.Value)
+		if err != nil {
 			return err
 		}
+		// If no timestamp was found: queue it to get marked.
+		if timestamp == nil {
+			objectsToMark = append(objectsToMark, managedEntity)
+			continue
+		}
+
+		// Filter out objects we don't have to cleanup depending on s.maxCreationDate.
+		if timestamp.After(s.maxCreationDate) {
+			log.Info("Skipping deletion of object: marked timestamp does not exceed maxCreationDate", "timestamp", timestamp, "inventoryPath", managedEntity.element.Path)
+			continue
+		}
+
+		// Filter out objects which have children.
+		if hasChildren[managedEntity.element.Path] {
+			log.Info("Skipping deletion of object: object has child objects of a different type", "inventoryPath", managedEntity.element.Path)
+			continue
+		}
+
+		objectsToDelete = append(objectsToDelete, managedEntity)
 	}
+
+	for i := range objectsToMark {
+		managedElement := objectsToMark[i]
+		log.Info("Marking resource object for deletion in vSphere", objectType, managedElement.element.Path)
+
+		if s.dryRun {
+			// Skipping actual mark on dryRun.
+			continue
+		}
+
+		if err := s.vSphereClients.FieldsManager.Set(ctx, managedElement.entity.Reference(), deletionMarkerKey, time.Now().Format(time.RFC3339)); err != nil {
+			return errors.Wrapf(err, "setting field %s on object %s", vSphereDeletionMarkerName, managedElement.element.Path)
+		}
+	}
+
+	// sort objects to delete so children are deleted before parents
+	sort.Slice(objectsToDelete, func(i, j int) bool {
+		a := objectsToDelete[i]
+		b := objectsToDelete[j]
+
+		return strings.Count(a.element.Path, "/") > strings.Count(b.element.Path, "/")
+	})
+
+	destroyTasks := []*object.Task{}
+	for _, managedEntity := range objectsToDelete {
+		log.Info("Destroying object in vSphere", objectType, managedEntity.element.Path)
+		if s.dryRun {
+			// Skipping actual destroy on dryRun.
+			continue
+		}
+
+		task, err := object.NewCommon(s.vSphereClients.Vim, managedEntity.entity.Reference()).Destroy(ctx)
+		if err != nil {
+			return err
+		}
+		log.Info("Created Destroy task for object", objectType, managedEntity.element.Path, "task", task.Reference().Value)
+		destroyTasks = append(destroyTasks, task)
+	}
+	// Wait for all destroy tasks to succeed.
+	if err := waitForTasksFinished(ctx, destroyTasks, false); err != nil {
+		return errors.Wrap(err, "failed to wait for object destroy task to finish")
+	}
+
 	return nil
 }
 
diff --git a/hack/tools/janitor/main.go b/hack/tools/janitor/main.go
index 0a4329bb..01740b07 100644
--- a/hack/tools/janitor/main.go
+++ b/hack/tools/janitor/main.go
@@ -26,7 +26,6 @@ import (
 	"github.com/pkg/errors"
 	"github.com/spf13/pflag"
 	"k8s.io/apimachinery/pkg/runtime"
-	kerrors "k8s.io/apimachinery/pkg/util/errors"
 	"k8s.io/klog/v2"
 	ipamv1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
 	ctrl "sigs.k8s.io/controller-runtime"
@@ -41,14 +40,18 @@ func init() {
 }
 
 var (
-	dryRun         bool
-	ipamNamespace  string
-	maxAge         time.Duration
-	vsphereFolders []string
+	dryRun               bool
+	ipamNamespace        string
+	maxAge               time.Duration
+	vsphereVMFolders     []string
+	vsphereFolders       []string
+	vsphereResourcePools []string
 )
 
 func initFlags(fs *pflag.FlagSet) {
-	fs.StringArrayVar(&vsphereFolders, "folder", []string{}, "Path to folders in vCenter to cleanup virtual machines.")
+	fs.StringArrayVar(&vsphereVMFolders, "vm-folder", []string{}, "Path to folders in vCenter to cleanup virtual machines.")
+	fs.StringArrayVar(&vsphereFolders, "folder", []string{}, "Path to a folder in vCenter to recursively cleanup empty subfolders.")
+	fs.StringArrayVar(&vsphereResourcePools, "resource-pool", []string{}, "Path to a resource pool in vCenter to recursively cleanup empty child resource pools.")
 	fs.StringVar(&ipamNamespace, "ipam-namespace", "", "Namespace for IPAddressClaim cleanup.")
 	fs.DurationVar(&maxAge, "max-age", time.Hour*12, "Maximum age of an object before it is getting deleted.")
 	fs.BoolVar(&dryRun, "dry-run", false, "dry-run results in not deleting anything but printing the actions.")
@@ -75,6 +78,8 @@ func run(ctx context.Context) error {
 
 	log.Info("Configured settings", "dry-run", dryRun)
 	log.Info("Configured settings", "folders", vsphereFolders)
+	log.Info("Configured settings", "vm-folders", vsphereVMFolders)
+	log.Info("Configured settings", "resource-pools", vsphereResourcePools)
 	log.Info("Configured settings", "ipam-namespace", ipamNamespace)
 	log.Info("Configured settings", "max-age", maxAge)
 
@@ -103,15 +108,11 @@ func run(ctx context.Context) error {
 
 	janitor := newJanitor(vSphereClients, ipamClient, maxAge, ipamNamespace, dryRun)
 
-	// First cleanup old vms to free up IPAddressClaims or cluster modules which are still in-use.
-	errList := []error{}
-	for _, folder := range vsphereFolders {
-		if err := janitor.deleteVSphereVMs(ctx, folder); err != nil {
-			errList = append(errList, errors.Wrapf(err, "cleaning up vSphereVMs for folder %q", folder))
-		}
-	}
-	if err := kerrors.NewAggregate(errList); err != nil {
-		return errors.Wrap(err, "cleaning up vSphereVMs")
+	log.Info("Configured settings", "janitor.maxCreationDate", janitor.maxCreationDate)
+
+	// First cleanup old vms and other vSphere resources to free up IPAddressClaims or cluster modules which are still in-use.
+	if err := janitor.cleanupVSphere(ctx, vsphereFolders, vsphereResourcePools, vsphereVMFolders); err != nil {
+		return errors.Wrap(err, "cleaning up vSphere")
 	}
 
 	// Second cleanup IPAddressClaims.
@@ -119,10 +120,5 @@ func run(ctx context.Context) error {
 		return errors.Wrap(err, "cleaning up IPAddressClaims")
 	}
 
-	// Third cleanup cluster modules.
-	if err := janitor.deleteVSphereClusterModules(ctx); err != nil {
-		return errors.Wrap(err, "cleaning up vSphere cluster modules")
-	}
-
 	return nil
 }
diff --git a/hack/tools/janitor/vsphere.go b/hack/tools/janitor/vsphere.go
index 5185e642..b73f45aa 100644
--- a/hack/tools/janitor/vsphere.go
+++ b/hack/tools/janitor/vsphere.go
@@ -18,13 +18,23 @@ package main
 
 import (
 	"context"
+	"fmt"
 	"net/url"
+	"time"
 
+	"github.com/pkg/errors"
 	"github.com/vmware/govmomi"
+	"github.com/vmware/govmomi/find"
+	"github.com/vmware/govmomi/list"
+	"github.com/vmware/govmomi/object"
+	"github.com/vmware/govmomi/property"
 	"github.com/vmware/govmomi/session"
 	"github.com/vmware/govmomi/vapi/rest"
+	"github.com/vmware/govmomi/view"
 	"github.com/vmware/govmomi/vim25"
+	"github.com/vmware/govmomi/vim25/mo"
 	"github.com/vmware/govmomi/vim25/soap"
+	"github.com/vmware/govmomi/vim25/types"
 	ctrl "sigs.k8s.io/controller-runtime"
 )
 
@@ -38,9 +48,12 @@ type getVSphereClientInput struct {
 
 // vSphereClients is a collection of different clients for vSphere.
 type vSphereClients struct {
-	Vim     *vim25.Client
-	Govmomi *govmomi.Client
-	Rest    *rest.Client
+	Vim           *vim25.Client
+	Govmomi       *govmomi.Client
+	Rest          *rest.Client
+	FieldsManager *object.CustomFieldsManager
+	Finder        *find.Finder
+	ViewManager   *view.Manager
 }
 
 // logout logs out all clients. It logs errors if the context contains a logger.
@@ -92,9 +105,109 @@ func newVSphereClients(ctx context.Context, input getVSphereClientInput) (*vSphe
 		return nil, err
 	}
 
+	fieldsManager, err := object.GetCustomFieldsManager(vimClient)
+	if err != nil {
+		return nil, err
+	}
+
+	viewManager := view.NewManager(vimClient)
+	finder := find.NewFinder(vimClient, false)
+
 	return &vSphereClients{
-		Vim:     vimClient,
-		Govmomi: govmomiClient,
-		Rest:    restClient,
+		Vim:           vimClient,
+		Govmomi:       govmomiClient,
+		Rest:          restClient,
+		FieldsManager: fieldsManager,
+		Finder:        finder,
+		ViewManager:   viewManager,
 	}, nil
 }
+
+const vSphereDeletionMarkerName = "capv-janitor-deletion-marker"
+
+func waitForTasksFinished(ctx context.Context, tasks []*object.Task, ignoreErrors bool) error {
+	for _, t := range tasks {
+		if err := t.Wait(ctx); !ignoreErrors && err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func getDeletionMarkerTimestamp(key int32, values []types.BaseCustomFieldValue) (*time.Time, error) {
+	// Find the value for the key
+	var b *types.BaseCustomFieldValue
+	for i := range values {
+		if values[i].GetCustomFieldValue().Key != key {
+			continue
+		}
+		b = &values[i]
+		break
+	}
+
+	// Key does not exist
+	if b == nil {
+		return nil, nil
+	}
+
+	value, ok := (*b).(*types.CustomFieldStringValue)
+	if !ok {
+		return nil, fmt.Errorf("cannot typecast %t to *types.CustomFieldStringValue", *b)
+	}
+
+	t, err := time.Parse(time.RFC3339, value.Value)
+	return &t, err
+}
+
+type managedElement struct {
+	entity  mo.ManagedEntity
+	element *list.Element
+}
+
+func recursiveList(ctx context.Context, inventoryPath string, govmomiClient *govmomi.Client, finder *find.Finder, viewManager *view.Manager, objectTypes ...string) ([]*managedElement, error) {
+	// Get the object at inventoryPath
+	objList, err := finder.ManagedObjectList(ctx, inventoryPath)
+	if err != nil {
+		return nil, err
+	}
+	if len(objList) != 1 {
+		return nil, errors.Errorf("expected to find exactly 1 object at managed object at path: %s", inventoryPath)
+	}
+
+	root := objList[0].Object.Reference()
+
+	v, err := viewManager.CreateContainerView(ctx, root, objectTypes, true)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = v.Destroy(ctx) }()
+
+	// Recursively find all objects.
+	managedObjects, err := v.Find(ctx, nil, property.Match{"name": "*"})
+	if err != nil {
+		return nil, err
+	}
+
+	managedElements := []*managedElement{}
+
+	if len(managedObjects) == 0 {
+		return managedElements, nil
+	}
+
+	// Retrieve the availableField and value attributes of the found object so we
+	// later can check for the deletion marker.
+	var objs []mo.ManagedEntity
+	if err := govmomiClient.Retrieve(ctx, managedObjects, []string{"availableField", "value"}, &objs); err != nil {
+		return nil, err
+	}
+
+	for _, entity := range objs {
+		element, err := finder.Element(ctx, entity.Reference())
+		if err != nil {
+			return nil, err
+		}
+		managedElements = append(managedElements, &managedElement{entity: entity, element: element})
+	}
+
+	return managedElements, nil
+}
diff --git a/main.go b/main.go
index 1d270aec..dd902db5 100644
--- a/main.go
+++ b/main.go
@@ -146,7 +146,11 @@ func InitFlags(fs *pflag.FlagSet) {
 		&managerOpts.EnableKeepAlive,
 		"enable-keep-alive",
 		defaultEnableKeepAlive,
-		"feature to enable keep alive handler in vsphere sessions. This functionality is enabled by default.")
+		"feature to enable keep alive handler in vsphere sessions. This functionality is disabled by default.")
+	_ = fs.MarkDeprecated("enable-keep-alive", "This flag has been deprecated and will be removed in a "+
+		"future release. Note: This feature has been disabled per default because we determined that we already keep alive "+
+		"sessions just by our regular reconciles. So we don't need an additional keep alive handler. Enabling "+
+		"this feature may lead to a deadlock in controllers communicating with vCenter.")
 	fs.DurationVar(
 		&managerOpts.KeepAliveDuration,
 		"keep-alive-duration",
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 412c96b4..d990b8e3 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -52,8 +52,8 @@ const (
 	// cluster are in maintenance mode.
 	MaintenanceAnnotationLabel = "capv." + infrav1.GroupName + "/maintenance"
 
-	// DefaultEnableKeepAlive is true by default.
-	DefaultEnableKeepAlive = true
+	// DefaultEnableKeepAlive is false by default.
+	DefaultEnableKeepAlive = false
 
 	// DefaultKeepAliveDuration unit minutes.
 	DefaultKeepAliveDuration = time.Minute * 5
diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go
index a42772cc..5a4d99fc 100644
--- a/pkg/manager/manager.go
+++ b/pkg/manager/manager.go
@@ -22,6 +22,7 @@ import (
 
 	"github.com/pkg/errors"
 	netopv1 "github.com/vmware-tanzu/net-operator-api/api/v1alpha1"
+	nsxopv1 "github.com/vmware-tanzu/nsx-operator/pkg/apis/v1alpha1"
 	vmoprv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1"
 	ncpv1 "github.com/vmware-tanzu/vm-operator/external/ncp/api/v1alpha1"
 	topologyv1 "github.com/vmware-tanzu/vm-operator/external/tanzu-topology/api/v1alpha1"
@@ -64,6 +65,7 @@ func New(ctx context.Context, opts Options) (Manager, error) {
 	_ = vmoprv1.AddToScheme(opts.Scheme)
 	_ = ncpv1.AddToScheme(opts.Scheme)
 	_ = netopv1.AddToScheme(opts.Scheme)
+	_ = nsxopv1.AddToScheme(opts.Scheme)
 	_ = topologyv1.AddToScheme(opts.Scheme)
 	_ = ipamv1.AddToScheme(opts.Scheme)
 
diff --git a/pkg/manager/network.go b/pkg/manager/network.go
index b7f82688..088e28c2 100644
--- a/pkg/manager/network.go
+++ b/pkg/manager/network.go
@@ -27,6 +27,8 @@ import (
 )
 
 const (
+	// NSXVPCNetworkProvider identifies the nsx-vpc network provider.
+	NSXVPCNetworkProvider = "NSX-VPC"
 	// NSXNetworkProvider identifies the NSX network provider.
 	NSXNetworkProvider = "NSX"
 	// VDSNetworkProvider identifies the VDS network provider.
@@ -41,6 +43,9 @@ func GetNetworkProvider(ctx context.Context, client client.Client, networkProvid
 	log := ctrl.LoggerFrom(ctx)
 
 	switch networkProvider {
+	case NSXVPCNetworkProvider:
+		log.Info("Pick NSX-VPC network provider")
+		return network.NSXTVpcNetworkProvider(client), nil
 	case NSXNetworkProvider:
 		// TODO: disableFirewall not configurable
 		log.Info("Pick NSX-T network provider")
diff --git a/pkg/services/govmomi/pci/device.go b/pkg/services/govmomi/pci/device.go
index f97edeb4..f92670bf 100644
--- a/pkg/services/govmomi/pci/device.go
+++ b/pkg/services/govmomi/pci/device.go
@@ -84,6 +84,7 @@ func createBackingInfo(spec infrav1.PCIDeviceSpec) *types.VirtualPCIPassthroughD
 				DeviceId: *spec.DeviceID,
 			},
 		},
+		CustomLabel: spec.CustomLabel,
 	}
 }
 
diff --git a/pkg/services/interfaces.go b/pkg/services/interfaces.go
index 6404ee39..7b3060ca 100644
--- a/pkg/services/interfaces.go
+++ b/pkg/services/interfaces.go
@@ -71,6 +71,9 @@ type NetworkProvider interface {
 	// HasLoadBalancer indicates whether this provider has a load balancer for Services.
 	HasLoadBalancer() bool
 
+	// SupportsVMReadinessProbe indicates whether this provider support vm readiness probe.
+	SupportsVMReadinessProbe() bool
+
 	// ProvisionClusterNetwork creates network resource for a given cluster
 	// This operation should be idempotent
 	ProvisionClusterNetwork(ctx context.Context, clusterCtx *vmware.ClusterContext) error
diff --git a/pkg/services/network/constants.go b/pkg/services/network/constants.go
index 94c981cd..68eb6b1f 100644
--- a/pkg/services/network/constants.go
+++ b/pkg/services/network/constants.go
@@ -22,6 +22,9 @@ const (
 	NSXTTypeNetwork = "nsx-t"
 	// NSXTVNetSelectorKey is also defined in VM Operator.
 	NSXTVNetSelectorKey = "ncp.vmware.com/virtual-network-name"
+	// NSXTVPCSubnetSetNetworkType is the name of the NSX VPC network type. Please refer to:
+	// https://github.com/vmware-tanzu/vm-operator/blob/main/api/v1alpha1/virtualmachine_types.go#L149.
+	NSXTVPCSubnetSetNetworkType = "nsx-t-subnetset"
 
 	// CAPVDefaultNetworkLabel is a label used to identify the default network.
 	CAPVDefaultNetworkLabel = "capv.vmware.com/is-default-network"
diff --git a/pkg/services/network/dummy_provider.go b/pkg/services/network/dummy_provider.go
index a0c9ce4f..387df7b1 100644
--- a/pkg/services/network/dummy_provider.go
+++ b/pkg/services/network/dummy_provider.go
@@ -38,6 +38,10 @@ func (np *dummyNetworkProvider) HasLoadBalancer() bool {
 	return false
 }
 
+func (np *dummyNetworkProvider) SupportsVMReadinessProbe() bool {
+	return true
+}
+
 func (np *dummyNetworkProvider) ProvisionClusterNetwork(_ context.Context, _ *vmware.ClusterContext) error {
 	return nil
 }
diff --git a/pkg/services/network/netop_provider.go b/pkg/services/network/netop_provider.go
index 52511c72..9b8cc407 100644
--- a/pkg/services/network/netop_provider.go
+++ b/pkg/services/network/netop_provider.go
@@ -48,6 +48,10 @@ func (np *netopNetworkProvider) HasLoadBalancer() bool {
 	return true
 }
 
+func (np *netopNetworkProvider) SupportsVMReadinessProbe() bool {
+	return true
+}
+
 // ProvisionClusterNetwork marks the ClusterNetworkReadyCondition true.
 func (np *netopNetworkProvider) ProvisionClusterNetwork(_ context.Context, clusterCtx *vmware.ClusterContext) error {
 	conditions.MarkTrue(clusterCtx.VSphereCluster, vmwarev1.ClusterNetworkReadyCondition)
diff --git a/pkg/services/network/nsxt_provider.go b/pkg/services/network/nsxt_provider.go
index d6f5cfc0..31d27cbc 100644
--- a/pkg/services/network/nsxt_provider.go
+++ b/pkg/services/network/nsxt_provider.go
@@ -56,6 +56,10 @@ func (np *nsxtNetworkProvider) HasLoadBalancer() bool {
 	return true
 }
 
+func (np *nsxtNetworkProvider) SupportsVMReadinessProbe() bool {
+	return true
+}
+
 // GetNSXTVirtualNetworkName returns the name of the NSX-T vnet object.
 func GetNSXTVirtualNetworkName(clusterName string) string {
 	return fmt.Sprintf("%s-vnet", clusterName)
diff --git a/pkg/services/network/nsxt_vpc_provider.go b/pkg/services/network/nsxt_vpc_provider.go
new file mode 100644
index 00000000..62193769
--- /dev/null
+++ b/pkg/services/network/nsxt_vpc_provider.go
@@ -0,0 +1,187 @@
+/*
+Copyright 2024 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 network
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/pkg/errors"
+	nsxopv1 "github.com/vmware-tanzu/nsx-operator/pkg/apis/v1alpha1"
+	vmoprv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/klog/v2"
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
+	"sigs.k8s.io/cluster-api/util/conditions"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+	vmwarev1 "sigs.k8s.io/cluster-api-provider-vsphere/apis/vmware/v1beta1"
+	"sigs.k8s.io/cluster-api-provider-vsphere/pkg/context/vmware"
+	"sigs.k8s.io/cluster-api-provider-vsphere/pkg/services"
+)
+
+// nsxtVPCNetworkProvider provisions nsx-vpc type cluster network.
+type nsxtVPCNetworkProvider struct {
+	client client.Client
+}
+
+// NSXTVpcNetworkProvider returns an instance of nsx-vpc type network provider.
+func NSXTVpcNetworkProvider(client client.Client) services.NetworkProvider {
+	return &nsxtVPCNetworkProvider{
+		client: client,
+	}
+}
+
+func (vp *nsxtVPCNetworkProvider) HasLoadBalancer() bool {
+	return true
+}
+
+func (vp *nsxtVPCNetworkProvider) SupportsVMReadinessProbe() bool {
+	// Note: The control plane VM network is private with nsx-vpc and
+	// readiness probe would fail. Therefore, nsxvpcNetworkProvider
+	// doesn't support VM readiness probe.
+	return false
+}
+
+// verifyNsxtVpcSubnetSetStatus checks the status conditions of a given SubnetSet within a cluster context.
+// If the subnet isn't ready, it is marked as false, and the function returns an error.
+// If the subnet is ready, the function updates the VSphereCluster with a "true" status and returns nil.
+func (vp *nsxtVPCNetworkProvider) verifyNsxtVpcSubnetSetStatus(vspherecluster *vmwarev1.VSphereCluster, subnetset *nsxopv1.SubnetSet) error {
+	clusterName := vspherecluster.Name
+	namespace := vspherecluster.Namespace
+	hasReadyCondition := false
+
+	for _, condition := range subnetset.Status.Conditions {
+		if condition.Type != nsxopv1.Ready {
+			continue
+		}
+		hasReadyCondition = true
+		if condition.Status != corev1.ConditionTrue {
+			conditions.MarkFalse(vspherecluster, vmwarev1.ClusterNetworkReadyCondition, vmwarev1.ClusterNetworkProvisionFailedReason, clusterv1.ConditionSeverityWarning, condition.Message)
+			return errors.Errorf("subnetset ready status is: '%s' in cluster %s. reason: %s, message: %s",
+				condition.Status, types.NamespacedName{Namespace: namespace, Name: clusterName}, condition.Reason, condition.Message)
+		}
+	}
+
+	if !hasReadyCondition {
+		conditions.MarkFalse(vspherecluster, vmwarev1.ClusterNetworkReadyCondition, vmwarev1.ClusterNetworkProvisionFailedReason, clusterv1.ConditionSeverityWarning, "No Ready status for SubnetSet")
+		return errors.Errorf("subnetset ready status in cluster %s has not been set", types.NamespacedName{Namespace: namespace, Name: clusterName})
+	}
+
+	conditions.MarkTrue(vspherecluster, vmwarev1.ClusterNetworkReadyCondition)
+	return nil
+}
+
+// VerifyNetworkStatus checks if the given runtime object is of type SubnetSet.
+// If it is, then it calls verifyNsxVpcSubnetSetStatus with the SubnetSet to verify its status.
+// If it's not, it returns an error.
+func (vp *nsxtVPCNetworkProvider) VerifyNetworkStatus(_ context.Context, clusterCtx *vmware.ClusterContext, obj runtime.Object) error {
+	subnetset, ok := obj.(*nsxopv1.SubnetSet)
+	if !ok {
+		return fmt.Errorf("expected NSX VPC SubnetSet but got %T", obj)
+	}
+
+	return vp.verifyNsxtVpcSubnetSetStatus(clusterCtx.VSphereCluster, subnetset)
+}
+
+// ProvisionClusterNetwork provisions a new network in the context of a given cluster.
+// It constructs a new SubnetSet and attempts to create or patch it on the cluster.
+// If it fails to do so, it marks the status of the VSphereCluster as false and returns an error.
+// If it succeeds, it calls verifyNsxVpcSubnetSetStatus to verify the status of the newly created/patched SubnetSet.
+func (vp *nsxtVPCNetworkProvider) ProvisionClusterNetwork(ctx context.Context, clusterCtx *vmware.ClusterContext) error {
+	log := ctrl.LoggerFrom(ctx)
+
+	cluster := clusterCtx.VSphereCluster
+	networkNamespace := cluster.Namespace
+	networkName := cluster.Name
+	log = log.WithValues("SubnetSet", klog.KRef(networkNamespace, networkName))
+
+	log.Info("Provisioning ")
+	defer log.Info("Finished provisioning")
+
+	subnetset := &nsxopv1.SubnetSet{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: networkNamespace,
+			Name:      networkName,
+		},
+		Spec: nsxopv1.SubnetSetSpec{
+			AdvancedConfig: nsxopv1.AdvancedConfig{
+				StaticIPAllocation: nsxopv1.StaticIPAllocation{
+					Enable: true,
+				},
+			},
+		},
+	}
+
+	_, err := ctrlutil.CreateOrPatch(ctx, vp.client, subnetset, func() error {
+		if err := ctrlutil.SetOwnerReference(
+			clusterCtx.VSphereCluster,
+			subnetset,
+			vp.client.Scheme(),
+		); err != nil {
+			return errors.Wrapf(err, "error setting %s as owner of %s", klog.KObj(clusterCtx.VSphereCluster), klog.KObj(subnetset))
+		}
+
+		return nil
+	})
+	if err != nil {
+		conditions.MarkFalse(clusterCtx.VSphereCluster, vmwarev1.ClusterNetworkReadyCondition, vmwarev1.ClusterNetworkProvisionFailedReason, clusterv1.ConditionSeverityWarning, err.Error())
+		return errors.Wrap(err, "Failed to provision network")
+	}
+
+	return vp.verifyNsxtVpcSubnetSetStatus(clusterCtx.VSphereCluster, subnetset)
+}
+
+// GetClusterNetworkName returns the name of a valid cluster network if one exists.
+func (vp *nsxtVPCNetworkProvider) GetClusterNetworkName(ctx context.Context, clusterCtx *vmware.ClusterContext) (string, error) {
+	subnetset := &nsxopv1.SubnetSet{}
+	cluster := clusterCtx.VSphereCluster
+	namespacedName := types.NamespacedName{
+		Namespace: cluster.Namespace,
+		Name:      cluster.Name,
+	}
+	if err := vp.client.Get(ctx, namespacedName, subnetset); err != nil {
+		return "", err
+	}
+	return namespacedName.Name, nil
+}
+
+// The GetVMServiceAnnotations method always returns an empty map representing annotations.
+func (vp *nsxtVPCNetworkProvider) GetVMServiceAnnotations(_ context.Context, _ *vmware.ClusterContext) (map[string]string, error) {
+	return map[string]string{}, nil
+}
+
+// ConfigureVirtualMachine configures a VirtualMachine object based on the networking configuration.
+func (vp *nsxtVPCNetworkProvider) ConfigureVirtualMachine(_ context.Context, clusterCtx *vmware.ClusterContext, vm *vmoprv1.VirtualMachine) error {
+	networkName := clusterCtx.VSphereCluster.Name
+	for _, vnif := range vm.Spec.NetworkInterfaces {
+		if vnif.NetworkType == NSXTVPCSubnetSetNetworkType && vnif.NetworkName == networkName {
+			// expected network interface is already found
+			return nil
+		}
+	}
+	vm.Spec.NetworkInterfaces = append(vm.Spec.NetworkInterfaces, vmoprv1.VirtualMachineNetworkInterface{
+		NetworkName: networkName,
+		NetworkType: NSXTVPCSubnetSetNetworkType,
+	})
+	return nil
+}
diff --git a/pkg/services/vmoperator/vmopmachine.go b/pkg/services/vmoperator/vmopmachine.go
index 0f199935..c7effe0e 100644
--- a/pkg/services/vmoperator/vmopmachine.go
+++ b/pkg/services/vmoperator/vmopmachine.go
@@ -43,7 +43,8 @@ import (
 
 // VmopMachineService reconciles VM Operator VM.
 type VmopMachineService struct {
-	Client client.Client
+	Client                                client.Client
+	ConfigureControlPlaneVMReadinessProbe bool
 }
 
 // GetMachinesInCluster returns a list of VSphereMachine objects belonging to the cluster.
@@ -345,7 +346,10 @@ func (v *VmopMachineService) reconcileVMOperatorVM(ctx context.Context, supervis
 		// Once the initial control plane node is ready, we can re-add the probe so
 		// that subsequent machines do not attempt to speak to a kube-apiserver
 		// that is not yet ready.
-		if infrautilv1.IsControlPlaneMachine(supervisorMachineCtx.Machine) && supervisorMachineCtx.Cluster.Status.ControlPlaneReady {
+		// Not all network providers (for example, NSX-VPC) provide support for VM
+		// readiness probes. The flag PerformsVMReadinessProbe is used to determine
+		// whether a VM readiness probe should be conducted.
+		if v.ConfigureControlPlaneVMReadinessProbe && infrautilv1.IsControlPlaneMachine(supervisorMachineCtx.Machine) && supervisorMachineCtx.Cluster.Status.ControlPlaneReady {
 			vmOperatorVM.Spec.ReadinessProbe = &vmoprv1.Probe{
 				TCPSocket: &vmoprv1.TCPSocketAction{
 					Port: intstr.FromInt(defaultAPIBindPort),


Test patch:
diff --git a/hack/tools/janitor/janitor_test.go b/hack/tools/janitor/janitor_test.go
new file mode 100644
index 00000000..c5f9c5ff
--- /dev/null
+++ b/hack/tools/janitor/janitor_test.go
@@ -0,0 +1,563 @@
+/*
+Copyright 2024 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 main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/gbytes"
+	"github.com/vmware/govmomi"
+	"github.com/vmware/govmomi/find"
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/simulator/vpx"
+	"github.com/vmware/govmomi/view"
+	"k8s.io/apimachinery/pkg/util/rand"
+	"k8s.io/klog/v2"
+	ctrl "sigs.k8s.io/controller-runtime"
+
+	"sigs.k8s.io/cluster-api-provider-vsphere/internal/test/helpers/vcsim"
+)
+
+func setup(ctx context.Context, t *testing.T) (*vSphereClients, *vcsim.Simulator) {
+	t.Helper()
+	model := &simulator.Model{
+		ServiceContent: vpx.ServiceContent,
+		RootFolder:     vpx.RootFolder,
+		Autostart:      true,
+		Datacenter:     1,
+		Portgroup:      1,
+		Host:           1,
+		Cluster:        1,
+		ClusterHost:    3,
+		DelayConfig:    simulator.DelayConfig{},
+	}
+
+	vcsim, err := vcsim.NewBuilder().WithModel(model).Build()
+	if err != nil {
+		panic(fmt.Sprintf("unable to create simulator %s", err))
+	}
+
+	fmt.Printf(" export GOVC_URL=%s\n", vcsim.ServerURL())
+	fmt.Printf(" export GOVC_USERNAME=%s\n", vcsim.Username())
+	fmt.Printf(" export GOVC_PASSWORD=%s\n", vcsim.Password())
+	fmt.Printf(" export GOVC_INSECURE=true\n")
+
+	clients, err := newVSphereClients(ctx, getVSphereClientInput{
+		Username:  vcsim.Username(),
+		Password:  vcsim.Password(),
+		Server:    vcsim.ServerURL().String(),
+		UserAgent: "capv-janitor-test",
+	})
+	if err != nil {
+		panic(err)
+	}
+
+	t.Cleanup(vcsim.Destroy)
+
+	return clients, vcsim
+}
+
+func setupTestCase(g *gomega.WithT, sim *vcsim.Simulator, objects []*vcsimObject) (string, map[string]bool) {
+	g.THelper()
+
+	relativePath := rand.String(10)
+
+	baseRP := vcsimResourcePool("")
+	baseFolder := vcsimFolder("")
+	baseDatastore := vcsimDatastore("", os.TempDir())
+	// Create base objects for the test case
+	g.Expect(baseRP.Create(sim, relativePath)).To(gomega.Succeed())
+	g.Expect(baseFolder.Create(sim, relativePath)).To(gomega.Succeed())
+	g.Expect(baseDatastore.Create(sim, relativePath)).To(gomega.Succeed())
+
+	createdObjects := map[string]bool{}
+
+	// Create objects
+	for _, object := range objects {
+		createdObjects[path.Join(object.objectType, object.pathSuffix)] = true
+		g.Expect(object.Create(sim, relativePath)).To(gomega.Succeed())
+	}
+
+	return relativePath, createdObjects
+}
+
+const (
+	folderBase       = "/DC0/vm"
+	resourcePoolBase = "/DC0/host/DC0_C0/Resources"
+)
+
+func Test_janitor_deleteVSphereVMs(t *testing.T) {
+	ctx := context.Background()
+	ctx = ctrl.LoggerInto(ctx, klog.Background())
+
+	// Initialize and start vcsim
+	clients, sim := setup(ctx, t)
+
+	deleteAll := time.Now().Add(time.Hour * 1)
+	deleteNone := time.Now()
+
+	tests := []struct {
+		name            string
+		objects         []*vcsimObject
+		maxCreationDate time.Time
+		wantErr         bool
+		want            map[string]bool
+	}{
+		{
+			name: "delete all VMs",
+			objects: []*vcsimObject{
+				vcsimVirtualMachine("foo"),
+			},
+			maxCreationDate: deleteAll,
+			wantErr:         false,
+			want:            nil,
+		},
+		{
+			name: "delete no VMs",
+			objects: []*vcsimObject{
+				vcsimVirtualMachine("foo"),
+			},
+			maxCreationDate: deleteNone,
+			wantErr:         false,
+			want: map[string]bool{
+				"VirtualMachine/foo": true,
+			},
+		},
+		{
+			name: "recursive vm deletion",
+			objects: []*vcsimObject{
+				vcsimResourcePool("a"),
+				vcsimFolder("a"),
+				vcsimResourcePool("a/b"),
+				vcsimFolder("a/b"),
+				vcsimResourcePool("a/b/c"),
+				vcsimFolder("a/b/c"),
+				vcsimVirtualMachine("foo"),
+				vcsimVirtualMachine("a/bar"),
+				vcsimVirtualMachine("a/b/c/foobar"),
+			},
+			maxCreationDate: deleteAll,
+			wantErr:         false,
+			want: map[string]bool{
+				"ResourcePool/a":     true,
+				"ResourcePool/a/b":   true,
+				"ResourcePool/a/b/c": true,
+				"Folder/a":           true,
+				"Folder/a/b":         true,
+				"Folder/a/b/c":       true,
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			g := gomega.NewWithT(t)
+
+			relativePath, _ := setupTestCase(g, sim, tt.objects)
+
+			s := &janitor{
+				dryRun:          false,
+				maxCreationDate: tt.maxCreationDate,
+				vSphereClients:  clients,
+			}
+
+			// use folder created for this test case as inventoryPath
+			inventoryPath := vcsimFolder("").Path(relativePath)
+
+			err := s.deleteVSphereVMs(ctx, inventoryPath)
+			if tt.wantErr {
+				g.Expect(err).To(gomega.HaveOccurred())
+			} else {
+				g.Expect(err).ToNot(gomega.HaveOccurred())
+			}
+
+			// Ensure the expected objects still exists
+			existingObjects, err := recursiveListFoldersAndResourcePools(ctx, relativePath, clients.Govmomi, clients.Finder, clients.ViewManager)
+			g.Expect(err).ToNot(gomega.HaveOccurred())
+			if tt.want != nil {
+				g.Expect(existingObjects).To(gomega.BeEquivalentTo(tt.want))
+			} else {
+				g.Expect(existingObjects).To(gomega.BeEmpty())
+			}
+		})
+	}
+}
+
+func Test_janitor_deleteObjectChildren(t *testing.T) {
+	ctx := context.Background()
+	ctx = ctrl.LoggerInto(ctx, klog.Background())
+
+	// Initialize and start vcsim
+	clients, sim := setup(ctx, t)
+
+	tests := []struct {
+		name       string
+		basePath   string
+		objectType string
+		objects    []*vcsimObject
+		wantErr    bool
+		want       map[string]bool
+	}{
+		{
+			name:       "should preserve resource pool if it contains a vm and delete empty resource pools",
+			basePath:   resourcePoolBase,
+			objectType: "ResourcePool",
+			objects: []*vcsimObject{
+				vcsimResourcePool("a"),
+				vcsimResourcePool("b"),
+				vcsimFolder("a"),
+				vcsimVirtualMachine("a/foo"),
+			},
+			want: map[string]bool{
+				"Folder/a":             true,
+				"ResourcePool/a":       true,
+				"VirtualMachine/a/foo": true,
+			},
+		},
+		{
+			name:       "should preserve folder if it contains a vm and delete empty folders",
+			basePath:   folderBase,
+			objectType: "Folder",
+			objects: []*vcsimObject{
+				vcsimResourcePool("a"),
+				vcsimFolder("a"),
+				vcsimFolder("b"),
+				vcsimVirtualMachine("a/foo"),
+			},
+			want: map[string]bool{
+				"Folder/a":             true,
+				"ResourcePool/a":       true,
+				"VirtualMachine/a/foo": true,
+			},
+		},
+		{
+			name:       "no-op",
+			basePath:   resourcePoolBase,
+			objectType: "ResourcePool",
+			objects:    []*vcsimObject{},
+		},
+		{
+			name:       "single resource pool",
+			basePath:   resourcePoolBase,
+			objectType: "ResourcePool",
+			objects: []*vcsimObject{
+				vcsimResourcePool("a"),
+			},
+		},
+		{
+			name:       "multiple nested resource pools",
+			basePath:   resourcePoolBase,
+			objectType: "ResourcePool",
+			objects: []*vcsimObject{
+				vcsimResourcePool("a"),
+				vcsimResourcePool("a/b"),
+				vcsimResourcePool("a/b/c"),
+				vcsimResourcePool("d"),
+				vcsimResourcePool("d/e"),
+				vcsimResourcePool("f"),
+			},
+		},
+		{
+			name:       "no-op",
+			basePath:   folderBase,
+			objectType: "Folder",
+			objects:    []*vcsimObject{},
+		},
+		{
+			name:       "single folder",
+			basePath:   folderBase,
+			objectType: "Folder",
+			objects: []*vcsimObject{
+				vcsimFolder("a"),
+			},
+		},
+		{
+			name:       "multiple nested folders",
+			basePath:   folderBase,
+			objectType: "Folder",
+			objects: []*vcsimObject{
+				vcsimFolder("a"),
+				vcsimFolder("a/b"),
+				vcsimFolder("a/b/c"),
+				vcsimFolder("d"),
+				vcsimFolder("d/e"),
+				vcsimFolder("f"),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			g := gomega.NewWithT(t)
+
+			relativePath, wantMarkedObjects := setupTestCase(g, sim, tt.objects)
+
+			inventoryPath := path.Join(tt.basePath, relativePath)
+
+			s := &janitor{
+				dryRun:          false,
+				maxCreationDate: time.Now().Add(time.Hour * 1),
+				vSphereClients:  clients,
+			}
+
+			// Run first iteration which should only tag the resource pools with a timestamp.
+			g.Expect(s.deleteObjectChildren(ctx, inventoryPath, tt.objectType)).To(gomega.Succeed())
+			existingObjects, err := recursiveListFoldersAndResourcePools(ctx, relativePath, clients.Govmomi, clients.Finder, clients.ViewManager)
+			g.Expect(err).ToNot(gomega.HaveOccurred())
+			g.Expect(existingObjects).To(gomega.BeEquivalentTo(wantMarkedObjects))
+
+			// Run second iteration which should destroy the resource pools with a timestamp.
+			g.Expect(s.deleteObjectChildren(ctx, inventoryPath, tt.objectType)).To(gomega.Succeed())
+			existingObjects, err = recursiveListFoldersAndResourcePools(ctx, relativePath, clients.Govmomi, clients.Finder, clients.ViewManager)
+			g.Expect(err).ToNot(gomega.HaveOccurred())
+			if tt.want != nil {
+				g.Expect(existingObjects).To(gomega.BeEquivalentTo(tt.want))
+			} else {
+				g.Expect(existingObjects).To(gomega.BeEmpty())
+			}
+
+			// Ensure the parent object still exists
+			assertObjectExists(ctx, g, clients.Finder, inventoryPath)
+		})
+	}
+}
+
+func Test_janitor_CleanupVSphere(t *testing.T) {
+	ctx := context.Background()
+	ctx = ctrl.LoggerInto(ctx, klog.Background())
+
+	// Initialize and start vcsim
+	clients, sim := setup(ctx, t)
+
+	deleteAll := time.Now().Add(time.Hour * 1)
+
+	tests := []struct {
+		name               string
+		dryRun             bool
+		maxCreationDate    time.Time
+		objects            []*vcsimObject
+		wantAfterFirstRun  map[string]bool
+		wantAfterSecondRun map[string]bool
+	}{
+		{
+			name:               "no-op",
+			dryRun:             false,
+			maxCreationDate:    deleteAll,
+			objects:            nil,
+			wantAfterFirstRun:  map[string]bool{},
+			wantAfterSecondRun: map[string]bool{},
+		},
+		{
+			name:               "dryRun: no-op",
+			dryRun:             true,
+			maxCreationDate:    deleteAll,
+			objects:            nil,
+			wantAfterFirstRun:  map[string]bool{},
+			wantAfterSecondRun: map[string]bool{},
+		},
+		{
+			name:            "delete everything",
+			dryRun:          false,
+			maxCreationDate: deleteAll,
+			objects: []*vcsimObject{
+				vcsimFolder("a"),
+				vcsimResourcePool("a"),
+				vcsimVirtualMachine("a/b"),
+				vcsimFolder("c"),
+				vcsimResourcePool("c"),
+			},
+			wantAfterFirstRun: map[string]bool{
+				"Folder/a":       true,
+				"Folder/c":       true,
+				"ResourcePool/a": true,
+				"ResourcePool/c": true,
+			},
+			wantAfterSecondRun: map[string]bool{},
+		},
+		{
+			name:            "dryRun: would delete everything",
+			dryRun:          true,
+			maxCreationDate: deleteAll,
+			objects: []*vcsimObject{
+				vcsimFolder("a"),
+				vcsimResourcePool("a"),
+				vcsimVirtualMachine("a/b"),
+				vcsimFolder("c"),
+				vcsimResourcePool("c"),
+			},
+			wantAfterFirstRun: map[string]bool{
+				"Folder/a":           true,
+				"Folder/c":           true,
+				"ResourcePool/a":     true,
+				"ResourcePool/c":     true,
+				"VirtualMachine/a/b": true,
+			},
+			wantAfterSecondRun: map[string]bool{
+				"Folder/a":           true,
+				"Folder/c":           true,
+				"ResourcePool/a":     true,
+				"ResourcePool/c":     true,
+				"VirtualMachine/a/b": true,
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			g := gomega.NewWithT(t)
+
+			relativePath, _ := setupTestCase(g, sim, tt.objects)
+
+			s := &janitor{
+				dryRun:          tt.dryRun,
+				maxCreationDate: tt.maxCreationDate,
+				vSphereClients:  clients,
+			}
+
+			folder := vcsimFolder("").Path(relativePath)
+			resourcePool := vcsimResourcePool("").Path(relativePath)
+
+			folders := []string{folder}
+			resourcePools := []string{resourcePool}
+
+			g.Expect(s.cleanupVSphere(ctx, folders, resourcePools, folders)).To(gomega.Succeed())
+			existingObjects, err := recursiveListFoldersAndResourcePools(ctx, relativePath, clients.Govmomi, clients.Finder, clients.ViewManager)
+			g.Expect(err).ToNot(gomega.HaveOccurred())
+			g.Expect(existingObjects).To(gomega.BeEquivalentTo(tt.wantAfterFirstRun))
+
+			g.Expect(s.cleanupVSphere(ctx, folders, resourcePools, folders)).To(gomega.Succeed())
+			existingObjects, err = recursiveListFoldersAndResourcePools(ctx, relativePath, clients.Govmomi, clients.Finder, clients.ViewManager)
+			g.Expect(err).ToNot(gomega.HaveOccurred())
+			g.Expect(existingObjects).To(gomega.BeEquivalentTo(tt.wantAfterSecondRun))
+
+			// Ensure the parent object still exists
+			assertObjectExists(ctx, g, clients.Finder, folder)
+			assertObjectExists(ctx, g, clients.Finder, resourcePool)
+		})
+	}
+}
+
+func assertObjectExists(ctx context.Context, g *gomega.WithT, finder *find.Finder, inventoryPath string) {
+	g.THelper()
+
+	e, err := finder.ManagedObjectList(ctx, inventoryPath)
+	g.Expect(err).ToNot(gomega.HaveOccurred())
+	g.Expect(e).To(gomega.HaveLen(1))
+}
+
+func recursiveListFoldersAndResourcePools(ctx context.Context, testPrefix string, govmomiClient *govmomi.Client, finder *find.Finder, viewManager *view.Manager) (map[string]bool, error) {
+	resourcePoolElements, err := recursiveList(ctx, path.Join(resourcePoolBase, testPrefix), govmomiClient, finder, viewManager)
+	if err != nil {
+		return nil, err
+	}
+
+	folderElements, err := recursiveList(ctx, path.Join(folderBase, testPrefix), govmomiClient, finder, viewManager)
+	if err != nil {
+		return nil, err
+	}
+
+	objects := map[string]bool{}
+
+	for _, e := range append(resourcePoolElements, folderElements...) {
+		splitted := strings.Split(e.element.Path, testPrefix+"/")
+		if len(splitted) == 2 {
+			objects[path.Join(e.element.Object.Reference().Type, splitted[1])] = true
+		}
+	}
+
+	return objects, nil
+}
+
+type vcsimObject struct {
+	pathSuffix       string
+	objectType       string
+	datastoreTempDir string
+}
+
+func (o vcsimObject) Path(testPrefix string) string {
+	var pathPrefix string
+
+	switch o.objectType {
+	case "ResourcePool":
+		pathPrefix = resourcePoolBase
+	case "Folder":
+		pathPrefix = folderBase
+	case "VirtualMachine":
+		// VMs exist at the folders.
+		pathPrefix = folderBase
+	case "Datastore":
+		pathPrefix = "/DC0/datastore"
+	default:
+		panic("unimplemented")
+	}
+
+	return path.Join(pathPrefix, testPrefix, o.pathSuffix)
+}
+
+func (o vcsimObject) Create(sim *vcsim.Simulator, testPrefix string) error {
+	var cmd string
+	switch o.objectType {
+	case "ResourcePool":
+		cmd = fmt.Sprintf("pool.create %s", o.Path(testPrefix))
+	case "Folder":
+		cmd = fmt.Sprintf("folder.create %s", o.Path(testPrefix))
+	case "Datastore":
+		tmpDir, err := os.MkdirTemp(o.datastoreTempDir, testPrefix)
+		if err != nil {
+			return err
+		}
+		cmd = fmt.Sprintf("datastore.create -type local -name %s -path %s /DC0/host/DC0_C0", testPrefix, tmpDir)
+	case "VirtualMachine":
+		fullPath := o.Path(testPrefix)
+		folderPath := path.Dir(fullPath)
+		rpPath := vcsimResourcePool(path.Dir(o.pathSuffix)).Path(testPrefix)
+		name := path.Base(fullPath)
+		networkPath := "/DC0/network/DC0_DVPG0"
+		cmd = fmt.Sprintf("vm.create -on=true -pool %s -folder %s -net %s -ds /DC0/datastore/%s %s", rpPath, folderPath, networkPath, testPrefix, name)
+	default:
+		panic("unimplemented")
+	}
+
+	stdout, stderr := gbytes.NewBuffer(), gbytes.NewBuffer()
+	err := sim.Run(cmd, stdout, stderr)
+	if err != nil {
+		fmt.Printf("stdout:\n%s\n", stdout.Contents())
+		fmt.Printf("stderr:\n%s\n", stderr.Contents())
+		return err
+	}
+	return nil
+}
+
+func vcsimResourcePool(p string) *vcsimObject {
+	return &vcsimObject{pathSuffix: p, objectType: "ResourcePool"}
+}
+
+func vcsimFolder(p string) *vcsimObject {
+	return &vcsimObject{pathSuffix: p, objectType: "Folder"}
+}
+
+func vcsimDatastore(p, datastoreTempDir string) *vcsimObject {
+	return &vcsimObject{pathSuffix: p, objectType: "Datastore", datastoreTempDir: datastoreTempDir}
+}
+
+func vcsimVirtualMachine(p string) *vcsimObject {
+	return &vcsimObject{pathSuffix: p, objectType: "VirtualMachine"}
+}
diff --git a/pkg/services/network/network_test.go b/pkg/services/network/network_test.go
index 049cb858..580877d2 100644
--- a/pkg/services/network/network_test.go
+++ b/pkg/services/network/network_test.go
@@ -19,10 +19,12 @@ package network
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	netopv1alpha1 "github.com/vmware-tanzu/net-operator-api/api/v1alpha1"
+	nsxopv1 "github.com/vmware-tanzu/nsx-operator/pkg/apis/v1alpha1"
 	vmoprv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1"
 	ncpv1 "github.com/vmware-tanzu/vm-operator/external/ncp/api/v1alpha1"
 	corev1 "k8s.io/api/core/v1"
@@ -42,10 +44,15 @@ import (
 
 const (
 	// Mocked virtualnetwork status reason and message.
-	testVnetNotRealizedReason  = "Cannot realize network"
-	testVnetNotRealizedMessage = "NetworkNotRealized"
+	testNetworkNotRealizedReason  = "Cannot realize network"
+	testNetworkNotRealizedMessage = "NetworkNotRealized"
 )
 
+// MockNSXTVpcNetworkProvider is the mock.
+type MockNSXTVpcNetworkProvider struct {
+	*nsxtVPCNetworkProvider
+}
+
 func createUnReadyNsxtVirtualNetwork(ctx *vmware.ClusterContext, status ncpv1.VirtualNetworkStatus) *ncpv1.VirtualNetwork {
 	// create an nsxt vnet with unready status caused by certain reasons from ncp
 	cluster := ctx.VSphereCluster
@@ -58,6 +65,20 @@ func createUnReadyNsxtVirtualNetwork(ctx *vmware.ClusterContext, status ncpv1.Vi
 	}
 }
 
+func (m *MockNSXTVpcNetworkProvider) ProvisionClusterNetwork(ctx context.Context, clusterCtx *vmware.ClusterContext) error {
+	err := m.nsxtVPCNetworkProvider.ProvisionClusterNetwork(ctx, clusterCtx)
+
+	if err != nil {
+		// Check if the error contains the string "subnetset ready status"
+		if strings.Contains(err.Error(), "subnetset ready status") {
+			return nil
+		}
+		// return the original error if it doesn't contain the specific string
+		return err
+	}
+	return nil
+}
+
 var _ = Describe("Network provider", func() {
 	var (
 		dummyNs          = "dummy-ns"
@@ -195,6 +216,33 @@ var _ = Describe("Network provider", func() {
 				Expect(vm.Spec.NetworkInterfaces[0].NetworkType).To(Equal("nsx-t"))
 			})
 		})
+
+		Context("with NSX-VPC network provider", func() {
+			BeforeEach(func() {
+				scheme := runtime.NewScheme()
+				Expect(ncpv1.AddToScheme(scheme)).To(Succeed())
+				client := fake.NewClientBuilder().WithScheme(scheme).Build()
+				np = NSXTVpcNetworkProvider(client)
+			})
+
+			It("should add nsx-t-subnetset type network interface", func() {
+				err = np.ConfigureVirtualMachine(ctx, clusterCtx, vm)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(vm.Spec.NetworkInterfaces).To(HaveLen(1))
+			})
+
+			It("nsx-t-subnetset type network interface already exists", func() {
+				err = np.ConfigureVirtualMachine(ctx, clusterCtx, vm)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(vm.Spec.NetworkInterfaces).To(HaveLen(1))
+			})
+
+			AfterEach(func() {
+				Expect(err).ToNot(HaveOccurred())
+				Expect(vm.Spec.NetworkInterfaces[0].NetworkName).To(Equal(vSphereCluster.Name))
+				Expect(vm.Spec.NetworkInterfaces[0].NetworkType).To(Equal("nsx-t-subnetset"))
+			})
+		})
 	})
 
 	Context("ProvisionClusterNetwork", func() {
@@ -202,6 +250,7 @@ var _ = Describe("Network provider", func() {
 			scheme             *runtime.Scheme
 			client             runtimeclient.Client
 			nsxNp              *nsxtNetworkProvider
+			vpcNp              *nsxtVPCNetworkProvider
 			runtimeObjs        []runtime.Object
 			vnetObj            runtime.Object
 			configmapObj       runtime.Object
@@ -257,6 +306,7 @@ var _ = Describe("Network provider", func() {
 			Expect(ncpv1.AddToScheme(scheme)).To(Succeed())
 			Expect(corev1.AddToScheme(scheme)).To(Succeed())
 			Expect(vmwarev1.AddToScheme(scheme)).To(Succeed())
+			Expect(nsxopv1.AddToScheme(scheme)).To(Succeed())
 		})
 
 		Context("with dummy network provider", func() {
@@ -459,7 +509,7 @@ var _ = Describe("Network provider", func() {
 				By("create a cluster with virtual network in not ready status")
 				status := ncpv1.VirtualNetworkStatus{
 					Conditions: []ncpv1.VirtualNetworkCondition{
-						{Type: "Ready", Status: "False", Reason: testVnetNotRealizedReason, Message: testVnetNotRealizedMessage},
+						{Type: "Ready", Status: "False", Reason: testNetworkNotRealizedReason, Message: testNetworkNotRealizedMessage},
 					},
 				}
 				vnetObj = createUnReadyNsxtVirtualNetwork(clusterCtx, status)
@@ -470,7 +520,174 @@ var _ = Describe("Network provider", func() {
 				err = np.VerifyNetworkStatus(ctx, clusterCtx, vnetObj)
 
 				expectedErrorMessage := fmt.Sprintf("virtual network ready status is: '%s' in cluster %s. reason: %s, message: %s",
-					"False", apitypes.NamespacedName{Namespace: dummyNs, Name: dummyCluster}, testVnetNotRealizedReason, testVnetNotRealizedMessage)
+					"False", apitypes.NamespacedName{Namespace: dummyNs, Name: dummyCluster}, testNetworkNotRealizedReason, testNetworkNotRealizedMessage)
+				Expect(err).To(MatchError(expectedErrorMessage))
+				Expect(conditions.IsFalse(clusterCtx.VSphereCluster, vmwarev1.ClusterNetworkReadyCondition)).To(BeTrue())
+			})
+		})
+
+		Context("with NSX-VPC network provider and subnetset exists", func() {
+			BeforeEach(func() {
+				client = fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(configmapObj, systemNamespaceObj).Build()
+				vpcNp, _ = NSXTVpcNetworkProvider(client).(*nsxtVPCNetworkProvider)
+				np = vpcNp
+				// The ProvisionClusterNetwork function would fail due to the absence of
+				// a netoperator to set the `subnetset ready` condition.
+				// We use the mock function to disregard this specific error.
+				// mocknp is an instance of MockNSXTVpcNetworkProvider initialized with nsxvpcNp.
+				mocknp := &MockNSXTVpcNetworkProvider{vpcNp}
+				err = mocknp.ProvisionClusterNetwork(ctx, clusterCtx)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("should not update subnetset", func() {
+				// Fetch the SubnetSet before the operation
+				initialSubnetSet := &nsxopv1.SubnetSet{}
+				err = client.Get(ctx, apitypes.NamespacedName{
+					Name:      dummyCluster,
+					Namespace: dummyNs,
+				}, initialSubnetSet)
+				Expect(err).NotTo(HaveOccurred())
+				status := nsxopv1.SubnetSetStatus{
+					Conditions: []nsxopv1.Condition{
+						{
+							Type:   "Ready",
+							Status: "True",
+						},
+					},
+				}
+				initialSubnetSet.Status = status
+
+				// Presumably there's code here that might modify the SubnetSet...
+				subnetset, err := np.GetClusterNetworkName(ctx, clusterCtx)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(subnetset).To(Equal(clusterCtx.VSphereCluster.Name))
+
+				createdSubnetSet := &nsxopv1.SubnetSet{}
+				err = client.Get(ctx, apitypes.NamespacedName{
+					Name:      dummyCluster,
+					Namespace: dummyNs,
+				}, createdSubnetSet)
+				Expect(err).ToNot(HaveOccurred())
+				// Check that the SubnetSetSpec was not changed
+				Expect(createdSubnetSet.Spec).To(Equal(initialSubnetSet.Spec), "SubnetSetSpec should not have been modified")
+			})
+
+			It("should successfully retrieve VM service annotations, confirm they are empty", func() {
+				annotations, err := np.GetVMServiceAnnotations(ctx, clusterCtx)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(annotations).To(Equal(make(map[string]string)))
+			})
+
+		})
+
+		Context("with NSX-VPC network provider and subnetset does not exist", func() {
+			var nsxvpcNp *nsxtVPCNetworkProvider
+
+			BeforeEach(func() {
+				client = fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(configmapObj, systemNamespaceObj).Build()
+				nsxvpcNp, _ = NSXTVpcNetworkProvider(client).(*nsxtVPCNetworkProvider)
+				// The ProvisionClusterNetwork function would fail due to the absence of
+				// a netoperator to set the `subnetset ready` condition.
+				// We use the mock function to disregard this specific error.
+				// mocknp is an instance of MockNSXTVpcNetworkProvider initialized with nsxvpcNp.
+				mocknp := &MockNSXTVpcNetworkProvider{nsxvpcNp}
+				err = mocknp.ProvisionClusterNetwork(ctx, clusterCtx)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("should create subnetset with new spec", func() {
+				subnetset, err := nsxvpcNp.GetClusterNetworkName(ctx, clusterCtx)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(subnetset).To(Equal(clusterCtx.VSphereCluster.Name))
+
+				createdSubnetSet := &nsxopv1.SubnetSet{}
+				err = client.Get(ctx, apitypes.NamespacedName{
+					Name:      dummyCluster,
+					Namespace: dummyNs,
+				}, createdSubnetSet)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(createdSubnetSet.Spec.AdvancedConfig.StaticIPAllocation.Enable).To(BeTrue())
+			})
+		})
+
+		Context("with NSX-VPC network provider and object passed to VerifyNetworkStatus is not a SubnetSet", func() {
+			var nsxvpcNp *nsxtVPCNetworkProvider
+
+			BeforeEach(func() {
+				client = fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(configmapObj, systemNamespaceObj).Build()
+				nsxvpcNp, _ = NSXTVpcNetworkProvider(client).(*nsxtVPCNetworkProvider)
+				np = nsxvpcNp
+			})
+
+			It("should return error when non-SubnetSet object passed to VerifyNetworkStatus", func() {
+				dummyObj := &ncpv1.VirtualNetwork{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: cluster.Namespace,
+						Name:      GetNSXTVirtualNetworkName(cluster.Name),
+					},
+				}
+				err = nsxvpcNp.VerifyNetworkStatus(ctx, clusterCtx, dummyObj)
+				Expect(err).To(HaveOccurred()) // Expect error because dummyObj is not a SubnetSet
+				Expect(err.Error()).To(Equal(fmt.Sprintf("expected NSX VPC SubnetSet but got %T", dummyObj)))
+			})
+		})
+
+		Context("with NSX-VPC network provider failure", func() {
+			var (
+				client       runtimeclient.Client
+				nsxvpcNp     *nsxtVPCNetworkProvider
+				scheme       *runtime.Scheme
+				subnetsetObj runtime.Object
+			)
+
+			BeforeEach(func() {
+				scheme = runtime.NewScheme()
+				Expect(nsxopv1.AddToScheme(scheme)).To(Succeed())
+				nsxvpcNp, _ = NSXTVpcNetworkProvider(client).(*nsxtVPCNetworkProvider)
+				np = nsxvpcNp
+			})
+
+			It("should return error when subnetset ready status is false", func() {
+				status := nsxopv1.SubnetSetStatus{
+					Conditions: []nsxopv1.Condition{
+						{
+							Type:    "Ready",
+							Status:  "False",
+							Reason:  testNetworkNotRealizedReason,
+							Message: testNetworkNotRealizedMessage,
+						},
+					},
+				}
+				subnetsetObj = &nsxopv1.SubnetSet{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: cluster.Namespace,
+						Name:      cluster.Name,
+					},
+					Status: status,
+				}
+				client = fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(subnetsetObj).Build()
+				err = np.VerifyNetworkStatus(ctx, clusterCtx, subnetsetObj)
+				expectedErrorMessage := fmt.Sprintf("subnetset ready status is: '%s' in cluster %s. reason: %s, message: %s",
+					"False", apitypes.NamespacedName{Namespace: dummyNs, Name: dummyCluster}, testNetworkNotRealizedReason, testNetworkNotRealizedMessage)
+				Expect(err).To(MatchError(expectedErrorMessage))
+				Expect(conditions.IsFalse(clusterCtx.VSphereCluster, vmwarev1.ClusterNetworkReadyCondition)).To(BeTrue())
+			})
+
+			It("should return error when subnetset ready status is not set", func() {
+				status := nsxopv1.SubnetSetStatus{
+					Conditions: []nsxopv1.Condition{},
+				}
+				subnetsetObj = &nsxopv1.SubnetSet{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: cluster.Namespace,
+						Name:      cluster.Name,
+					},
+					Status: status,
+				}
+				client = fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(subnetsetObj).Build()
+				err = np.VerifyNetworkStatus(ctx, clusterCtx, subnetsetObj)
+				expectedErrorMessage := fmt.Sprintf("subnetset ready status in cluster %s has not been set", apitypes.NamespacedName{Namespace: dummyNs, Name: dummyCluster})
 				Expect(err).To(MatchError(expectedErrorMessage))
 				Expect(conditions.IsFalse(clusterCtx.VSphereCluster, vmwarev1.ClusterNetworkReadyCondition)).To(BeTrue())
 			})
diff --git a/pkg/services/vmoperator/vmopmachine_test.go b/pkg/services/vmoperator/vmopmachine_test.go
index bfdb0461..38c6a1ed 100644
--- a/pkg/services/vmoperator/vmopmachine_test.go
+++ b/pkg/services/vmoperator/vmopmachine_test.go
@@ -37,6 +37,7 @@ import (
 	vmwarev1 "sigs.k8s.io/cluster-api-provider-vsphere/apis/vmware/v1beta1"
 	"sigs.k8s.io/cluster-api-provider-vsphere/pkg/context/fake"
 	"sigs.k8s.io/cluster-api-provider-vsphere/pkg/context/vmware"
+	network "sigs.k8s.io/cluster-api-provider-vsphere/pkg/services/network"
 	"sigs.k8s.io/cluster-api-provider-vsphere/pkg/util"
 )
 
@@ -118,7 +119,7 @@ var _ = Describe("VirtualMachine tests", func() {
 		clusterContext, controllerManagerContext := util.CreateClusterContext(cluster, vsphereCluster)
 		supervisorMachineContext = util.CreateMachineContext(clusterContext, machine, vsphereMachine)
 		supervisorMachineContext.ControllerManagerContext = controllerManagerContext
-		vmService = VmopMachineService{Client: controllerManagerContext.Client}
+		vmService = VmopMachineService{Client: controllerManagerContext.Client, ConfigureControlPlaneVMReadinessProbe: network.DummyLBNetworkProvider().SupportsVMReadinessProbe()}
 	})
 
 	Context("Reconcile VirtualMachine", func() {
@@ -614,7 +615,7 @@ var _ = Describe("GetMachinesInCluster", func() {
 	}
 
 	controllerManagerContext := fake.NewControllerManagerContext(initObjs...)
-	vmService := VmopMachineService{Client: controllerManagerContext.Client}
+	vmService := VmopMachineService{Client: controllerManagerContext.Client, ConfigureControlPlaneVMReadinessProbe: network.DummyLBNetworkProvider().SupportsVMReadinessProbe()}
 
 	It("returns a list of VMs belonging to the cluster", func() {
 		objs, err := vmService.GetMachinesInCluster(context.TODO(),
diff --git a/test/e2e/cluster_upgrade_test.go b/test/e2e/cluster_upgrade_test.go
index 223aac37..506a7c6a 100644
--- a/test/e2e/cluster_upgrade_test.go
+++ b/test/e2e/cluster_upgrade_test.go
@@ -26,7 +26,7 @@ import (
 	"sigs.k8s.io/cluster-api/test/framework/kubernetesversions"
 )
 
-var _ = Describe("When upgrading a workload cluster using ClusterClass and testing K8S conformance [Conformance] [K8s-Upgrade] [ClusterClass]", func() {
+var _ = Describe("When upgrading a workload cluster using ClusterClass and testing K8S conformance [supervisor] [Conformance] [K8s-Upgrade] [ClusterClass]", func() {
 	// Note: This installs a cluster based on KUBERNETES_VERSION_UPGRADE_FROM and then upgrades to
 	// KUBERNETES_VERSION_UPGRADE_TO and runs conformance tests.
 	// Note: We are resolving KUBERNETES_VERSION_UPGRADE_FROM and KUBERNETES_VERSION_UPGRADE_TO and then setting
@@ -58,7 +58,7 @@ var _ = Describe("When upgrading a workload cluster using ClusterClass and testi
 	})
 })
 
-var _ = Describe("When upgrading a workload cluster using ClusterClass [ClusterClass]", func() {
+var _ = Describe("When upgrading a workload cluster using ClusterClass [supervisor] [ClusterClass]", func() {
 	// Note: This installs a cluster based on KUBERNETES_VERSION_UPGRADE_FROM and then upgrades to
 	// KUBERNETES_VERSION_UPGRADE_TO.
 	const specName = "k8s-upgrade" // aligned to CAPI
diff --git a/test/e2e/config/vsphere.yaml b/test/e2e/config/vsphere.yaml
index 8e8297ea..fd748060 100644
--- a/test/e2e/config/vsphere.yaml
+++ b/test/e2e/config/vsphere.yaml
@@ -175,6 +175,8 @@ providers:
           - sourcePath: "../../../test/e2e/data/infrastructure-vsphere-supervisor/main/cluster-template-topology-supervisor.yaml"
           - sourcePath: "../../../test/e2e/data/infrastructure-vsphere-supervisor/main/cluster-template-supervisor.yaml"
           - sourcePath: "../../../test/e2e/data/infrastructure-vsphere-supervisor/main/clusterclass-quick-start-supervisor.yaml"
+          - sourcePath: "../../../test/e2e/data/infrastructure-vsphere-supervisor/main/cluster-template-install-on-bootstrap-supervisor.yaml"
+          - sourcePath: "../../../test/e2e/data/infrastructure-vsphere-supervisor/main/cluster-template-conformance-supervisor.yaml"
           - sourcePath: "../data/shared/capv/main/metadata.yaml"
       - name: v1.9.0
         # Use manifest from source files
@@ -246,6 +248,9 @@ variables:
   VSPHERE_MACHINE_CLASS_NAME: "test-machine-class"
   VSPHERE_MACHINE_CLASS_CPU: "4"
   VSPHERE_MACHINE_CLASS_MEMORY: "8Gi"
+  VSPHERE_MACHINE_CLASS_NAME_CONFORMANCE: "test-machine-class-conformance"
+  VSPHERE_MACHINE_CLASS_CPU_CONFORMANCE: "8"
+  VSPHERE_MACHINE_CLASS_MEMORY_CONFORMANCE: "8Gi"
   VSPHERE_CONTENT_LIBRARY: "capv"
   VSPHERE_IMAGE_NAME: "ubuntu-2204-kube-v1.29.0"
   VSPHERE_NETWORK: "sddc-cgw-network-6"
diff --git a/test/e2e/data/infrastructure-vsphere-supervisor/main/clusterclass/patch-prekubeadmscript.yaml b/test/e2e/data/infrastructure-vsphere-supervisor/main/clusterclass/patch-prekubeadmscript.yaml
index 04771c3d..88254817 100644
--- a/test/e2e/data/infrastructure-vsphere-supervisor/main/clusterclass/patch-prekubeadmscript.yaml
+++ b/test/e2e/data/infrastructure-vsphere-supervisor/main/clusterclass/patch-prekubeadmscript.yaml
@@ -4,7 +4,8 @@
     definitions:
     - jsonPatches:
       - op: add
-        path: /spec/template/spec/kubeadmConfigSpec/preKubeadmCommands/0
+        # Note: We are adding prekubeadmscript.sh (install on bootstrap) at the end of the preKubeadmCommands because getting an ip for the machine (dhclient eth0) must always be executed as a first command.
+        path: /spec/template/spec/kubeadmConfigSpec/preKubeadmCommands/-
         value: "/opt/prekubeadmscript.sh"
       - op: add
         path: /spec/template/spec/kubeadmConfigSpec/files/-
@@ -21,7 +22,8 @@
           controlPlane: true
     - jsonPatches:
       - op: add
-        path: /spec/template/spec/preKubeadmCommands/0
+        # Note: We are adding prekubeadmscript.sh (install on bootstrap) at the end of the preKubeadmCommands because getting an ip for the machine (dhclient eth0) must always be executed as a first command.
+        path: /spec/template/spec/preKubeadmCommands/-
         value: "/opt/prekubeadmscript.sh"
       - op: add
         path: /spec/template/spec/files/-
diff --git a/test/e2e/data/infrastructure-vsphere-supervisor/main/conformance/kustomization.yaml b/test/e2e/data/infrastructure-vsphere-supervisor/main/conformance/kustomization.yaml
new file mode 100644
index 00000000..b9feefcf
--- /dev/null
+++ b/test/e2e/data/infrastructure-vsphere-supervisor/main/conformance/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+  - ../base
+patchesStrategicMerge:
+  - worker-node-size.yaml
diff --git a/test/e2e/data/infrastructure-vsphere-supervisor/main/conformance/worker-node-size.yaml b/test/e2e/data/infrastructure-vsphere-supervisor/main/conformance/worker-node-size.yaml
new file mode 100644
index 00000000..ffe3a249
--- /dev/null
+++ b/test/e2e/data/infrastructure-vsphere-supervisor/main/conformance/worker-node-size.yaml
@@ -0,0 +1,10 @@
+---
+apiVersion: vmware.infrastructure.cluster.x-k8s.io/v1beta1
+kind: VSphereMachineTemplate
+metadata:
+  name: ${CLUSTER_NAME}-worker
+  namespace: ${NAMESPACE}
+spec:
+  template:
+    spec:
+      className: ${VSPHERE_MACHINE_CLASS_NAME_CONFORMANCE}
diff --git a/test/e2e/data/infrastructure-vsphere-supervisor/main/install-on-bootstrap/inject-install-on-bootstrap.yaml b/test/e2e/data/infrastructure-vsphere-supervisor/main/install-on-bootstrap/inject-install-on-bootstrap.yaml
new file mode 100644
index 00000000..6abed91d
--- /dev/null
+++ b/test/e2e/data/infrastructure-vsphere-supervisor/main/install-on-bootstrap/inject-install-on-bootstrap.yaml
@@ -0,0 +1,109 @@
+# Note: Prior art: https://github.com/kubernetes-sigs/cluster-api/blob/21a350718f5e84a580bc677213bc25faec3291e7/test/framework/kubernetesversions/data/debian_injection_script.envsubst.sh.tpl
+- op: add
+  path: /spec/topology/variables/-
+  value:
+    name: preKubeadmScript
+    # Note: This file has to be escaped as it is run through envsubst by clusterctl
+    value: |
+      #!/bin/bash
+
+      set -o nounset
+      set -o pipefail
+      set -o errexit
+      
+      function retry {
+        attempt=0
+        max_attempts=$${1}
+        interval=$${2}
+        shift; shift
+        until [[ $${attempt} -ge "$${max_attempts}" ]] ; do
+          attempt=$((attempt+1))
+          set +e
+          eval "$*" && return || echo "failed $${attempt} times: $*"
+          set -e
+          sleep "$${interval}"
+        done
+        echo "error: reached max attempts at retry($*)"
+        return 1
+      }
+      
+      [[ $(id -u) != 0 ]] && SUDO="sudo" || SUDO=""
+      
+      # This test installs release packages or binaries that are a result of the CI and release builds.
+      # It runs '... --version' commands to verify that the binaries are correctly installed
+      # and finally uninstalls the packages.
+      # For the release packages it tests all versions in the support skew.
+      LINE_SEPARATOR="*************************************************"
+      echo "$${LINE_SEPARATOR}"
+      
+      ## Variables (replaced by JSON patching)
+      KUBERNETES_VERSION=$${KUBERNETES_VERSION}
+      ##
+      
+      # Note: We assume if kubectl has the right version, everything else has as well
+      if [[ $(kubectl version --client=true -o json | jq '.clientVersion.gitVersion' -r) = "$${KUBERNETES_VERSION}" ]]; then
+        echo "Detected Kubernetes $${KUBERNETES_VERSION} via kubectl version, nothing to do"
+        exit 0
+      fi
+      
+      if [[ "$${KUBERNETES_VERSION}" != "" ]]; then
+        CI_DIR=/tmp/k8s-ci
+        mkdir -p "$${CI_DIR}"
+        declare -a PACKAGES_TO_TEST=("kubectl" "kubelet" "kubeadm")
+        # Let's just also download the control plane images for worker nodes. It's easier then optimizing it.
+        declare -a CONTAINERS_TO_TEST=("kube-apiserver" "kube-controller-manager" "kube-proxy" "kube-scheduler")
+        CONTAINER_EXT="tar"
+        echo "* testing CI version $${KUBERNETES_VERSION}"
+        # Check for semver
+        if [[ "$${KUBERNETES_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+          CI_URL="https://dl.k8s.io/release/$${KUBERNETES_VERSION}/bin/linux/amd64"
+          VERSION_WITHOUT_PREFIX="$${KUBERNETES_VERSION#v}"
+          export DEBIAN_FRONTEND=noninteractive
+          # sometimes the network is not immediately available, so we have to retry the apt-get update
+          retry 10 5 "apt-get update"
+          apt-get install -y apt-transport-https ca-certificates curl gpg
+          mkdir -p -m 755 /etc/apt/keyrings
+          curl -fsSL https://pkgs.k8s.io/core:/stable:/$${KUBERNETES_VERSION:0:5}/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
+          echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/$${KUBERNETES_VERSION:0:5}/deb/ /" | tee /etc/apt/sources.list.d/kubernetes.list
+          apt-get update
+          # replace . with \.
+          VERSION_REGEX="$${VERSION_WITHOUT_PREFIX//./\\.}"
+          PACKAGE_VERSION="$(apt-cache madison kubelet | grep "$${VERSION_REGEX}-" | head -n1 | cut -d '|' -f 2 | tr -d '[:space:]')"
+          for CI_PACKAGE in "$${PACKAGES_TO_TEST[@]}"; do
+            echo "* installing package: $${CI_PACKAGE} $${PACKAGE_VERSION}"
+            apt-mark unhold "$${CI_PACKAGE}"
+            apt-get install --allow-downgrades -y "$${CI_PACKAGE}=$${PACKAGE_VERSION}"
+            apt-mark hold "$${CI_PACKAGE}"
+          done
+          systemctl restart kubelet
+        else
+          CI_URL="https://dl.k8s.io/ci/$${KUBERNETES_VERSION}/bin/linux/amd64"
+          for CI_PACKAGE in "$${PACKAGES_TO_TEST[@]}"; do
+            # Browser: https://console.cloud.google.com/storage/browser/k8s-release-dev?project=k8s-release-dev
+            # e.g.: https://storage.googleapis.com/k8s-release-dev/ci/v1.21.0-beta.1.378+cf3374e43491c5/bin/linux/amd64/kubectl
+            echo "* downloading binary: $${CI_URL}/$${CI_PACKAGE}"
+            wget "$${CI_URL}/$${CI_PACKAGE}" -O "$${CI_DIR}/$${CI_PACKAGE}"
+            chmod +x "$${CI_DIR}/$${CI_PACKAGE}"
+            mv "$${CI_DIR}/$${CI_PACKAGE}" "/usr/bin/$${CI_PACKAGE}"
+          done
+          systemctl restart kubelet
+        fi
+        IMAGE_REGISTRY_PREFIX=registry.k8s.io
+        # Kubernetes builds from 1.20 through 1.24 are tagged with k8s.gcr.io
+        if [[ "$${KUBERNETES_VERSION}" =~ ^v1\.(1[0-9]|2[0-4])[\.[0-9]+ ]]; then
+          IMAGE_REGISTRY_PREFIX=k8s.gcr.io
+        fi
+        for CI_CONTAINER in "$${CONTAINERS_TO_TEST[@]}"; do
+          echo "* downloading package: $${CI_URL}/$${CI_CONTAINER}.$${CONTAINER_EXT}"
+          wget "$${CI_URL}/$${CI_CONTAINER}.$${CONTAINER_EXT}" -O "$${CI_DIR}/$${CI_CONTAINER}.$${CONTAINER_EXT}"
+          $${SUDO} ctr -n k8s.io images import "$${CI_DIR}/$${CI_CONTAINER}.$${CONTAINER_EXT}" || echo "* ignoring expected 'ctr images import' result"
+          $${SUDO} ctr -n k8s.io images tag "$${IMAGE_REGISTRY_PREFIX}/$${CI_CONTAINER}-amd64:$${KUBERNETES_VERSION//+/_}" "$${IMAGE_REGISTRY_PREFIX}/$${CI_CONTAINER}:$${KUBERNETES_VERSION//+/_}"
+          $${SUDO} ctr -n k8s.io images tag "$${IMAGE_REGISTRY_PREFIX}/$${CI_CONTAINER}-amd64:$${KUBERNETES_VERSION//+/_}" "gcr.io/k8s-staging-ci-images/$${CI_CONTAINER}:$${KUBERNETES_VERSION//+/_}"
+        done
+      fi
+      echo "* checking binary versions"
+      echo "ctr version: " "$(ctr version)"
+      echo "kubeadm version: " "$(kubeadm version -o=short)"
+      echo "kubectl version: " "$(kubectl version --client=true)"
+      echo "kubelet version: " "$(kubelet --version)"
+      echo "$${LINE_SEPARATOR}"
diff --git a/test/e2e/data/infrastructure-vsphere-supervisor/main/install-on-bootstrap/kustomization.yaml b/test/e2e/data/infrastructure-vsphere-supervisor/main/install-on-bootstrap/kustomization.yaml
new file mode 100644
index 00000000..1a90a2c1
--- /dev/null
+++ b/test/e2e/data/infrastructure-vsphere-supervisor/main/install-on-bootstrap/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+  - ../topology
+patches:
+  - target:
+      kind: Cluster
+    path: ./inject-install-on-bootstrap.yaml
diff --git a/test/e2e/e2e_setup_test.go b/test/e2e/e2e_setup_test.go
index 26792e63..49d1b3d6 100644
--- a/test/e2e/e2e_setup_test.go
+++ b/test/e2e/e2e_setup_test.go
@@ -287,6 +287,11 @@ func setupNamespaceWithVMOperatorDependenciesVCenter(managementClusterProxy fram
 					Cpus:   mustParseInt64(e2eConfig.GetVariable("VSPHERE_MACHINE_CLASS_CPU")),
 					Memory: resource.MustParse(e2eConfig.GetVariable("VSPHERE_MACHINE_CLASS_MEMORY")),
 				},
+				{
+					Name:   e2eConfig.GetVariable("VSPHERE_MACHINE_CLASS_NAME_CONFORMANCE"),
+					Cpus:   mustParseInt64(e2eConfig.GetVariable("VSPHERE_MACHINE_CLASS_CPU_CONFORMANCE")),
+					Memory: resource.MustParse(e2eConfig.GetVariable("VSPHERE_MACHINE_CLASS_MEMORY_CONFORMANCE")),
+				},
 			},
 		},
 	}
diff --git a/test/e2e/k8s_conformance_test.go b/test/e2e/k8s_conformance_test.go
index cd5ab9c0..27ee4955 100644
--- a/test/e2e/k8s_conformance_test.go
+++ b/test/e2e/k8s_conformance_test.go
@@ -25,7 +25,7 @@ import (
 	"sigs.k8s.io/cluster-api/test/framework/kubernetesversions"
 )
 
-var _ = Describe("When testing K8S conformance [Conformance] [K8s-Install]", func() {
+var _ = Describe("When testing K8S conformance [supervisor] [Conformance] [K8s-Install]", func() {
 	// Note: This installs a cluster based on KUBERNETES_VERSION and runs conformance tests.
 	const specName = "k8s-conformance" // copied from CAPI
 	Setup(specName, func(testSpecificSettingsGetter func() testSettings) {
@@ -43,7 +43,7 @@ var _ = Describe("When testing K8S conformance [Conformance] [K8s-Install]", fun
 	})
 })
 
-var _ = Describe("When testing K8S conformance with K8S latest ci [Conformance] [K8s-Install-ci-latest]", func() {
+var _ = Describe("When testing K8S conformance with K8S latest ci [supervisor] [Conformance] [K8s-Install-ci-latest]", func() {
 	// Note: This installs a cluster based on KUBERNETES_VERSION_LATEST_CI and runs conformance tests.
 	// Note: We are resolving KUBERNETES_VERSION_LATEST_CI and then setting the resolved version as
 	// KUBERNETES_VERSION env var. This only works without side effects on other tests because we are
diff --git a/test/e2e/quick_start_test.go b/test/e2e/quick_start_test.go
index 3240a3a4..c4f0cc96 100644
--- a/test/e2e/quick_start_test.go
+++ b/test/e2e/quick_start_test.go
@@ -40,7 +40,7 @@ var _ = Describe("Cluster Creation using Cluster API quick-start test [vcsim] [s
 	})
 })
 
-var _ = Describe("ClusterClass Creation using Cluster API quick-start test [PR-Blocking] [ClusterClass] [vcsim] [supervisor]", func() {
+var _ = Describe("ClusterClass Creation using Cluster API quick-start test [vcsim] [supervisor] [PR-Blocking] [ClusterClass]", func() {
 	const specName = "quick-start-cluster-class" // prefix (quick-start) copied from CAPI
 	Setup(specName, func(testSpecificSettingsGetter func() testSettings) {
 		capi_e2e.QuickStartSpec(ctx, func() capi_e2e.QuickStartSpecInput {
diff --git a/test/go.sum b/test/go.sum
index 809e564b..43affe4e 100644
--- a/test/go.sum
+++ b/test/go.sum
@@ -630,6 +630,8 @@ github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXV
 github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
 github.com/vmware-tanzu/net-operator-api v0.0.0-20231019160108-42131d6e8360 h1:yG158jviUd3wRqCTJcSDzp+prUZWtSA9dhfm/Rf8m9M=
 github.com/vmware-tanzu/net-operator-api v0.0.0-20231019160108-42131d6e8360/go.mod h1:dtVG693FvGuOSxJvTaKRVGU0EJR8yvLG3E2VaDDHILM=
+github.com/vmware-tanzu/nsx-operator/pkg/apis v0.1.0 h1:HdnQb/X9vJ8a5WQ03g/0nDr9igIIK1fF6wO5wOtkJT4=
+github.com/vmware-tanzu/nsx-operator/pkg/apis v0.1.0/go.mod h1:Q4JzNkNMvjo7pXtlB5/R3oME4Nhah7fAObWgghVmtxk=
 github.com/vmware-tanzu/vm-operator/api v1.8.5 h1:E8rpRdV8+cNp/eNZ/QUHvlrbpPh8uk6bKqwEEmGWe64=
 github.com/vmware-tanzu/vm-operator/api v1.8.5/go.mod h1:SXaSFtnw2502Tzy0bfQVHrvbFDijR96r1ihUYQWPOK8=
 github.com/vmware-tanzu/vm-operator/external/ncp v0.0.0-20231214185006-5477585eebfd h1:qdfVf7KFW+XX7+D4xC/mlBpRA9+B+opdDPxGdqjxO+4=