Repository: Azure/ARO-RP
Pull request #1324 (2021-02-17 15:12:25)

Validator refactor 2
Work in progress
* Spilts dynamic validator to separate interface
* Errors building was taken out of this PR
* Change quota validation 

jim-minter: Re the error separation, I think I am pushing us in the wrong direction.  How about these as alternative insights:
* The CloudErrorCodes are already the mechanism by which we should switch on classes of error; all these other structs are adding a layer of abstraction when we already kind of have one
* Our customers expect to be able to disambiguate via CloudErrorCodes; the operator should be no different, it should be doing the same.  If any CloudErrorCodes isn't sufficiently unambiguous we should split it into two.
mjudeikis: > Re the error separation, I think I am pushing us in the wrong direction. How about these as alternative insights:
> 
> * The CloudErrorCodes are already the mechanism by which we should switch on classes of error; all these other structs are adding a layer of abstraction when we already kind of have one
> * Our customers expect to be able to disambiguate via CloudErrorCodes; the operator should be no different, it should be doing the same.  If any CloudErrorCodes isn't sufficiently unambiguous we should split it into two.

Feel free to check-out and commit ontop. Removed error building at all so we can take this in separate PR if we chose to.
I would like to get this merged asap to unblock everything else. 
azure-pipelines[bot]: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
mjudeikis: /azp run e2e
azure-pipelines[bot]: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
mjudeikis: /azp run e2e
azure-pipelines[bot]: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
github-actions[bot]: Please rebase pull request.
mjudeikis: Need more time on this but oncall taking over. Will do once have more time
mjudeikis: Looks like there is issue with CIDR validator. Need to re-check
github-actions[bot]: Please rebase pull request.

Patch:
diff --git a/pkg/api/validate/dynamic.go b/pkg/api/validate/dynamic.go
deleted file mode 100644
index 69d9bbef2..000000000
--- a/pkg/api/validate/dynamic.go
+++ /dev/null
@@ -1,211 +0,0 @@
-package validate
-
-// Copyright (c) Microsoft Corporation.
-// Licensed under the Apache License 2.0.
-
-import (
-	"context"
-	"fmt"
-	"net/http"
-	"sort"
-	"strings"
-	"time"
-
-	mgmtnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-07-01/network"
-	"github.com/Azure/go-autorest/autorest"
-	"github.com/Azure/go-autorest/autorest/azure"
-	"github.com/sirupsen/logrus"
-	"k8s.io/apimachinery/pkg/util/wait"
-
-	"github.com/Azure/ARO-RP/pkg/api"
-	"github.com/Azure/ARO-RP/pkg/env"
-	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/authorization"
-	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/network"
-	"github.com/Azure/ARO-RP/pkg/util/permissions"
-	"github.com/Azure/ARO-RP/pkg/util/refreshable"
-	"github.com/Azure/ARO-RP/pkg/util/steps"
-	"github.com/Azure/ARO-RP/pkg/util/subnet"
-)
-
-// SlimDynamic validate in the operator context.
-type SlimDynamic interface {
-	ValidateVnetPermissions(ctx context.Context) error
-	ValidateRouteTablesPermissions(ctx context.Context) error
-	// etc
-	// does Quota code go in here too?
-}
-
-type dynamic struct {
-	log             *logrus.Entry
-	vnetr           *azure.Resource
-	masterSubnetID  string
-	workerSubnetIDs []string
-
-	code string
-	typ  string
-
-	permissions     authorization.PermissionsClient
-	virtualNetworks virtualNetworksGetClient
-}
-
-func NewValidator(log *logrus.Entry, env env.Core, masterSubnetID string, workerSubnetIDs []string, subscriptionID string, authorizer refreshable.Authorizer, code string, typ string) (*dynamic, error) {
-	vnetID, _, err := subnet.Split(masterSubnetID)
-	if err != nil {
-		return nil, err
-	}
-
-	vnetr, err := azure.ParseResourceID(vnetID)
-	if err != nil {
-		return nil, err
-	}
-
-	return &dynamic{
-		log:             log,
-		vnetr:           &vnetr,
-		masterSubnetID:  masterSubnetID,
-		workerSubnetIDs: workerSubnetIDs,
-
-		code: code,
-		typ:  typ,
-
-		permissions:     authorization.NewPermissionsClient(env.Environment(), subscriptionID, authorizer),
-		virtualNetworks: newVirtualNetworksCache(network.NewVirtualNetworksClient(env.Environment(), subscriptionID, authorizer)),
-	}, nil
-}
-
-func (dv *dynamic) ValidateVnetPermissions(ctx context.Context) error {
-	dv.log.Printf("ValidateVnetPermissions (%s)", dv.typ)
-
-	err := dv.validateActions(ctx, dv.vnetr, []string{
-		"Microsoft.Network/virtualNetworks/join/action",
-		"Microsoft.Network/virtualNetworks/read",
-		"Microsoft.Network/virtualNetworks/write",
-		"Microsoft.Network/virtualNetworks/subnets/join/action",
-		"Microsoft.Network/virtualNetworks/subnets/read",
-		"Microsoft.Network/virtualNetworks/subnets/write",
-	})
-
-	if err == wait.ErrWaitTimeout {
-		return api.NewCloudError(http.StatusBadRequest, dv.code, "", "The %s does not have Network Contributor permission on vnet '%s'.", dv.typ, dv.vnetr)
-	}
-	if detailedErr, ok := err.(autorest.DetailedError); ok &&
-		detailedErr.StatusCode == http.StatusNotFound {
-		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The vnet '%s' could not be found.", dv.vnetr)
-	}
-	return err
-}
-
-func (dv *dynamic) ValidateRouteTablesPermissions(ctx context.Context) error {
-	vnet, err := dv.virtualNetworks.Get(ctx, dv.vnetr.ResourceGroup, dv.vnetr.ResourceName, "")
-	if err != nil {
-		return err
-	}
-
-	m := map[string]string{}
-
-	rtID, err := getRouteTableID(&vnet, "properties.masterProfile.subnetId", dv.masterSubnetID)
-	if err != nil {
-		return err
-	}
-
-	if rtID != "" {
-		m[strings.ToLower(rtID)] = "properties.masterProfile.subnetId"
-	}
-
-	for i, s := range dv.workerSubnetIDs {
-		path := fmt.Sprintf("properties.workerProfiles[%d].subnetId", i)
-
-		rtID, err := getRouteTableID(&vnet, path, s)
-		if err != nil {
-			return err
-		}
-
-		if _, ok := m[strings.ToLower(rtID)]; ok || rtID == "" {
-			continue
-		}
-		m[strings.ToLower(rtID)] = path
-	}
-
-	rts := make([]string, 0, len(m))
-	for rt := range m {
-		rts = append(rts, rt)
-	}
-
-	sort.Slice(rts, func(i, j int) bool { return strings.Compare(m[rts[i]], m[rts[j]]) < 0 })
-
-	for _, rt := range rts {
-		err := dv.validateRouteTablePermissions(ctx, rt, m[rt])
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-func (dv *dynamic) validateRouteTablePermissions(ctx context.Context, rtID string, path string) error {
-	dv.log.Printf("validateRouteTablePermissions(%s, %s)", dv.typ, path)
-
-	rtr, err := azure.ParseResourceID(rtID)
-	if err != nil {
-		return err
-	}
-
-	err = dv.validateActions(ctx, &rtr, []string{
-		"Microsoft.Network/routeTables/join/action",
-		"Microsoft.Network/routeTables/read",
-		"Microsoft.Network/routeTables/write",
-	})
-	if err == wait.ErrWaitTimeout {
-		return api.NewCloudError(http.StatusBadRequest, dv.code, "", "The %s does not have Network Contributor permission on route table '%s'.", dv.typ, rtID)
-	}
-	if detailedErr, ok := err.(autorest.DetailedError); ok &&
-		detailedErr.StatusCode == http.StatusNotFound {
-		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedRouteTable, "", "The route table '%s' could not be found.", rtID)
-	}
-	return err
-}
-
-func (dv *dynamic) validateActions(ctx context.Context, r *azure.Resource, actions []string) error {
-	timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
-	defer cancel()
-
-	return wait.PollImmediateUntil(20*time.Second, func() (bool, error) {
-		dv.log.Debug("retry validateActions")
-		perms, err := dv.permissions.ListForResource(ctx, r.ResourceGroup, r.Provider, "", r.ResourceType, r.ResourceName)
-
-		if detailedErr, ok := err.(autorest.DetailedError); ok &&
-			detailedErr.StatusCode == http.StatusForbidden {
-			return false, steps.ErrWantRefresh
-		}
-		if err != nil {
-			return false, err
-		}
-
-		for _, action := range actions {
-			ok, err := permissions.CanDoAction(perms, action)
-			if !ok || err != nil {
-				// TODO(jminter): I don't understand if there are genuinely
-				// cases where CanDoAction can return false then true shortly
-				// after. I'm a little skeptical; if it can't happen we can
-				// simplify this code.  We should add a metric on this.
-				return false, err
-			}
-		}
-
-		return true, nil
-	}, timeoutCtx.Done())
-}
-
-func getRouteTableID(vnet *mgmtnetwork.VirtualNetwork, path, subnetID string) (string, error) {
-	s := findSubnet(vnet, subnetID)
-	if s == nil {
-		return "", api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The subnet '%s' could not be found.", subnetID)
-	}
-
-	if s.RouteTable == nil {
-		return "", nil
-	}
-
-	return *s.RouteTable.ID, nil
-}
diff --git a/pkg/api/validate/dynamic/cache_permissions.go b/pkg/api/validate/dynamic/cache_permissions.go
new file mode 100644
index 000000000..d6d0649a1
--- /dev/null
+++ b/pkg/api/validate/dynamic/cache_permissions.go
@@ -0,0 +1,70 @@
+package dynamic
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+
+	mgmtauthorization "github.com/Azure/azure-sdk-for-go/services/preview/authorization/mgmt/2018-09-01-preview/authorization"
+)
+
+type permissionsCacheClient interface {
+	ListForResource(ctx context.Context, resourceGroupName string, resourceProviderNamespace string, parentResourcePath string, resourceType string, resourceName string) (permissions []mgmtauthorization.Permission, err error)
+	ListForResourceGroup(ctx context.Context, resourceGroupName string) (permissions []mgmtauthorization.Permission, err error)
+}
+
+type permissionsListForResourceCacheKey struct {
+	resourceGroupName         string
+	resourceProviderNamespace string
+	parentResourcePath        string
+	resourceType              string
+	resourceName              string
+}
+
+type permissionsListForResourceGroupCacheKey struct {
+	resourceGroupName string
+}
+
+type permissionsCache struct {
+	c   permissionsCacheClient
+	lr  map[permissionsListForResourceCacheKey][]mgmtauthorization.Permission
+	lrg map[permissionsListForResourceGroupCacheKey][]mgmtauthorization.Permission
+}
+
+func (prm *permissionsCache) ListForResource(ctx context.Context, resourceGroupName string, resourceProviderNamespace string, parentResourcePath string, resourceType string, resourceName string) ([]mgmtauthorization.Permission, error) {
+	if _, ok := prm.lr[permissionsListForResourceCacheKey{resourceGroupName, resourceProviderNamespace, parentResourcePath, resourceType, resourceName}]; !ok {
+		perm, err := prm.c.ListForResource(ctx, resourceGroupName, resourceProviderNamespace, parentResourcePath, resourceType, resourceName)
+		if err != nil {
+			return perm, err
+		}
+
+		prm.lr[permissionsListForResourceCacheKey{resourceGroupName, resourceProviderNamespace, parentResourcePath, resourceType, resourceName}] = perm
+	}
+
+	return prm.lr[permissionsListForResourceCacheKey{resourceGroupName, resourceProviderNamespace, parentResourcePath, resourceType, resourceName}], nil
+}
+
+func (prm *permissionsCache) ListForResourceGroup(ctx context.Context, resourceGroupName string) ([]mgmtauthorization.Permission, error) {
+	if _, ok := prm.lrg[permissionsListForResourceGroupCacheKey{resourceGroupName}]; !ok {
+		perm, err := prm.c.ListForResourceGroup(ctx, resourceGroupName)
+		if err != nil {
+			return perm, err
+		}
+
+		prm.lrg[permissionsListForResourceGroupCacheKey{resourceGroupName}] = perm
+	}
+
+	return prm.lrg[permissionsListForResourceGroupCacheKey{resourceGroupName}], nil
+}
+
+// newPermissionsCache returns a new permissionsCache. It knows nothing
+// about updates and is not thread-safe, but it does retry in the face of
+// errors.
+func newPermissionsCache(c permissionsCacheClient) permissionsCacheClient {
+	return &permissionsCache{
+		c:   c,
+		lr:  map[permissionsListForResourceCacheKey][]mgmtauthorization.Permission{},
+		lrg: map[permissionsListForResourceGroupCacheKey][]mgmtauthorization.Permission{},
+	}
+}
diff --git a/pkg/api/validate/cache.go b/pkg/api/validate/dynamic/cache_vnet.go
similarity index 98%
rename from pkg/api/validate/cache.go
rename to pkg/api/validate/dynamic/cache_vnet.go
index afa904ae5..d88f46f9d 100644
--- a/pkg/api/validate/cache.go
+++ b/pkg/api/validate/dynamic/cache_vnet.go
@@ -1,4 +1,4 @@
-package validate
+package dynamic
 
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
diff --git a/pkg/api/validate/dynamic/dynamic.go b/pkg/api/validate/dynamic/dynamic.go
new file mode 100644
index 000000000..0c0a44788
--- /dev/null
+++ b/pkg/api/validate/dynamic/dynamic.go
@@ -0,0 +1,421 @@
+package dynamic
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+
+	mgmtnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-07-01/network"
+	"github.com/Azure/go-autorest/autorest"
+	"github.com/Azure/go-autorest/autorest/azure"
+	"github.com/apparentlymart/go-cidr/cidr"
+	"github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/authorization"
+	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/compute"
+	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/features"
+	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/network"
+	"github.com/Azure/ARO-RP/pkg/util/permissions"
+	"github.com/Azure/ARO-RP/pkg/util/refreshable"
+	"github.com/Azure/ARO-RP/pkg/util/steps"
+	"github.com/Azure/ARO-RP/pkg/util/subnet"
+)
+
+type Subnet struct {
+	ID   string
+	Path string
+}
+
+// Dynamic validate in the operator context.
+type Dynamic interface {
+	ValidateVnet(ctx context.Context, location string, subnets []Subnet, additionalCIDRs ...string) error
+	ValidateSubnets(ctx context.Context, oc *api.OpenShiftCluster, subnets []Subnet) error
+	ValidateProviders(ctx context.Context) error
+	ValidateServicePrincipal(ctx context.Context, clientID, clientSecret, tenantID string) error
+
+	ValidateQuota(ctx context.Context, oc *api.OpenShiftCluster) error
+}
+
+type dynamic struct {
+	log            *logrus.Entry
+	authorizerType AuthorizerType
+	azEnv          *azure.Environment
+
+	permissions     authorization.PermissionsClient
+	providers       features.ProvidersClient
+	virtualNetworks virtualNetworksGetClient
+	spUsage         compute.UsageClient
+}
+
+type AuthorizerType string
+
+const AuthorizerFirstParty AuthorizerType = "resource provider"
+const AuthorizerClusterServicePrincipal AuthorizerType = "cluster"
+
+func NewValidator(log *logrus.Entry, azEnv *azure.Environment, subscriptionID string, authorizer refreshable.Authorizer, authorizerType AuthorizerType) (*dynamic, error) {
+	return &dynamic{
+		log:            log,
+		authorizerType: authorizerType,
+		azEnv:          azEnv,
+
+		providers:       features.NewProvidersClient(azEnv, subscriptionID, authorizer),
+		spUsage:         compute.NewUsageClient(azEnv, subscriptionID, authorizer),
+		permissions:     newPermissionsCache(authorization.NewPermissionsClient(azEnv, subscriptionID, authorizer)),
+		virtualNetworks: newVirtualNetworksCache(network.NewVirtualNetworksClient(azEnv, subscriptionID, authorizer)),
+	}, nil
+}
+
+func (dv *dynamic) ValidateVnet(ctx context.Context, location string, subnets []Subnet, additionalCIDRs ...string) error {
+	if len(subnets) == 0 {
+		return fmt.Errorf("no subnets provided")
+	}
+
+	// each subnet is threated individually as it would be from the different vnet
+	for _, s := range subnets {
+		err := dv.validatePermissions(ctx, s)
+		if err != nil {
+			return err
+		}
+
+		err = dv.validateRouteTablePermissions(ctx, s)
+		if err != nil {
+			return err
+		}
+
+		err = dv.validateLocation(ctx, s, location)
+		if err != nil {
+			return err
+		}
+
+	}
+
+	return dv.validateCIDRRanges(ctx, subnets, additionalCIDRs...)
+}
+
+func (dv *dynamic) validatePermissions(ctx context.Context, s Subnet) error {
+	dv.log.Printf("ValidatePermissions")
+
+	vnetID, _, err := subnet.Split(s.ID)
+	if err != nil {
+		return err
+	}
+
+	vnetr, err := azure.ParseResourceID(vnetID)
+	if err != nil {
+		return err
+	}
+
+	errCode := api.CloudErrorCodeInvalidResourceProviderPermissions
+	if dv.authorizerType == AuthorizerClusterServicePrincipal {
+		errCode = api.CloudErrorCodeInvalidServicePrincipalPermissions
+	}
+
+	err = dv.validateActions(ctx, &vnetr, []string{
+		"Microsoft.Network/virtualNetworks/join/action",
+		"Microsoft.Network/virtualNetworks/read",
+		"Microsoft.Network/virtualNetworks/write",
+		"Microsoft.Network/virtualNetworks/subnets/join/action",
+		"Microsoft.Network/virtualNetworks/subnets/read",
+		"Microsoft.Network/virtualNetworks/subnets/write",
+	})
+
+	if err == wait.ErrWaitTimeout {
+		return api.NewCloudError(http.StatusBadRequest, errCode, "", "The %s service principal does not have Network Contributor permission on vnet '%s'.", dv.authorizerType, vnetID)
+	}
+	if detailedErr, ok := err.(autorest.DetailedError); ok &&
+		detailedErr.StatusCode == http.StatusNotFound {
+		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The vnet '%s' could not be found.", vnetID)
+	}
+	return err
+}
+
+// validateRouteTablesPermissions will validate permissions on provided subnet
+func (dv *dynamic) validateRouteTablePermissions(ctx context.Context, s Subnet) error {
+	log.Printf("validateRouteTablePermissions")
+
+	vnetID, _, err := subnet.Split(s.ID)
+	if err != nil {
+		return err
+	}
+
+	vnetr, err := azure.ParseResourceID(vnetID)
+	if err != nil {
+		return err
+	}
+
+	vnet, err := dv.virtualNetworks.Get(ctx, vnetr.ResourceGroup, vnetr.ResourceName, "")
+	if err != nil {
+		return err
+	}
+
+	rtID, err := getRouteTableID(&vnet, s.ID)
+	if err != nil || rtID == "" { // error or no route table
+		return err
+	}
+
+	rtr, err := azure.ParseResourceID(rtID)
+	if err != nil {
+		return err
+	}
+
+	errCode := api.CloudErrorCodeInvalidResourceProviderPermissions
+	if dv.authorizerType == AuthorizerClusterServicePrincipal {
+		errCode = api.CloudErrorCodeInvalidServicePrincipalPermissions
+	}
+
+	err = dv.validateActions(ctx, &rtr, []string{
+		"Microsoft.Network/routeTables/join/action",
+		"Microsoft.Network/routeTables/read",
+		"Microsoft.Network/routeTables/write",
+	})
+	if err == wait.ErrWaitTimeout {
+		return api.NewCloudError(http.StatusBadRequest, errCode, "", "The %s service principal does not have Network Contributor permission on route table '%s'.", dv.authorizerType, rtID)
+	}
+	if detailedErr, ok := err.(autorest.DetailedError); ok &&
+		detailedErr.StatusCode == http.StatusNotFound {
+		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedRouteTable, "", "The route table '%s' could not be found.", rtID)
+	}
+	return err
+}
+
+func (dv *dynamic) validateActions(ctx context.Context, r *azure.Resource, actions []string) error {
+	timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+	defer cancel()
+
+	return wait.PollImmediateUntil(20*time.Second, func() (bool, error) {
+		dv.log.Debug("retry validateActions")
+		perms, err := dv.permissions.ListForResource(ctx, r.ResourceGroup, r.Provider, "", r.ResourceType, r.ResourceName)
+
+		if detailedErr, ok := err.(autorest.DetailedError); ok &&
+			detailedErr.StatusCode == http.StatusForbidden {
+			return false, steps.ErrWantRefresh
+		}
+		if err != nil {
+			return false, err
+		}
+
+		for _, action := range actions {
+			ok, err := permissions.CanDoAction(perms, action)
+			if !ok || err != nil {
+				// TODO(jminter): I don't understand if there are genuinely
+				// cases where CanDoAction can return false then true shortly
+				// after. I'm a little skeptical; if it can't happen we can
+				// simplify this code.  We should add a metric on this.
+				return false, err
+			}
+		}
+
+		return true, nil
+	}, timeoutCtx.Done())
+}
+
+func (dv *dynamic) validateCIDRRanges(ctx context.Context, subnets []Subnet, additionalCIDRs ...string) error {
+	dv.log.Print("ValidateCIDRRanges")
+
+	// During cluster runtime they get enriched and contains multiple
+	// duplicate values for multiple worker pools. CIDRRange validation
+	// only cares about unique CIDR ranges.
+	subnets = uniqueSubnetSlice(subnets)
+
+	var CIDRArray []*net.IPNet
+
+	// unique names of subnets from all node pools
+	for _, s := range subnets {
+		vnetID, _, err := subnet.Split(s.ID)
+		if err != nil {
+			return err
+		}
+
+		vnetr, err := azure.ParseResourceID(vnetID)
+		if err != nil {
+			return err
+		}
+
+		vnet, err := dv.virtualNetworks.Get(ctx, vnetr.ResourceGroup, vnetr.ResourceName, "")
+		if err != nil {
+			return err
+		}
+
+		s, err := findSubnet(&vnet, s.ID)
+		if err != nil {
+			return err
+		}
+
+		_, net, err := net.ParseCIDR(*s.AddressPrefix)
+		if err != nil {
+			return err
+		}
+		CIDRArray = append(CIDRArray, net)
+	}
+
+	for _, c := range additionalCIDRs {
+		_, cidr, err := net.ParseCIDR(c)
+		if err != nil {
+			return err
+		}
+		CIDRArray = append(CIDRArray, cidr)
+	}
+
+	err := cidr.VerifyNoOverlap(CIDRArray, &net.IPNet{IP: net.IPv4zero, Mask: net.IPMask(net.IPv4zero)})
+	if err != nil {
+		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The provided CIDRs must not overlap: '%s'.", err)
+	}
+
+	return nil
+}
+
+func (dv *dynamic) validateLocation(ctx context.Context, s Subnet, location string) error {
+	dv.log.Print("validateLocation")
+
+	vnetID, _, err := subnet.Split(s.ID)
+	if err != nil {
+		return err
+	}
+
+	vnetr, err := azure.ParseResourceID(vnetID)
+	if err != nil {
+		return err
+	}
+
+	vnet, err := dv.virtualNetworks.Get(ctx, vnetr.ResourceGroup, vnetr.ResourceName, "")
+	if err != nil {
+		return err
+	}
+
+	if !strings.EqualFold(*vnet.Location, location) {
+		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The vnet location '%s' must match the cluster location '%s'.", *vnet.Location, location)
+	}
+
+	return nil
+}
+
+func (dv *dynamic) ValidateSubnets(ctx context.Context, oc *api.OpenShiftCluster, subnets []Subnet) error {
+	dv.log.Printf("validateSubnet")
+	if len(subnets) == 0 {
+		return fmt.Errorf("no subnets found")
+	}
+
+	for _, s := range subnets {
+		vnetID, _, err := subnet.Split(s.ID)
+		if err != nil {
+			return err
+		}
+
+		vnetr, err := azure.ParseResourceID(vnetID)
+		if err != nil {
+			return err
+		}
+
+		vnet, err := dv.virtualNetworks.Get(ctx, vnetr.ResourceGroup, vnetr.ResourceName, "")
+		if err != nil {
+			return err
+		}
+
+		ss, err := findSubnet(&vnet, s.ID)
+		if err != nil {
+			return err
+		}
+		if ss == nil {
+			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, s.Path, "The provided subnet '%s' could not be found.", s.ID)
+		}
+
+		if strings.EqualFold(oc.Properties.MasterProfile.SubnetID, *ss.ID) {
+			if ss.PrivateLinkServiceNetworkPolicies == nil ||
+				!strings.EqualFold(*ss.PrivateLinkServiceNetworkPolicies, "Disabled") {
+				return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, s.Path, "The provided subnet '%s' is invalid: must have privateLinkServiceNetworkPolicies disabled.", s.ID)
+			}
+		}
+
+		var found bool
+		if ss.ServiceEndpoints != nil {
+			for _, se := range *ss.ServiceEndpoints {
+				if strings.EqualFold(*se.Service, "Microsoft.ContainerRegistry") &&
+					se.ProvisioningState == mgmtnetwork.Succeeded {
+					found = true
+					break
+				}
+			}
+		}
+		if !found {
+			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, s.Path, "The provided subnet '%s' is invalid: must have Microsoft.ContainerRegistry serviceEndpoint.", s.ID)
+		}
+
+		if oc.Properties.ProvisioningState == api.ProvisioningStateCreating {
+			if ss.SubnetPropertiesFormat != nil &&
+				ss.SubnetPropertiesFormat.NetworkSecurityGroup != nil {
+				return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, s.Path, "The provided subnet '%s' is invalid: must not have a network security group attached.", s.ID)
+			}
+
+		} else {
+			nsgID, err := subnet.NetworkSecurityGroupID(oc, *ss.ID)
+			if err != nil {
+				return err
+			}
+
+			if ss.SubnetPropertiesFormat == nil ||
+				ss.SubnetPropertiesFormat.NetworkSecurityGroup == nil ||
+				!strings.EqualFold(*ss.SubnetPropertiesFormat.NetworkSecurityGroup.ID, nsgID) {
+				return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, s.Path, "The provided subnet '%s' is invalid: must have network security group '%s' attached.", s.ID, nsgID)
+			}
+		}
+
+		_, net, err := net.ParseCIDR(*ss.AddressPrefix)
+		if err != nil {
+			return err
+		}
+
+		ones, _ := net.Mask.Size()
+		if ones > 27 {
+			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, s.Path, "The provided subnet '%s' is invalid: must be /27 or larger.", s.ID)
+		}
+	}
+
+	return nil
+}
+
+func getRouteTableID(vnet *mgmtnetwork.VirtualNetwork, subnetID string) (string, error) {
+	s, err := findSubnet(vnet, subnetID)
+	if err != nil {
+		return "", err
+	}
+
+	if s == nil || s.RouteTable == nil {
+		return "", nil
+	}
+
+	return *s.RouteTable.ID, nil
+}
+
+func findSubnet(vnet *mgmtnetwork.VirtualNetwork, subnetID string) (*mgmtnetwork.Subnet, error) {
+	if vnet.Subnets != nil {
+		for _, s := range *vnet.Subnets {
+			if strings.EqualFold(*s.ID, subnetID) {
+				return &s, nil
+			}
+		}
+	}
+
+	return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The provided subnet '%s' could not be found.", subnetID)
+}
+
+// uniqueSubnetSlice returns string subnets with unique values only
+func uniqueSubnetSlice(slice []Subnet) []Subnet {
+	keys := make(map[string]bool)
+	list := []Subnet{}
+	for _, entry := range slice {
+		if _, value := keys[entry.ID]; !value {
+			keys[entry.ID] = true
+			list = append(list, entry)
+		}
+	}
+	return list
+}
diff --git a/pkg/api/validate/dynamic/provider.go b/pkg/api/validate/dynamic/provider.go
new file mode 100644
index 000000000..82e2ec09f
--- /dev/null
+++ b/pkg/api/validate/dynamic/provider.go
@@ -0,0 +1,42 @@
+package dynamic
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"net/http"
+
+	mgmtfeatures "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-07-01/features"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+)
+
+func (dv *dynamic) ValidateProviders(ctx context.Context) error {
+	dv.log.Print("ValidateProviders")
+
+	providers, err := dv.providers.List(ctx, nil, "")
+	if err != nil {
+		return err
+	}
+
+	providerMap := make(map[string]mgmtfeatures.Provider, len(providers))
+
+	for _, provider := range providers {
+		providerMap[*provider.Namespace] = provider
+	}
+
+	for _, provider := range []string{
+		"Microsoft.Authorization",
+		"Microsoft.Compute",
+		"Microsoft.Network",
+		"Microsoft.Storage",
+	} {
+		if providerMap[provider].RegistrationState == nil ||
+			*providerMap[provider].RegistrationState != "Registered" {
+			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorResourceProviderNotRegistered, "", "The resource provider '%s' is not registered.", provider)
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/api/validate/quota.go b/pkg/api/validate/dynamic/quota.go
similarity index 69%
rename from pkg/api/validate/quota.go
rename to pkg/api/validate/dynamic/quota.go
index 07dfce616..7a10c66d0 100644
--- a/pkg/api/validate/quota.go
+++ b/pkg/api/validate/dynamic/quota.go
@@ -1,4 +1,4 @@
-package validate
+package dynamic
 
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
@@ -8,14 +8,7 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/Azure/go-autorest/autorest/azure"
-	"github.com/sirupsen/logrus"
-
 	"github.com/Azure/ARO-RP/pkg/api"
-	"github.com/Azure/ARO-RP/pkg/env"
-	"github.com/Azure/ARO-RP/pkg/util/aad"
-	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/compute"
-	"github.com/Azure/ARO-RP/pkg/util/refreshable"
 )
 
 func addRequiredResources(requiredResources map[string]int, vmSize api.VMSize, count int) error {
@@ -85,67 +78,30 @@ func addRequiredResources(requiredResources map[string]int, vmSize api.VMSize, c
 	return nil
 }
 
-type AzureQuotaValidator interface {
-	Validate(context.Context) error
-}
-
-type quotaValidator struct {
-	log *logrus.Entry
-	env env.Core
-
-	oc      *api.OpenShiftCluster
-	spUsage compute.UsageClient
-}
-
-func NewAzureQuotaValidator(ctx context.Context, log *logrus.Entry, env env.Core, oc *api.OpenShiftCluster, subscriptionDoc *api.SubscriptionDocument) (AzureQuotaValidator, error) {
-	r, err := azure.ParseResourceID(oc.ID)
-	if err != nil {
-		return nil, err
-	}
-
-	err = validateServicePrincipalProfile(ctx, log, env, oc, subscriptionDoc)
-	if err != nil {
-		return nil, err
-	}
-
-	spp := oc.Properties.ServicePrincipalProfile
-	token, err := aad.GetToken(ctx, log, spp.ClientID, string(spp.ClientSecret), subscriptionDoc.Subscription.Properties.TenantID, env.Environment().ActiveDirectoryEndpoint, env.Environment().ResourceManagerEndpoint)
-	if err != nil {
-		return nil, err
-	}
-
-	spAuthorizer := refreshable.NewAuthorizer(token)
-
-	validator := &quotaValidator{
-		log: log,
-		env: env,
+// ValidateQuota checks usage quotas vs. resources required by cluster before cluster
+// creation
+func (dv *dynamic) ValidateQuota(ctx context.Context, oc *api.OpenShiftCluster) error {
+	dv.log.Print("ValidateQuota")
 
-		oc:      oc,
-		spUsage: compute.NewUsageClient(env.Environment(), r.SubscriptionID, spAuthorizer),
+	// If ValidateQuota runs outside install process, we should skip quota validation
+	if oc.Properties.Install == nil || oc.Properties.Install.Phase != api.InstallPhaseBootstrap {
+		return nil
 	}
 
-	return validator, nil
-}
-
-// Validate checks usage quotas vs. resources required by cluster before cluster
-// creation
-func (qv *quotaValidator) Validate(ctx context.Context) error {
-	qv.log.Print("ValidateQuotas")
-
 	requiredResources := map[string]int{}
-	err := addRequiredResources(requiredResources, qv.oc.Properties.MasterProfile.VMSize, 3)
+	err := addRequiredResources(requiredResources, oc.Properties.MasterProfile.VMSize, 3)
 	if err != nil {
 		return err
 	}
 	//worker node resource calculation
-	for _, w := range qv.oc.Properties.WorkerProfiles {
+	for _, w := range oc.Properties.WorkerProfiles {
 		err = addRequiredResources(requiredResources, w.VMSize, w.Count)
 		if err != nil {
 			return err
 		}
 	}
 
-	usages, err := qv.spUsage.List(ctx, qv.oc.Location)
+	usages, err := dv.spUsage.List(ctx, oc.Location)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/api/validate/dynamic/serviceprincipal.go b/pkg/api/validate/dynamic/serviceprincipal.go
new file mode 100644
index 000000000..748857eb3
--- /dev/null
+++ b/pkg/api/validate/dynamic/serviceprincipal.go
@@ -0,0 +1,41 @@
+package dynamic
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"log"
+	"net/http"
+
+	"github.com/form3tech-oss/jwt-go"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/util/aad"
+	"github.com/Azure/ARO-RP/pkg/util/azureclaim"
+)
+
+func (dv *dynamic) ValidateServicePrincipal(ctx context.Context, clientID, clientSecret, tenantID string) error {
+	// TODO: once aad.GetToken is mockable, write a unit test for this function
+	log.Print("ValidateServicePrincipal")
+
+	token, err := aad.GetToken(ctx, dv.log, clientID, clientSecret, tenantID, dv.azEnv.ActiveDirectoryEndpoint, dv.azEnv.GraphEndpoint)
+	if err != nil {
+		return err
+	}
+
+	p := &jwt.Parser{}
+	c := &azureclaim.AzureClaim{}
+	_, _, err = p.ParseUnverified(token.OAuthToken(), c)
+	if err != nil {
+		return err
+	}
+
+	for _, role := range c.Roles {
+		if role == "Application.ReadWrite.OwnedBy" {
+			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidServicePrincipalCredentials, "properties.servicePrincipalProfile", "The provided service principal must not have the Application.ReadWrite.OwnedBy permission.")
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/api/validate/openshiftcluster_validatedynamic.go b/pkg/api/validate/openshiftcluster_validatedynamic.go
index d4880bb2b..4a3661406 100644
--- a/pkg/api/validate/openshiftcluster_validatedynamic.go
+++ b/pkg/api/validate/openshiftcluster_validatedynamic.go
@@ -6,22 +6,14 @@ package validate
 import (
 	"context"
 	"fmt"
-	"net"
-	"net/http"
-	"strings"
 
-	mgmtnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-07-01/network"
-	mgmtfeatures "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-07-01/features"
-	"github.com/apparentlymart/go-cidr/cidr"
-	"github.com/form3tech-oss/jwt-go"
 	"github.com/sirupsen/logrus"
 
 	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/api/validate/dynamic"
 	"github.com/Azure/ARO-RP/pkg/env"
 	"github.com/Azure/ARO-RP/pkg/util/aad"
-	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/features"
 	"github.com/Azure/ARO-RP/pkg/util/refreshable"
-	"github.com/Azure/ARO-RP/pkg/util/subnet"
 )
 
 // OpenShiftClusterDynamicValidator is the dynamic validator interface
@@ -38,18 +30,9 @@ func NewOpenShiftClusterDynamicValidator(log *logrus.Entry, env env.Core, oc *ap
 		oc:              oc,
 		subscriptionDoc: subscriptionDoc,
 		fpAuthorizer:    fpAuthorizer,
-		providers:       features.NewProvidersClient(env.Environment(), subscriptionDoc.ID, fpAuthorizer),
 	}
 }
 
-type azureClaim struct {
-	Roles []string `json:"roles,omitempty"`
-}
-
-func (*azureClaim) Valid() error {
-	return fmt.Errorf("unimplemented")
-}
-
 type openShiftClusterDynamicValidator struct {
 	log *logrus.Entry
 	env env.Core
@@ -57,37 +40,31 @@ type openShiftClusterDynamicValidator struct {
 	oc              *api.OpenShiftCluster
 	subscriptionDoc *api.SubscriptionDocument
 	fpAuthorizer    refreshable.Authorizer
-	providers       features.ProvidersClient
 }
 
 // Dynamic validates an OpenShift cluster
 func (dv *openShiftClusterDynamicValidator) Dynamic(ctx context.Context) error {
 	// Get all subnets
-	mSubnetID := dv.oc.Properties.MasterProfile.SubnetID
-	wSubnetIDs := []string{}
+	var subnets []dynamic.Subnet
+	subnets = append(subnets, dynamic.Subnet{
+		ID:   dv.oc.Properties.MasterProfile.SubnetID,
+		Path: "properties.masterProfile.subnetId",
+	})
 
-	for _, s := range dv.oc.Properties.WorkerProfiles {
-		wSubnetIDs = append(wSubnetIDs, s.SubnetID)
+	for i, s := range dv.oc.Properties.WorkerProfiles {
+		subnets = append(subnets, dynamic.Subnet{
+			ID:   s.SubnetID,
+			Path: fmt.Sprintf("properties.workerProfiles[%d].subnetId", i),
+		})
 	}
 
 	// FP validation
-	fpDynamic, err := NewValidator(dv.log, dv.env, mSubnetID, wSubnetIDs, dv.subscriptionDoc.ID, dv.fpAuthorizer, api.CloudErrorCodeInvalidResourceProviderPermissions, "resource provider")
+	fpDynamic, err := dynamic.NewValidator(dv.log, dv.env.Environment(), dv.subscriptionDoc.ID, dv.fpAuthorizer, dynamic.AuthorizerFirstParty)
 	if err != nil {
 		return err
 	}
 
-	err = fpDynamic.ValidateVnetPermissions(ctx)
-	if err != nil {
-		return err
-	}
-
-	err = fpDynamic.ValidateRouteTablesPermissions(ctx)
-	if err != nil {
-		return err
-	}
-
-	// SP validation
-	err = validateServicePrincipalProfile(ctx, dv.log, dv.env, dv.oc, dv.subscriptionDoc)
+	err = fpDynamic.ValidateVnet(ctx, dv.oc.Location, subnets, dv.oc.Properties.NetworkProfile.PodCIDR, dv.oc.Properties.NetworkProfile.ServiceCIDR)
 	if err != nil {
 		return err
 	}
@@ -100,232 +77,36 @@ func (dv *openShiftClusterDynamicValidator) Dynamic(ctx context.Context) error {
 
 	spAuthorizer := refreshable.NewAuthorizer(token)
 
-	spDynamic, err := NewValidator(dv.log, dv.env, mSubnetID, wSubnetIDs, dv.subscriptionDoc.ID, spAuthorizer, api.CloudErrorCodeInvalidServicePrincipalPermissions, "provided service principal")
-	if err != nil {
-		return err
-	}
-
-	err = spDynamic.ValidateVnetPermissions(ctx)
-	if err != nil {
-		return err
-	}
-
-	err = spDynamic.ValidateRouteTablesPermissions(ctx)
-	if err != nil {
-		return err
-	}
-
-	// Additional checks - use any dynamic because they both have the correct permissions
-	vnet, err := spDynamic.virtualNetworks.Get(ctx, spDynamic.vnetr.ResourceGroup, spDynamic.vnetr.ResourceName, "")
+	spDynamic, err := dynamic.NewValidator(dv.log, dv.env.Environment(), dv.subscriptionDoc.ID, spAuthorizer, dynamic.AuthorizerClusterServicePrincipal)
 	if err != nil {
 		return err
 	}
 
-	err = dv.validateCIDRRanges(ctx, &vnet)
-	if err != nil {
-		return err
-	}
-
-	err = dv.validateVnetLocation(ctx, &vnet)
-	if err != nil {
-		return err
-	}
-
-	err = dv.validateProviders(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (dv *openShiftClusterDynamicValidator) validateVnetLocation(ctx context.Context, vnet *mgmtnetwork.VirtualNetwork) error {
-	dv.log.Print("validateVnetLocation")
-
-	if !strings.EqualFold(*vnet.Location, dv.oc.Location) {
-		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The vnet location '%s' must match the cluster location '%s'.", *vnet.Location, dv.oc.Location)
-	}
-
-	return nil
-}
-
-func (dv *openShiftClusterDynamicValidator) validateCIDRRanges(ctx context.Context, vnet *mgmtnetwork.VirtualNetwork) error {
-	dv.log.Print("validateCIDRRanges")
-
-	var subnets []string
-	var CIDRArray []*net.IPNet
-
-	// unique names of subnets from all node pools
-	for i, subnet := range dv.oc.Properties.WorkerProfiles {
-		exists := false
-		for _, s := range subnets {
-			if strings.EqualFold(strings.ToLower(subnet.SubnetID), strings.ToLower(s)) {
-				exists = true
-				break
-			}
-		}
-
-		if !exists {
-			subnets = append(subnets, subnet.SubnetID)
-			path := fmt.Sprintf("properties.workerProfiles[%d].subnetId", i)
-			c, err := dv.validateSubnet(ctx, vnet, path, subnet.SubnetID)
-			if err != nil {
-				return err
-			}
-
-			CIDRArray = append(CIDRArray, c)
-		}
-	}
-
-	masterCIDR, err := dv.validateSubnet(ctx, vnet, "properties.MasterProfile.subnetId", dv.oc.Properties.MasterProfile.SubnetID)
-	if err != nil {
-		return err
-	}
-	_, podCIDR, err := net.ParseCIDR(dv.oc.Properties.NetworkProfile.PodCIDR)
+	// SP validation
+	err = spDynamic.ValidateServicePrincipal(ctx, spp.ClientID, string(spp.ClientSecret), dv.subscriptionDoc.Subscription.Properties.TenantID)
 	if err != nil {
 		return err
 	}
 
-	_, serviceCIDR, err := net.ParseCIDR(dv.oc.Properties.NetworkProfile.ServiceCIDR)
+	err = spDynamic.ValidateVnet(ctx, dv.oc.Location, subnets, dv.oc.Properties.NetworkProfile.PodCIDR, dv.oc.Properties.NetworkProfile.ServiceCIDR)
 	if err != nil {
 		return err
 	}
 
-	CIDRArray = append(CIDRArray, masterCIDR, podCIDR, serviceCIDR)
-
-	err = cidr.VerifyNoOverlap(CIDRArray, &net.IPNet{IP: net.IPv4zero, Mask: net.IPMask(net.IPv4zero)})
-	if err != nil {
-		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, "", "The provided CIDRs must not overlap: '%s'.", err)
-	}
-
-	return nil
-}
-
-func (dv *openShiftClusterDynamicValidator) validateSubnet(ctx context.Context, vnet *mgmtnetwork.VirtualNetwork, path, subnetID string) (*net.IPNet, error) {
-	dv.log.Printf("validateSubnet (%s)", path)
-
-	s := findSubnet(vnet, subnetID)
-	if s == nil {
-		return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The provided subnet '%s' could not be found.", subnetID)
-	}
-
-	if strings.EqualFold(dv.oc.Properties.MasterProfile.SubnetID, subnetID) {
-		if s.PrivateLinkServiceNetworkPolicies == nil ||
-			!strings.EqualFold(*s.PrivateLinkServiceNetworkPolicies, "Disabled") {
-			return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The provided subnet '%s' is invalid: must have privateLinkServiceNetworkPolicies disabled.", subnetID)
-		}
-	}
-
-	var found bool
-	if s.ServiceEndpoints != nil {
-		for _, se := range *s.ServiceEndpoints {
-			if strings.EqualFold(*se.Service, "Microsoft.ContainerRegistry") &&
-				se.ProvisioningState == mgmtnetwork.Succeeded {
-				found = true
-				break
-			}
-		}
-	}
-	if !found {
-		return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The provided subnet '%s' is invalid: must have Microsoft.ContainerRegistry serviceEndpoint.", subnetID)
-	}
-
-	if dv.oc.Properties.ProvisioningState == api.ProvisioningStateCreating {
-		if s.SubnetPropertiesFormat != nil &&
-			s.SubnetPropertiesFormat.NetworkSecurityGroup != nil {
-			return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The provided subnet '%s' is invalid: must not have a network security group attached.", subnetID)
-		}
-
-	} else {
-		nsgID, err := subnet.NetworkSecurityGroupID(dv.oc, *s.ID)
-		if err != nil {
-			return nil, err
-		}
-
-		if s.SubnetPropertiesFormat == nil ||
-			s.SubnetPropertiesFormat.NetworkSecurityGroup == nil ||
-			!strings.EqualFold(*s.SubnetPropertiesFormat.NetworkSecurityGroup.ID, nsgID) {
-			return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The provided subnet '%s' is invalid: must have network security group '%s' attached.", subnetID, nsgID)
-		}
-	}
-
-	_, net, err := net.ParseCIDR(*s.AddressPrefix)
-	if err != nil {
-		return nil, err
-	}
-
-	ones, _ := net.Mask.Size()
-	if ones > 27 {
-		return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidLinkedVNet, path, "The provided subnet '%s' is invalid: must be /27 or larger.", subnetID)
-	}
-
-	return net, nil
-}
-
-func (dv *openShiftClusterDynamicValidator) validateProviders(ctx context.Context) error {
-	dv.log.Print("validateProviders")
-
-	providers, err := dv.providers.List(ctx, nil, "")
+	err = spDynamic.ValidateSubnets(ctx, dv.oc, subnets)
 	if err != nil {
 		return err
 	}
 
-	providerMap := make(map[string]mgmtfeatures.Provider, len(providers))
-
-	for _, provider := range providers {
-		providerMap[*provider.Namespace] = provider
-	}
-
-	for _, provider := range []string{
-		"Microsoft.Authorization",
-		"Microsoft.Compute",
-		"Microsoft.Network",
-		"Microsoft.Storage",
-	} {
-		if providerMap[provider].RegistrationState == nil ||
-			*providerMap[provider].RegistrationState != "Registered" {
-			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorResourceProviderNotRegistered, "", "The resource provider '%s' is not registered.", provider)
-		}
-	}
-
-	return nil
-}
-
-func validateServicePrincipalProfile(ctx context.Context, log *logrus.Entry, env env.Core, oc *api.OpenShiftCluster, sub *api.SubscriptionDocument) error {
-	// TODO: once aad.GetToken is mockable, write a unit test for this function
-
-	log.Print("validateServicePrincipalProfile")
-
-	spp := oc.Properties.ServicePrincipalProfile
-	token, err := aad.GetToken(ctx, log, spp.ClientID, string(spp.ClientSecret), sub.Subscription.Properties.TenantID, env.Environment().ActiveDirectoryEndpoint, env.Environment().GraphEndpoint)
+	err = spDynamic.ValidateProviders(ctx)
 	if err != nil {
 		return err
 	}
 
-	p := &jwt.Parser{}
-	c := &azureClaim{}
-	_, _, err = p.ParseUnverified(token.OAuthToken(), c)
+	err = spDynamic.ValidateQuota(ctx, dv.oc)
 	if err != nil {
 		return err
 	}
 
-	for _, role := range c.Roles {
-		if role == "Application.ReadWrite.OwnedBy" {
-			return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidServicePrincipalCredentials, "properties.servicePrincipalProfile", "The provided service principal must not have the Application.ReadWrite.OwnedBy permission.")
-		}
-	}
-
-	return nil
-}
-
-func findSubnet(vnet *mgmtnetwork.VirtualNetwork, subnetID string) *mgmtnetwork.Subnet {
-	if vnet.Subnets != nil {
-		for _, s := range *vnet.Subnets {
-			if strings.EqualFold(*s.ID, subnetID) {
-				return &s
-			}
-		}
-	}
-
 	return nil
 }
diff --git a/pkg/cluster/install.go b/pkg/cluster/install.go
index 5989e403e..8360e29e8 100644
--- a/pkg/cluster/install.go
+++ b/pkg/cluster/install.go
@@ -81,7 +81,6 @@ func (m *manager) Install(ctx context.Context) error {
 
 	steps := map[api.InstallPhase][]steps.Step{
 		api.InstallPhaseBootstrap: {
-			steps.AuthorizationRefreshingAction(nil, steps.Action(m.validateQuota)),
 			steps.AuthorizationRefreshingAction(m.fpAuthorizer, steps.Action(m.validateResources)),
 			steps.Action(m.ensureACRToken),
 			steps.Action(m.generateSSHKey),
diff --git a/pkg/cluster/validate.go b/pkg/cluster/validate.go
index c600a5c2d..872785bbb 100644
--- a/pkg/cluster/validate.go
+++ b/pkg/cluster/validate.go
@@ -6,7 +6,6 @@ package cluster
 import (
 	"context"
 
-	"github.com/Azure/ARO-RP/pkg/api"
 	"github.com/Azure/ARO-RP/pkg/api/validate"
 )
 
@@ -16,20 +15,3 @@ func (m *manager) validateResources(ctx context.Context) error {
 	)
 	return ocDynamicValidator.Dynamic(ctx)
 }
-
-func (m *manager) validateQuota(ctx context.Context) error {
-	// we don't validate the quota after we have potentially deployed our VMs,
-	// since the quota validator does not take resources we have deployed into
-	// account
-	if m.doc.OpenShiftCluster.Properties.Install == nil || m.doc.OpenShiftCluster.Properties.Install.Phase != api.InstallPhaseBootstrap {
-		return nil
-	}
-
-	// TODO: remove this with validator refactor
-	qv, err := validate.NewAzureQuotaValidator(ctx, m.log, m.env, m.doc.OpenShiftCluster, m.subscriptionDoc)
-	if err != nil {
-		return err
-	}
-
-	return qv.Validate(ctx)
-}
diff --git a/pkg/util/azureclaim/azureclaim.go b/pkg/util/azureclaim/azureclaim.go
new file mode 100644
index 000000000..26bbdbd3f
--- /dev/null
+++ b/pkg/util/azureclaim/azureclaim.go
@@ -0,0 +1,15 @@
+package azureclaim
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import "fmt"
+
+type AzureClaim struct {
+	Roles    []string `json:"roles,omitempty"`
+	TenantID string   `json:"tid,omitempty"`
+}
+
+func (*AzureClaim) Valid() error {
+	return fmt.Errorf("unimplemented")
+}
diff --git a/pkg/util/instancemetadata/prod.go b/pkg/util/instancemetadata/prod.go
index 8e0219215..7844b4445 100644
--- a/pkg/util/instancemetadata/prod.go
+++ b/pkg/util/instancemetadata/prod.go
@@ -14,15 +14,9 @@ import (
 	"github.com/Azure/go-autorest/autorest/adal"
 	"github.com/Azure/go-autorest/autorest/azure"
 	"github.com/form3tech-oss/jwt-go"
-)
-
-type azureClaim struct {
-	TenantID string `json:"tid,omitempty"`
-}
 
-func (*azureClaim) Valid() error {
-	return fmt.Errorf("unimplemented")
-}
+	"github.com/Azure/ARO-RP/pkg/util/azureclaim"
+)
 
 type ServicePrincipalToken interface {
 	RefreshWithContext(context.Context) error
@@ -74,7 +68,7 @@ func (p *prod) populateTenantIDFromMSI(ctx context.Context) error {
 	}
 
 	parser := &jwt.Parser{}
-	c := &azureClaim{}
+	c := &azureclaim.AzureClaim{}
 	_, _, err = parser.ParseUnverified(token.OAuthToken(), c)
 	if err != nil {
 		return err


Test patch:
diff --git a/pkg/api/validate/dynamic/dynamic_test.go b/pkg/api/validate/dynamic/dynamic_test.go
new file mode 100644
index 000000000..b6c47a46d
--- /dev/null
+++ b/pkg/api/validate/dynamic/dynamic_test.go
@@ -0,0 +1,720 @@
+package dynamic
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"testing"
+
+	mgmtnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-07-01/network"
+	mgmtauthorization "github.com/Azure/azure-sdk-for-go/services/preview/authorization/mgmt/2018-09-01-preview/authorization"
+	"github.com/Azure/go-autorest/autorest"
+	"github.com/Azure/go-autorest/autorest/to"
+	"github.com/golang/mock/gomock"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	mock_authorization "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/authorization"
+	mock_network "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/network"
+)
+
+var (
+	resourceGroupName = "testGroup"
+	subscriptionID    = "0000000-0000-0000-0000-000000000000"
+	resourceGroupID   = "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroupName
+	vnetName          = "testVnet"
+	vnetID            = resourceGroupID + "/providers/Microsoft.Network/virtualNetworks/" + vnetName
+	masterSubnet      = vnetID + "/subnet/masterSubnet"
+	workerSubnet      = vnetID + "/subnet/workerSubnet"
+	masterSubnetPath  = "properties.masterProfile.subnetId"
+	masterRtID        = resourceGroupID + "/providers/Microsoft.Network/routeTables/masterRt"
+	workerRtID        = resourceGroupID + "/providers/Microsoft.Network/routeTables/workerRt"
+	masterNSGv1       = resourceGroupID + "/providers/Microsoft.Network/networkSecurityGroups/aro-controlplane-nsg"
+	workerNSGv1       = resourceGroupID + "/providers/Microsoft.Network/networkSecurityGroups/aro-node-nsg"
+)
+
+func TestValidateVnetPermissions(t *testing.T) {
+	ctx := context.Background()
+
+	resourceType := "virtualNetworks"
+	resourceProvider := "Microsoft.Network"
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	for _, tt := range []struct {
+		name    string
+		mocks   func(*mock_authorization.MockPermissionsClient, func())
+		wantErr string
+	}{
+		{
+			name: "pass",
+			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
+				permissionsClient.EXPECT().
+					ListForResource(gomock.Any(), resourceGroupName, resourceProvider, "", resourceType, vnetName).
+					Return([]mgmtauthorization.Permission{
+						{
+							Actions: &[]string{
+								"Microsoft.Network/virtualNetworks/join/action",
+								"Microsoft.Network/virtualNetworks/read",
+								"Microsoft.Network/virtualNetworks/write",
+								"Microsoft.Network/virtualNetworks/subnets/join/action",
+								"Microsoft.Network/virtualNetworks/subnets/read",
+								"Microsoft.Network/virtualNetworks/subnets/write",
+							},
+							NotActions: &[]string{},
+						},
+					}, nil)
+			},
+		},
+		{
+			name: "fail: missing permissions",
+			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
+				permissionsClient.EXPECT().
+					ListForResource(gomock.Any(), resourceGroupName, resourceProvider, "", resourceType, vnetName).
+					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
+						cancel()
+					}).
+					Return(
+						[]mgmtauthorization.Permission{
+							{
+								Actions:    &[]string{},
+								NotActions: &[]string{},
+							},
+						},
+						nil,
+					)
+			},
+			wantErr: "400: InvalidServicePrincipalPermissions: : The cluster service principal does not have Network Contributor permission on vnet '" + vnetID + "'.",
+		},
+		{
+			name: "fail: not found",
+			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
+				permissionsClient.EXPECT().
+					ListForResource(gomock.Any(), resourceGroupName, resourceProvider, "", resourceType, vnetName).
+					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
+						cancel()
+					}).
+					Return(
+						nil,
+						autorest.DetailedError{
+							StatusCode: http.StatusNotFound,
+						},
+					)
+			},
+			wantErr: "400: InvalidLinkedVNet: : The vnet '" + vnetID + "' could not be found.",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(ctx)
+			defer cancel()
+
+			permissionsClient := mock_authorization.NewMockPermissionsClient(controller)
+			tt.mocks(permissionsClient, cancel)
+
+			dv := &dynamic{
+				authorizerType: AuthorizerClusterServicePrincipal,
+				log:            logrus.NewEntry(logrus.StandardLogger()),
+				permissions:    permissionsClient,
+			}
+
+			err := dv.validatePermissions(ctx, Subnet{ID: masterSubnet})
+			if err != nil && err.Error() != tt.wantErr ||
+				err == nil && tt.wantErr != "" {
+				t.Error(fmt.Errorf("\n%s\n !=\n%s", err.Error(), tt.wantErr))
+			}
+		})
+	}
+}
+
+func TestGetRouteTableID(t *testing.T) {
+	for _, tt := range []struct {
+		name       string
+		modifyVnet func(*mgmtnetwork.VirtualNetwork)
+		wantErr    string
+	}{
+		{
+			name: "pass",
+		},
+		{
+			name: "pass: no route table",
+			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].RouteTable = nil
+			},
+		},
+		{
+			name: "fail: can't find subnet",
+			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
+				vnet.Subnets = nil
+			},
+			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + masterSubnet + "' could not be found.",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			vnet := &mgmtnetwork.VirtualNetwork{
+				ID: &vnetID,
+				VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
+					Subnets: &[]mgmtnetwork.Subnet{
+						{
+							ID: &masterSubnet,
+							SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
+								RouteTable: &mgmtnetwork.RouteTable{
+									ID: &masterRtID,
+								},
+							},
+						},
+					},
+				},
+			}
+
+			if tt.modifyVnet != nil {
+				tt.modifyVnet(vnet)
+			}
+
+			// purposefully hardcoding path to "" so it is not needed in the wantErr message
+			_, err := getRouteTableID(vnet, masterSubnet)
+			if err != nil && err.Error() != tt.wantErr ||
+				err == nil && tt.wantErr != "" {
+				t.Error(fmt.Errorf("\n%s\n !=\n%s", err.Error(), tt.wantErr))
+			}
+		})
+	}
+}
+
+func TestValidateRouteTablesPermissions(t *testing.T) {
+	ctx := context.Background()
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	for _, tt := range []struct {
+		name            string
+		subnet          Subnet
+		permissionMocks func(*mock_authorization.MockPermissionsClient, func())
+		vnetMocks       func(*mock_network.MockVirtualNetworksClient, mgmtnetwork.VirtualNetwork)
+		wantErr         string
+	}{
+		{
+			name:   "fail: failed to get vnet",
+			subnet: Subnet{ID: masterSubnet},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, errors.New("failed to get vnet"))
+			},
+			wantErr: "failed to get vnet",
+		},
+		{
+			name:   "fail: master subnet doesn't exist",
+			subnet: Subnet{ID: masterSubnet},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnet.Subnets = nil
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + masterSubnet + "' could not be found.",
+		},
+		{
+			name:   "fail: worker subnet ID doesn't exist",
+			subnet: Subnet{ID: workerSubnet},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[1].ID = to.StringPtr("not valid")
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + workerSubnet + "' could not be found.",
+		},
+		{
+			name:   "fail: permissions don't exist",
+			subnet: Subnet{ID: workerSubnet},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			permissionMocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
+				permissionsClient.EXPECT().
+					ListForResource(gomock.Any(), resourceGroupName, "Microsoft.Network", "", "routeTables", gomock.Any()).
+					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
+						cancel()
+					}).
+					Return(
+						[]mgmtauthorization.Permission{
+							{
+								Actions:    &[]string{},
+								NotActions: &[]string{},
+							},
+						},
+						nil,
+					)
+			},
+			wantErr: "400: InvalidServicePrincipalPermissions: : The cluster service principal does not have Network Contributor permission on route table '" + workerRtID + "'.",
+		},
+		{
+			name:   "pass",
+			subnet: Subnet{ID: masterSubnet},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			permissionMocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
+				permissionsClient.EXPECT().
+					ListForResource(gomock.Any(), resourceGroupName, "Microsoft.Network", "", "routeTables", gomock.Any()).
+					AnyTimes().
+					Return([]mgmtauthorization.Permission{
+						{
+							Actions: &[]string{
+								"Microsoft.Network/routeTables/join/action",
+								"Microsoft.Network/routeTables/read",
+								"Microsoft.Network/routeTables/write",
+							},
+							NotActions: &[]string{},
+						},
+					}, nil)
+			},
+		},
+		{
+			name:   "pass: no route table to check",
+			subnet: Subnet{ID: masterSubnet},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].RouteTable = nil
+				(*vnet.Subnets)[1].RouteTable = nil
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(ctx)
+			defer cancel()
+
+			permissionsClient := mock_authorization.NewMockPermissionsClient(controller)
+			vnetClient := mock_network.NewMockVirtualNetworksClient(controller)
+
+			vnet := &mgmtnetwork.VirtualNetwork{
+				ID: &vnetID,
+				VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
+					Subnets: &[]mgmtnetwork.Subnet{
+						{
+							ID: &masterSubnet,
+							SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
+								RouteTable: &mgmtnetwork.RouteTable{
+									ID: &masterRtID,
+								},
+							},
+						},
+						{
+							ID: &workerSubnet,
+							SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
+								RouteTable: &mgmtnetwork.RouteTable{
+									ID: &workerRtID,
+								},
+							},
+						},
+					},
+				},
+			}
+
+			dv := &dynamic{
+				authorizerType:  AuthorizerClusterServicePrincipal,
+				log:             logrus.NewEntry(logrus.StandardLogger()),
+				permissions:     permissionsClient,
+				virtualNetworks: vnetClient,
+			}
+
+			if tt.permissionMocks != nil {
+				tt.permissionMocks(permissionsClient, cancel)
+			}
+
+			if tt.vnetMocks != nil {
+				tt.vnetMocks(vnetClient, *vnet)
+			}
+
+			err := dv.validateRouteTablePermissions(ctx, tt.subnet)
+			if err != nil && err.Error() != tt.wantErr ||
+				err == nil && tt.wantErr != "" {
+				t.Error(fmt.Errorf("\n%s\n !=\n%s", err.Error(), tt.wantErr))
+			}
+		})
+	}
+}
+
+func TestValidateCIDRRanges(t *testing.T) {
+	ctx := context.Background()
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	for _, tt := range []struct {
+		name      string
+		modifyOC  func(*api.OpenShiftCluster)
+		vnetMocks func(*mock_network.MockVirtualNetworksClient, mgmtnetwork.VirtualNetwork)
+		wantErr   string
+	}{
+		{
+			name: "pass",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+		},
+		{
+			name: "fail: conflicting ranges",
+			modifyOC: func(oc *api.OpenShiftCluster) {
+				oc.Properties.NetworkProfile.ServiceCIDR = "10.0.0.0/24"
+			},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: : The provided CIDRs must not overlap: '10.0.0.0/24 overlaps with 10.0.0.0/24'.",
+		},
+	} {
+		oc := &api.OpenShiftCluster{
+			Properties: api.OpenShiftClusterProperties{
+				ClusterProfile: api.ClusterProfile{
+					ResourceGroupID: resourceGroupID,
+				},
+				NetworkProfile: api.NetworkProfile{
+					PodCIDR:     "10.0.2.0/24",
+					ServiceCIDR: "10.0.3.0/24",
+				},
+				MasterProfile: api.MasterProfile{
+					SubnetID: masterSubnet,
+				},
+				WorkerProfiles: []api.WorkerProfile{
+					{
+						SubnetID: workerSubnet,
+					},
+					{
+						SubnetID: workerSubnet,
+					},
+				},
+			},
+		}
+
+		vnet := mgmtnetwork.VirtualNetwork{
+			ID:       &vnetID,
+			Location: to.StringPtr("eastus"),
+			VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
+				Subnets: &[]mgmtnetwork.Subnet{
+					{
+						ID: &masterSubnet,
+						SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
+							AddressPrefix: to.StringPtr("10.0.0.0/24"),
+							NetworkSecurityGroup: &mgmtnetwork.SecurityGroup{
+								ID: &masterNSGv1,
+							},
+							ServiceEndpoints: &[]mgmtnetwork.ServiceEndpointPropertiesFormat{
+								{
+									Service:           to.StringPtr("Microsoft.ContainerRegistry"),
+									ProvisioningState: mgmtnetwork.Succeeded,
+								},
+							},
+							PrivateLinkServiceNetworkPolicies: to.StringPtr("Disabled"),
+						},
+					},
+					{
+						ID: &workerSubnet,
+						SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
+							AddressPrefix: to.StringPtr("10.0.1.0/24"),
+							NetworkSecurityGroup: &mgmtnetwork.SecurityGroup{
+								ID: &workerNSGv1,
+							},
+							ServiceEndpoints: &[]mgmtnetwork.ServiceEndpointPropertiesFormat{
+								{
+									Service:           to.StringPtr("Microsoft.ContainerRegistry"),
+									ProvisioningState: mgmtnetwork.Succeeded,
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		if tt.modifyOC != nil {
+			tt.modifyOC(oc)
+		}
+
+		vnetClient := mock_network.NewMockVirtualNetworksClient(controller)
+		if tt.vnetMocks != nil {
+			tt.vnetMocks(vnetClient, vnet)
+		}
+
+		dv := &dynamic{
+			log:             logrus.NewEntry(logrus.StandardLogger()),
+			virtualNetworks: vnetClient,
+		}
+
+		err := dv.validateCIDRRanges(ctx, []Subnet{
+			{ID: masterSubnet},
+			{ID: workerSubnet}},
+			oc.Properties.NetworkProfile.PodCIDR, oc.Properties.NetworkProfile.ServiceCIDR)
+		if err != nil && err.Error() != tt.wantErr ||
+			err == nil && tt.wantErr != "" {
+			t.Error(err)
+		}
+	}
+}
+
+func TestValidateVnetLocation(t *testing.T) {
+	ctx := context.Background()
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	for _, tt := range []struct {
+		name     string
+		location string
+		wantErr  string
+	}{
+		{
+			name:     "pass",
+			location: "eastus",
+		},
+		{
+			name:     "fail: location differs",
+			location: "neverland",
+			wantErr:  "400: InvalidLinkedVNet: : The vnet location 'neverland' must match the cluster location 'eastus'.",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+
+			vnet := mgmtnetwork.VirtualNetwork{
+				ID:       to.StringPtr(vnetID),
+				Location: to.StringPtr(tt.location),
+			}
+
+			vnetClient := mock_network.NewMockVirtualNetworksClient(controller)
+			vnetClient.EXPECT().
+				Get(gomock.Any(), resourceGroupName, vnetName, "").
+				Return(vnet, nil)
+
+			dv := &dynamic{
+				log:             logrus.NewEntry(logrus.StandardLogger()),
+				virtualNetworks: vnetClient,
+			}
+
+			err := dv.validateLocation(ctx, Subnet{ID: masterSubnet}, "eastus")
+			if err != nil && err.Error() != tt.wantErr ||
+				err == nil && tt.wantErr != "" {
+				t.Error(err)
+			}
+		})
+	}
+}
+
+func TestValidateSubnets(t *testing.T) {
+	ctx := context.Background()
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	for _, tt := range []struct {
+		name      string
+		modifyOC  func(*api.OpenShiftCluster)
+		vnetMocks func(*mock_network.MockVirtualNetworksClient, mgmtnetwork.VirtualNetwork)
+		wantErr   string
+	}{
+		{
+			name: "pass",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+		},
+		{
+			name: "pass (master subnet)",
+			modifyOC: func(oc *api.OpenShiftCluster) {
+				oc.Properties.MasterProfile = api.MasterProfile{
+					SubnetID: masterSubnet,
+				}
+			},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+		},
+		{
+			name: "pass (cluster in creating provisioning status)",
+			modifyOC: func(oc *api.OpenShiftCluster) {
+				oc.Properties.ProvisioningState = api.ProvisioningStateCreating
+			},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].NetworkSecurityGroup = nil
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+		},
+		{
+			name: "fail: subnet doe not exist on vnet",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnet.Subnets = nil
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + masterSubnet + "' could not be found.",
+		},
+		{
+			name: "fail: private link service network policies enabled on master subnet",
+			modifyOC: func(oc *api.OpenShiftCluster) {
+				oc.Properties.MasterProfile = api.MasterProfile{
+					SubnetID: masterSubnet,
+				}
+			},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].PrivateLinkServiceNetworkPolicies = to.StringPtr("Enabled")
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The provided subnet '" + masterSubnet + "' is invalid: must have privateLinkServiceNetworkPolicies disabled.",
+		},
+		{
+			name: "fail: container registry endpoint doesn't exist",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].ServiceEndpoints = nil
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The provided subnet '" + masterSubnet + "' is invalid: must have Microsoft.ContainerRegistry serviceEndpoint.",
+		},
+		{
+			name: "fail: network provisioning state not succeeded",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*(*vnet.Subnets)[0].ServiceEndpoints)[0].ProvisioningState = mgmtnetwork.Failed
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The provided subnet '" + masterSubnet + "' is invalid: must have Microsoft.ContainerRegistry serviceEndpoint.",
+		},
+		{
+			name: "fail: provisioning state creating: subnet has NSG",
+			modifyOC: func(oc *api.OpenShiftCluster) {
+				oc.Properties.ProvisioningState = api.ProvisioningStateCreating
+			},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The provided subnet '" + masterSubnet + "' is invalid: must not have a network security group attached.",
+		},
+		{
+			name: "fail: invalid architecture version returns no NSG",
+			modifyOC: func(oc *api.OpenShiftCluster) {
+				oc.Properties.ArchitectureVersion = 9001
+			},
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "unknown architecture version 9001",
+		},
+		{
+			name: "fail: nsg id doesn't match expected",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].NetworkSecurityGroup.ID = to.StringPtr("not matching")
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The provided subnet '" + masterSubnet + "' is invalid: must have network security group '" + masterNSGv1 + "' attached.",
+		},
+		{
+			name: "fail: invalid subnet CIDR",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].AddressPrefix = to.StringPtr("not-valid")
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "invalid CIDR address: not-valid",
+		},
+		{
+			name: "fail: too small subnet CIDR",
+			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
+				(*vnet.Subnets)[0].AddressPrefix = to.StringPtr("10.0.0.0/28")
+				vnetClient.EXPECT().
+					Get(gomock.Any(), resourceGroupName, vnetName, "").
+					Return(vnet, nil)
+			},
+			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The provided subnet '" + masterSubnet + "' is invalid: must be /27 or larger.",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			oc := &api.OpenShiftCluster{
+				Properties: api.OpenShiftClusterProperties{
+					ClusterProfile: api.ClusterProfile{
+						ResourceGroupID: resourceGroupID,
+					},
+				},
+			}
+			vnet := mgmtnetwork.VirtualNetwork{
+				ID: &vnetID,
+				VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
+					Subnets: &[]mgmtnetwork.Subnet{
+						{
+							ID: &masterSubnet,
+							SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
+								AddressPrefix: to.StringPtr("10.0.0.0/24"),
+								NetworkSecurityGroup: &mgmtnetwork.SecurityGroup{
+									ID: &masterNSGv1,
+								},
+								ServiceEndpoints: &[]mgmtnetwork.ServiceEndpointPropertiesFormat{
+									{
+										Service:           to.StringPtr("Microsoft.ContainerRegistry"),
+										ProvisioningState: mgmtnetwork.Succeeded,
+									},
+								},
+								PrivateLinkServiceNetworkPolicies: to.StringPtr("Disabled"),
+							},
+						},
+					},
+				},
+			}
+
+			if tt.modifyOC != nil {
+				tt.modifyOC(oc)
+			}
+			vnetClient := mock_network.NewMockVirtualNetworksClient(controller)
+			if tt.vnetMocks != nil {
+				tt.vnetMocks(vnetClient, vnet)
+			}
+			dv := &dynamic{
+				virtualNetworks: vnetClient,
+				log:             logrus.NewEntry(logrus.StandardLogger()),
+			}
+
+			err := dv.ValidateSubnets(ctx, oc, []Subnet{{ID: masterSubnet, Path: masterSubnetPath}})
+			if err != nil && err.Error() != tt.wantErr ||
+				err == nil && tt.wantErr != "" {
+				t.Error(fmt.Errorf("\n%s\n !=\n%s", err.Error(), tt.wantErr))
+			}
+		})
+	}
+}
diff --git a/pkg/api/validate/dynamic/provider_test.go b/pkg/api/validate/dynamic/provider_test.go
new file mode 100644
index 000000000..c558129b5
--- /dev/null
+++ b/pkg/api/validate/dynamic/provider_test.go
@@ -0,0 +1,154 @@
+package dynamic
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	mgmtfeatures "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-07-01/features"
+	"github.com/Azure/go-autorest/autorest/to"
+	"github.com/golang/mock/gomock"
+	"github.com/sirupsen/logrus"
+
+	mock_features "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/features"
+)
+
+func TestValidateProviders(t *testing.T) {
+	ctx := context.Background()
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	for _, tt := range []struct {
+		name    string
+		mocks   func(*mock_features.MockProvidersClient)
+		wantErr string
+	}{
+		{
+			name: "pass",
+			mocks: func(providersClient *mock_features.MockProvidersClient) {
+				providersClient.EXPECT().
+					List(gomock.Any(), nil, "").
+					Return([]mgmtfeatures.Provider{
+						{
+							Namespace:         to.StringPtr("Microsoft.Authorization"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Compute"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Network"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Storage"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("otherRegisteredProvider"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("otherNotRegisteredProvider"),
+							RegistrationState: to.StringPtr("NotRegistered"),
+						},
+					}, nil)
+			},
+		},
+		{
+			name: "fail: compute not registered",
+			mocks: func(providersClient *mock_features.MockProvidersClient) {
+				providersClient.EXPECT().
+					List(gomock.Any(), nil, "").
+					Return([]mgmtfeatures.Provider{
+						{
+							Namespace:         to.StringPtr("Microsoft.Authorization"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Compute"),
+							RegistrationState: to.StringPtr("NotRegistered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Network"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Storage"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("otherRegisteredProvider"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("otherNotRegisteredProvider"),
+							RegistrationState: to.StringPtr("NotRegistered"),
+						},
+					}, nil)
+			},
+			wantErr: "400: ResourceProviderNotRegistered: : The resource provider 'Microsoft.Compute' is not registered.",
+		},
+		{
+			name: "fail: storage missing",
+			mocks: func(providersClient *mock_features.MockProvidersClient) {
+				providersClient.EXPECT().
+					List(gomock.Any(), nil, "").
+					Return([]mgmtfeatures.Provider{
+						{
+							Namespace:         to.StringPtr("Microsoft.Authorization"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Compute"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("Microsoft.Network"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("otherRegisteredProvider"),
+							RegistrationState: to.StringPtr("Registered"),
+						},
+						{
+							Namespace:         to.StringPtr("otherNotRegisteredProvider"),
+							RegistrationState: to.StringPtr("NotRegistered"),
+						},
+					}, nil)
+			},
+			wantErr: "400: ResourceProviderNotRegistered: : The resource provider 'Microsoft.Storage' is not registered.",
+		},
+		{
+			name: "error case",
+			mocks: func(providersClient *mock_features.MockProvidersClient) {
+				providersClient.EXPECT().
+					List(gomock.Any(), nil, "").
+					Return(nil, errors.New("random error"))
+			},
+			wantErr: "random error",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			providerClient := mock_features.NewMockProvidersClient(controller)
+
+			tt.mocks(providerClient)
+
+			dv := &dynamic{
+				log:       logrus.NewEntry(logrus.StandardLogger()),
+				providers: providerClient,
+			}
+
+			err := dv.ValidateProviders(ctx)
+			if err != nil && err.Error() != tt.wantErr ||
+				err == nil && tt.wantErr != "" {
+				t.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/api/validate/quota_test.go b/pkg/api/validate/dynamic/quota_test.go
similarity index 89%
rename from pkg/api/validate/quota_test.go
rename to pkg/api/validate/dynamic/quota_test.go
index 12face1b6..1649c5da4 100644
--- a/pkg/api/validate/quota_test.go
+++ b/pkg/api/validate/dynamic/quota_test.go
@@ -1,4 +1,4 @@
-package validate
+package dynamic
 
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
@@ -16,7 +16,7 @@ import (
 	mock_compute "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/compute"
 )
 
-func TestQuotaCheck(t *testing.T) {
+func TestValidateQuota(t *testing.T) {
 	ctx := context.Background()
 
 	type test struct {
@@ -140,26 +140,30 @@ func TestQuotaCheck(t *testing.T) {
 				tt.mocks(tt, usageClient)
 			}
 
-			qv := quotaValidator{
-				log: logrus.NewEntry(logrus.StandardLogger()),
-				oc: &api.OpenShiftCluster{
-					Location: "ocLocation",
-					Properties: api.OpenShiftClusterProperties{
-						MasterProfile: api.MasterProfile{
+			oc := &api.OpenShiftCluster{
+				Location: "ocLocation",
+				Properties: api.OpenShiftClusterProperties{
+					Install: &api.Install{
+						Phase: api.InstallPhaseBootstrap,
+					},
+					MasterProfile: api.MasterProfile{
+						VMSize: "Standard_D8s_v3",
+					},
+					WorkerProfiles: []api.WorkerProfile{
+						{
 							VMSize: "Standard_D8s_v3",
-						},
-						WorkerProfiles: []api.WorkerProfile{
-							{
-								VMSize: "Standard_D8s_v3",
-								Count:  10,
-							},
+							Count:  10,
 						},
 					},
 				},
+			}
+
+			qv := dynamic{
+				log:     logrus.NewEntry(logrus.StandardLogger()),
 				spUsage: usageClient,
 			}
 
-			err := qv.Validate(ctx)
+			err := qv.ValidateQuota(ctx, oc)
 			if err != nil && err.Error() != tt.wantErr ||
 				err == nil && tt.wantErr != "" {
 				t.Error(err)
diff --git a/pkg/api/validate/dynamic_test.go b/pkg/api/validate/dynamic_test.go
deleted file mode 100644
index 4a7c892c2..000000000
--- a/pkg/api/validate/dynamic_test.go
+++ /dev/null
@@ -1,455 +0,0 @@
-package validate
-
-// Copyright (c) Microsoft Corporation.
-// Licensed under the Apache License 2.0.
-
-import (
-	"context"
-	"errors"
-	"net/http"
-	"strings"
-	"testing"
-
-	mgmtnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-07-01/network"
-	mgmtauthorization "github.com/Azure/azure-sdk-for-go/services/preview/authorization/mgmt/2018-09-01-preview/authorization"
-	"github.com/Azure/go-autorest/autorest"
-	"github.com/Azure/go-autorest/autorest/azure"
-	"github.com/Azure/go-autorest/autorest/to"
-	"github.com/golang/mock/gomock"
-	"github.com/sirupsen/logrus"
-
-	mock_authorization "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/authorization"
-	mock_network "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/network"
-)
-
-func TestValidateVnetPermissions(t *testing.T) {
-	ctx := context.Background()
-
-	resourceGroupName := "testGroup"
-	vnetName := "testVnet"
-	subscriptionID := "0000000-0000-0000-0000-000000000000"
-	vnetID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroupName + "/providers/Microsoft.Network/virtualNetworks/" + vnetName
-	resourceType := "virtualNetworks"
-	resourceProvider := "Microsoft.Network"
-
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	for _, tt := range []struct {
-		name    string
-		mocks   func(*mock_authorization.MockPermissionsClient, func())
-		wantErr string
-	}{
-		{
-			name: "pass",
-			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), resourceGroupName, resourceProvider, "", resourceType, vnetName).
-					Return([]mgmtauthorization.Permission{
-						{
-							Actions: &[]string{
-								"Microsoft.Network/virtualNetworks/join/action",
-								"Microsoft.Network/virtualNetworks/read",
-								"Microsoft.Network/virtualNetworks/write",
-								"Microsoft.Network/virtualNetworks/subnets/join/action",
-								"Microsoft.Network/virtualNetworks/subnets/read",
-								"Microsoft.Network/virtualNetworks/subnets/write",
-							},
-							NotActions: &[]string{},
-						},
-					}, nil)
-			},
-		},
-		{
-			name: "fail: missing permissions",
-			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), resourceGroupName, resourceProvider, "", resourceType, vnetName).
-					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
-						cancel()
-					}).
-					Return(
-						[]mgmtauthorization.Permission{
-							{
-								Actions:    &[]string{},
-								NotActions: &[]string{},
-							},
-						},
-						nil,
-					)
-			},
-			wantErr: "400: InvalidResourceProviderPermissions: : The resource provider does not have Network Contributor permission on vnet '" + vnetID + "'.",
-		},
-		{
-			name: "fail: not found",
-			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), resourceGroupName, resourceProvider, "", resourceType, vnetName).
-					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
-						cancel()
-					}).
-					Return(
-						nil,
-						autorest.DetailedError{
-							StatusCode: http.StatusNotFound,
-						},
-					)
-			},
-			wantErr: "400: InvalidLinkedVNet: : The vnet '" + vnetID + "' could not be found.",
-		},
-	} {
-		t.Run(tt.name, func(t *testing.T) {
-			ctx, cancel := context.WithCancel(ctx)
-			defer cancel()
-
-			permissionsClient := mock_authorization.NewMockPermissionsClient(controller)
-			tt.mocks(permissionsClient, cancel)
-
-			dv := &dynamic{
-				log:         logrus.NewEntry(logrus.StandardLogger()),
-				permissions: permissionsClient,
-				vnetr: &azure.Resource{
-					ResourceGroup:  resourceGroupName,
-					ResourceType:   resourceType,
-					Provider:       resourceProvider,
-					ResourceName:   vnetName,
-					SubscriptionID: subscriptionID,
-				},
-				code: "InvalidResourceProviderPermissions",
-				typ:  "resource provider",
-			}
-
-			err := dv.ValidateVnetPermissions(ctx)
-			if err != nil && err.Error() != tt.wantErr ||
-				err == nil && tt.wantErr != "" {
-				t.Error(err)
-			}
-		})
-	}
-}
-
-func TestGetRouteTableID(t *testing.T) {
-	resourceGroupID := "/subscriptions/0000000-0000-0000-0000-000000000000/resourceGroups/testGroup"
-	vnetID := resourceGroupID + "/providers/Microsoft.Network/virtualNetworks/testVnet"
-	genericSubnet := vnetID + "/subnet/genericSubnet"
-	routeTableID := resourceGroupID + "/providers/Microsoft.Network/routeTables/testRouteTable"
-
-	for _, tt := range []struct {
-		name       string
-		modifyVnet func(*mgmtnetwork.VirtualNetwork)
-		wantErr    string
-	}{
-		{
-			name: "pass",
-		},
-		{
-			name: "pass: no route table",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].RouteTable = nil
-			},
-		},
-		{
-			name: "fail: can't find subnet",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				vnet.Subnets = nil
-			},
-			wantErr: "400: InvalidLinkedVNet: : The subnet '" + genericSubnet + "' could not be found.",
-		},
-	} {
-		vnet := &mgmtnetwork.VirtualNetwork{
-			ID: &vnetID,
-			VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
-				Subnets: &[]mgmtnetwork.Subnet{
-					{
-						ID: &genericSubnet,
-						SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
-							RouteTable: &mgmtnetwork.RouteTable{
-								ID: &routeTableID,
-							},
-						},
-					},
-				},
-			},
-		}
-
-		if tt.modifyVnet != nil {
-			tt.modifyVnet(vnet)
-		}
-
-		// purposefully hardcoding path to "" so it is not needed in the wantErr message
-		_, err := getRouteTableID(vnet, "", genericSubnet)
-		if err != nil && err.Error() != tt.wantErr ||
-			err == nil && tt.wantErr != "" {
-			t.Error(err)
-		}
-	}
-}
-
-func TestValidateRouteTablePermissions(t *testing.T) {
-	ctx := context.Background()
-
-	resourceGroupName := "testGroup"
-	resourceGroupID := "/subscriptions/0000000-0000-0000-0000-000000000000/resourceGroups/" + resourceGroupName
-	routeTableName := "testRouteTable"
-	routeTableID := resourceGroupID + "/providers/Microsoft.Network/routeTables/" + routeTableName
-
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	for _, tt := range []struct {
-		name    string
-		rtID    string
-		mocks   func(*mock_authorization.MockPermissionsClient, func())
-		wantErr string
-	}{
-		{
-			name: "pass",
-			rtID: routeTableID,
-			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), resourceGroupName, "Microsoft.Network", "", "routeTables", routeTableName).
-					Return([]mgmtauthorization.Permission{
-						{
-							Actions: &[]string{
-								"Microsoft.Network/routeTables/join/action",
-								"Microsoft.Network/routeTables/read",
-								"Microsoft.Network/routeTables/write",
-							},
-							NotActions: &[]string{},
-						},
-					}, nil)
-			},
-		},
-		{
-			name:    "fail: cannot parse resource id",
-			rtID:    "invalid_route_table_id",
-			wantErr: "parsing failed for invalid_route_table_id. Invalid resource Id format",
-		},
-		{
-			name: "fail: missing permissions",
-			rtID: routeTableID,
-			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), resourceGroupName, "Microsoft.Network", "", "routeTables", routeTableName).
-					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
-						cancel()
-					}).
-					Return([]mgmtauthorization.Permission{
-						{
-							Actions:    &[]string{},
-							NotActions: &[]string{},
-						},
-					}, nil)
-			},
-			wantErr: "400: InvalidResourceProviderPermissions: : The resource provider does not have Network Contributor permission on route table '" + routeTableID + "'.",
-		},
-		{
-			name: "fail: not found",
-			rtID: routeTableID,
-			mocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), resourceGroupName, "Microsoft.Network", "", "routeTables", routeTableName).
-					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
-						cancel()
-					}).
-					Return(
-						nil,
-						autorest.DetailedError{
-							StatusCode: http.StatusNotFound,
-						},
-					)
-			},
-			wantErr: "400: InvalidLinkedRouteTable: : The route table '" + routeTableID + "' could not be found.",
-		},
-	} {
-		ctx, cancel := context.WithCancel(ctx)
-		defer cancel()
-
-		permissionsClient := mock_authorization.NewMockPermissionsClient(controller)
-		if tt.mocks != nil {
-			tt.mocks(permissionsClient, cancel)
-		}
-
-		dv := &dynamic{
-			log:         logrus.NewEntry(logrus.StandardLogger()),
-			permissions: permissionsClient,
-			code:        "InvalidResourceProviderPermissions",
-			typ:         "resource provider",
-		}
-
-		// purposefully hardcoding path to "" so it is not needed in the wantErr message
-		err := dv.validateRouteTablePermissions(ctx, tt.rtID, "")
-		if err != nil && err.Error() != tt.wantErr ||
-			err == nil && tt.wantErr != "" {
-			t.Error(err)
-		}
-	}
-}
-
-func TestValidateRouteTablesPermissions(t *testing.T) {
-	ctx := context.Background()
-
-	subscriptionID := "0000000-0000-0000-0000-000000000000"
-	resourceGroupName := "testGroup"
-	resourceGroupID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroupName
-	vnetName := "testVnet"
-	vnetID := resourceGroupID + "/providers/Microsoft.Network/virtualNetworks/" + vnetName
-	masterSubnet := vnetID + "/subnet/masterSubnet"
-	workerSubnet := vnetID + "/subnet/workerSubnet"
-	masterRtID := resourceGroupID + "/providers/Microsoft.Network/routeTables/masterRt"
-	workerRtID := resourceGroupID + "/providers/Microsoft.Network/routeTables/workerRt"
-
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	for _, tt := range []struct {
-		name            string
-		permissionMocks func(*mock_authorization.MockPermissionsClient, func())
-		vnetMocks       func(*mock_network.MockVirtualNetworksClient, mgmtnetwork.VirtualNetwork)
-		wantErr         string
-	}{
-		{
-			name: "fail: failed to get vnet",
-			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
-				vnetClient.EXPECT().
-					Get(gomock.Any(), resourceGroupName, vnetName, "").
-					Return(vnet, errors.New("failed to get vnet"))
-			},
-			wantErr: "failed to get vnet",
-		},
-		{
-			name: "fail: master subnet doesn't exist",
-			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
-				vnet.Subnets = nil
-				vnetClient.EXPECT().
-					Get(gomock.Any(), resourceGroupName, vnetName, "").
-					Return(vnet, nil)
-			},
-			wantErr: "400: InvalidLinkedVNet: properties.masterProfile.subnetId: The subnet '" + masterSubnet + "' could not be found.",
-		},
-		{
-			name: "fail: worker subnet ID doesn't exist",
-			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[1].ID = to.StringPtr("not valid")
-				vnetClient.EXPECT().
-					Get(gomock.Any(), resourceGroupName, vnetName, "").
-					Return(vnet, nil)
-			},
-			wantErr: "400: InvalidLinkedVNet: properties.workerProfiles[0].subnetId: The subnet '" + workerSubnet + "' could not be found.",
-		},
-		{
-			name: "fail: permissions don't exist",
-			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
-				vnetClient.EXPECT().
-					Get(gomock.Any(), resourceGroupName, vnetName, "").
-					Return(vnet, nil)
-			},
-			permissionMocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), strings.ToLower(resourceGroupName), strings.ToLower("Microsoft.Network"), "", strings.ToLower("routeTables"), gomock.Any()).
-					Do(func(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) {
-						cancel()
-					}).
-					Return(
-						[]mgmtauthorization.Permission{
-							{
-								Actions:    &[]string{},
-								NotActions: &[]string{},
-							},
-						},
-						nil,
-					)
-			},
-			wantErr: "400: InvalidResourceProviderPermissions: : The resource provider does not have Network Contributor permission on route table '" + strings.ToLower(masterRtID) + "'.",
-		},
-		{
-			name: "pass",
-			vnetMocks: func(vnetClient *mock_network.MockVirtualNetworksClient, vnet mgmtnetwork.VirtualNetwork) {
-				vnetClient.EXPECT().
-					Get(gomock.Any(), resourceGroupName, vnetName, "").
-					Return(vnet, nil)
-			},
-			permissionMocks: func(permissionsClient *mock_authorization.MockPermissionsClient, cancel func()) {
-				permissionsClient.EXPECT().
-					ListForResource(gomock.Any(), strings.ToLower(resourceGroupName), strings.ToLower("Microsoft.Network"), "", strings.ToLower("routeTables"), gomock.Any()).
-					AnyTimes().
-					Return([]mgmtauthorization.Permission{
-						{
-							Actions: &[]string{
-								"Microsoft.Network/routeTables/join/action",
-								"Microsoft.Network/routeTables/read",
-								"Microsoft.Network/routeTables/write",
-							},
-							NotActions: &[]string{},
-						},
-					}, nil)
-			},
-		},
-	} {
-		t.Run(tt.name, func(t *testing.T) {
-			ctx, cancel := context.WithCancel(ctx)
-			defer cancel()
-
-			permissionsClient := mock_authorization.NewMockPermissionsClient(controller)
-			vnetClient := mock_network.NewMockVirtualNetworksClient(controller)
-
-			vnet := &mgmtnetwork.VirtualNetwork{
-				ID: &vnetID,
-				VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
-					Subnets: &[]mgmtnetwork.Subnet{
-						{
-							ID: &masterSubnet,
-							SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
-								RouteTable: &mgmtnetwork.RouteTable{
-									ID: &masterRtID,
-								},
-							},
-						},
-						{
-							ID: &workerSubnet,
-							SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
-								RouteTable: &mgmtnetwork.RouteTable{
-									ID: &workerRtID,
-								},
-							},
-						},
-					},
-				},
-			}
-
-			dv := &dynamic{
-				log:             logrus.NewEntry(logrus.StandardLogger()),
-				permissions:     permissionsClient,
-				virtualNetworks: vnetClient,
-
-				vnetr: &azure.Resource{
-					ResourceGroup:  resourceGroupName,
-					ResourceName:   vnetName,
-					SubscriptionID: subscriptionID,
-					Provider:       "Microsoft.Network",
-					ResourceType:   "virtualNetworks",
-				},
-
-				masterSubnetID:  masterSubnet,
-				workerSubnetIDs: []string{workerSubnet},
-
-				code: "InvalidResourceProviderPermissions",
-				typ:  "resource provider",
-			}
-
-			if tt.permissionMocks != nil {
-				tt.permissionMocks(permissionsClient, cancel)
-			}
-
-			if tt.vnetMocks != nil {
-				tt.vnetMocks(vnetClient, *vnet)
-			}
-
-			err := dv.ValidateRouteTablesPermissions(ctx)
-			if err != nil && err.Error() != tt.wantErr ||
-				err == nil && tt.wantErr != "" {
-				t.Error(err)
-			}
-		})
-	}
-}
diff --git a/pkg/api/validate/openshiftcluster_validatedynamic_test.go b/pkg/api/validate/openshiftcluster_validatedynamic_test.go
deleted file mode 100644
index f6ca3fe36..000000000
--- a/pkg/api/validate/openshiftcluster_validatedynamic_test.go
+++ /dev/null
@@ -1,459 +0,0 @@
-package validate
-
-// Copyright (c) Microsoft Corporation.
-// Licensed under the Apache License 2.0.
-
-import (
-	"context"
-	"errors"
-	"testing"
-
-	mgmtnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-07-01/network"
-	mgmtfeatures "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-07-01/features"
-	"github.com/Azure/go-autorest/autorest/to"
-	"github.com/golang/mock/gomock"
-	"github.com/sirupsen/logrus"
-
-	"github.com/Azure/ARO-RP/pkg/api"
-	mock_features "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/features"
-)
-
-func TestValidateProviders(t *testing.T) {
-	ctx := context.Background()
-
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	for _, tt := range []struct {
-		name    string
-		mocks   func(*mock_features.MockProvidersClient)
-		wantErr string
-	}{
-		{
-			name: "pass",
-			mocks: func(providersClient *mock_features.MockProvidersClient) {
-				providersClient.EXPECT().
-					List(gomock.Any(), nil, "").
-					Return([]mgmtfeatures.Provider{
-						{
-							Namespace:         to.StringPtr("Microsoft.Authorization"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Compute"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Network"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Storage"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("otherRegisteredProvider"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("otherNotRegisteredProvider"),
-							RegistrationState: to.StringPtr("NotRegistered"),
-						},
-					}, nil)
-			},
-		},
-		{
-			name: "fail: compute not registered",
-			mocks: func(providersClient *mock_features.MockProvidersClient) {
-				providersClient.EXPECT().
-					List(gomock.Any(), nil, "").
-					Return([]mgmtfeatures.Provider{
-						{
-							Namespace:         to.StringPtr("Microsoft.Authorization"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Compute"),
-							RegistrationState: to.StringPtr("NotRegistered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Network"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Storage"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("otherRegisteredProvider"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("otherNotRegisteredProvider"),
-							RegistrationState: to.StringPtr("NotRegistered"),
-						},
-					}, nil)
-			},
-			wantErr: "400: ResourceProviderNotRegistered: : The resource provider 'Microsoft.Compute' is not registered.",
-		},
-		{
-			name: "fail: storage missing",
-			mocks: func(providersClient *mock_features.MockProvidersClient) {
-				providersClient.EXPECT().
-					List(gomock.Any(), nil, "").
-					Return([]mgmtfeatures.Provider{
-						{
-							Namespace:         to.StringPtr("Microsoft.Authorization"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Compute"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("Microsoft.Network"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("otherRegisteredProvider"),
-							RegistrationState: to.StringPtr("Registered"),
-						},
-						{
-							Namespace:         to.StringPtr("otherNotRegisteredProvider"),
-							RegistrationState: to.StringPtr("NotRegistered"),
-						},
-					}, nil)
-			},
-			wantErr: "400: ResourceProviderNotRegistered: : The resource provider 'Microsoft.Storage' is not registered.",
-		},
-		{
-			name: "error case",
-			mocks: func(providersClient *mock_features.MockProvidersClient) {
-				providersClient.EXPECT().
-					List(gomock.Any(), nil, "").
-					Return(nil, errors.New("random error"))
-			},
-			wantErr: "random error",
-		},
-	} {
-		t.Run(tt.name, func(t *testing.T) {
-			providerClient := mock_features.NewMockProvidersClient(controller)
-
-			tt.mocks(providerClient)
-
-			dv := &openShiftClusterDynamicValidator{
-				log:       logrus.NewEntry(logrus.StandardLogger()),
-				providers: providerClient,
-			}
-
-			err := dv.validateProviders(ctx)
-			if err != nil && err.Error() != tt.wantErr ||
-				err == nil && tt.wantErr != "" {
-				t.Error(err)
-			}
-		})
-	}
-}
-
-func TestValidateVnetLocation(t *testing.T) {
-	ctx := context.Background()
-
-	for _, tt := range []struct {
-		name     string
-		location string
-		wantErr  string
-	}{
-		{
-			name:     "pass",
-			location: "eastus",
-		},
-		{
-			name:     "fail: location differs",
-			location: "neverland",
-			wantErr:  "400: InvalidLinkedVNet: : The vnet location 'neverland' must match the cluster location 'eastus'.",
-		},
-	} {
-		t.Run(tt.name, func(t *testing.T) {
-			dv := &openShiftClusterDynamicValidator{
-				log: logrus.NewEntry(logrus.StandardLogger()),
-				oc: &api.OpenShiftCluster{
-					Location: "eastus",
-				},
-			}
-
-			vnet := &mgmtnetwork.VirtualNetwork{
-				Location: to.StringPtr(tt.location),
-			}
-
-			err := dv.validateVnetLocation(ctx, vnet)
-			if err != nil && err.Error() != tt.wantErr ||
-				err == nil && tt.wantErr != "" {
-				t.Error(err)
-			}
-		})
-	}
-}
-
-func TestValidateSubnet(t *testing.T) {
-	ctx := context.Background()
-
-	resourceGroupID := "/subscriptions/0000000-0000-0000-0000-000000000000/resourceGroups/testGroup"
-	vnetID := resourceGroupID + "/providers/Microsoft.Network/virtualNetworks/testVnet"
-	genericSubnet := vnetID + "/subnet/genericSubnet"
-	masterNSGv1 := resourceGroupID + "/providers/Microsoft.Network/networkSecurityGroups/aro-controlplane-nsg"
-
-	for _, tt := range []struct {
-		name       string
-		modifyOC   func(*api.OpenShiftCluster)
-		modifyVnet func(*mgmtnetwork.VirtualNetwork)
-		wantErr    string
-	}{
-		{
-			name: "pass",
-		},
-		{
-			name: "pass (master subnet)",
-			modifyOC: func(oc *api.OpenShiftCluster) {
-				oc.Properties.MasterProfile = api.MasterProfile{
-					SubnetID: genericSubnet,
-				}
-			},
-		},
-		{
-			name: "pass (cluster in creating provisioning status)",
-			modifyOC: func(oc *api.OpenShiftCluster) {
-				oc.Properties.ProvisioningState = api.ProvisioningStateCreating
-			},
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].NetworkSecurityGroup = nil
-			},
-		},
-		{
-			name: "fail: subnet doe not exist on vnet",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				vnet.Subnets = nil
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' could not be found.",
-		},
-		{
-			name: "fail: private link service network policies enabled on master subnet",
-			modifyOC: func(oc *api.OpenShiftCluster) {
-				oc.Properties.MasterProfile = api.MasterProfile{
-					SubnetID: genericSubnet,
-				}
-			},
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].PrivateLinkServiceNetworkPolicies = to.StringPtr("Enabled")
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' is invalid: must have privateLinkServiceNetworkPolicies disabled.",
-		},
-		{
-			name: "fail: container registry endpoint doesn't exist",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].ServiceEndpoints = nil
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' is invalid: must have Microsoft.ContainerRegistry serviceEndpoint.",
-		},
-		{
-			name: "fail: network provisioning state not succeeded",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*(*vnet.Subnets)[0].ServiceEndpoints)[0].ProvisioningState = mgmtnetwork.Failed
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' is invalid: must have Microsoft.ContainerRegistry serviceEndpoint.",
-		},
-		{
-			name: "fail: provisioning state creating: subnet has NSG",
-			modifyOC: func(oc *api.OpenShiftCluster) {
-				oc.Properties.ProvisioningState = api.ProvisioningStateCreating
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' is invalid: must not have a network security group attached.",
-		},
-		{
-			name: "fail: invalid architecture version returns no NSG",
-			modifyOC: func(oc *api.OpenShiftCluster) {
-				oc.Properties.ArchitectureVersion = 9001
-			},
-			wantErr: "unknown architecture version 9001",
-		},
-		{
-			name: "fail: nsg id doesn't match expected",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].NetworkSecurityGroup.ID = to.StringPtr("not matching")
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' is invalid: must have network security group '" + masterNSGv1 + "' attached.",
-		},
-		{
-			name: "fail: invalid subnet CIDR",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].AddressPrefix = to.StringPtr("not-valid")
-			},
-			wantErr: "invalid CIDR address: not-valid",
-		},
-		{
-			name: "fail: too small subnet CIDR",
-			modifyVnet: func(vnet *mgmtnetwork.VirtualNetwork) {
-				(*vnet.Subnets)[0].AddressPrefix = to.StringPtr("10.0.0.0/28")
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided subnet '" + genericSubnet + "' is invalid: must be /27 or larger.",
-		},
-	} {
-		oc := &api.OpenShiftCluster{
-			Properties: api.OpenShiftClusterProperties{
-				ClusterProfile: api.ClusterProfile{
-					ResourceGroupID: resourceGroupID,
-				},
-			},
-		}
-		vnet := &mgmtnetwork.VirtualNetwork{
-			ID: &vnetID,
-			VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
-				Subnets: &[]mgmtnetwork.Subnet{
-					{
-						ID: &genericSubnet,
-						SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
-							AddressPrefix: to.StringPtr("10.0.0.0/24"),
-							NetworkSecurityGroup: &mgmtnetwork.SecurityGroup{
-								ID: &masterNSGv1,
-							},
-							ServiceEndpoints: &[]mgmtnetwork.ServiceEndpointPropertiesFormat{
-								{
-									Service:           to.StringPtr("Microsoft.ContainerRegistry"),
-									ProvisioningState: mgmtnetwork.Succeeded,
-								},
-							},
-							PrivateLinkServiceNetworkPolicies: to.StringPtr("Disabled"),
-						},
-					},
-				},
-			},
-		}
-
-		if tt.modifyOC != nil {
-			tt.modifyOC(oc)
-		}
-		if tt.modifyVnet != nil {
-			tt.modifyVnet(vnet)
-		}
-
-		dv := &openShiftClusterDynamicValidator{
-			log: logrus.NewEntry(logrus.StandardLogger()),
-			oc:  oc,
-		}
-
-		// purposefully hardcoding path to "" so it is not needed in the wantErr message
-		_, err := dv.validateSubnet(ctx, vnet, "", genericSubnet)
-		if err != nil && err.Error() != tt.wantErr ||
-			err == nil && tt.wantErr != "" {
-			t.Error(err)
-		}
-	}
-}
-
-func TestValidateCIDRRanges(t *testing.T) {
-	ctx := context.Background()
-
-	resourceGroupID := "/subscriptions/0000000-0000-0000-0000-000000000000/resourceGroups/testGroup"
-	vnetID := resourceGroupID + "/providers/Microsoft.Network/virtualNetworks/testVnet"
-	masterSubnet := vnetID + "/subnet/masterSubnet"
-	workerSubnet := vnetID + "/subnet/workerSubnet"
-	masterNSGv1 := resourceGroupID + "/providers/Microsoft.Network/networkSecurityGroups/aro-controlplane-nsg"
-	workerNSGv1 := resourceGroupID + "/providers/Microsoft.Network/networkSecurityGroups/aro-node-nsg"
-
-	for _, tt := range []struct {
-		name       string
-		modifyOC   func(*api.OpenShiftCluster)
-		modifyVnet func(*mgmtnetwork.VirtualNetwork)
-		wantErr    string
-	}{
-		{
-			name: "pass",
-		},
-		{
-			name: "fail: conflicting ranges",
-			modifyOC: func(oc *api.OpenShiftCluster) {
-				oc.Properties.NetworkProfile.ServiceCIDR = "10.0.0.0/24"
-			},
-			wantErr: "400: InvalidLinkedVNet: : The provided CIDRs must not overlap: '10.0.0.0/24 overlaps with 10.0.0.0/24'.",
-		},
-	} {
-		oc := &api.OpenShiftCluster{
-			Properties: api.OpenShiftClusterProperties{
-				ClusterProfile: api.ClusterProfile{
-					ResourceGroupID: resourceGroupID,
-				},
-				NetworkProfile: api.NetworkProfile{
-					PodCIDR:     "10.0.2.0/24",
-					ServiceCIDR: "10.0.3.0/24",
-				},
-				MasterProfile: api.MasterProfile{
-					SubnetID: masterSubnet,
-				},
-				WorkerProfiles: []api.WorkerProfile{
-					{
-						SubnetID: workerSubnet,
-					},
-					{
-						SubnetID: workerSubnet,
-					},
-				},
-			},
-		}
-
-		vnet := &mgmtnetwork.VirtualNetwork{
-			ID: &vnetID,
-			VirtualNetworkPropertiesFormat: &mgmtnetwork.VirtualNetworkPropertiesFormat{
-				Subnets: &[]mgmtnetwork.Subnet{
-					{
-						ID: &masterSubnet,
-						SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
-							AddressPrefix: to.StringPtr("10.0.0.0/24"),
-							NetworkSecurityGroup: &mgmtnetwork.SecurityGroup{
-								ID: &masterNSGv1,
-							},
-							ServiceEndpoints: &[]mgmtnetwork.ServiceEndpointPropertiesFormat{
-								{
-									Service:           to.StringPtr("Microsoft.ContainerRegistry"),
-									ProvisioningState: mgmtnetwork.Succeeded,
-								},
-							},
-							PrivateLinkServiceNetworkPolicies: to.StringPtr("Disabled"),
-						},
-					},
-					{
-						ID: &workerSubnet,
-						SubnetPropertiesFormat: &mgmtnetwork.SubnetPropertiesFormat{
-							AddressPrefix: to.StringPtr("10.0.1.0/24"),
-							NetworkSecurityGroup: &mgmtnetwork.SecurityGroup{
-								ID: &workerNSGv1,
-							},
-							ServiceEndpoints: &[]mgmtnetwork.ServiceEndpointPropertiesFormat{
-								{
-									Service:           to.StringPtr("Microsoft.ContainerRegistry"),
-									ProvisioningState: mgmtnetwork.Succeeded,
-								},
-							},
-						},
-					},
-				},
-			},
-		}
-
-		if tt.modifyOC != nil {
-			tt.modifyOC(oc)
-		}
-		if tt.modifyVnet != nil {
-			tt.modifyVnet(vnet)
-		}
-
-		dv := &openShiftClusterDynamicValidator{
-			log: logrus.NewEntry(logrus.StandardLogger()),
-			oc:  oc,
-		}
-
-		err := dv.validateCIDRRanges(ctx, vnet)
-		if err != nil && err.Error() != tt.wantErr ||
-			err == nil && tt.wantErr != "" {
-			t.Error(err)
-		}
-	}
-}
diff --git a/pkg/util/stringutils/stringutils_test.go b/pkg/util/stringutils/stringutils_test.go
index 7fc6e1f97..90bcc12bd 100644
--- a/pkg/util/stringutils/stringutils_test.go
+++ b/pkg/util/stringutils/stringutils_test.go
@@ -3,7 +3,9 @@ package stringutils
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
 
-import "testing"
+import (
+	"testing"
+)
 
 func TestLastTokenByte(t *testing.T) {
 	result := LastTokenByte("a/b/c/d", '/')