Repository: Azure/ARO-RP
Pull request #2116 (2022-05-05 14:51:37)

NSG controller - reconcile nil NSG
### Which issue this PR addresses:

<!--
Please include a link to the ADO work item as well as any GitHub issues.

Usage: `Fixes #<GitHub issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes ADO-10554108

### What this PR does / why we need it:

Fixes the issue when there is no NSG assigned to a subnet. Previously it would error out. RIght now it attaches the correct NSG.

### Test plan for issue:

added unit test to cover the case

### Is there any documentation that needs to be updated for this PR?

n/a - bug


Patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index ceac8eee4..c33a4b88d 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,1 +1,1 @@
-* @jewzaam @m1kola @bennerv @hawkowl @rogbas @petrkotas @ross-bryan
+* @jewzaam @m1kola @bennerv @hawkowl @rogbas @petrkotas @ross-bryan @darthhexx
diff --git a/docs/deploy-full-rp-service-in-dev.md b/docs/deploy-full-rp-service-in-dev.md
index c9f01a799..641201e56 100644
--- a/docs/deploy-full-rp-service-in-dev.md
+++ b/docs/deploy-full-rp-service-in-dev.md
@@ -30,60 +30,12 @@
 
 1. Run `make deploy`
     > __NOTE:__ This will fail on the first attempt to run due to certificate and container mirroring requirements.
-    > __NOTE:__ If you reuse an old name, you might run into soft-delete of the keyvaults. `az keyvault recover --name` to fix this.
 
-<!-- TODO: this is almost duplicated elsewhere.  Would be nice to move to common area -->
-1. Update the certificates in keyvault
-
-    ```bash
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-svc" \
-        --name rp-mdm \
-        --file secrets/rp-metrics-int.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-gwy" \
-        --name gwy-mdm \
-        --file secrets/rp-metrics-int.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-svc" \
-        --name rp-mdsd \
-        --file secrets/rp-logging-int.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-gwy" \
-        --name gwy-mdsd \
-        --file secrets/rp-logging-int.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-svc" \
-        --name cluster-mdsd \
-        --file secrets/cluster-logging-int.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-svc" \
-        --name dev-arm \
-        --file secrets/arm.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-svc" \
-        --name rp-firstparty \
-        --file secrets/firstparty.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-svc" \
-        --name rp-server \
-        --file secrets/localhost.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-por" \
-        --name portal-server \
-        --file secrets/localhost.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-por" \
-        --name portal-client \
-        --file secrets/portal-client.pem >/dev/null
-    az keyvault certificate import \
-        --vault-name "$KEYVAULT_PREFIX-dbt" \
-        --name dbtoken-server \
-        --file secrets/localhost.pem >/dev/null
-    ```
+    > __NOTE:__ If the deployment fails with `InvalidResourceReference` due to the RP Network Security Groups not found, delete the gateway predeploy deployment, and re-run.
 
 1. Mirror the OpenShift images to your new ACR
     <!-- TODO (bv) allow mirroring through a pipeline would be faster and a nice to have -->
+    > __NOTE:__ Running the mirroring through a VM in Azure rather than a local workstation is recommended for better performance.
 
     1. Setup mirroring environment variables
         ```bash
@@ -105,6 +57,9 @@
         ```
 
     1. Push the ARO and Fluentbit images to your ACR
+
+        __NOTE:__ If running this step from a VM separate from your workstation, ensure the commit tag used to build the image matches the commit tag where `make deploy` is run.
+
         ```bash
         make publish-image-aro-multistage
         make publish-image-fluentbit
@@ -136,30 +91,67 @@
     done
     ```
 
-1. Delete the existing VMSS
-    > __NOTE:__ This needs to be deleted as deploying won't recreate the VMSS if the commit hash is the same.
-    ```bash
-    az vmss delete -g ${RESOURCEGROUP} --name rp-vmss-$(git rev-parse --short=7 HEAD)$([[ $(git status --porcelain) = "" ]] || echo -dirty)
-
-    az vmss delete -g $USER-gwy-$LOCATION --name gateway-vmss-$(git rev-parse --short=7 HEAD)$([[ $(git status --porcelain) = "" ]] || echo -dirty)
-    ```
-
-1. Run `make deploy`
+<!-- TODO: this is almost duplicated elsewhere.  Would be nice to move to common area -->
+1. Update the certificates in keyvault
+    > __NOTE:__ If you reuse an old name, you might run into soft-delete of the keyvaults. Run `az keyvault recover --name` to fix this.
 
-## Deploying a cluster
+    > __NOTE:__ Check to ensure that the $KEYVAULT_PREFIX environment variable set on workstation matches the prefix deployed into the resource group.
 
-1. Setup a local tunnel to the RP
     ```bash
-    make tunnel
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-svc" \
+        --name rp-mdm \
+        --file secrets/rp-metrics-int.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-gwy" \
+        --name gwy-mdm \
+        --file secrets/rp-metrics-int.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-svc" \
+        --name rp-mdsd \
+        --file secrets/rp-logging-int.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-gwy" \
+        --name gwy-mdsd \
+        --file secrets/rp-logging-int.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-svc" \
+        --name cluster-mdsd \
+        --file secrets/cluster-logging-int.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-svc" \
+        --name dev-arm \
+        --file secrets/arm.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-svc" \
+        --name rp-firstparty \
+        --file secrets/firstparty.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-svc" \
+        --name rp-server \
+        --file secrets/localhost.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-por" \
+        --name portal-server \
+        --file secrets/localhost.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-por" \
+        --name portal-client \
+        --file secrets/portal-client.pem >/dev/null
+    az keyvault certificate import \
+        --vault-name "$KEYVAULT_PREFIX-dbt" \
+        --name dbtoken-server \
+        --file secrets/localhost.pem >/dev/null
     ```
 
-1. Deploy your cluster
+1. Delete the existing VMSS
+    > __NOTE:__ This needs to be deleted as deploying won't recreate the VMSS if the commit hash is the same.
+
     ```bash
-    RESOURCEGROUP=v4-$LOCATION CLUSTER=$USER-cluster go run ./hack/cluster create
+    az vmss delete -g ${RESOURCEGROUP} --name rp-vmss-$(git rev-parse --short=7 HEAD)$([[ $(git status --porcelain) = "" ]] || echo -dirty) && az vmss delete -g $USER-gwy-$LOCATION --name gateway-vmss-$(git rev-parse --short=7 HEAD)$([[ $(git status --porcelain) = "" ]] || echo -dirty)
     ```
 
-    > __NOTE:__ The cluster will not be accessible via DNS unless you update the parent domain of the cluster.
-
+1. Run `make deploy`
 
 ## SSH to RP VMSS Instance
 
@@ -215,6 +207,8 @@
     make tunnel
     ```
 
+    > __NOTE:__ `make tunnel` will print the public IP of your new RP VM NIC. Ensure that it's correct.
+
 1. Update environment variable to deploy in a different resource group
     ```bash
     export RESOURCEGROUP=myResourceGroup
@@ -222,16 +216,46 @@
 
 1. Create the resource group if it doesn't exist
     ```bash
-    az group create $RESOURCEGROUP --location $LOCATION
+    az group create --resource-group $RESOURCEGROUP --location $LOCATION
     ```
 
 1. Create VNets / Subnets
     ```bash
-    az network vnet create -g $RESOURCEGROUP -n dev-vnet --address-prefixes 10.0.0.0/8
+    az network vnet create \
+        --resource-group $RESOURCEGROUP \
+        --name aro-vnet \
+        --address-prefixes 10.0.0.0/22
+    ```
+
+    ```bash
+    az network vnet subnet create \
+        --resource-group $RESOURCEGROUP \
+        --vnet-name aro-vnet \
+        --name master-subnet \
+        --address-prefixes 10.0.0.0/23 \
+        --service-endpoints Microsoft.ContainerRegistry
+    ```
+
+    ```bash
+    az network vnet subnet create \
+        --resource-group $RESOURCEGROUP \
+        --vnet-name aro-vnet \
+        --name worker-subnet \
+        --address-prefixes 10.0.2.0/23 \
+        --service-endpoints Microsoft.ContainerRegistry
     ```
 
 1. Create the cluster
     ```bash
     export NO_INTERNET=true
-    CLUSTER=$USER go run ./hack/cluster create
+    export CLUSTER=$USER
+
+    az aro create \
+        --resource-group $RESOURCEGROUP \
+        --name $CLUSTER \
+        --vnet aro-vnet \
+        --master-subnet master-subnet \
+        --worker-subnet worker-subnet
     ```
+
+    > __NOTE:__ The `az aro` CLI extension must be registered in order to run `az aro` commands against a local or tunneled RP. The usual hack script used to create clusters does not work due to keyvault mirroring requirements. The name of the cluster depends on the DNS zone that was created in an earlier step.
diff --git a/docs/upstream-differences.md b/docs/upstream-differences.md
index 1dbc92a10..f42a9a2c3 100644
--- a/docs/upstream-differences.md
+++ b/docs/upstream-differences.md
@@ -88,3 +88,5 @@ Once installer fork is ready:
 1. After this point, you should be able to create a dev cluster using the RP and it should use the new release.
 1. `make discoverycache`.
     * This command requires a running cluster with the new version.
+1. The list of the hard-coded namespaces in `pkg/util/namespace/namespace.go` needs to be updated regularly as every
+   minor version of upstream OCP introduces a new namespace or two.
\ No newline at end of file
diff --git a/pkg/api/admin/openshiftcluster.go b/pkg/api/admin/openshiftcluster.go
index ad65544d4..a7924468c 100644
--- a/pkg/api/admin/openshiftcluster.go
+++ b/pkg/api/admin/openshiftcluster.go
@@ -176,12 +176,18 @@ const (
 	VMSizeStandardD16sV3 VMSize = "Standard_D16s_v3"
 	VMSizeStandardD32sV3 VMSize = "Standard_D32s_v3"
 
-	VMSizeStandardE4sV3   VMSize = "Standard_E4s_v3"
-	VMSizeStandardE8sV3   VMSize = "Standard_E8s_v3"
-	VMSizeStandardE16sV3  VMSize = "Standard_E16s_v3"
-	VMSizeStandardE32sV3  VMSize = "Standard_E32s_v3"
-	VMSizeStandardE64isV3 VMSize = "Standard_E64is_v3"
-	VMSizeStandardE64iV3  VMSize = "Standard_E64i_v3"
+	VMSizeStandardE4sV3     VMSize = "Standard_E4s_v3"
+	VMSizeStandardE8sV3     VMSize = "Standard_E8s_v3"
+	VMSizeStandardE16sV3    VMSize = "Standard_E16s_v3"
+	VMSizeStandardE32sV3    VMSize = "Standard_E32s_v3"
+	VMSizeStandardE64isV3   VMSize = "Standard_E64is_v3"
+	VMSizeStandardE64iV3    VMSize = "Standard_E64i_v3"
+	VMSizeStandardE80isV4   VMSize = "Standard_E80is_v4"
+	VMSizeStandardE80idsV4  VMSize = "Standard_E80ids_v4"
+	VMSizeStandardE104iV5   VMSize = "Standard_E104i_v5"
+	VMSizeStandardE104isV5  VMSize = "Standard_E104is_v5"
+	VMSizeStandardE104idV5  VMSize = "Standard_E104id_v5"
+	VMSizeStandardE104idsV5 VMSize = "Standard_E104ids_v5"
 
 	VMSizeStandardF4sV2  VMSize = "Standard_F4s_v2"
 	VMSizeStandardF8sV2  VMSize = "Standard_F8s_v2"
diff --git a/pkg/api/openshiftcluster.go b/pkg/api/openshiftcluster.go
index 5635fae8b..65bf994cf 100644
--- a/pkg/api/openshiftcluster.go
+++ b/pkg/api/openshiftcluster.go
@@ -294,12 +294,18 @@ const (
 	VMSizeStandardD16sV3 VMSize = "Standard_D16s_v3"
 	VMSizeStandardD32sV3 VMSize = "Standard_D32s_v3"
 
-	VMSizeStandardE4sV3   VMSize = "Standard_E4s_v3"
-	VMSizeStandardE8sV3   VMSize = "Standard_E8s_v3"
-	VMSizeStandardE16sV3  VMSize = "Standard_E16s_v3"
-	VMSizeStandardE32sV3  VMSize = "Standard_E32s_v3"
-	VMSizeStandardE64isV3 VMSize = "Standard_E64is_v3"
-	VMSizeStandardE64iV3  VMSize = "Standard_E64i_v3"
+	VMSizeStandardE4sV3     VMSize = "Standard_E4s_v3"
+	VMSizeStandardE8sV3     VMSize = "Standard_E8s_v3"
+	VMSizeStandardE16sV3    VMSize = "Standard_E16s_v3"
+	VMSizeStandardE32sV3    VMSize = "Standard_E32s_v3"
+	VMSizeStandardE64isV3   VMSize = "Standard_E64is_v3"
+	VMSizeStandardE64iV3    VMSize = "Standard_E64i_v3"
+	VMSizeStandardE80isV4   VMSize = "Standard_E80is_v4"
+	VMSizeStandardE80idsV4  VMSize = "Standard_E80ids_v4"
+	VMSizeStandardE104iV5   VMSize = "Standard_E104i_v5"
+	VMSizeStandardE104isV5  VMSize = "Standard_E104is_v5"
+	VMSizeStandardE104idV5  VMSize = "Standard_E104id_v5"
+	VMSizeStandardE104idsV5 VMSize = "Standard_E104ids_v5"
 
 	VMSizeStandardF4sV2  VMSize = "Standard_F4s_v2"
 	VMSizeStandardF8sV2  VMSize = "Standard_F8s_v2"
diff --git a/pkg/api/validate/dynamic/quota.go b/pkg/api/validate/dynamic/quota.go
index 5fa0d58d8..61edc8c74 100644
--- a/pkg/api/validate/dynamic/quota.go
+++ b/pkg/api/validate/dynamic/quota.go
@@ -28,12 +28,18 @@ func addRequiredResources(requiredResources map[string]int, vmSize api.VMSize, c
 		api.VMSizeStandardD16sV3: {CoreCount: 16, Family: "standardDSv3Family"},
 		api.VMSizeStandardD32sV3: {CoreCount: 32, Family: "standardDSv3Family"},
 
-		api.VMSizeStandardE4sV3:   {CoreCount: 4, Family: "standardESv3Family"},
-		api.VMSizeStandardE8sV3:   {CoreCount: 8, Family: "standardESv3Family"},
-		api.VMSizeStandardE16sV3:  {CoreCount: 16, Family: "standardESv3Family"},
-		api.VMSizeStandardE32sV3:  {CoreCount: 32, Family: "standardESv3Family"},
-		api.VMSizeStandardE64isV3: {CoreCount: 64, Family: "standardESv3Family"},
-		api.VMSizeStandardE64iV3:  {CoreCount: 64, Family: "standardESv3Family"},
+		api.VMSizeStandardE4sV3:     {CoreCount: 4, Family: "standardESv3Family"},
+		api.VMSizeStandardE8sV3:     {CoreCount: 8, Family: "standardESv3Family"},
+		api.VMSizeStandardE16sV3:    {CoreCount: 16, Family: "standardESv3Family"},
+		api.VMSizeStandardE32sV3:    {CoreCount: 32, Family: "standardESv3Family"},
+		api.VMSizeStandardE64isV3:   {CoreCount: 64, Family: "standardESv3Family"},
+		api.VMSizeStandardE64iV3:    {CoreCount: 64, Family: "standardESv3Family"},
+		api.VMSizeStandardE80isV4:   {CoreCount: 80, Family: "standardEISv4Family"},
+		api.VMSizeStandardE80idsV4:  {CoreCount: 80, Family: "standardEIDSv4Family"},
+		api.VMSizeStandardE104iV5:   {CoreCount: 104, Family: "standardEIv5Family"},
+		api.VMSizeStandardE104isV5:  {CoreCount: 104, Family: "standardEISv5Family"},
+		api.VMSizeStandardE104idV5:  {CoreCount: 104, Family: "standardEIDv5Family"},
+		api.VMSizeStandardE104idsV5: {CoreCount: 104, Family: "standardEIDSv5Family"},
 
 		api.VMSizeStandardF4sV2:  {CoreCount: 4, Family: "standardFSv2Family"},
 		api.VMSizeStandardF8sV2:  {CoreCount: 8, Family: "standardFSv2Family"},
diff --git a/pkg/api/validate/vm.go b/pkg/api/validate/vm.go
index 79f8b19b5..88da91571 100644
--- a/pkg/api/validate/vm.go
+++ b/pkg/api/validate/vm.go
@@ -7,66 +7,88 @@ import (
 	"github.com/Azure/ARO-RP/pkg/api"
 )
 
+var supportedMasterVMSizes = map[api.VMSize]bool{
+	// General purpose
+	api.VMSizeStandardD8sV3:  true,
+	api.VMSizeStandardD16sV3: true,
+	api.VMSizeStandardD32sV3: true,
+	// Memory optimized
+	api.VMSizeStandardE64iV3:    true,
+	api.VMSizeStandardE64isV3:   true,
+	api.VMSizeStandardE80isV4:   true,
+	api.VMSizeStandardE80idsV4:  true,
+	api.VMSizeStandardE104iV5:   true,
+	api.VMSizeStandardE104isV5:  true,
+	api.VMSizeStandardE104idV5:  true,
+	api.VMSizeStandardE104idsV5: true,
+	// Compute optimized
+	api.VMSizeStandardF72sV2: true,
+	// Memory and storage optimized
+	api.VMSizeStandardGS5: true,
+	api.VMSizeStandardG5:  true,
+	// Memory and compute optimized
+	api.VMSizeStandardM128ms: true,
+}
+
+var supportedWorkerVMSizes = map[api.VMSize]bool{
+	// General purpose
+	api.VMSizeStandardD4asV4:  true,
+	api.VMSizeStandardD8asV4:  true,
+	api.VMSizeStandardD16asV4: true,
+	api.VMSizeStandardD32asV4: true,
+	api.VMSizeStandardD4sV3:   true,
+	api.VMSizeStandardD8sV3:   true,
+	api.VMSizeStandardD16sV3:  true,
+	api.VMSizeStandardD32sV3:  true,
+	// Memory optimized
+	api.VMSizeStandardE4sV3:     true,
+	api.VMSizeStandardE8sV3:     true,
+	api.VMSizeStandardE16sV3:    true,
+	api.VMSizeStandardE32sV3:    true,
+	api.VMSizeStandardE64isV3:   true,
+	api.VMSizeStandardE64iV3:    true,
+	api.VMSizeStandardE80isV4:   true,
+	api.VMSizeStandardE80idsV4:  true,
+	api.VMSizeStandardE104iV5:   true,
+	api.VMSizeStandardE104isV5:  true,
+	api.VMSizeStandardE104idV5:  true,
+	api.VMSizeStandardE104idsV5: true,
+	// Compute optimized
+	api.VMSizeStandardF4sV2:  true,
+	api.VMSizeStandardF8sV2:  true,
+	api.VMSizeStandardF16sV2: true,
+	api.VMSizeStandardF32sV2: true,
+	api.VMSizeStandardF72sV2: true,
+	// Memory and storage optimized
+	api.VMSizeStandardG5:  true,
+	api.VMSizeStandardGS5: true,
+	// Memory and compute optimized
+	api.VMSizeStandardM128ms: true,
+	// Storage optimized
+	api.VMSizeStandardL4s:    true,
+	api.VMSizeStandardL8s:    true,
+	api.VMSizeStandardL16s:   true,
+	api.VMSizeStandardL32s:   true,
+	api.VMSizeStandardL8sV2:  true,
+	api.VMSizeStandardL16sV2: true,
+	api.VMSizeStandardL32sV2: true,
+	api.VMSizeStandardL48sV2: true,
+	api.VMSizeStandardL64sV2: true,
+}
+
 func DiskSizeIsValid(sizeGB int) bool {
 	return sizeGB >= 128
 }
 
 func VMSizeIsValid(vmSize api.VMSize, requiredD2sV3Workers, isMaster bool) bool {
 	if isMaster {
-		switch vmSize {
-		case api.VMSizeStandardD8sV3,
-			api.VMSizeStandardD16sV3,
-			api.VMSizeStandardD32sV3,
-			api.VMSizeStandardE64iV3,
-			api.VMSizeStandardE64isV3,
-			api.VMSizeStandardF72sV2,
-			api.VMSizeStandardGS5,
-			api.VMSizeStandardG5,
-			api.VMSizeStandardM128ms:
-			return true
-		}
-	} else {
-		if requiredD2sV3Workers {
-			switch vmSize {
-			case api.VMSizeStandardD2sV3:
-				return true
-			}
-		} else {
-			switch vmSize {
-			case api.VMSizeStandardD4asV4,
-				api.VMSizeStandardD8asV4,
-				api.VMSizeStandardD16asV4,
-				api.VMSizeStandardD32asV4,
-				api.VMSizeStandardD4sV3,
-				api.VMSizeStandardD8sV3,
-				api.VMSizeStandardD16sV3,
-				api.VMSizeStandardD32sV3,
-				api.VMSizeStandardE4sV3,
-				api.VMSizeStandardE8sV3,
-				api.VMSizeStandardE16sV3,
-				api.VMSizeStandardE32sV3,
-				api.VMSizeStandardE64iV3,
-				api.VMSizeStandardE64isV3,
-				api.VMSizeStandardF4sV2,
-				api.VMSizeStandardF8sV2,
-				api.VMSizeStandardF16sV2,
-				api.VMSizeStandardF32sV2,
-				api.VMSizeStandardF72sV2,
-				api.VMSizeStandardG5,
-				api.VMSizeStandardGS5,
-				api.VMSizeStandardM128ms,
-				api.VMSizeStandardL4s,
-				api.VMSizeStandardL8s,
-				api.VMSizeStandardL16s,
-				api.VMSizeStandardL32s,
-				api.VMSizeStandardL8sV2,
-				api.VMSizeStandardL16sV2,
-				api.VMSizeStandardL32sV2,
-				api.VMSizeStandardL48sV2,
-				api.VMSizeStandardL64sV2:
-				return true
-			}
-		}
+		return supportedMasterVMSizes[vmSize]
 	}
+
+	if (supportedWorkerVMSizes[vmSize] && !requiredD2sV3Workers) ||
+		(requiredD2sV3Workers && vmSize == api.VMSizeStandardD2sV3) {
+		return true
+	}
+
 	return false
 }
diff --git a/pkg/cluster/clusterserviceprincipal.go b/pkg/cluster/clusterserviceprincipal.go
index d2441046d..b1751435b 100644
--- a/pkg/cluster/clusterserviceprincipal.go
+++ b/pkg/cluster/clusterserviceprincipal.go
@@ -66,7 +66,7 @@ func (m *manager) createOrUpdateClusterServicePrincipalRBAC(ctx context.Context)
 			ContentVersion: "1.0.0.0",
 			Resources:      []*arm.Resource{m.clusterServicePrincipalRBAC()},
 		}
-		err = m.deployARMTemplate(ctx, resourceGroup, "storage", t, nil)
+		err = arm.DeployTemplate(ctx, m.log, m.deployments, resourceGroup, "storage", t, nil)
 		if err != nil {
 			return err
 		}
diff --git a/pkg/cluster/denyassignment.go b/pkg/cluster/denyassignment.go
index 1881cb6f7..9c2368315 100644
--- a/pkg/cluster/denyassignment.go
+++ b/pkg/cluster/denyassignment.go
@@ -32,5 +32,5 @@ func (m *manager) createOrUpdateDenyAssignment(ctx context.Context) error {
 		},
 	}
 
-	return m.deployARMTemplate(ctx, resourceGroup, "storage", t, nil)
+	return arm.DeployTemplate(ctx, m.log, m.deployments, resourceGroup, "storage", t, nil)
 }
diff --git a/pkg/cluster/deployresources.go b/pkg/cluster/deployresources.go
index f2b2556ee..505dc4ecb 100644
--- a/pkg/cluster/deployresources.go
+++ b/pkg/cluster/deployresources.go
@@ -52,7 +52,7 @@ func (m *manager) deployResourceTemplate(ctx context.Context) error {
 			m.computeMasterVMs(installConfig, zones, machineMaster),
 		},
 	}
-	return m.deployARMTemplate(ctx, resourceGroup, "resources", t, map[string]interface{}{
+	return arm.DeployTemplate(ctx, m.log, m.deployments, resourceGroup, "resources", t, map[string]interface{}{
 		"sas": map[string]interface{}{
 			"value": map[string]interface{}{
 				"signedStart":         m.doc.OpenShiftCluster.Properties.Install.Now.Format(time.RFC3339),
diff --git a/pkg/cluster/deploystorage.go b/pkg/cluster/deploystorage.go
index 846a1f9ff..638639405 100644
--- a/pkg/cluster/deploystorage.go
+++ b/pkg/cluster/deploystorage.go
@@ -167,7 +167,7 @@ func (m *manager) deployStorageTemplate(ctx context.Context) error {
 		t.Resources = append(t.Resources, m.denyAssignment())
 	}
 
-	return m.deployARMTemplate(ctx, resourceGroup, "storage", t, nil)
+	return arm.DeployTemplate(ctx, m.log, m.deployments, resourceGroup, "storage", t, nil)
 }
 
 func (m *manager) ensureGraph(ctx context.Context, installConfig *installconfig.InstallConfig, image *releaseimage.Image) error {
diff --git a/pkg/cluster/gatewayprivateendpoint.go b/pkg/cluster/gatewayprivateendpoint.go
index 093efcd31..bd9c859ef 100644
--- a/pkg/cluster/gatewayprivateendpoint.go
+++ b/pkg/cluster/gatewayprivateendpoint.go
@@ -33,7 +33,7 @@ func (m *manager) ensureGatewayUpgrade(ctx context.Context) error {
 		ContentVersion: "1.0.0.0",
 		Resources:      []*arm.Resource{m.networkPrivateEndpoint()},
 	}
-	err = m.deployARMTemplate(ctx, resourceGroup, "storage", t, nil)
+	err = arm.DeployTemplate(ctx, m.log, m.deployments, resourceGroup, "storage", t, nil)
 	if err != nil {
 		m.log.Print(err)
 		return nil
diff --git a/pkg/cluster/storageaccounts.go b/pkg/cluster/storageaccounts.go
index 89d512c2b..a73983517 100644
--- a/pkg/cluster/storageaccounts.go
+++ b/pkg/cluster/storageaccounts.go
@@ -81,7 +81,7 @@ func (m *manager) migrateStorageAccounts(ctx context.Context) error {
 		},
 	}
 
-	return m.deployARMTemplate(ctx, resourceGroup, "storage", t, nil)
+	return arm.DeployTemplate(ctx, m.log, m.deployments, resourceGroup, "storage", t, nil)
 }
 
 func (m *manager) populateRegistryStorageAccountName(ctx context.Context) error {
diff --git a/pkg/frontend/admin_openshiftcluster_startvm.go b/pkg/frontend/admin_openshiftcluster_startvm.go
new file mode 100644
index 000000000..be43f2383
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_startvm.go
@@ -0,0 +1,60 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"github.com/gorilla/mux"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/database/cosmosdb"
+	"github.com/Azure/ARO-RP/pkg/frontend/middleware"
+)
+
+func (f *frontend) postAdminOpenShiftClusterStartVM(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	log := ctx.Value(middleware.ContextKeyLog).(*logrus.Entry)
+	r.URL.Path = filepath.Dir(r.URL.Path)
+
+	err := f._postAdminOpenShiftClusterStartVM(ctx, r, log)
+
+	adminReply(log, w, nil, nil, err)
+}
+
+func (f *frontend) _postAdminOpenShiftClusterStartVM(ctx context.Context, r *http.Request, log *logrus.Entry) error {
+	vars := mux.Vars(r)
+
+	vmName := r.URL.Query().Get("vmName")
+	err := validateAdminVMName(vmName)
+	if err != nil {
+		return err
+	}
+
+	resourceID := strings.TrimPrefix(r.URL.Path, "/admin")
+
+	doc, err := f.dbOpenShiftClusters.Get(ctx, resourceID)
+	switch {
+	case cosmosdb.IsErrorStatusCode(err, http.StatusNotFound):
+		return api.NewCloudError(http.StatusNotFound, api.CloudErrorCodeResourceNotFound, "", "The Resource '%s/%s' under resource group '%s' was not found.", vars["resourceType"], vars["resourceName"], vars["resourceGroupName"])
+	case err != nil:
+		return err
+	}
+
+	subscriptionDoc, err := f.getSubscriptionDocument(ctx, doc.Key)
+	if err != nil {
+		return err
+	}
+
+	a, err := f.azureActionsFactory(log, f.env, doc.OpenShiftCluster, subscriptionDoc)
+	if err != nil {
+		return err
+	}
+
+	return a.VMStartAndWait(ctx, vmName)
+}
diff --git a/pkg/frontend/admin_openshiftcluster_stopvm.go b/pkg/frontend/admin_openshiftcluster_stopvm.go
new file mode 100644
index 000000000..e2a013979
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_stopvm.go
@@ -0,0 +1,60 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"github.com/gorilla/mux"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/database/cosmosdb"
+	"github.com/Azure/ARO-RP/pkg/frontend/middleware"
+)
+
+func (f *frontend) postAdminOpenShiftClusterStopVM(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	log := ctx.Value(middleware.ContextKeyLog).(*logrus.Entry)
+	r.URL.Path = filepath.Dir(r.URL.Path)
+
+	err := f._postAdminOpenShiftClusterStopVM(ctx, r, log)
+
+	adminReply(log, w, nil, nil, err)
+}
+
+func (f *frontend) _postAdminOpenShiftClusterStopVM(ctx context.Context, r *http.Request, log *logrus.Entry) error {
+	vars := mux.Vars(r)
+
+	vmName := r.URL.Query().Get("vmName")
+	err := validateAdminVMName(vmName)
+	if err != nil {
+		return err
+	}
+
+	resourceID := strings.TrimPrefix(r.URL.Path, "/admin")
+
+	doc, err := f.dbOpenShiftClusters.Get(ctx, resourceID)
+	switch {
+	case cosmosdb.IsErrorStatusCode(err, http.StatusNotFound):
+		return api.NewCloudError(http.StatusNotFound, api.CloudErrorCodeResourceNotFound, "", "The Resource '%s/%s' under resource group '%s' was not found.", vars["resourceType"], vars["resourceName"], vars["resourceGroupName"])
+	case err != nil:
+		return err
+	}
+
+	subscriptionDoc, err := f.getSubscriptionDocument(ctx, doc.Key)
+	if err != nil {
+		return err
+	}
+
+	a, err := f.azureActionsFactory(log, f.env, doc.OpenShiftCluster, subscriptionDoc)
+	if err != nil {
+		return err
+	}
+
+	return a.VMStopAndWait(ctx, vmName)
+}
diff --git a/pkg/frontend/admin_openshiftcluster_vmresize.go b/pkg/frontend/admin_openshiftcluster_vmresize.go
new file mode 100644
index 000000000..e04a7e08b
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_vmresize.go
@@ -0,0 +1,111 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"github.com/gorilla/mux"
+	"github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	kruntime "k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/database/cosmosdb"
+	"github.com/Azure/ARO-RP/pkg/frontend/middleware"
+)
+
+func (f *frontend) postAdminOpenShiftClusterVMResize(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	log := ctx.Value(middleware.ContextKeyLog).(*logrus.Entry)
+	r.URL.Path = filepath.Dir(r.URL.Path)
+
+	err := f._postAdminOpenShiftClusterVMResize(ctx, r, log)
+
+	adminReply(log, w, nil, nil, err)
+}
+
+func (f *frontend) _postAdminOpenShiftClusterVMResize(ctx context.Context, r *http.Request, log *logrus.Entry) error {
+	vars := mux.Vars(r)
+
+	vmName := r.URL.Query().Get("vmName")
+	err := validateAdminVMName(vmName)
+	if err != nil {
+		return err
+	}
+
+	resourceID := strings.TrimPrefix(r.URL.Path, "/admin")
+
+	doc, err := f.dbOpenShiftClusters.Get(ctx, resourceID)
+	switch {
+	case cosmosdb.IsErrorStatusCode(err, http.StatusNotFound):
+		return api.NewCloudError(http.StatusNotFound, api.CloudErrorCodeResourceNotFound, "",
+			"The Resource '%s/%s' under resource group '%s' was not found.",
+			vars["resourceType"], vars["resourceName"], vars["resourceGroupName"])
+	case err != nil:
+		return err
+	}
+
+	subscriptionDoc, err := f.getSubscriptionDocument(ctx, doc.Key)
+	if err != nil {
+		return err
+	}
+
+	vmSize := r.URL.Query().Get("vmSize")
+	err = validateAdminVMSize(vmSize)
+	if err != nil {
+		return err
+	}
+
+	a, err := f.azureActionsFactory(log, f.env, doc.OpenShiftCluster, subscriptionDoc)
+	if err != nil {
+		return err
+	}
+
+	k, err := f.kubeActionsFactory(log, f.env, doc.OpenShiftCluster)
+	if err != nil {
+		return err
+	}
+
+	nodeList, err := k.KubeList(ctx, "node", "")
+	if err != nil {
+		return err
+	}
+
+	var u unstructured.Unstructured
+	var nodes corev1.NodeList
+	if err = json.Unmarshal(nodeList, &u); err != nil {
+		return err
+	}
+
+	err = kruntime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &nodes)
+	if err != nil {
+		return err
+	}
+
+	nodeExists := false
+	for _, node := range nodes.Items {
+		if _, ok := node.ObjectMeta.Labels["node-role.kubernetes.io/master"]; !ok {
+			continue
+		}
+
+		if strings.EqualFold(vmName, node.ObjectMeta.Name) {
+			nodeExists = true
+			break
+		}
+	}
+
+	if !nodeExists {
+		return api.NewCloudError(http.StatusNotFound, api.CloudErrorCodeNotFound, "",
+			`"The master node '%s' under resource group '%s' was not found."`,
+			vmName, vars["resourceGroupName"])
+	}
+
+	return a.VMResize(ctx, vmName, vmSize)
+}
diff --git a/pkg/frontend/admin_openshiftcluster_vmsizelist.go b/pkg/frontend/admin_openshiftcluster_vmsizelist.go
new file mode 100644
index 000000000..5a712b363
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_vmsizelist.go
@@ -0,0 +1,78 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	mgmtcompute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2020-06-01/compute"
+	"github.com/gorilla/mux"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/database/cosmosdb"
+	"github.com/Azure/ARO-RP/pkg/frontend/middleware"
+)
+
+// /admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/skus
+func (f *frontend) getAdminOpenShiftClusterVMResizeOptions(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	log := ctx.Value(middleware.ContextKeyLog).(*logrus.Entry)
+	r.URL.Path = filepath.Dir(r.URL.Path)
+
+	b, err := f._getAdminOpenShiftClusterVMResizeOptions(ctx, r, log)
+
+	adminReply(log, w, nil, b, err)
+}
+
+func (f *frontend) _getAdminOpenShiftClusterVMResizeOptions(ctx context.Context, r *http.Request, log *logrus.Entry) ([]byte, error) {
+	vars := mux.Vars(r)
+
+	resourceID := strings.TrimPrefix(r.URL.Path, "/admin")
+
+	doc, err := f.dbOpenShiftClusters.Get(ctx, resourceID)
+	switch {
+	case cosmosdb.IsErrorStatusCode(err, http.StatusNotFound):
+		return nil, api.NewCloudError(http.StatusNotFound, api.CloudErrorCodeResourceNotFound, "",
+			"The Resource '%s/%s' under resource group '%s' was not found.",
+			vars["resourceType"], vars["resourceName"], vars["resourceGroupName"])
+	case err != nil:
+		return nil, err
+	}
+
+	subscriptionDoc, err := f.getSubscriptionDocument(ctx, doc.Key)
+	if err != nil {
+		return nil, err
+	}
+
+	a, err := f.azureActionsFactory(log, f.env, doc.OpenShiftCluster, subscriptionDoc)
+	if err != nil {
+		return nil, err
+	}
+
+	skus, err := a.VMSizeList(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return json.Marshal(f.filterVMSkus(skus))
+}
+
+func (f *frontend) filterVMSkus(skus []mgmtcompute.ResourceSku) []string {
+	filteredSkus := []string{}
+
+	for _, sku := range skus {
+		if sku.Restrictions != nil && len(*sku.Restrictions) == 0 {
+			if sku.Name != nil {
+				filteredSkus = append(filteredSkus, *sku.Name)
+			}
+		}
+	}
+
+	return filteredSkus
+}
diff --git a/pkg/frontend/adminactions/azureactions.go b/pkg/frontend/adminactions/azureactions.go
index d0e9bf737..e9ba404c9 100644
--- a/pkg/frontend/adminactions/azureactions.go
+++ b/pkg/frontend/adminactions/azureactions.go
@@ -5,8 +5,10 @@ package adminactions
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 
+	mgmtcompute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2020-06-01/compute"
 	"github.com/sirupsen/logrus"
 
 	"github.com/Azure/ARO-RP/pkg/api"
@@ -23,6 +25,10 @@ type AzureActions interface {
 	ResourcesList(ctx context.Context) ([]byte, error)
 	NICReconcileFailedState(ctx context.Context, nicName string) error
 	VMRedeployAndWait(ctx context.Context, vmName string) error
+	VMStartAndWait(ctx context.Context, vmName string) error
+	VMStopAndWait(ctx context.Context, vmName string) error
+	VMSizeList(ctx context.Context) ([]mgmtcompute.ResourceSku, error)
+	VMResize(ctx context.Context, vmName string, vmSize string) error
 	VMSerialConsole(ctx context.Context, w http.ResponseWriter, log *logrus.Entry, vmName string) error
 }
 
@@ -32,6 +38,7 @@ type azureActions struct {
 	oc  *api.OpenShiftCluster
 
 	resources          features.ResourcesClient
+	resourceSkus       compute.ResourceSkusClient
 	virtualMachines    compute.VirtualMachinesClient
 	virtualNetworks    network.VirtualNetworksClient
 	diskEncryptionSets compute.DiskEncryptionSetsClient
@@ -55,6 +62,7 @@ func NewAzureActions(log *logrus.Entry, env env.Interface, oc *api.OpenShiftClus
 		oc:  oc,
 
 		resources:          features.NewResourcesClient(env.Environment(), subscriptionDoc.ID, fpAuth),
+		resourceSkus:       compute.NewResourceSkusClient(env.Environment(), subscriptionDoc.ID, fpAuth),
 		virtualMachines:    compute.NewVirtualMachinesClient(env.Environment(), subscriptionDoc.ID, fpAuth),
 		virtualNetworks:    network.NewVirtualNetworksClient(env.Environment(), subscriptionDoc.ID, fpAuth),
 		diskEncryptionSets: compute.NewDiskEncryptionSetsClient(env.Environment(), subscriptionDoc.ID, fpAuth),
@@ -68,3 +76,29 @@ func (a *azureActions) VMRedeployAndWait(ctx context.Context, vmName string) err
 	clusterRGName := stringutils.LastTokenByte(a.oc.Properties.ClusterProfile.ResourceGroupID, '/')
 	return a.virtualMachines.RedeployAndWait(ctx, clusterRGName, vmName)
 }
+
+func (a *azureActions) VMStartAndWait(ctx context.Context, vmName string) error {
+	clusterRGName := stringutils.LastTokenByte(a.oc.Properties.ClusterProfile.ResourceGroupID, '/')
+	return a.virtualMachines.StartAndWait(ctx, clusterRGName, vmName)
+}
+
+func (a *azureActions) VMStopAndWait(ctx context.Context, vmName string) error {
+	clusterRGName := stringutils.LastTokenByte(a.oc.Properties.ClusterProfile.ResourceGroupID, '/')
+	return a.virtualMachines.StopAndWait(ctx, clusterRGName, vmName)
+}
+
+func (a *azureActions) VMSizeList(ctx context.Context) ([]mgmtcompute.ResourceSku, error) {
+	filter := fmt.Sprintf("location eq '%s'", a.env.Location())
+	return a.resourceSkus.List(ctx, filter)
+}
+
+func (a *azureActions) VMResize(ctx context.Context, vmName string, size string) error {
+	clusterRGName := stringutils.LastTokenByte(a.oc.Properties.ClusterProfile.ResourceGroupID, '/')
+	vm, err := a.virtualMachines.Get(ctx, clusterRGName, vmName, mgmtcompute.InstanceView)
+	if err != nil {
+		return err
+	}
+
+	vm.HardwareProfile.VMSize = mgmtcompute.VirtualMachineSizeTypes(size)
+	return a.virtualMachines.CreateOrUpdateAndWait(ctx, clusterRGName, vmName, vm)
+}
diff --git a/pkg/frontend/frontend.go b/pkg/frontend/frontend.go
index e020eb84a..29d24f962 100644
--- a/pkg/frontend/frontend.go
+++ b/pkg/frontend/frontend.go
@@ -244,6 +244,18 @@ func (f *frontend) authenticatedRoutes(r *mux.Router) {
 
 	s.Methods(http.MethodPost).HandlerFunc(f.postAdminOpenShiftClusterRedeployVM).Name("postAdminOpenShiftClusterRedeployVM")
 
+	s = r.
+		Path("/admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/stopvm").
+		Subrouter()
+
+	s.Methods(http.MethodPost).HandlerFunc(f.postAdminOpenShiftClusterStopVM).Name("postAdminOpenShiftClusterStopVM")
+
+	s = r.
+		Path("/admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/startvm").
+		Subrouter()
+
+	s.Methods(http.MethodPost).HandlerFunc(f.postAdminOpenShiftClusterStartVM).Name("postAdminOpenShiftClusterStartVM")
+
 	s = r.
 		Path("/admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/upgrade").
 		Subrouter()
@@ -256,6 +268,18 @@ func (f *frontend) authenticatedRoutes(r *mux.Router) {
 
 	s.Methods(http.MethodGet).HandlerFunc(f.getAdminOpenShiftClusters).Name("getAdminOpenShiftClusters")
 
+	s = r.
+		Path("/admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/skus").
+		Subrouter()
+
+	s.Methods(http.MethodGet).HandlerFunc(f.getAdminOpenShiftClusterVMResizeOptions).Name("getAdminOpenShiftClusterVMResizeOptions")
+
+	s = r.
+		Path("/admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/resize").
+		Subrouter()
+
+	s.Methods(http.MethodPost).HandlerFunc(f.postAdminOpenShiftClusterVMResize).Name("postAdminOpenShiftClusterVMResize")
+
 	s = r.
 		Path("/admin/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}/reconcilefailednic").
 		Subrouter()
diff --git a/pkg/frontend/validate.go b/pkg/frontend/validate.go
index 71c4defb8..52d309710 100644
--- a/pkg/frontend/validate.go
+++ b/pkg/frontend/validate.go
@@ -77,7 +77,7 @@ func (f *frontend) validateOpenShiftUniqueKey(ctx context.Context, doc *api.Open
 var rxKubernetesString = regexp.MustCompile(`(?i)^[-a-z0-9.]{0,255}$`)
 
 func validateAdminKubernetesObjectsNonCustomer(method, groupKind, namespace, name string) error {
-	if !utilnamespace.IsOpenShift(namespace) {
+	if !utilnamespace.IsOpenShiftNamespace(namespace) {
 		return api.NewCloudError(http.StatusForbidden, api.CloudErrorCodeForbidden, "", "Access to the provided namespace '%s' is forbidden.", namespace)
 	}
 
@@ -127,6 +127,12 @@ func validateNetworkInterfaceName(nicName string) error {
 	if nicName == "" || !rxNetworkInterfaceName.MatchString(nicName) {
 		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidParameter, "", "The provided nicName '%s' is invalid.", nicName)
 	}
+	return nil
+}
 
+func validateAdminVMSize(vmSize string) error {
+	if vmSize == "" {
+		return api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidParameter, "", "The provided vmSize '%s' is invalid.", vmSize)
+	}
 	return nil
 }
diff --git a/pkg/monitor/cluster/clusterversions.go b/pkg/monitor/cluster/clusterversions.go
index cf85a4961..804c50d99 100644
--- a/pkg/monitor/cluster/clusterversions.go
+++ b/pkg/monitor/cluster/clusterversions.go
@@ -38,14 +38,26 @@ func (mon *Monitor) emitClusterVersions(ctx context.Context) error {
 		availableRP = version.GitCommit
 	}
 
+	actualVersion := actualVersion(cv)
+	actualMinorVersion := ""
+	if len(actualVersion) > 0 {
+		parsedVersion, err := version.ParseVersion(actualVersion)
+		if err != nil {
+			return err
+		}
+		actualMinorVersion = parsedVersion.MinorVersion()
+	}
+
 	mon.emitGauge("cluster.versions", 1, map[string]string{
-		"actualVersion":                        actualVersion(cv),
+		"actualVersion":                        actualVersion,
 		"desiredVersion":                       desiredVersion(cv),
 		"provisionedByResourceProviderVersion": mon.oc.Properties.ProvisionedBy,              // last successful Put or Patch
 		"resourceProviderVersion":              version.GitCommit,                            // RP version currently running
 		"operatorVersion":                      operatorVersion,                              // operator version in the cluster
 		"availableVersion":                     availableVersion(cv, version.UpgradeStreams), // current available version for upgrade from stream
 		"availableRP":                          availableRP,                                  // current RP version available for document update, empty when none
+		"latestGaMinorVersion":                 version.InstallStream.Version.MinorVersion(), // Latest GA in ARO Minor version
+		"actualMinorVersion":                   actualMinorVersion,                           // Minor version, empty if actual version is not in expected form
 	})
 
 	return nil
diff --git a/pkg/monitor/cluster/daemonsetstatuses.go b/pkg/monitor/cluster/daemonsetstatuses.go
index f7869d2ed..9d1db8fdb 100644
--- a/pkg/monitor/cluster/daemonsetstatuses.go
+++ b/pkg/monitor/cluster/daemonsetstatuses.go
@@ -24,7 +24,7 @@ func (mon *Monitor) emitDaemonsetStatuses(ctx context.Context) error {
 		count += int64(len(dss.Items))
 
 		for _, ds := range dss.Items {
-			if !namespace.IsOpenShift(ds.Namespace) {
+			if !namespace.IsOpenShiftNamespace(ds.Namespace) {
 				continue
 			}
 
diff --git a/pkg/monitor/cluster/deploymentstatuses.go b/pkg/monitor/cluster/deploymentstatuses.go
index ed02e290b..771fe8989 100644
--- a/pkg/monitor/cluster/deploymentstatuses.go
+++ b/pkg/monitor/cluster/deploymentstatuses.go
@@ -24,7 +24,7 @@ func (mon *Monitor) emitDeploymentStatuses(ctx context.Context) error {
 		count += int64(len(ds.Items))
 
 		for _, d := range ds.Items {
-			if !namespace.IsOpenShift(d.Namespace) {
+			if !namespace.IsOpenShiftNamespace(d.Namespace) {
 				continue
 			}
 
diff --git a/pkg/monitor/cluster/jobconditions.go b/pkg/monitor/cluster/jobconditions.go
index f45104ca3..9941f9380 100644
--- a/pkg/monitor/cluster/jobconditions.go
+++ b/pkg/monitor/cluster/jobconditions.go
@@ -30,7 +30,7 @@ func (mon *Monitor) emitJobConditions(ctx context.Context) error {
 		count += int64(len(jobs.Items))
 
 		for _, job := range jobs.Items {
-			if !namespace.IsOpenShift(job.Namespace) {
+			if !namespace.IsOpenShiftNamespace(job.Namespace) {
 				continue
 			}
 
diff --git a/pkg/monitor/cluster/podconditions.go b/pkg/monitor/cluster/podconditions.go
index 119db6454..48956d19b 100644
--- a/pkg/monitor/cluster/podconditions.go
+++ b/pkg/monitor/cluster/podconditions.go
@@ -51,7 +51,7 @@ func (mon *Monitor) emitPodConditions(ctx context.Context) error {
 
 func (mon *Monitor) _emitPodConditions(ps *corev1.PodList) {
 	for _, p := range ps.Items {
-		if !namespace.IsOpenShift(p.Namespace) {
+		if !namespace.IsOpenShiftNamespace(p.Namespace) {
 			continue
 		}
 
@@ -89,7 +89,7 @@ func (mon *Monitor) _emitPodConditions(ps *corev1.PodList) {
 
 func (mon *Monitor) _emitPodContainerStatuses(ps *corev1.PodList) {
 	for _, p := range ps.Items {
-		if !namespace.IsOpenShift(p.Namespace) {
+		if !namespace.IsOpenShiftNamespace(p.Namespace) {
 			continue
 		}
 
@@ -126,7 +126,7 @@ func (mon *Monitor) _emitPodContainerStatuses(ps *corev1.PodList) {
 
 func (mon *Monitor) _emitPodContainerRestartCounter(ps *corev1.PodList) {
 	for _, p := range ps.Items {
-		if !namespace.IsOpenShiftSystemNamespace(p.Namespace) {
+		if !namespace.IsOpenShiftNamespace(p.Namespace) {
 			continue
 		}
 
diff --git a/pkg/monitor/cluster/prometheusalerts.go b/pkg/monitor/cluster/prometheusalerts.go
index c0208e981..8df500943 100644
--- a/pkg/monitor/cluster/prometheusalerts.go
+++ b/pkg/monitor/cluster/prometheusalerts.go
@@ -80,7 +80,7 @@ func (mon *Monitor) emitPrometheusAlerts(ctx context.Context) error {
 	mon.emitGauge("prometheus.alerts.count", int64(len(alerts)), nil)
 
 	for _, alert := range alerts {
-		if !namespace.IsOpenShift(string(alert.Labels["namespace"])) {
+		if !namespace.IsOpenShiftNamespace(string(alert.Labels["namespace"])) {
 			continue
 		}
 
diff --git a/pkg/monitor/cluster/replicasetstatuses.go b/pkg/monitor/cluster/replicasetstatuses.go
index fc5fc8dc2..1b9d30bbd 100644
--- a/pkg/monitor/cluster/replicasetstatuses.go
+++ b/pkg/monitor/cluster/replicasetstatuses.go
@@ -24,7 +24,7 @@ func (mon *Monitor) emitReplicasetStatuses(ctx context.Context) error {
 		count += int64(len(rss.Items))
 
 		for _, rs := range rss.Items {
-			if !namespace.IsOpenShift(rs.Namespace) {
+			if !namespace.IsOpenShiftNamespace(rs.Namespace) {
 				continue
 			}
 
diff --git a/pkg/monitor/cluster/statefulsetstatuses.go b/pkg/monitor/cluster/statefulsetstatuses.go
index d348eec7d..57a95db9c 100644
--- a/pkg/monitor/cluster/statefulsetstatuses.go
+++ b/pkg/monitor/cluster/statefulsetstatuses.go
@@ -24,7 +24,7 @@ func (mon *Monitor) emitStatefulsetStatuses(ctx context.Context) error {
 		count += int64(len(sss.Items))
 
 		for _, ss := range sss.Items {
-			if !namespace.IsOpenShift(ss.Namespace) {
+			if !namespace.IsOpenShiftNamespace(ss.Namespace) {
 				continue
 			}
 
diff --git a/pkg/operator/controllers/subnets/subnet_nsg.go b/pkg/operator/controllers/subnets/subnet_nsg.go
index a4cb76780..47a7db3e6 100644
--- a/pkg/operator/controllers/subnets/subnet_nsg.go
+++ b/pkg/operator/controllers/subnets/subnet_nsg.go
@@ -21,7 +21,7 @@ func (r *reconcileManager) ensureSubnetNSG(ctx context.Context, s subnet.Subnet)
 	if err != nil {
 		return err
 	}
-	if subnetObject.SubnetPropertiesFormat == nil || subnetObject.SubnetPropertiesFormat.NetworkSecurityGroup == nil {
+	if subnetObject.SubnetPropertiesFormat == nil {
 		return fmt.Errorf("received nil, expected a value in subnetProperties when trying to Get subnet %s", s.ResourceID)
 	}
 
@@ -30,14 +30,18 @@ func (r *reconcileManager) ensureSubnetNSG(ctx context.Context, s subnet.Subnet)
 		return err
 	}
 
-	if !strings.EqualFold(*subnetObject.NetworkSecurityGroup.ID, correctNSGResourceID) {
-		r.log.Infof("Fixing NSG from %s to %s", *subnetObject.NetworkSecurityGroup.ID, correctNSGResourceID)
+	// if the NSG is assigned && it's the correct NSG - do nothing
+	if subnetObject.SubnetPropertiesFormat.NetworkSecurityGroup != nil && strings.EqualFold(*subnetObject.NetworkSecurityGroup.ID, correctNSGResourceID) {
+		return nil
+	}
 
-		subnetObject.NetworkSecurityGroup = &mgmtnetwork.SecurityGroup{ID: &correctNSGResourceID}
-		err = r.subnets.CreateOrUpdate(ctx, s.ResourceID, subnetObject)
-		if err != nil {
-			return err
-		}
+	// else the NSG assignment needs to be corrected
+	oldNSG := "nil"
+	if subnetObject.SubnetPropertiesFormat.NetworkSecurityGroup != nil {
+		oldNSG = *subnetObject.NetworkSecurityGroup.ID
 	}
-	return nil
+	r.log.Infof("Fixing NSG from %s to %s", oldNSG, correctNSGResourceID)
+	subnetObject.NetworkSecurityGroup = &mgmtnetwork.SecurityGroup{ID: &correctNSGResourceID}
+	err = r.subnets.CreateOrUpdate(ctx, s.ResourceID, subnetObject)
+	return err
 }
diff --git a/pkg/cluster/arm.go b/pkg/util/arm/deploy.go
similarity index 69%
rename from pkg/cluster/arm.go
rename to pkg/util/arm/deploy.go
index 83124cd3c..3bad254fc 100644
--- a/pkg/cluster/arm.go
+++ b/pkg/util/arm/deploy.go
@@ -1,4 +1,4 @@
-package cluster
+package arm
 
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
@@ -11,15 +11,16 @@ import (
 	mgmtfeatures "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-07-01/features"
 	"github.com/Azure/go-autorest/autorest"
 	"github.com/Azure/go-autorest/autorest/azure"
+	"github.com/sirupsen/logrus"
 
 	"github.com/Azure/ARO-RP/pkg/api"
-	"github.com/Azure/ARO-RP/pkg/util/arm"
+	"github.com/Azure/ARO-RP/pkg/util/azureclient/mgmt/features"
 	"github.com/Azure/ARO-RP/pkg/util/azureerrors"
 )
 
-func (m *manager) deployARMTemplate(ctx context.Context, resourceGroupName string, deploymentName string, template *arm.Template, parameters map[string]interface{}) error {
-	m.log.Printf("deploying %s template", deploymentName)
-	err := m.deployments.CreateOrUpdateAndWait(ctx, resourceGroupName, deploymentName, mgmtfeatures.Deployment{
+func DeployTemplate(ctx context.Context, log *logrus.Entry, deployments features.DeploymentsClient, resourceGroupName string, deploymentName string, template *Template, parameters map[string]interface{}) error {
+	log.Printf("deploying %s template", deploymentName)
+	err := deployments.CreateOrUpdateAndWait(ctx, resourceGroupName, deploymentName, mgmtfeatures.Deployment{
 		Properties: &mgmtfeatures.DeploymentProperties{
 			Template:   template,
 			Parameters: parameters,
@@ -28,8 +29,8 @@ func (m *manager) deployARMTemplate(ctx context.Context, resourceGroupName strin
 	})
 
 	if azureerrors.IsDeploymentActiveError(err) {
-		m.log.Printf("waiting for %s template to be deployed", deploymentName)
-		err = m.deployments.Wait(ctx, resourceGroupName, deploymentName)
+		log.Printf("waiting for %s template to be deployed", deploymentName)
+		err = deployments.Wait(ctx, resourceGroupName, deploymentName)
 	}
 
 	if azureerrors.HasAuthorizationFailedError(err) ||
diff --git a/pkg/util/azureclient/mgmt/compute/virtualmachines_addons.go b/pkg/util/azureclient/mgmt/compute/virtualmachines_addons.go
index 8617d2cc3..5b6113860 100644
--- a/pkg/util/azureclient/mgmt/compute/virtualmachines_addons.go
+++ b/pkg/util/azureclient/mgmt/compute/virtualmachines_addons.go
@@ -7,6 +7,7 @@ import (
 	"context"
 
 	mgmtcompute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2020-06-01/compute"
+	"github.com/Azure/go-autorest/autorest/to"
 )
 
 // VirtualMachinesClientAddons contains addons for VirtualMachinesClient
@@ -15,6 +16,7 @@ type VirtualMachinesClientAddons interface {
 	DeleteAndWait(ctx context.Context, resourceGroupName string, VMName string, forceDeletion *bool) error
 	RedeployAndWait(ctx context.Context, resourceGroupName string, VMName string) error
 	StartAndWait(ctx context.Context, resourceGroupName string, VMName string) error
+	StopAndWait(ctx context.Context, resourceGroupName string, VMName string) error
 	List(ctx context.Context, resourceGroupName string) (result []mgmtcompute.VirtualMachine, err error)
 }
 
@@ -54,6 +56,15 @@ func (c *virtualMachinesClient) StartAndWait(ctx context.Context, resourceGroupN
 	return future.WaitForCompletionRef(ctx, c.Client)
 }
 
+func (c *virtualMachinesClient) StopAndWait(ctx context.Context, resourceGroupName string, VMName string) error {
+	future, err := c.PowerOff(ctx, resourceGroupName, VMName, to.BoolPtr(false))
+	if err != nil {
+		return err
+	}
+
+	return future.WaitForCompletionRef(ctx, c.Client)
+}
+
 func (c *virtualMachinesClient) List(ctx context.Context, resourceGroupName string) (result []mgmtcompute.VirtualMachine, err error) {
 	page, err := c.VirtualMachinesClient.List(ctx, resourceGroupName)
 	if err != nil {
diff --git a/pkg/util/mocks/adminactions/adminactions.go b/pkg/util/mocks/adminactions/adminactions.go
index f8c83279f..21501dc6a 100644
--- a/pkg/util/mocks/adminactions/adminactions.go
+++ b/pkg/util/mocks/adminactions/adminactions.go
@@ -9,6 +9,7 @@ import (
 	http "net/http"
 	reflect "reflect"
 
+	compute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2020-06-01/compute"
 	gomock "github.com/golang/mock/gomock"
 	logrus "github.com/sirupsen/logrus"
 	unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -175,6 +176,20 @@ func (mr *MockAzureActionsMockRecorder) VMRedeployAndWait(arg0, arg1 interface{}
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VMRedeployAndWait", reflect.TypeOf((*MockAzureActions)(nil).VMRedeployAndWait), arg0, arg1)
 }
 
+// VMResize mocks base method.
+func (m *MockAzureActions) VMResize(arg0 context.Context, arg1, arg2 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "VMResize", arg0, arg1, arg2)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// VMResize indicates an expected call of VMResize.
+func (mr *MockAzureActionsMockRecorder) VMResize(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VMResize", reflect.TypeOf((*MockAzureActions)(nil).VMResize), arg0, arg1, arg2)
+}
+
 // VMSerialConsole mocks base method.
 func (m *MockAzureActions) VMSerialConsole(arg0 context.Context, arg1 http.ResponseWriter, arg2 *logrus.Entry, arg3 string) error {
 	m.ctrl.T.Helper()
@@ -188,3 +203,46 @@ func (mr *MockAzureActionsMockRecorder) VMSerialConsole(arg0, arg1, arg2, arg3 i
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VMSerialConsole", reflect.TypeOf((*MockAzureActions)(nil).VMSerialConsole), arg0, arg1, arg2, arg3)
 }
+
+// VMSizeList mocks base method.
+func (m *MockAzureActions) VMSizeList(arg0 context.Context) ([]compute.ResourceSku, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "VMSizeList", arg0)
+	ret0, _ := ret[0].([]compute.ResourceSku)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// VMSizeList indicates an expected call of VMSizeList.
+func (mr *MockAzureActionsMockRecorder) VMSizeList(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VMSizeList", reflect.TypeOf((*MockAzureActions)(nil).VMSizeList), arg0)
+}
+
+// VMStartAndWait mocks base method.
+func (m *MockAzureActions) VMStartAndWait(arg0 context.Context, arg1 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "VMStartAndWait", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// VMStartAndWait indicates an expected call of VMStartAndWait.
+func (mr *MockAzureActionsMockRecorder) VMStartAndWait(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VMStartAndWait", reflect.TypeOf((*MockAzureActions)(nil).VMStartAndWait), arg0, arg1)
+}
+
+// VMStopAndWait mocks base method.
+func (m *MockAzureActions) VMStopAndWait(arg0 context.Context, arg1 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "VMStopAndWait", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// VMStopAndWait indicates an expected call of VMStopAndWait.
+func (mr *MockAzureActionsMockRecorder) VMStopAndWait(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VMStopAndWait", reflect.TypeOf((*MockAzureActions)(nil).VMStopAndWait), arg0, arg1)
+}
diff --git a/pkg/util/mocks/azureclient/mgmt/compute/compute.go b/pkg/util/mocks/azureclient/mgmt/compute/compute.go
index f06be48d1..299207613 100644
--- a/pkg/util/mocks/azureclient/mgmt/compute/compute.go
+++ b/pkg/util/mocks/azureclient/mgmt/compute/compute.go
@@ -211,6 +211,20 @@ func (mr *MockVirtualMachinesClientMockRecorder) StartAndWait(arg0, arg1, arg2 i
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartAndWait", reflect.TypeOf((*MockVirtualMachinesClient)(nil).StartAndWait), arg0, arg1, arg2)
 }
 
+// StopAndWait mocks base method.
+func (m *MockVirtualMachinesClient) StopAndWait(arg0 context.Context, arg1, arg2 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "StopAndWait", arg0, arg1, arg2)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// StopAndWait indicates an expected call of StopAndWait.
+func (mr *MockVirtualMachinesClientMockRecorder) StopAndWait(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopAndWait", reflect.TypeOf((*MockVirtualMachinesClient)(nil).StopAndWait), arg0, arg1, arg2)
+}
+
 // MockUsageClient is a mock of UsageClient interface.
 type MockUsageClient struct {
 	ctrl     *gomock.Controller
diff --git a/pkg/util/namespace/namespace.go b/pkg/util/namespace/namespace.go
index fd7fd785c..3b9a98979 100644
--- a/pkg/util/namespace/namespace.go
+++ b/pkg/util/namespace/namespace.go
@@ -3,23 +3,74 @@ package namespace
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
 
-import (
-	"strings"
-)
+// IsOpenShiftNamespace returns true if ns is a namespace in the defined hardcoded map.
+// We should only add new namespaces into this hardcoded list but never delete
+// the existing ones in the namespace list to avoid backward compatibility issues.
+func IsOpenShiftNamespace(ns string) bool {
+	nsmap := map[string]struct{}{
+		// Allow validation against non-namespaced objects via Geneva actions
+		"": {},
 
-// IsOpenShift returns true if ns is an openshift managed namespace, including the default namespace
-func IsOpenShift(ns string) bool {
-	return ns == "" ||
-		ns == "default" ||
-		ns == "openshift" ||
-		strings.HasPrefix(ns, "kube-") ||
-		strings.HasPrefix(ns, "openshift-")
-}
+		// ARO specific namespaces
+		"openshift-azure-logging":            {},
+		"openshift-azure-operator":           {},
+		"openshift-managed-upgrade-operator": {},
 
-// IsOpenShiftSystemNamespace returns true if ns is an openshift managed namespace, without the default namespace
-func IsOpenShiftSystemNamespace(ns string) bool {
-	return ns == "" ||
-		ns == "openshift" ||
-		strings.HasPrefix(ns, "kube-") ||
-		strings.HasPrefix(ns, "openshift-")
+		// OCP namespaces
+		"openshift":                                        {},
+		"openshift-apiserver":                              {},
+		"openshift-apiserver-operator":                     {},
+		"openshift-authentication-operator":                {},
+		"openshift-cloud-controller-manager":               {},
+		"openshift-cloud-controller-manager-operator":      {},
+		"openshift-cloud-credential-operator":              {},
+		"openshift-cluster-csi-drivers":                    {},
+		"openshift-cluster-machine-approver":               {},
+		"openshift-cluster-node-tuning-operator":           {},
+		"openshift-cluster-samples-operator":               {},
+		"openshift-cluster-storage-operator":               {},
+		"openshift-config":                                 {},
+		"openshift-config-managed":                         {},
+		"openshift-config-operator":                        {},
+		"openshift-console":                                {},
+		"openshift-console-operator":                       {},
+		"openshift-console-user-settings":                  {},
+		"openshift-controller-manager":                     {},
+		"openshift-controller-manager-operator":            {},
+		"openshift-dns":                                    {},
+		"openshift-dns-operator":                           {},
+		"openshift-etcd":                                   {},
+		"openshift-etcd-operator":                          {},
+		"openshift-host-network":                           {},
+		"openshift-image-registry":                         {},
+		"openshift-ingress":                                {},
+		"openshift-ingress-canary":                         {},
+		"openshift-ingress-operator":                       {},
+		"openshift-insights":                               {},
+		"openshift-kni-infra":                              {},
+		"openshift-kube-apiserver":                         {},
+		"openshift-kube-apiserver-operator":                {},
+		"openshift-kube-controller-manager":                {},
+		"openshift-kube-controller-manager-operator":       {},
+		"openshift-kube-scheduler":                         {},
+		"openshift-kube-scheduler-operator":                {},
+		"openshift-kube-storage-version-migrator":          {},
+		"openshift-kube-storage-version-migrator-operator": {},
+		"openshift-machine-api":                            {},
+		"openshift-machine-config-operator":                {},
+		"openshift-marketplace":                            {},
+		"openshift-monitoring":                             {},
+		"openshift-multus":                                 {},
+		"openshift-network-diagnostics":                    {},
+		"openshift-network-operator":                       {},
+		"openshift-oauth-apiserver":                        {},
+		"openshift-openstack-infra":                        {},
+		"openshift-operators":                              {},
+		"openshift-ovirt-infra":                            {},
+		"openshift-sdn":                                    {},
+		"openshift-service-ca":                             {},
+		"openshift-service-ca-operator":                    {},
+	}
+	_, ok := nsmap[ns]
+	return ok
 }
diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go
index cccc8dd68..1820d022b 100644
--- a/pkg/util/version/version.go
+++ b/pkg/util/version/version.go
@@ -77,3 +77,7 @@ func (v *Version) Eq(w *Version) bool {
 	}
 	return true
 }
+
+func (v *Version) MinorVersion() string {
+	return fmt.Sprintf("%d.%d", v.V[0], v.V[1])
+}


Test patch:
diff --git a/pkg/api/validate/dynamic/sku_test.go b/pkg/api/validate/dynamic/sku_test.go
index 1a31b013d..71c35aa06 100644
--- a/pkg/api/validate/dynamic/sku_test.go
+++ b/pkg/api/validate/dynamic/sku_test.go
@@ -23,22 +23,50 @@ func TestValidateVMSku(t *testing.T) {
 		name                  string
 		restrictions          mgmtcompute.ResourceSkuRestrictionsReasonCode
 		restrictionLocation   *[]string
+		restrictedZones       []string
 		targetLocation        string
 		workerProfile1Sku     string
 		workerProfile2Sku     string
 		masterProfileSku      string
 		availableSku          string
+		availableSku2         string
 		restrictedSku         string
 		resourceSkusClientErr error
 		wantErr               string
 	}{
 		{
-			name:              "worker and master sku are valid",
+			name:              "worker and master skus are valid",
 			workerProfile1Sku: "Standard_D4s_v2",
 			workerProfile2Sku: "Standard_D4s_v2",
 			masterProfileSku:  "Standard_D4s_v2",
 			availableSku:      "Standard_D4s_v2",
 		},
+		{
+			name:              "worker and master skus are distinct, both valid",
+			workerProfile1Sku: "Standard_E104i_v5",
+			workerProfile2Sku: "Standard_E104i_v5",
+			masterProfileSku:  "Standard_D4s_v2",
+			availableSku:      "Standard_E104i_v5",
+			availableSku2:     "Standard_D4s_v2",
+		},
+		{
+			name:              "worker and master skus are distinct, one invalid",
+			workerProfile1Sku: "Standard_E104i_v5",
+			workerProfile2Sku: "Standard_E104i_v5",
+			masterProfileSku:  "Standard_D4s_v2",
+			availableSku:      "Standard_E104i_v5",
+			availableSku2:     "Standard_E104i_v5",
+			wantErr:           "400: InvalidParameter: properties.masterProfile.VMSize: The selected SKU 'Standard_D4s_v2' is unavailable in region 'eastus'",
+		},
+		{
+			name:              "worker and master skus are distinct, both invalid",
+			workerProfile1Sku: "Standard_E104i_v5",
+			workerProfile2Sku: "Standard_E104i_v5",
+			masterProfileSku:  "Standard_D4s_v2",
+			availableSku:      "Standard_L8s_v2",
+			availableSku2:     "Standard_L16s_v2",
+			wantErr:           "400: InvalidParameter: properties.masterProfile.VMSize: The selected SKU 'Standard_D4s_v2' is unavailable in region 'eastus'",
+		},
 		{
 			name:                  "unable to retrieve skus information",
 			workerProfile1Sku:     "Standard_D4s_v2",
@@ -96,12 +124,30 @@ func TestValidateVMSku(t *testing.T) {
 			restrictedSku:     "Standard_L80",
 			wantErr:           "400: InvalidParameter: properties.masterProfile.VMSize: The selected SKU 'Standard_L80' is restricted in region 'eastus' for selected subscription",
 		},
+		{
+			name:         "sku is restricted in a single zone",
+			restrictions: mgmtcompute.NotAvailableForSubscription,
+			restrictionLocation: &[]string{
+				"eastus",
+			},
+			restrictedZones:   []string{"3"},
+			workerProfile1Sku: "Standard_D4s_v2",
+			workerProfile2Sku: "Standard_D4s_v2",
+			masterProfileSku:  "Standard_L80",
+			availableSku:      "Standard_D4s_v2",
+			restrictedSku:     "Standard_L80",
+			wantErr:           "400: InvalidParameter: properties.masterProfile.VMSize: The selected SKU 'Standard_L80' is restricted in region 'eastus' for selected subscription",
+		},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			if tt.targetLocation == "" {
 				tt.targetLocation = "eastus"
 			}
 
+			if tt.restrictedZones == nil {
+				tt.restrictedZones = []string{"1", "2", "3"}
+			}
+
 			controller := gomock.NewController(t)
 			defer controller.Finish()
 
@@ -132,11 +178,21 @@ func TestValidateVMSku(t *testing.T) {
 					Capabilities: &[]mgmtcompute.ResourceSkuCapabilities{},
 					ResourceType: to.StringPtr("virtualMachines"),
 				},
+				{
+					Name:      &tt.availableSku2,
+					Locations: &[]string{"eastus"},
+					LocationInfo: &[]mgmtcompute.ResourceSkuLocationInfo{
+						{Zones: &[]string{"1, 2, 3"}},
+					},
+					Restrictions: &[]mgmtcompute.ResourceSkuRestrictions{},
+					Capabilities: &[]mgmtcompute.ResourceSkuCapabilities{},
+					ResourceType: to.StringPtr("virtualMachines"),
+				},
 				{
 					Name:      &tt.restrictedSku,
 					Locations: &[]string{tt.targetLocation},
 					LocationInfo: &[]mgmtcompute.ResourceSkuLocationInfo{
-						{Zones: &[]string{"1, 2, 3"}},
+						{Zones: &tt.restrictedZones},
 					},
 					Restrictions: &[]mgmtcompute.ResourceSkuRestrictions{
 						{
diff --git a/pkg/api/validate/vm_test.go b/pkg/api/validate/vm_test.go
new file mode 100644
index 000000000..b2d551cb2
--- /dev/null
+++ b/pkg/api/validate/vm_test.go
@@ -0,0 +1,105 @@
+package validate
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"testing"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+)
+
+func TestDiskSizeIsValid(t *testing.T) {
+	for _, tt := range []struct {
+		name          string
+		diskSize      int
+		desiredResult bool
+	}{
+		{
+			name:          "size is valid",
+			diskSize:      129,
+			desiredResult: true,
+		},
+		{
+			name:          "size is not valid",
+			diskSize:      127,
+			desiredResult: false,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			result := DiskSizeIsValid(tt.diskSize)
+
+			if result != tt.desiredResult {
+				t.Errorf("Want %v, got %v", tt.desiredResult, result)
+			}
+		})
+	}
+}
+
+func TestVMSizeIsValid(t *testing.T) {
+	for _, tt := range []struct {
+		name                string
+		vmSize              api.VMSize
+		requireD2sV3Workers bool
+		isMaster            bool
+		desiredResult       bool
+	}{
+		{
+			name:                "vmSize is supported for use in ARO as worker node",
+			vmSize:              api.VMSizeStandardF72sV2,
+			requireD2sV3Workers: false,
+			isMaster:            false,
+			desiredResult:       true,
+		},
+		{
+			name:                "vmSize is not supported for use in ARO as worker node",
+			vmSize:              api.VMSize("Unsupported_Csv_v6"),
+			requireD2sV3Workers: false,
+			isMaster:            false,
+			desiredResult:       false,
+		},
+		{
+			name:                "vmSize is supported for use in ARO as master node",
+			vmSize:              api.VMSizeStandardF72sV2,
+			requireD2sV3Workers: false,
+			isMaster:            true,
+			desiredResult:       true,
+		},
+		{
+			name:                "vmSize is not supported for use in ARO as master node",
+			vmSize:              api.VMSizeStandardD2sV3,
+			requireD2sV3Workers: false,
+			isMaster:            true,
+			desiredResult:       false,
+		},
+		{
+			name:                "install requires Standard_D2s_v3 workers, worker vmSize is not Standard_D2s_v3",
+			vmSize:              api.VMSizeStandardF72sV2,
+			requireD2sV3Workers: true,
+			isMaster:            false,
+			desiredResult:       false,
+		},
+		{
+			name:                "install requires Standard_D2s_v3 workers, worker vmSize is Standard_D2s_v3",
+			vmSize:              api.VMSizeStandardD2sV3,
+			requireD2sV3Workers: true,
+			isMaster:            false,
+			desiredResult:       true,
+		},
+		{
+			name:                "install requires Standard_D2s_v3 workers, vmSize is is a master",
+			vmSize:              api.VMSizeStandardF72sV2,
+			requireD2sV3Workers: true,
+			isMaster:            true,
+			desiredResult:       true,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			result := VMSizeIsValid(tt.vmSize, tt.requireD2sV3Workers, tt.isMaster)
+
+			if result != tt.desiredResult {
+				t.Errorf("Want %v, got %v", tt.desiredResult, result)
+			}
+		})
+	}
+}
diff --git a/pkg/frontend/admin_openshiftcluster_kubernetesobjects_test.go b/pkg/frontend/admin_openshiftcluster_kubernetesobjects_test.go
index 31ec22fcd..bae3bbd56 100644
--- a/pkg/frontend/admin_openshiftcluster_kubernetesobjects_test.go
+++ b/pkg/frontend/admin_openshiftcluster_kubernetesobjects_test.go
@@ -45,7 +45,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			name:         "cluster exist in db - get",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
 			objKind:      "ConfigMap",
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			objName:      "config",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 				k.EXPECT().
@@ -60,7 +60,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			name:         "cluster exist in db - list",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
 			objKind:      "ConfigMap",
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 				k.EXPECT().
 					KubeList(gomock.Any(), tt.objKind, tt.objNamespace).
@@ -73,7 +73,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			method:       http.MethodGet,
 			name:         "no groupKind provided",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			objName:      "config",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 			},
@@ -85,7 +85,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			name:         "secret requested",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
 			objKind:      "Secret",
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			objName:      "config",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 			},
@@ -97,7 +97,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			name:         "cluster exist in db",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
 			objKind:      "ConfigMap",
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			objName:      "config",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 				k.EXPECT().
@@ -110,7 +110,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			method:       http.MethodDelete,
 			name:         "no groupKind provided",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			objName:      "config",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 			},
@@ -122,7 +122,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			name:         "no name provided",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
 			objKind:      "this",
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 			},
 			wantStatusCode: http.StatusBadRequest,
@@ -133,7 +133,7 @@ func TestAdminKubernetesObjectsGetAndDelete(t *testing.T) {
 			name:         "secret requested",
 			resourceID:   fmt.Sprintf("/subscriptions/%s/resourcegroups/resourceGroup/providers/Microsoft.RedHatOpenShift/openShiftClusters/resourceName", mockSubID),
 			objKind:      "Secret",
-			objNamespace: "openshift-project",
+			objNamespace: "openshift",
 			objName:      "config",
 			mocks: func(tt *test, k *mock_adminactions.MockKubeActions) {
 			},
@@ -222,21 +222,21 @@ func TestValidateAdminKubernetesObjectsNonCustomer(t *testing.T) {
 		{
 			test:      "invalid groupKind",
 			groupKind: "$",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			name:      "Valid-NAME-01",
 			wantErr:   "400: InvalidParameter: : The provided groupKind '$' is invalid.",
 		},
 		{
 			test:      "forbidden groupKind",
 			groupKind: "Secret",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			name:      "Valid-NAME-01",
 			wantErr:   "403: Forbidden: : Access to secrets is forbidden.",
 		},
 		{
 			test:      "forbidden groupKind",
 			groupKind: "Anything.oauth.openshift.io",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			name:      "Valid-NAME-01",
 			wantErr:   "403: Forbidden: : Access to secrets is forbidden.",
 		},
@@ -266,7 +266,7 @@ func TestValidateAdminKubernetesObjectsNonCustomer(t *testing.T) {
 		},
 		{
 			test:      "empty groupKind",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			name:      "Valid-NAME-01",
 			wantErr:   "400: InvalidParameter: : The provided groupKind '' is invalid.",
 		},
@@ -275,12 +275,12 @@ func TestValidateAdminKubernetesObjectsNonCustomer(t *testing.T) {
 			groupKind: "Valid-kind.openshift.io",
 			namespace: "openshift-/",
 			name:      "Valid-NAME-01",
-			wantErr:   "400: InvalidParameter: : The provided namespace 'openshift-/' is invalid.",
+			wantErr:   "403: Forbidden: : Access to the provided namespace 'openshift-/' is forbidden.",
 		},
 		{
 			test:      "invalid name",
 			groupKind: "Valid-kind.openshift.io",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			name:      longName,
 			wantErr:   "400: InvalidParameter: : The provided name '" + longName + "' is invalid.",
 		},
@@ -288,14 +288,14 @@ func TestValidateAdminKubernetesObjectsNonCustomer(t *testing.T) {
 			test:      "post: empty name",
 			method:    http.MethodPost,
 			groupKind: "Valid-kind.openshift.io",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			wantErr:   "400: InvalidParameter: : The provided name '' is invalid.",
 		},
 		{
 			test:      "delete: empty name",
 			method:    http.MethodDelete,
 			groupKind: "Valid-kind.openshift.io",
-			namespace: "openshift-ns",
+			namespace: "openshift",
 			wantErr:   "400: InvalidParameter: : The provided name '' is invalid.",
 		},
 	} {
@@ -353,6 +353,10 @@ func TestAdminPostKubernetesObjects(t *testing.T) {
 			objInBody: &unstructured.Unstructured{
 				Object: map[string]interface{}{
 					"kind": "Secret",
+					"metadata": map[string]interface{}{
+						"namespace": "openshift",
+						"name":      "secret",
+					},
 				},
 			},
 			mocks:          func(tt *test, k *mock_adminactions.MockKubeActions) {},
diff --git a/pkg/frontend/admin_openshiftcluster_startvm_test.go b/pkg/frontend/admin_openshiftcluster_startvm_test.go
new file mode 100644
index 000000000..cc3553bd4
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_startvm_test.go
@@ -0,0 +1,110 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/env"
+	"github.com/Azure/ARO-RP/pkg/frontend/adminactions"
+	"github.com/Azure/ARO-RP/pkg/metrics/noop"
+	mock_adminactions "github.com/Azure/ARO-RP/pkg/util/mocks/adminactions"
+	testdatabase "github.com/Azure/ARO-RP/test/database"
+)
+
+func TestAdminStartVM(t *testing.T) {
+	mockSubID := "00000000-0000-0000-0000-000000000000"
+	mockTenantID := "00000000-0000-0000-0000-000000000000"
+
+	ctx := context.Background()
+
+	type test struct {
+		name           string
+		resourceID     string
+		fixture        func(*testdatabase.Fixture)
+		vmName         string
+		mocks          func(*test, *mock_adminactions.MockAzureActions)
+		wantStatusCode int
+		wantResponse   []byte
+		wantError      string
+	}
+
+	for _, tt := range []*test{
+		{
+			name:       "basic coverage",
+			vmName:     "aro-worker-australiasoutheast-7tcq7",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks: func(tt *test, a *mock_adminactions.MockAzureActions) {
+				a.EXPECT().VMStartAndWait(gomock.Any(), tt.vmName).Return(nil)
+			},
+			wantStatusCode: http.StatusOK,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			ti := newTestInfra(t).WithOpenShiftClusters().WithSubscriptions()
+			defer ti.done()
+
+			a := mock_adminactions.NewMockAzureActions(ti.controller)
+			tt.mocks(tt, a)
+
+			err := ti.buildFixtures(tt.fixture)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			f, err := NewFrontend(ctx, ti.audit, ti.log, ti.env, ti.asyncOperationsDatabase, ti.openShiftClustersDatabase, ti.subscriptionsDatabase, api.APIs, &noop.Noop{}, nil, nil, func(*logrus.Entry, env.Interface, *api.OpenShiftCluster, *api.SubscriptionDocument) (adminactions.AzureActions, error) {
+				return a, nil
+			}, nil)
+
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			go f.Run(ctx, nil, nil)
+
+			resp, b, err := ti.request(http.MethodPost,
+				fmt.Sprintf("https://server/admin%s/startvm?vmName=%s", tt.resourceID, tt.vmName),
+				nil, nil)
+			if err != nil {
+				t.Error(err)
+			}
+
+			err = validateResponse(resp, b, tt.wantStatusCode, tt.wantError, tt.wantResponse)
+			if err != nil {
+				t.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/frontend/admin_openshiftcluster_stopvm_test.go b/pkg/frontend/admin_openshiftcluster_stopvm_test.go
new file mode 100644
index 000000000..4f7ac5970
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_stopvm_test.go
@@ -0,0 +1,110 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/env"
+	"github.com/Azure/ARO-RP/pkg/frontend/adminactions"
+	"github.com/Azure/ARO-RP/pkg/metrics/noop"
+	mock_adminactions "github.com/Azure/ARO-RP/pkg/util/mocks/adminactions"
+	testdatabase "github.com/Azure/ARO-RP/test/database"
+)
+
+func TestAdminStopVM(t *testing.T) {
+	mockSubID := "00000000-0000-0000-0000-000000000000"
+	mockTenantID := "00000000-0000-0000-0000-000000000000"
+
+	ctx := context.Background()
+
+	type test struct {
+		name           string
+		resourceID     string
+		fixture        func(*testdatabase.Fixture)
+		vmName         string
+		mocks          func(*test, *mock_adminactions.MockAzureActions)
+		wantStatusCode int
+		wantResponse   []byte
+		wantError      string
+	}
+
+	for _, tt := range []*test{
+		{
+			name:       "basic coverage",
+			vmName:     "aro-worker-australiasoutheast-7tcq7",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks: func(tt *test, a *mock_adminactions.MockAzureActions) {
+				a.EXPECT().VMStopAndWait(gomock.Any(), tt.vmName).Return(nil)
+			},
+			wantStatusCode: http.StatusOK,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			ti := newTestInfra(t).WithOpenShiftClusters().WithSubscriptions()
+			defer ti.done()
+
+			a := mock_adminactions.NewMockAzureActions(ti.controller)
+			tt.mocks(tt, a)
+
+			err := ti.buildFixtures(tt.fixture)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			f, err := NewFrontend(ctx, ti.audit, ti.log, ti.env, ti.asyncOperationsDatabase, ti.openShiftClustersDatabase, ti.subscriptionsDatabase, api.APIs, &noop.Noop{}, nil, nil, func(*logrus.Entry, env.Interface, *api.OpenShiftCluster, *api.SubscriptionDocument) (adminactions.AzureActions, error) {
+				return a, nil
+			}, nil)
+
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			go f.Run(ctx, nil, nil)
+
+			resp, b, err := ti.request(http.MethodPost,
+				fmt.Sprintf("https://server/admin%s/stopvm?vmName=%s", tt.resourceID, tt.vmName),
+				nil, nil)
+			if err != nil {
+				t.Error(err)
+			}
+
+			err = validateResponse(resp, b, tt.wantStatusCode, tt.wantError, tt.wantResponse)
+			if err != nil {
+				t.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/frontend/admin_openshiftcluster_vmresize_test.go b/pkg/frontend/admin_openshiftcluster_vmresize_test.go
new file mode 100644
index 000000000..97897c6a8
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_vmresize_test.go
@@ -0,0 +1,240 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/env"
+	"github.com/Azure/ARO-RP/pkg/frontend/adminactions"
+	"github.com/Azure/ARO-RP/pkg/metrics/noop"
+	mock_adminactions "github.com/Azure/ARO-RP/pkg/util/mocks/adminactions"
+	testdatabase "github.com/Azure/ARO-RP/test/database"
+)
+
+func TestAdminVMResize(t *testing.T) {
+	mockSubID := "00000000-0000-0000-0000-000000000000"
+	mockTenantID := "00000000-0000-0000-0000-000000000000"
+	ctx := context.Background()
+
+	type test struct {
+		name           string
+		resourceID     string
+		vmName         string
+		vmSize         string
+		fixture        func(f *testdatabase.Fixture)
+		mocks          func(*test, *mock_adminactions.MockAzureActions, *mock_adminactions.MockKubeActions)
+		wantStatusCode int
+		wantResponse   []byte
+		wantError      string
+	}
+
+	for _, tt := range []*test{
+		{
+			name:       "basic coverage",
+			vmName:     "aro-fake-node-0",
+			vmSize:     "Standard_D8s_v3",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks: func(tt *test, a *mock_adminactions.MockAzureActions, k *mock_adminactions.MockKubeActions) {
+				node := corev1.Node{
+					TypeMeta: metav1.TypeMeta{
+						Kind: "node",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "aro-fake-node-0",
+
+						Labels: map[string]string{
+							"node-role.kubernetes.io/master": "true",
+						},
+					},
+				}
+				nodeList := corev1.NodeList{
+					TypeMeta: metav1.TypeMeta{
+						Kind: "List",
+					},
+					Items: []corev1.Node{node},
+				}
+				marsh, _ := json.Marshal(nodeList)
+				k.EXPECT().KubeList(gomock.Any(), "node", "").Return([]byte(marsh), nil)
+				a.EXPECT().VMResize(gomock.Any(), tt.vmName, tt.vmSize).Return(nil)
+			},
+			wantStatusCode: http.StatusOK,
+		},
+		{
+			name:       "cluster not found",
+			vmName:     "aro-fake-node-0",
+			vmSize:     "Standard_D8s_v3",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks:          func(tt *test, a *mock_adminactions.MockAzureActions, k *mock_adminactions.MockKubeActions) {},
+			wantStatusCode: http.StatusNotFound,
+			wantError:      `404: ResourceNotFound: : The Resource 'openshiftclusters/resourcename' under resource group 'resourcegroup' was not found.`,
+		},
+		{
+			name:       "subscription doc not found",
+			vmName:     "aro-fake-node-0",
+			vmSize:     "Standard_D8s_v3",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+			},
+			mocks:          func(tt *test, a *mock_adminactions.MockAzureActions, k *mock_adminactions.MockKubeActions) {},
+			wantStatusCode: http.StatusBadRequest,
+			wantError:      fmt.Sprintf(`400: InvalidSubscriptionState: : Request is not allowed in unregistered subscription '%s'.`, mockSubID),
+		},
+		{
+			name:       "master node not found",
+			vmName:     "aro-fake-node-0",
+			vmSize:     "Standard_D8s_v3",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks: func(tt *test, a *mock_adminactions.MockAzureActions, k *mock_adminactions.MockKubeActions) {
+				node := corev1.Node{
+					TypeMeta: metav1.TypeMeta{
+						Kind: "node",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "aro-fake-node-0",
+
+						Labels: map[string]string{
+							"node-role.kubernetes.io/worker": "true",
+						},
+					},
+				}
+				nodeList := corev1.NodeList{
+					TypeMeta: metav1.TypeMeta{
+						Kind: "List",
+					},
+					Items: []corev1.Node{node},
+				}
+				marsh, _ := json.Marshal(nodeList)
+				k.EXPECT().KubeList(gomock.Any(), "node", "").Return([]byte(marsh), nil)
+			},
+			wantStatusCode: http.StatusNotFound,
+			wantError:      `404: NotFound: : "The master node 'aro-fake-node-0' under resource group 'resourcegroup' was not found."`,
+		},
+		{
+			name:           "invalid vmname",
+			vmName:         "%26&ampersandvmname",
+			resourceID:     testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			vmSize:         "Standard_D8s_v3",
+			mocks:          func(tt *test, a *mock_adminactions.MockAzureActions, k *mock_adminactions.MockKubeActions) {},
+			wantStatusCode: http.StatusBadRequest,
+			wantError:      `400: InvalidParameter: : The provided vmName '&' is invalid.`,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			ti := newTestInfra(t).WithSubscriptions().WithOpenShiftClusters()
+			defer ti.done()
+
+			a := mock_adminactions.NewMockAzureActions(ti.controller)
+			k := mock_adminactions.NewMockKubeActions(ti.controller)
+			tt.mocks(tt, a, k)
+
+			err := ti.buildFixtures(tt.fixture)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			f, err := NewFrontend(ctx, ti.audit, ti.log, ti.env, ti.asyncOperationsDatabase, ti.openShiftClustersDatabase, ti.subscriptionsDatabase, api.APIs, &noop.Noop{}, nil,
+				func(e *logrus.Entry, i env.Interface, osc *api.OpenShiftCluster) (adminactions.KubeActions, error) {
+					return k, nil
+				}, func(*logrus.Entry, env.Interface, *api.OpenShiftCluster, *api.SubscriptionDocument) (adminactions.AzureActions, error) {
+					return a, nil
+				}, nil)
+
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			go f.Run(ctx, nil, nil)
+
+			resp, b, err := ti.request(http.MethodPost,
+				fmt.Sprintf("https://server/admin%s/resize?vmName=%s&vmSize=%s", tt.resourceID, tt.vmName, tt.vmSize),
+				nil, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			err = validateResponse(resp, b, tt.wantStatusCode, tt.wantError, tt.wantResponse)
+			if err != nil {
+				t.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/frontend/admin_openshiftcluster_vmsizelist_test.go b/pkg/frontend/admin_openshiftcluster_vmsizelist_test.go
new file mode 100644
index 000000000..4347e3436
--- /dev/null
+++ b/pkg/frontend/admin_openshiftcluster_vmsizelist_test.go
@@ -0,0 +1,162 @@
+package frontend
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	mgmtcompute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2020-06-01/compute"
+	"github.com/Azure/go-autorest/autorest/to"
+	"github.com/golang/mock/gomock"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/env"
+	"github.com/Azure/ARO-RP/pkg/frontend/adminactions"
+	"github.com/Azure/ARO-RP/pkg/metrics/noop"
+	mock_adminactions "github.com/Azure/ARO-RP/pkg/util/mocks/adminactions"
+	testdatabase "github.com/Azure/ARO-RP/test/database"
+)
+
+func TestAdminListVMSizeList(t *testing.T) {
+	mockSubID := "00000000-0000-0000-0000-000000000000"
+	mockTenantID := "00000000-0000-0000-0000-000000000000"
+	ctx := context.Background()
+
+	type test struct {
+		name           string
+		resourceID     string
+		fixture        func(f *testdatabase.Fixture)
+		mocks          func(*test, *mock_adminactions.MockAzureActions)
+		wantStatusCode int
+		wantResponse   []byte
+		wantError      string
+	}
+
+	for _, tt := range []*test{
+		{
+			name:       "happy path",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks: func(tt *test, a *mock_adminactions.MockAzureActions) {
+				a.EXPECT().
+					VMSizeList(gomock.Any()).
+					Return([]mgmtcompute.ResourceSku{
+						{
+							Name: to.StringPtr("Standard_D8s_v90"),
+							Restrictions: &[]mgmtcompute.ResourceSkuRestrictions{
+								{
+									Type: mgmtcompute.Location,
+								},
+							},
+						},
+						{
+							Name:         to.StringPtr("Standard_D8s_v9001"),
+							Restrictions: &[]mgmtcompute.ResourceSkuRestrictions{},
+						},
+					}, nil)
+			},
+			wantStatusCode: http.StatusOK,
+			wantResponse:   []byte(`["Standard_D8s_v9001"]` + "\n"),
+		},
+		{
+			name:       "cluster not found",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddSubscriptionDocuments(&api.SubscriptionDocument{
+					ID: mockSubID,
+					Subscription: &api.Subscription{
+						State: api.SubscriptionStateRegistered,
+						Properties: &api.SubscriptionProperties{
+							TenantID: mockTenantID,
+						},
+					},
+				})
+			},
+			mocks:          func(tt *test, a *mock_adminactions.MockAzureActions) {},
+			wantStatusCode: http.StatusNotFound,
+			wantError:      `404: ResourceNotFound: : The Resource 'openshiftclusters/resourcename' under resource group 'resourcegroup' was not found.`,
+		},
+		{
+			name:       "subscription doc not found",
+			resourceID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+			fixture: func(f *testdatabase.Fixture) {
+				f.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
+					Key: strings.ToLower(testdatabase.GetResourcePath(mockSubID, "resourceName")),
+					OpenShiftCluster: &api.OpenShiftCluster{
+						ID: testdatabase.GetResourcePath(mockSubID, "resourceName"),
+						Properties: api.OpenShiftClusterProperties{
+							ClusterProfile: api.ClusterProfile{
+								ResourceGroupID: fmt.Sprintf("/subscriptions/%s/resourceGroups/test-cluster", mockSubID),
+							},
+						},
+					},
+				})
+			},
+			mocks:          func(tt *test, a *mock_adminactions.MockAzureActions) {},
+			wantStatusCode: http.StatusBadRequest,
+			wantError:      fmt.Sprintf(`400: InvalidSubscriptionState: : Request is not allowed in unregistered subscription '%s'.`, mockSubID),
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			ti := newTestInfra(t).WithSubscriptions().WithOpenShiftClusters()
+			defer ti.done()
+
+			a := mock_adminactions.NewMockAzureActions(ti.controller)
+			tt.mocks(tt, a)
+
+			err := ti.buildFixtures(tt.fixture)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			f, err := NewFrontend(ctx, ti.audit, ti.log, ti.env, ti.asyncOperationsDatabase, ti.openShiftClustersDatabase, ti.subscriptionsDatabase, api.APIs, &noop.Noop{}, nil, nil, func(*logrus.Entry, env.Interface, *api.OpenShiftCluster, *api.SubscriptionDocument) (adminactions.AzureActions, error) {
+				return a, nil
+			}, nil)
+
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			go f.Run(ctx, nil, nil)
+
+			resp, b, err := ti.request(http.MethodGet,
+				fmt.Sprintf("https://server/admin%s/skus", tt.resourceID),
+				nil, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			err = validateResponse(resp, b, tt.wantStatusCode, tt.wantError, tt.wantResponse)
+			if err != nil {
+				t.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/monitor/cluster/clusterversions_test.go b/pkg/monitor/cluster/clusterversions_test.go
index 23fd60838..9ebd186d1 100644
--- a/pkg/monitor/cluster/clusterversions_test.go
+++ b/pkg/monitor/cluster/clusterversions_test.go
@@ -44,6 +44,7 @@ func TestEmitClusterVersion(t *testing.T) {
 		wantProvisionedByResourceProviderVersion string
 		wantAvailableVersion                     string
 		wantAvailableRP                          string
+		wantActualMinorVersion                   string
 	}{
 		{
 			name: "without spec",
@@ -79,6 +80,7 @@ func TestEmitClusterVersion(t *testing.T) {
 			wantProvisionedByResourceProviderVersion: "",
 			wantAvailableVersion:                     "4.5.39",
 			wantAvailableRP:                          "unknown",
+			wantActualMinorVersion:                   "4.5",
 		},
 		{
 			name: "with spec",
@@ -103,6 +105,7 @@ func TestEmitClusterVersion(t *testing.T) {
 			wantDesiredVersion:                       "4.5.4",
 			wantProvisionedByResourceProviderVersion: "",
 			wantAvailableRP:                          "unknown",
+			wantActualMinorVersion:                   "",
 		},
 		{
 			name: "with ProvisionedBy",
@@ -158,6 +161,8 @@ func TestEmitClusterVersion(t *testing.T) {
 				"resourceProviderVersion":              "unknown",
 				"availableVersion":                     tt.wantAvailableVersion,
 				"availableRP":                          tt.wantAvailableRP,
+				"latestGaMinorVersion":                 version.InstallStream.Version.MinorVersion(),
+				"actualMinorVersion":                   tt.wantActualMinorVersion,
 			})
 
 			err := mon.emitClusterVersions(ctx)
diff --git a/pkg/monitor/cluster/podconditions_test.go b/pkg/monitor/cluster/podconditions_test.go
index e29b1f372..8574bffb4 100644
--- a/pkg/monitor/cluster/podconditions_test.go
+++ b/pkg/monitor/cluster/podconditions_test.go
@@ -235,7 +235,7 @@ func TestEmitPodContainerRestartCounter(t *testing.T) {
 		&corev1.Pod{ // #6 Multi-container pod
 			ObjectMeta: metav1.ObjectMeta{
 				Name:      "multi-container-pod",
-				Namespace: "openshift-test",
+				Namespace: "openshift",
 			},
 			Status: corev1.PodStatus{
 				ContainerStatuses: []corev1.ContainerStatus{
@@ -285,7 +285,7 @@ func TestEmitPodContainerRestartCounter(t *testing.T) {
 
 	m.EXPECT().EmitGauge("pod.restartcounter", int64(restartCounterThreshold*2), map[string]string{
 		"name":      "multi-container-pod",
-		"namespace": "openshift-test",
+		"namespace": "openshift",
 	})
 
 	ps, _ := cli.CoreV1().Pods("").List(context.Background(), metav1.ListOptions{})
diff --git a/pkg/operator/controllers/subnets/subnet_controller_test.go b/pkg/operator/controllers/subnets/subnet_controller_test.go
index 34ffad3e6..5221510d2 100644
--- a/pkg/operator/controllers/subnets/subnet_controller_test.go
+++ b/pkg/operator/controllers/subnets/subnet_controller_test.go
@@ -311,6 +311,43 @@ func TestReconcileManager(t *testing.T) {
 				instace.Spec.ArchitectureVersion = int(api.ArchitectureVersionV2)
 			},
 		},
+		{
+			name:                        "Architecture V2 - empty NSG",
+			operatorFlagEnabled:         true,
+			operatorFlagNSG:             true,
+			operatorFlagServiceEndpoint: true,
+			subnetMock: func(mock *mock_subnet.MockManager, kmock *mock_subnet.MockKubeManager) {
+				kmock.EXPECT().List(gomock.Any()).Return([]subnet.Subnet{
+					{
+						ResourceID: subnetResourceIdMaster,
+						IsMaster:   true,
+					},
+					{
+						ResourceID: subnetResourceIdWorker,
+						IsMaster:   false,
+					},
+				}, nil)
+
+				subnetObjectMaster := getValidSubnet()
+				subnetObjectMaster.NetworkSecurityGroup = nil
+				mock.EXPECT().Get(gomock.Any(), subnetResourceIdMaster).Return(subnetObjectMaster, nil).MaxTimes(2)
+
+				subnetObjectMasterUpdate := getValidSubnet()
+				subnetObjectMasterUpdate.NetworkSecurityGroup.ID = to.StringPtr(nsgv2ResourceId)
+				mock.EXPECT().CreateOrUpdate(gomock.Any(), subnetResourceIdMaster, subnetObjectMasterUpdate).Return(nil)
+
+				subnetObjectWorker := getValidSubnet()
+				subnetObjectWorker.NetworkSecurityGroup = nil
+				mock.EXPECT().Get(gomock.Any(), subnetResourceIdWorker).Return(subnetObjectWorker, nil).MaxTimes(2)
+
+				subnetObjectWorkerUpdate := getValidSubnet()
+				subnetObjectWorkerUpdate.NetworkSecurityGroup.ID = to.StringPtr(nsgv2ResourceId)
+				mock.EXPECT().CreateOrUpdate(gomock.Any(), subnetResourceIdWorker, subnetObjectWorkerUpdate).Return(nil)
+			},
+			instance: func(instace *arov1alpha1.Cluster) {
+				instace.Spec.ArchitectureVersion = int(api.ArchitectureVersionV2)
+			},
+		},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			controller := gomock.NewController(t)
diff --git a/pkg/cluster/arm_test.go b/pkg/util/arm/deploy_test.go
similarity index 92%
rename from pkg/cluster/arm_test.go
rename to pkg/util/arm/deploy_test.go
index f4ab08fc1..4ea0b3ba2 100644
--- a/pkg/cluster/arm_test.go
+++ b/pkg/util/arm/deploy_test.go
@@ -1,4 +1,4 @@
-package cluster
+package arm
 
 // Copyright (c) Microsoft Corporation.
 // Licensed under the Apache License 2.0.
@@ -14,7 +14,6 @@ import (
 	"github.com/sirupsen/logrus"
 	"k8s.io/apimachinery/pkg/util/wait"
 
-	"github.com/Azure/ARO-RP/pkg/util/arm"
 	mock_features "github.com/Azure/ARO-RP/pkg/util/mocks/azureclient/mgmt/features"
 )
 
@@ -25,7 +24,7 @@ func TestDeployARMTemplate(t *testing.T) {
 
 	resourceGroup := "fakeResourceGroup"
 
-	armTemplate := &arm.Template{}
+	armTemplate := &Template{}
 	params := map[string]interface{}{}
 
 	deployment := mgmtfeatures.Deployment{
@@ -108,12 +107,9 @@ func TestDeployARMTemplate(t *testing.T) {
 			deploymentsClient := mock_features.NewMockDeploymentsClient(controller)
 			tt.mocks(deploymentsClient)
 
-			m := &manager{
-				log:         logrus.NewEntry(logrus.StandardLogger()),
-				deployments: deploymentsClient,
-			}
+			log := logrus.NewEntry(logrus.StandardLogger())
 
-			err := m.deployARMTemplate(ctx, resourceGroup, deploymentName, armTemplate, params)
+			err := DeployTemplate(ctx, log, deploymentsClient, resourceGroup, deploymentName, armTemplate, params)
 
 			if err != nil && err.Error() != tt.wantErr ||
 				err == nil && tt.wantErr != "" {
diff --git a/pkg/util/dynamichelper/dynamichelper_test.go b/pkg/util/dynamichelper/dynamichelper_test.go
index b5e1791b1..ee14985fd 100644
--- a/pkg/util/dynamichelper/dynamichelper_test.go
+++ b/pkg/util/dynamichelper/dynamichelper_test.go
@@ -11,6 +11,7 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	kruntime "k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 
 	"github.com/Azure/ARO-RP/pkg/util/cmp"
 )
@@ -288,3 +289,76 @@ func TestMerge(t *testing.T) {
 		})
 	}
 }
+
+func TestMakeURLSegments(t *testing.T) {
+	for _, tt := range []struct {
+		gvr         *schema.GroupVersionResource
+		namespace   string
+		uname, name string
+		url         []string
+		want        []string
+	}{
+		{
+			uname: "Group is empty",
+			gvr: &schema.GroupVersionResource{
+				Group:    "",
+				Version:  "4.10",
+				Resource: "test-resource",
+			},
+			namespace: "openshift",
+			name:      "test-name-1",
+			want:      []string{"api", "4.10", "namespaces", "openshift", "test-resource", "test-name-1"},
+		},
+		{
+			uname: "Group is not empty",
+			gvr: &schema.GroupVersionResource{
+				Group:    "test-group",
+				Version:  "4.10",
+				Resource: "test-resource",
+			},
+			namespace: "openshift-apiserver",
+			name:      "test-name-2",
+			want:      []string{"apis", "test-group", "4.10", "namespaces", "openshift-apiserver", "test-resource", "test-name-2"},
+		},
+		{
+			uname: "Namespace is empty",
+			gvr: &schema.GroupVersionResource{
+				Group:    "test-group",
+				Version:  "4.10",
+				Resource: "test-resource",
+			},
+			namespace: "",
+			name:      "test-name-3",
+			want:      []string{"apis", "test-group", "4.10", "test-resource", "test-name-3"},
+		},
+		{
+			uname: "Namespace is not empty",
+			gvr: &schema.GroupVersionResource{
+				Group:    "test-group",
+				Version:  "4.10",
+				Resource: "test-resource",
+			},
+			namespace: "openshift-sdn",
+			name:      "test-name-3",
+			want:      []string{"apis", "test-group", "4.10", "namespaces", "openshift-sdn", "test-resource", "test-name-3"},
+		},
+		{
+			uname: "Name is empty",
+			gvr: &schema.GroupVersionResource{
+				Group:    "test-group",
+				Version:  "4.10",
+				Resource: "test-resource",
+			},
+			namespace: "openshift-ns",
+			name:      "",
+			want:      []string{"apis", "test-group", "4.10", "namespaces", "openshift-ns", "test-resource"},
+		},
+	} {
+		t.Run(tt.uname, func(t *testing.T) {
+			got := makeURLSegments(tt.gvr, tt.namespace, tt.name)
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Error(cmp.Diff(got, tt.want))
+			}
+		})
+	}
+}
diff --git a/pkg/util/namespace/namespace_test.go b/pkg/util/namespace/namespace_test.go
index ae2cd2729..23821b025 100644
--- a/pkg/util/namespace/namespace_test.go
+++ b/pkg/util/namespace/namespace_test.go
@@ -7,73 +7,62 @@ import (
 	"testing"
 )
 
-func TestIsOpenShift(t *testing.T) {
+func TestIsOpenShiftNamespace(t *testing.T) {
 	for _, tt := range []struct {
 		namespace string
 		want      bool
 	}{
 		{
-			want: true,
+			namespace: "",
+			want:      true,
 		},
 		{
-			namespace: "openshift-ns",
+			namespace: "openshift-apiserver",
 			want:      true,
 		},
 		{
-			namespace: "openshift",
+			namespace: "openshift-azure-operator",
 			want:      true,
 		},
 		{
-			namespace: "kube-ns",
+			namespace: "openshift-azure-logging",
 			want:      true,
 		},
 		{
-			namespace: "default",
-			want:      true,
+			namespace: "openshift-gitops",
+			want:      false,
 		},
 		{
-			namespace: "customer",
+			namespace: "openshift-authentication",
+			want:      false,
 		},
-	} {
-		t.Run(tt.namespace, func(t *testing.T) {
-			got := IsOpenShift(tt.namespace)
-			if tt.want != got {
-				t.Error(got)
-			}
-		})
-	}
-}
-
-func TestIsOpenShiftSystemNamespace(t *testing.T) {
-	for _, tt := range []struct {
-		namespace string
-		want      bool
-	}{
 		{
-			want: true,
+			namespace: "default",
+			want:      false,
 		},
 		{
-			namespace: "openshift-ns",
-			want:      true,
+			namespace: "customer",
+			want:      false,
 		},
 		{
-			namespace: "openshift",
-			want:      true,
+			namespace: "openshift-/",
+			want:      false,
 		},
 		{
-			namespace: "kube-ns",
-			want:      true,
+			namespace: "openshift-ns",
+			want:      false,
 		},
 		{
-			namespace: "default",
+			namespace: "kube-ns",
 			want:      false,
 		},
 		{
-			namespace: "customer",
+			namespace: "openshift-operator-lifecycle-manager",
+			want:      false,
 		},
 	} {
 		t.Run(tt.namespace, func(t *testing.T) {
-			got := IsOpenShiftSystemNamespace(tt.namespace)
+			got := IsOpenShiftNamespace(tt.namespace)
 			if tt.want != got {
 				t.Error(got)
 			}
diff --git a/test/e2e/adminapi_kubernetesobjects.go b/test/e2e/adminapi_kubernetesobjects.go
index e91302982..7f665e69a 100644
--- a/test/e2e/adminapi_kubernetesobjects.go
+++ b/test/e2e/adminapi_kubernetesobjects.go
@@ -26,7 +26,7 @@ var _ = Describe("[Admin API] Kubernetes objects action", func() {
 	const objName = "e2e-test-object"
 
 	When("in a standard openshift namespace", func() {
-		const namespace = "default"
+		const namespace = "openshift"
 
 		It("should be able to create, get, list, update and delete objects", func() {
 			defer func() {