Repository: kinvolk/lokomotive
Pull request #219 (2020-03-26 09:59:58)

Add AKS platform support
Draft with PoC of AKS support

TODO:
- [x] full worker pool support
- [x] installing `prometheus-operator` component currently fails
- [x] `errors.Wrapf` -> `fmt.Errorf`
- [x] CI configuration
- [x] Add labels and taints support for worker pools
- [x] Tests
- [x] Validation for all fields + tests for it
- [x] Ensure that all e2e tests are passing
- [x] CI periodic cleanup script
- [x] plug in the CI
- [x] Clarify and implement how should we handle the secrets when creating the cluster
- [x] docs


Follow-up tasks:
- [ ] Add [kured](https://github.com/weaveworks/kured) component for doing reboots for updates
- [ ] Add support for [azurerm Terraform backend type](https://www.terraform.io/docs/backends/types/azurerm.html) for production setups

To test, `az` binary is needed locally and you need to do `az login` before running `lokoctl`.

Refs #215 #216 

invidian: >installing prometheus-operator component currently fails

Hm, this seems to be problem of `prometheus-operator` helm chart and AKS :thinking: 
invidian: It seems that with current latest version of Kubernetes on AKS (`1.16.3`), the `prometheus-operator` installation will fail. If one upgrades to `1.17.3`, which is currently in preview, things start working just fine. The workaround is to make webhook failures be ignored for the time being.
invidian: Added worker labels and taints as the missing feature for it.
invidian: As part of CI process, we need to define, what information we require from the user to create a cluster. Here are the options:
- Full automation: the user logs in using `az login` and has a full access to the subscription, including creating applications and granting roles to them (currently implemented). Terraform will then create service principle **and** resource group for running AKS.
- Require service principle ID and secret: we would still manage the resource group used for AKS, but the service principle will be provided.
- Require service principle ID, secret and resource group: we don't create any other extra resource than AKS, user must create everything by hand before running `lokoctl`
- Make all options optional, so full automation by default, but available fallback lower privileges

If one tries to create AKS cluster using Azure portal, one needs to create resource group to create a resource, but the service principle is being created automatically (and if you cancel creation of AKS, it leaves garbage application registered :smile:, it also has no identifier linking to the attempt of creating AKS cluster. At least it does not create the access secret too).

invidian: >It seems that with current latest version of Kubernetes on AKS (1.16.3), the prometheus-operator installation will fail. If one upgrades to 1.17.3, which is currently in preview, things start working just fine. The workaround is to make webhook failures be ignored for the time being.

Hm, the workaround worked just fine yesterday and now I'm not able to get it to work anymore :(
invidian: >Hm, the workaround worked just fine yesterday and now I'm not able to get it to work anymore :(

I managed to eventually get it to work. But it seems just ignoring webhooks is not sufficient to get it to work, they must be disabled.
invidian: Added today:
- support for labels and taints for worker pools
- initial CI configuration

Still missing:
- documentation
- tests
- CI job
invidian: Progress:
- Added unit tests.
- Added validation rules for all required fields.
- Figured out how to handle both privileged and not so privileged users. Now one can create a cluster just with having service principle credentials.

Missing:
- CI job
- documentation
- CI cleanup script
- including components tests into the CI
invidian: Hm, I just realized there is one more distinction, which we possibly need to do. There are actually 2 types of credentials, which we need to handle here. One is ones, which are used for running Terraform and which create all the resources and another one is, which will be used to actually run the AKS cluster :confused: 
invidian: Currently, we fail to scrape following metrics:
```
    TestPrometheus/ComponentMetrics/prometheus-kube-proxy: components_metrics_test.go:98: Running scrape test iteration #19
    TestPrometheus/ComponentMetrics/prometheus-kube-scheduler: components_metrics_test.go:98: Running scrape test iteration #19
    TestPrometheus/ComponentMetrics/prometheus-coredns: components_metrics_test.go:98: Running scrape test iteration #19
```

Additionally, alerts for missing controlplane pods are currently triggering, they need to be disabled.
invidian: I resolved the issue with `prometheus-operator`. As some K8s components are not available for scraping in AKS, I had to disable alerts and scraping for them. CoreDNS required special care, with customized selector for pods.

I also have test CI job prepared, which is currently passing with all tests.

I also started looking into CI cleanup script, and it seems there should be relatively easy way to do the cleanups, based on selecting all resources with `owner` tag and then removing them based on `updated-at` tag. Need to dive in into Python SDK for Azure.

Left TODO:
- [ ] Convert test CI job to production pipelines
- [ ] Add AKS documentation
- [ ] CI cleanup script
- [ ] Clarify and implement how should we handle the secrets when creating the cluster

invidian: Done:
- Convert test CI job to production pipelines (PRs are created)
- CI cleanup script

Left TODO:
- Add documentation
- Clarify and implement how should we handle the secrets when creating the cluster
iaguis: Regarding https://github.com/kinvolk/lokomotive/pull/219#issuecomment-605099625 I think the last option (full automation by default but allow the user to pass other options if they have it) makes most sense to me.

For the more restrictive option, intuitively it feels like we should mimic what the Azure UI does: so we should require passing the Resource Group and Lokomotive would create the Service Principal but I don't have much experience with Azure so I'm not sure if the options you mention (require Service Principal ID and Secret and Lokomotive would manage the SP and/or the RG) make more sense.
invidian: >Clarify and implement how should we handle the secrets when creating the cluster

I decided to take similar approach as with `aws`, where we do not validate the credentials used to create cluster, we leave Terraform to do it + the approach with backend/external-dns component, where we allow to fetch the AWS credentials from the environment variables. **However**, to avoid interfering with Terraform authentication, I use separate set of environment variables to pass information to the platform. They start with `LOKOMOTIVE_AKS_` prefix, to indicate the use.

With that resolved, the only missing piece is documentation.
invidian: I added " Add support for azurerm Terraform backend type for production setups" as a follow-up task, as I spotted, that for production example configuration, we always recommend to use remote state and while we work on Azure, it would probably make sense to store the state there as well.
invidian: >Added some comments and suggestions.

I addressed them all. Did you have a chance to try it out to verify, that it works for you?
iaguis: > I addressed them all. Did you have a chance to try it out to verify, that it works for you?

I ran through the quickstart and everything worked smoothly :+1: 
iaguis: I guess for managed platforms we should have other ways to deploy PodSecurityPolicies and things like that other than bootkube, right? Maybe we can have a list of differences there and add them over time?
invidian: >I guess for managed platforms we should have other ways to deploy PodSecurityPolicies and things like that other than bootkube, right? Maybe we can have a list of differences there and add them over time?

Hm, should I try to make a list of noticeable differences for the user and add it to documentation?
iaguis: > Hm, should I try to make a list of noticeable differences for the user and add it to documentation?

Yes, and we should have associated tracking issues.
iaguis: Oops, accidental click :sweat_smile: 
invidian: >Feel free to document differences on a different PR.

Will do, as I have other follow-up issues to create anyway.

>LGTM.

Now only to get CI passing...

Patch:
diff --git a/Makefile b/Makefile
index 57c08a81..4d9ac213 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ VERSION :=
 MOD ?= vendor
 DOCS_DIR ?= docs/cli
 
-ALL_BUILD_TAGS := "aws,packet,e2e,disruptivee2e,poste2e"
+ALL_BUILD_TAGS := "aws,packet,aks,e2e,disruptivee2e,poste2e"
 
 ## Adds a '-dirty' suffix to version string if there are uncommitted changes
 changes := $(shell git status --porcelain)
@@ -88,12 +88,12 @@ endif
 
 .PHONY: run-e2e-tests
 run-e2e-tests:
-	KUBECONFIG=${kubeconfig} go test -mod=$(MOD) -tags="$(platform),e2e" -covermode=atomic -buildmode=exe -v ./test/...
+	KUBECONFIG=${kubeconfig} go test -mod=$(MOD) -tags="$(platform),e2e" -covermode=atomic -buildmode=exe -v -count=1 ./test/...
 	# Test if the metrics are actually being scraped
-	KUBECONFIG=${kubeconfig} PLATFORM=${platform} go test -mod=$(MOD) -tags="$(platform),poste2e" -covermode=atomic -buildmode=exe -v ./test/...
+	KUBECONFIG=${kubeconfig} PLATFORM=${platform} go test -mod=$(MOD) -tags="$(platform),poste2e" -covermode=atomic -buildmode=exe -v -count=1 ./test/...
 	# This is a test that should be run in the end to reduce the disruption to other tests because
 	# it will delete a node.
-	KUBECONFIG=${kubeconfig} go test -mod=$(MOD) -tags="$(platform),disruptivee2e" -covermode=atomic -buildmode=exe -v ./test/...
+	KUBECONFIG=${kubeconfig} go test -mod=$(MOD) -tags="$(platform),disruptivee2e" -covermode=atomic -buildmode=exe -v -count=1 ./test/...
 
 .PHONY: all
 all: build test
diff --git a/ci/aks/aks-cluster.lokocfg.envsubst b/ci/aks/aks-cluster.lokocfg.envsubst
new file mode 100644
index 00000000..7f519188
--- /dev/null
+++ b/ci/aks/aks-cluster.lokocfg.envsubst
@@ -0,0 +1,118 @@
+variable "cert_manager_email" {
+  default = "$EMAIL"
+}
+variable "cluster_name" {
+  default = "$CLUSTER_ID"
+}
+
+variable "aws_zone_id" {
+  default = "$AWS_DNS_ZONE_ID"
+}
+
+variable "aws_access_key_id" {
+  default = "$AWS_ACCESS_KEY_ID"
+}
+
+variable "aws_secret_access_key" {
+  default = "$AWS_SECRET_ACCESS_KEY"
+}
+
+variable "aws_dns_zone" {
+  default = "$AWS_DNS_ZONE"
+}
+
+variable "resource_group_name" {
+  default = "$CLUSTER_ID"
+}
+
+variable "grafana_admin_password" {
+  default = "admin"
+}
+
+variable "asset_dir" {
+  default = "~/lokoctl-assets"
+}
+
+variable "workers_count" {
+  default = 2
+}
+
+variable "workers_type" {
+  default = "Standard_D2_v2"
+}
+
+variable "location" {
+  default = "Germany West Central"
+}
+
+variable "worker_labels" {
+  default = {
+    "testing.io" = "yes",
+    "roleofnode" = "testing",
+  }
+}
+
+cluster "aks" {
+  asset_dir    = pathexpand(var.asset_dir)
+  cluster_name = var.cluster_name
+
+  location            = var.location
+  resource_group_name = var.resource_group_name
+
+  worker_pool "default" {
+    vm_size = var.workers_type
+    count   = var.workers_count
+    labels  = var.worker_labels
+  }
+
+  tags = {
+    "owner" = "LokomotiveCIAKS"
+  }
+}
+
+component "prometheus-operator" {
+  grafana_admin_password = var.grafana_admin_password
+  disable_webhooks       = true
+
+  monitor {
+    etcd                    = false
+    kube_controller_manager = false
+    kube_scheduler          = false
+    kube_proxy              = false
+    kubelet                 = false
+  }
+
+  coredns {
+    selector = {
+      "k8s-app" = "kube-dns",
+    }
+  }
+}
+
+component "contour" {
+  ingress_hosts = [
+    "httpbin.${var.cluster_name}.${var.aws_dns_zone}",
+  ]
+  service_monitor = true
+}
+
+component "cert-manager" {
+  email           = var.cert_manager_email
+  service_monitor = true
+}
+
+component "external-dns" {
+  policy   = "sync"
+  owner_id = var.cluster_name
+  aws {
+    zone_id               = var.aws_zone_id
+    aws_access_key_id     = var.aws_access_key_id
+    aws_secret_access_key = var.aws_secret_access_key
+  }
+
+  service_monitor = true
+}
+
+component "httpbin" {
+  ingress_host = "httpbin.${var.cluster_name}.${var.aws_dns_zone}"
+}
diff --git a/cli/cmd/cluster-apply.go b/cli/cmd/cluster-apply.go
index 187b9be0..2ae73681 100644
--- a/cli/cmd/cluster-apply.go
+++ b/cli/cmd/cluster-apply.go
@@ -80,12 +80,12 @@ func runClusterApply(cmd *cobra.Command, args []string) {
 	fmt.Printf("\nYour configurations are stored in %s\n", assetDir)
 
 	kubeconfigPath := assetsKubeconfig(assetDir)
-	if err := verifyCluster(kubeconfigPath, p.GetExpectedNodes()); err != nil {
+	if err := verifyCluster(kubeconfigPath, p.Meta().ExpectedNodes); err != nil {
 		ctxLogger.Fatalf("Verify cluster: %v", err)
 	}
 
-	// Do controlplane upgrades only if cluster already exists.
-	if exists {
+	// Do controlplane upgrades only if cluster already exists and it is not a managed platform.
+	if exists && !p.Meta().Managed {
 		fmt.Printf("\nEnsuring that cluster controlplane is up to date.\n")
 
 		cu := controlplaneUpdater{
diff --git a/cli/cmd/cluster.go b/cli/cmd/cluster.go
index 6bea1b1f..bf12f900 100644
--- a/cli/cmd/cluster.go
+++ b/cli/cmd/cluster.go
@@ -79,7 +79,7 @@ func initialize(ctxLogger *logrus.Entry) (*terraform.Executor, platform.Platform
 		b = local.NewLocalBackend()
 	}
 
-	assetDir, err := homedir.Expand(p.GetAssetDir())
+	assetDir, err := homedir.Expand(p.Meta().AssetDir)
 	if err != nil {
 		ctxLogger.Fatalf("Error expanding path: %v", err)
 	}
@@ -97,7 +97,7 @@ func initialize(ctxLogger *logrus.Entry) (*terraform.Executor, platform.Platform
 // initializeTerraform initialized Terraform directory using given backend and platform
 // and returns configured executor.
 func initializeTerraform(ctxLogger *logrus.Entry, p platform.Platform, b backend.Backend) *terraform.Executor {
-	assetDir, err := homedir.Expand(p.GetAssetDir())
+	assetDir, err := homedir.Expand(p.Meta().AssetDir)
 	if err != nil {
 		ctxLogger.Fatalf("Error expanding path: %v", err)
 	}
diff --git a/cli/cmd/health.go b/cli/cmd/health.go
index a359351c..898215e4 100644
--- a/cli/cmd/health.go
+++ b/cli/cmd/health.go
@@ -64,7 +64,7 @@ func runHealth(cmd *cobra.Command, args []string) {
 		contextLogger.Fatal("No cluster configured")
 	}
 
-	cluster, err := lokomotive.NewCluster(client, p.GetExpectedNodes())
+	cluster, err := lokomotive.NewCluster(client, p.Meta().ExpectedNodes)
 	if err != nil {
 		contextLogger.Fatalf("Error in creating new Lokomotive cluster: %q", err)
 	}
diff --git a/cli/cmd/root.go b/cli/cmd/root.go
index 4118aaa6..8000e3db 100644
--- a/cli/cmd/root.go
+++ b/cli/cmd/root.go
@@ -22,6 +22,7 @@ import (
 	"github.com/spf13/viper"
 
 	// Register platforms by adding an anonymous import.
+	_ "github.com/kinvolk/lokomotive/pkg/platform/aks"
 	_ "github.com/kinvolk/lokomotive/pkg/platform/aws"
 	_ "github.com/kinvolk/lokomotive/pkg/platform/baremetal"
 	_ "github.com/kinvolk/lokomotive/pkg/platform/packet"
diff --git a/cli/cmd/utils.go b/cli/cmd/utils.go
index c105d104..9ed5dfc3 100644
--- a/cli/cmd/utils.go
+++ b/cli/cmd/utils.go
@@ -85,7 +85,7 @@ func getAssetDir() (string, error) {
 		return "", nil
 	}
 
-	return cfg.GetAssetDir(), nil
+	return cfg.Meta().AssetDir, nil
 }
 
 // expandKubeconfigPath tries to expand ~ in the given kubeconfig path.
diff --git a/docs/configuration-reference/components/prometheus-operator.md b/docs/configuration-reference/components/prometheus-operator.md
index 76dd7af7..cf82932b 100644
--- a/docs/configuration-reference/components/prometheus-operator.md
+++ b/docs/configuration-reference/components/prometheus-operator.md
@@ -97,6 +97,15 @@ Example:
 | `alertmanager_external_url` | The external URL the Alertmanager instances will be available under. This is necessary to generate correct URLs. This is necessary if Alertmanager is not served from root of a DNS name. | "" | false |
 | `alertmanager_config` | Provide YAML file path to configure Alertmanager. See [https://prometheus.io/docs/alerting/configuration/#configuration-file](https://prometheus.io/docs/alerting/configuration/#configuration-file). | `{"global":{"resolve_timeout":"5m"},"route":{"group_by":["job"],"group_wait":"30s","group_interval":"5m","repeat_interval":"12h","receiver":"null","routes":[{"match":{"alertname":"Watchdog"},"receiver":"null"}]},"receivers":[{"name":"null"}]}` | false |
 | `alertmanager_node_selector` | Node selector to specify nodes where the AlertManager pods should be deployed. | {} | false |
+| `disable_webhooks` | Disables validation and mutation webhooks. This might be required on older versions of Kubernetes to install successfully. | false | false |
+| `monitor` | Block, which allows to disable scraping of individual Kubernetes components. | - | false |
+| `monitor.etcd` | Controls if the default Prometheus instance should scrape etcd metrics. | true | false |
+| `monitor.kube_controller_manager` | Controls if the default Prometheus instance should scrape kube-controller-manager metrics. | true | false |
+| `monitor.kube_scheduler` | Controls if the default Prometheus instance should scrape kube-scheduler metrics. | true | false |
+| `monitor.kube_proxy` | Controls if the default Prometheus instance should scrape kube-proxy metrics. | true | false |
+| `monitor.kubelet` | Controls if the default Prometheus instance should scrape kubelet metrics. | true | false |
+| `coredns` | Block, which allows to customize, how CoreDNS is scraped. | - | false |
+| `coredns.selector` | Defines, how CoreDNS pods should be selected for scraping. | {"k8s-app":"coredns","tier":"control-plane"} | false |
 
 ## Applying
 
diff --git a/docs/configuration-reference/platforms/aks.md b/docs/configuration-reference/platforms/aks.md
new file mode 100644
index 00000000..4542105b
--- /dev/null
+++ b/docs/configuration-reference/platforms/aks.md
@@ -0,0 +1,129 @@
+# Lokomotive AKS configuration reference
+
+## Contents
+
+* [Introduction](#introduction)
+* [Prerequisites](#prerequisites)
+* [Configuration](#configuration)
+* [Attribute reference](#attribute-reference)
+* [Applying](#applying)
+* [Destroying](#destroying)
+
+## Introduction
+
+This configuration reference provides information on configuring a Lokomotive cluster on Azure AKS with all the configuration options available to the user.
+
+## Prerequisites
+
+* `lokoctl` [installed locally](../../installer/lokoctl.md).
+* `kubectl` installed locally to access the Kubernetes cluster.
+
+## Configuration
+
+To create a Lokomotive cluster, we need to define a configuration.
+
+Example configuration file:
+
+```tf
+#myakscluster.lokocfg
+variable "state_s3_bucket" {}
+variable "lock_dynamodb_table" {}
+variable "asset_dir" {}
+variable "cluster_name" {}
+variable "workers_count" {}
+variable "state_s3_key" {}
+variable "state_s3_region" {}
+variable "workers_vm_size" {}
+variable "location" {}
+variable "tenant_id" {}
+variable "subscription_id" {}
+variable "client_id" {}
+variable "client_secret" {}
+variable "resource_group_name" {}
+variable "application_name" {}
+variable "manage_resource_group" {}
+
+backend "s3" {
+  bucket         = var.state_s3_bucket
+  key            = var.state_s3_key
+  region         = var.state_s3_region
+  dynamodb_table = var.lock_dynamodb_table
+}
+
+# backend "local" {
+#   path = "path/to/local/file"
+#}
+
+
+cluster "aks" {
+  asset_dir    = pathexpand(var.asset_dir)
+  cluster_name = var.cluster_name
+
+  tenant_id       = var.tenant_id
+  subscription_id = var.subscription_id
+  client_id       = var.client_id
+  client_secret   = var.client_secret
+
+  location              = var.location
+  resource_group_name   = var.resource_group_name
+  application_name      = var.application_name
+  manage_resource_group = var.manage_resource_group
+
+  worker_pool "default" {
+    count   = var.workers_count
+    vm_size = var.workers_vm_size
+
+    labels = {
+      "key" = "value",
+    }
+
+    taints = [
+      "node-role.kubernetes.io/master=NoSchedule",
+    ]
+  }
+
+  tags = {
+    "key" = "value",
+  }
+}
+```
+
+**NOTE**: Should you feel differently about the default values, you can set default values using the `variable`
+block in the cluster configuration.
+
+## Attribute reference
+
+| Argument                | Description                                                  |    Default    | Required |
+| ----------------------- | ------------------------------------------------------------ | :-----------: | :------: |
+| `asset_dir`             | Location where Lokomotive stores cluster assets.             |       -       |   true   |
+| `cluster_name`          | Name of the cluster. **NOTE**: It must be unique per resource group. |       -       |   true   |
+| `tenant_id`             | Azure Tenant ID. Can also be provided using the `LOKOMOTIVE_AKS_TENANT_ID` environment variable. |       -       |   true   |
+| `subscription_id`       | Azure Subscription ID. Can also be provided using the `LOKOMOTIVE_AKS_SUBSCRIPTION_ID` environment variable. |       -       |   true   |
+| `resource_group_name`   | Name of the resource group, where AKS cluster object will be created. Please note, that AKS will also create a separate resource group for workers and other required objects, like load balancers, disks etc. If `manage_resource_group` parameter is set to `false`, this resource group must be manually created before cluster creation. |       -       |   true   |
+| `client_id`             | Azure service principal ID used  for running the AKS cluster. Can also be provided using the `LOKOMOTIVE_AKS_CLIENT_ID`. This parameter is mutually exclusive with `application_name` parameter. |       -       |  false   |
+| `client_secret`         | Azure service principal secret used  for running the AKS cluster. Can also be provided using the `LOKOMOTIVE_AKS_CLIENT_SECRET`. This parameter is mutually exclusive with `application_name` parameter. |       -       |  false   |
+| `tags`                  | Additional tags for Azure resources.                  |       -       |  false   |
+| `location`              | Azure location where resources will be created. Valid values can be obtained using the following command from Azure CLI: `az account list-locations -o table`. | "West Europe" |  false   |
+| `application_name`      | Azure AD application name. If specified, a new Application will be created in Azure AD together with a service principal, which will be used to run the AKS cluster on behalf of the user to provide full cluster creation automation. Please note that this requires [permissions to create applications in Azure AD](https://docs.microsoft.com/en-us/azure/active-directory/users-groups-roles/roles-delegate-app-roles). This parameter is mutually exclusive with `client_id` and `client_secret`. |       -       |  false   |
+| `manage_resource_group` | If `true`, a resource group for the AKS object will be created on behalf of the user. |     true      |  false   |
+| `worker_pool`           | Configuration block for worker pools. At least one worker pool must be defined. |       -       |   true   |
+| `worker_pool.count`     | Number of workers in the worker pool. Can be changed afterwards to add or delete workers. |       -       |   true   |
+| `worker_pool.vm_size`   | Azure VM size for worker nodes.                              |       -       |   true   |
+| `worker_pool.labels`    | Map of Kubernetes Node object labels.                        |       -       |  false   |
+| `worker_pool.taints`    | List of Kubernetes Node taints.                              |       -       |  false   |
+
+## Applying
+
+To create the cluster, execute the following command:
+
+```console
+lokoctl cluster apply
+```
+
+## Destroying
+
+To destroy the Lokomotive cluster, execute the following command:
+
+```console
+lokoctl cluster destroy --confirm
+```
diff --git a/docs/configuration-reference/platforms/aws.md b/docs/configuration-reference/platforms/aws.md
index 51564d02..027f25a8 100644
--- a/docs/configuration-reference/platforms/aws.md
+++ b/docs/configuration-reference/platforms/aws.md
@@ -58,7 +58,7 @@ backend "s3" {
   dynamodb_table = var.lock_dynamodb_table
 }
 
-# backed "local" {
+# backend "local" {
 #   path = "path/to/local/file"
 #}
 
diff --git a/docs/configuration-reference/platforms/packet.md b/docs/configuration-reference/platforms/packet.md
index d3f6441b..b15e892c 100644
--- a/docs/configuration-reference/platforms/packet.md
+++ b/docs/configuration-reference/platforms/packet.md
@@ -52,7 +52,7 @@ backend "s3" {
   dynamodb_table = var.lock_dynamodb_table
 }
 
-# backed "local" {
+# backend "local" {
 #   path = "path/to/local/file"
 #}
 
diff --git a/docs/quickstarts/aks.md b/docs/quickstarts/aks.md
new file mode 100644
index 00000000..f6d3af18
--- /dev/null
+++ b/docs/quickstarts/aks.md
@@ -0,0 +1,200 @@
+# Lokomotive AKS quickstart guide
+
+## Contents
+
+* [Introduction](#introduction)
+* [Requirements](#requirements)
+* [Step 1: Install lokoctl](#step-1-install-lokoctl)
+* [Step 2: Set up a working directory](#step-2-set-up-a-working-directory)
+* [Step 3: Set up Azure API credentials](#step-3-set-up-azure-api-credentials)
+* [Step 4: Prepare AKS credentials](#step-4-prepare-aks-credentials)
+* [Step 5: Define cluster configuration](#step-5-define-cluster-configuration)
+* [Step 6: Create Lokomotive cluster](#step-6-create-lokomotive-cluster)
+* [Verification](#verification)
+* [Cleanup](#cleanup)
+* [Conclusion](#conclusion)
+* [Next steps](#next-steps)
+
+## Introduction
+
+This quickstart guide walks through the steps needed to create a Lokomotive cluster on AKS.
+
+By the end of this guide, you'll have a production-ready Kubernetes cluster running on Azure AKS.
+
+_Note: Lokomotive on AKS currently provides Kubernetes 1.16 as opposed to other platforms, which provide 1.18. This is because of limitations of Azure platform._
+
+## Requirements
+
+* Basic understanding of Kubernetes concepts.
+* [Azure account](https://azure.microsoft.com/en-us/free/).
+* `kubectl` installed locally to access the Kubernetes cluster.
+
+## Steps
+
+### Step 1: Install lokoctl
+
+lokoctl is a command-line interface for Lokomotive.
+
+To install `lokoctl`, follow the instructions in the [lokoctl installation](../installer/lokoctl.md)
+guide.
+
+### Step 2: Set up a working directory
+
+It's better to start fresh in a new working directory, as the state of the cluster is stored in this
+directory.
+
+This also makes the cleanup task easier.
+
+```console
+mkdir -p lokomotive-infra/myakscluster
+cd lokomotive-infra/myakscluster
+```
+
+### Step 3: Set up Azure API credentials
+
+To create an AKS resource in Azure, you need to be authenticatied to Azure API. Follow
+[Authenticating to Azure](https://www.terraform.io/docs/providers/azurerm/index.html#authenticating-to-azure)
+to set up environment variables required for authentication.
+
+### Step 4: Prepare AKS credentials
+
+An AKS cluster requires a set of service principal credentials to run, as it talks to Azure API to create Load Balancers,
+Disks and other objects. Depending on your level of privileges in Azure, there are different ways to provide them.
+
+#### Azure AD Application Creator (full automation)
+
+If you are an Azure AD administrator or if your Azure user has permissions to create Azure AD Applications then
+you don't need to prepare anything manually. When configuring a cluster, set the `application_name` property
+to e.g. the cluster name and `lokoctl` will create Azure AD application for you, together with the associated
+service principal and credentials. Those credentials will be automatically used for running AKS.
+
+#### Subscription collaborator
+
+If you are a user with full administrative access to your subscription, then you need to ask your administrator to create
+Azure AD application for you and provide you a Service Principal Client ID and a Client secret, which will be used by AKS cluster.
+
+You can then provide them to the configuration using either `LOKOMOTIVE_AKS_CLIENT_ID` and `LOKOMOTIVE_AKS_CLIENT_SECRET` environment
+variables or via `client_id` and `client_secret` parameters. See [AKS attribute reference](../configuration-reference/platforms/aks.md#attribute-reference) for more details.
+
+#### Resource group collaborator
+
+If your Azure user has only access to a single Resource Group, you must set the `manage_resource_group` property to `false`,
+as otherwise `lokoctl` will try to create a Resource Group for you.
+
+You also need Service Principal credentials, as explained in [#subscription-collabolator](#subscription-collabolator).
+
+### Step 5: Define cluster configuration
+
+To create a Lokomotive cluster, you need to define a configuration.
+
+A [production-ready configuration](../../examples/aks-production) is already provided for ease of
+use. Copy the example configuration to the working directory and modify accordingly.
+
+The provided configuration installs the Lokomotive cluster and the following components:
+
+* [prometheus-operator](../configuration-reference/components/prometheus-operator.md)
+* [cert-manager](../configuration-reference/components/cert-manager.md)
+* [contour](../configuration-reference/components/contour.md)
+
+You can configure the components as per your requirements.
+
+Create a variables file named `lokocfg.vars` in the working directory to set values for variables
+defined in the configuration file.
+
+```console
+#lokocfg.vars
+state_s3_bucket = "name-of-the-s3-bucket-to-store-the-cluster-state"
+lock_dynamodb_table = "name-of-the-dynamodb-table-for-state-locking"
+
+cert_manager_email = "email-address-used-for-cert-manager-component"
+grafana_admin_password = "password-for-grafana"
+```
+
+**NOTE**: You can separate component configurations from cluster configuration in separate
+configuration files if doing so fits your needs.
+
+Example:
+```console
+$ ls lokomotive-infra/myakscluster
+cluster.lokocfg  prometheus-operator.lokocfg  lokocfg.vars
+```
+
+For advanced cluster configurations and more information refer to the [AKS configuration
+guide](../configuration-reference/platforms/aks.md).
+
+### Step 6: Create Lokomotive cluster
+
+Run the following command to create the cluster:
+
+```console
+lokoctl cluster apply
+```
+Once the command finishes, your Lokomotive cluster details are stored in the path you've specified
+under `asset_dir`.
+
+## Verification
+
+A successful installation results in the output:
+
+```console
+azurerm_kubernetes_cluster.aks: Still creating... [8m0s elapsed]
+azurerm_kubernetes_cluster.aks: Still creating... [8m10s elapsed]
+azurerm_kubernetes_cluster.aks: Still creating... [8m20s elapsed]
+azurerm_kubernetes_cluster.aks: Creation complete after 8m24s [id=/subscriptions/55555555-4444-3333-2222-1111111111/resourcegroups/ci1586244933-fg/providers/Microsoft.ContainerService/managedClusters/ci1586244933-fg]
+local_file.kubeconfig: Creating...
+local_file.kubeconfig: Creation complete after 0s [id=f96468e341a652192af7508836430241e6f49df1]
+
+Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
+
+Outputs:
+
+initialized = true
+
+Your configurations are stored in /root/lokoctl-assets
+
+Now checking health and readiness of the cluster nodes ...
+
+Node                               Ready    Reason          Message
+
+aks-default-31666422-vmss000000    True     KubeletReady    kubelet is posting ready status. AppArmor enabled
+aks-default-31666422-vmss000001    True     KubeletReady    kubelet is posting ready status. AppArmor enabled
+
+Success - cluster is healthy and nodes are ready!
+```
+
+Use the generated `kubeconfig` file to access the Kubernetes cluster and list nodes.
+
+```console
+export KUBECONFIG=./lokomotive-assets/cluster-assets/auth/kubeconfig
+kubectl get nodes
+```
+
+## Using the cluster
+
+At this point you have access to the Kubernetes cluster and can use it!
+If you don't have Kubernetes experience you can check out the [Kubernetes
+Basics official
+documentation](https://kubernetes.io/docs/tutorials/kubernetes-basics/deploy-app/deploy-intro/)
+to learn about its usage.
+
+## Cleanup
+
+To destroy the Lokomotive cluster, execute the following command:
+
+```console
+lokoctl cluster destroy --confirm
+```
+
+You can safely delete the working directory created for this quickstart guide if you no longer
+require it.
+
+## Conclusion
+
+After walking through this guide, you've learned how to set up a Lokomotive cluster on AKS.
+
+## Next steps
+
+You can now start deploying your workloads on the cluster.
+
+For more information on installing supported Lokomotive components, you can visit the [component
+configuration references](../configuration-reference/components).
diff --git a/examples/aks-production/cluster.lokocfg b/examples/aks-production/cluster.lokocfg
new file mode 100644
index 00000000..3aa711d7
--- /dev/null
+++ b/examples/aks-production/cluster.lokocfg
@@ -0,0 +1,95 @@
+variable "subscription_id" {}
+variable "tenant_id" {}
+variable "grafana_admin_password" {}
+variable "resource_group_name" {}
+variable "cert_manager_email" {}
+variable "state_s3_bucket" {}
+variable "lock_dynamodb_table" {}
+
+variable "manage_resource_group" {
+  default = true
+}
+
+variable "application_name" {
+  default = ""
+}
+
+variable "asset_dir" {
+  default = "./lokomotive-assets"
+}
+
+variable "cluster_name" {
+  default = "lokomotive-cluster"
+}
+
+variable "workers_count" {
+  default = 3
+}
+
+variable "workers_type" {
+  default = "Standard_D2_v2"
+}
+
+variable "location" {
+  default = "West Europe"
+}
+
+variable "state_s3_key" {
+  default = "lokomotive/terraform.tfstate"
+}
+
+variable "state_s3_region" {
+  default = "eu-central-1"
+}
+
+backend "s3" {
+  bucket         = var.state_s3_bucket
+  key            = var.state_s3_key
+  region         = var.state_s3_region
+  dynamodb_table = var.lock_dynamodb_table
+}
+
+cluster "aks" {
+  asset_dir    = pathexpand(var.asset_dir)
+  cluster_name = var.cluster_name
+
+  subscription_id       = var.subscription_id
+  tenant_id             = var.tenant_id
+  location              = var.location
+  resource_group_name   = var.resource_group_name
+  application_name      = var.application_name
+  manage_resource_group = var.manage_resource_group
+
+  worker_pool "default" {
+    vm_size = var.workers_type
+    count   = var.workers_count
+  }
+}
+
+component "prometheus-operator" {
+  grafana_admin_password = var.grafana_admin_password
+  disable_webhooks       = true
+
+  monitor {
+    etcd                    = false
+    kube_controller_manager = false
+    kube_scheduler          = false
+    kube_proxy              = false
+    kubelet                 = false
+  }
+
+  coredns {
+    selector = {
+      "k8s-app" = "kube-dns",
+    }
+  }
+}
+
+component "cert-manager" {
+  email           = var.cert_manager_email
+  service_monitor = true
+}
+
+component "contour" {
+  service_monitor = true
+}
diff --git a/pkg/components/prometheus-operator/component.go b/pkg/components/prometheus-operator/component.go
index 13cfc0ff..3f57dd62 100644
--- a/pkg/components/prometheus-operator/component.go
+++ b/pkg/components/prometheus-operator/component.go
@@ -31,6 +31,20 @@ func init() {
 	components.Register(name, newComponent())
 }
 
+// Monitor holds information about which Kubernetes components should be monitored with the default Prometheus instance.
+type Monitor struct {
+	Etcd                  bool `hcl:"etcd,optional"`
+	KubeControllerManager bool `hcl:"kube_controller_manager,optional"`
+	KubeScheduler         bool `hcl:"kube_scheduler,optional"`
+	KubeProxy             bool `hcl:"kube_proxy,optional"`
+	Kubelet               bool `hcl:"kubelet,optional"`
+}
+
+// CoreDNS holds information about how CoreDNS should be scraped.
+type CoreDNS struct {
+	Selector map[string]string `hcl:"selector,optional"`
+}
+
 type component struct {
 	GrafanaAdminPassword string   `hcl:"grafana_admin_password,attr"`
 	Namespace            string   `hcl:"namespace,optional"`
@@ -48,6 +62,11 @@ type component struct {
 	AlertManagerExternalURL  string            `hcl:"alertmanager_external_url,optional"`
 	AlertManagerConfig       string            `hcl:"alertmanager_config,optional"`
 	AlertManagerNodeSelector map[string]string `hcl:"alertmanager_node_selector,optional"`
+
+	DisableWebhooks bool `hcl:"disable_webhooks,optional"`
+
+	Monitor *Monitor `hcl:"monitor,block"`
+	CoreDNS *CoreDNS `hcl:"coredns,block"`
 }
 
 func newComponent() *component {
@@ -77,6 +96,19 @@ func newComponent() *component {
 		Namespace:                   "monitoring",
 		WatchLabeledServiceMonitors: true,
 		WatchLabeledPrometheusRules: true,
+		Monitor: &Monitor{
+			Etcd:                  true,
+			KubeControllerManager: true,
+			KubeScheduler:         true,
+			KubeProxy:             true,
+			Kubelet:               true,
+		},
+		CoreDNS: &CoreDNS{
+			Selector: map[string]string{
+				"k8s-app": "coredns",
+				"tier":    "control-plane",
+			},
+		},
 	}
 }
 
diff --git a/pkg/components/prometheus-operator/template.go b/pkg/components/prometheus-operator/template.go
index 170f3f62..8b328f5b 100644
--- a/pkg/components/prometheus-operator/template.go
+++ b/pkg/components/prometheus-operator/template.go
@@ -53,15 +53,24 @@ grafana:
   rbac:
     pspUseAppArmor: false
 kubeEtcd:
+  enabled: {{.Monitor.Etcd}}
   endpoints: {{.EtcdEndpoints}}
 prometheus-node-exporter:
   service: {}
-{{ if .PrometheusOperatorNodeSelector }}
+{{ if (or .PrometheusOperatorNodeSelector .DisableWebhooks) }}
 prometheusOperator:
+  {{- if .DisableWebhooks }}
+  tlsProxy:
+    enabled: false
+  admissionWebhooks:
+    enabled: false
+  {{- end }}
+  {{- if .PrometheusOperatorNodeSelector }}
   nodeSelector:
     {{ range $key, $value := .PrometheusOperatorNodeSelector }}
     {{ $key }}: {{ $value }}
     {{ end }}
+  {{- end }}
 {{ end }}
 prometheus:
   prometheusSpec:
@@ -86,6 +95,7 @@ prometheus:
               storage: 50Gi
 
 kubeControllerManager:
+  enabled: {{.Monitor.KubeControllerManager}}
   service:
     selector:
       k8s-app: kube-controller-manager
@@ -94,10 +104,12 @@ kubeControllerManager:
 coreDns:
   service:
     selector:
-      k8s-app: coredns
-      tier: control-plane
+      {{ range $k, $v := .CoreDNS.Selector }}
+      {{ $k }}: "{{ $v }}"
+      {{- end }}
 
 kubeScheduler:
+  enabled: {{.Monitor.KubeScheduler}}
   service:
     selector:
       k8s-app: kube-scheduler
@@ -108,4 +120,10 @@ kube-state-metrics:
     annotations:
       seccomp.security.alpha.kubernetes.io/allowedProfileNames: 'docker/default'
       seccomp.security.alpha.kubernetes.io/defaultProfileName:  'docker/default'
+
+kubeProxy:
+  enabled: {{.Monitor.KubeProxy}}
+
+kubelet:
+  enabled: {{.Monitor.Kubelet}}
 `
diff --git a/pkg/platform/aks/aks.go b/pkg/platform/aks/aks.go
new file mode 100644
index 00000000..38042c72
--- /dev/null
+++ b/pkg/platform/aks/aks.go
@@ -0,0 +1,346 @@
+// Copyright 2020 The Lokomotive Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package aks is a Platform implementation for creating a Kubernetes cluster using
+// Azure AKS.
+package aks
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"text/template"
+
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/gohcl"
+	"github.com/mitchellh/go-homedir"
+
+	"github.com/kinvolk/lokomotive/pkg/platform"
+	"github.com/kinvolk/lokomotive/pkg/platform/util"
+	"github.com/kinvolk/lokomotive/pkg/terraform"
+)
+
+// workerPool defines "worker_pool" block.
+type workerPool struct {
+	// Label field.
+	Name string `hcl:"name,label"`
+
+	// Block properties.
+	Count  int               `hcl:"count,optional"`
+	VMSize string            `hcl:"vm_size,optional"`
+	Labels map[string]string `hcl:"labels,optional"`
+	Taints []string          `hcl:"taints,optional"`
+}
+
+// config defines "cluster" block for AKS.
+type config struct {
+	AssetDir    string            `hcl:"asset_dir,optional"`
+	ClusterName string            `hcl:"cluster_name,optional"`
+	Tags        map[string]string `hcl:"tags,optional"`
+
+	// Azure specific.
+	TenantID       string `hcl:"tenant_id,optional"`
+	SubscriptionID string `hcl:"subscription_id,optional"`
+	ClientID       string `hcl:"client_id,optional"`
+	ClientSecret   string `hcl:"client_secret,optional"`
+
+	Location string `hcl:"location,optional"`
+
+	// ApplicationName for created service principal.
+	ApplicationName string `hcl:"application_name,optional"`
+
+	ResourceGroupName   string `hcl:"resource_group_name,optional"`
+	ManageResourceGroup bool   `hcl:"manage_resource_group,optional"`
+
+	WorkerPools []workerPool `hcl:"worker_pool,block"`
+}
+
+const (
+	name = "aks"
+
+	// Environment variables used to load sensitive parts of the configuration.
+	clientIDEnv       = "LOKOMOTIVE_AKS_CLIENT_ID"
+	clientSecretEnv   = "LOKOMOTIVE_AKS_CLIENT_SECRET" // #nosec G101
+	subscriptionIDEnv = "LOKOMOTIVE_AKS_SUBSCRIPTION_ID"
+	tenantIDEnv       = "LOKOMOTIVE_AKS_TENANT_ID"
+)
+
+// init registers AKS as a platform.
+func init() { //nolint:gochecknoinits
+	c := &config{
+		Location:            "West Europe",
+		ManageResourceGroup: true,
+	}
+
+	platform.Register(name, c)
+}
+
+// LoadConfig loads configuration values into the config struct from given HCL configuration.
+func (c *config) LoadConfig(configBody *hcl.Body, evalContext *hcl.EvalContext) hcl.Diagnostics {
+	if configBody == nil {
+		emptyConfig := hcl.EmptyBody()
+		configBody = &emptyConfig
+	}
+
+	if d := gohcl.DecodeBody(*configBody, evalContext, c); d.HasErrors() {
+		return d
+	}
+
+	return c.checkValidConfig()
+}
+
+// checkValidConfig validates cluster configuration.
+func (c *config) checkValidConfig() hcl.Diagnostics {
+	var d hcl.Diagnostics
+
+	d = append(d, c.checkNotEmptyWorkers()...)
+	d = append(d, c.checkWorkerPoolNamesUnique()...)
+	d = append(d, c.checkWorkerPools()...)
+	d = append(d, c.checkCredentials()...)
+	d = append(d, c.checkRequiredFields()...)
+
+	return d
+}
+
+// checkWorkerPools validates all configured worker pool fields.
+func (c *config) checkWorkerPools() hcl.Diagnostics {
+	var d hcl.Diagnostics
+
+	for _, w := range c.WorkerPools {
+		if w.VMSize == "" {
+			d = append(d, &hcl.Diagnostic{
+				Severity: hcl.DiagError,
+				Summary:  fmt.Sprintf("pool %q: VMSize field can't be empty", w.Name),
+			})
+		}
+
+		if w.Count <= 0 {
+			d = append(d, &hcl.Diagnostic{
+				Severity: hcl.DiagError,
+				Summary:  fmt.Sprintf("pool %q: count must be bigger than 0", w.Name),
+			})
+		}
+	}
+
+	return d
+}
+
+// checkRequiredFields checks if that all required fields are populated in the top level configuration.
+func (c *config) checkRequiredFields() hcl.Diagnostics {
+	var d hcl.Diagnostics
+
+	if c.SubscriptionID == "" && os.Getenv(subscriptionIDEnv) == "" {
+		d = append(d, &hcl.Diagnostic{
+			Severity: hcl.DiagError,
+			Summary:  "cannot find the Azure subscription ID",
+			Detail: fmt.Sprintf("%q field is empty and %q environment variable "+
+				"is not defined. At least one of these should be defined",
+				"SubscriptionID", subscriptionIDEnv),
+		})
+	}
+
+	if c.TenantID == "" && os.Getenv(tenantIDEnv) == "" {
+		d = append(d, &hcl.Diagnostic{
+			Severity: hcl.DiagError,
+			Summary:  "cannot find the Azure client ID",
+			Detail: fmt.Sprintf("%q field is empty and %q environment variable "+
+				"is not defined. At least one of these should be defined", "TenantID", tenantIDEnv),
+		})
+	}
+
+	f := map[string]string{
+		"AssetDir":          c.AssetDir,
+		"ClusterName":       c.ClusterName,
+		"ResourceGroupName": c.ResourceGroupName,
+	}
+
+	for k, v := range f {
+		if v == "" {
+			d = append(d, &hcl.Diagnostic{
+				Severity: hcl.DiagError,
+				Summary:  fmt.Sprintf("field %q can't be empty", k),
+			})
+		}
+	}
+
+	return d
+}
+
+// checkCredentials checks if credentials are correctly defined.
+func (c *config) checkCredentials() hcl.Diagnostics {
+	var d hcl.Diagnostics
+
+	// If the application name is defined, we assume that we work as a highly privileged
+	// account which has permissions to create new Azure AD application, so Client ID
+	// and Client Secret fields are not needed.
+	if c.ApplicationName != "" {
+		if c.ClientID != "" {
+			d = append(d, &hcl.Diagnostic{
+				Severity: hcl.DiagError,
+				Summary:  "ClientID and ApplicationName are mutually exclusive",
+			})
+		}
+
+		if c.ClientSecret != "" {
+			d = append(d, &hcl.Diagnostic{
+				Severity: hcl.DiagError,
+				Summary:  "ClientSecret and ApplicationName are mutually exclusive",
+			})
+		}
+
+		return d
+	}
+
+	if c.ClientSecret == "" && os.Getenv(clientSecretEnv) == "" {
+		d = append(d, &hcl.Diagnostic{
+			Severity: hcl.DiagError,
+			Summary:  "cannot find the Azure client secret",
+			Detail: fmt.Sprintf("%q field is empty and %q environment variable "+
+				"is not defined. At least one of these should be defined", "ClientSecret", clientSecretEnv),
+		})
+	}
+
+	if c.ClientID == "" && os.Getenv(clientIDEnv) == "" {
+		d = append(d, &hcl.Diagnostic{
+			Severity: hcl.DiagError,
+			Summary:  "cannot find the Azure client ID",
+			Detail: fmt.Sprintf("%q field is empty and %q environment variable is "+
+				"not defined. At least one of these should be defined", "ClientID", clientIDEnv),
+		})
+	}
+
+	return d
+}
+
+// checkNotEmptyWorkers checks if the cluster has at least 1 node pool defined.
+func (c *config) checkNotEmptyWorkers() hcl.Diagnostics {
+	var diagnostics hcl.Diagnostics
+
+	if len(c.WorkerPools) == 0 {
+		diagnostics = append(diagnostics, &hcl.Diagnostic{
+			Severity: hcl.DiagError,
+			Summary:  "At least one worker pool must be defined",
+			Detail:   "Make sure to define at least one worker pool block in your cluster block",
+		})
+	}
+
+	return diagnostics
+}
+
+// checkWorkerPoolNamesUnique verifies that all worker pool names are unique.
+func (c *config) checkWorkerPoolNamesUnique() hcl.Diagnostics {
+	var diagnostics hcl.Diagnostics
+
+	dup := make(map[string]bool)
+
+	for _, w := range c.WorkerPools {
+		if !dup[w.Name] {
+			dup[w.Name] = true
+			continue
+		}
+
+		// It is duplicated.
+		diagnostics = append(diagnostics, &hcl.Diagnostic{
+			Severity: hcl.DiagError,
+			Summary:  "Worker pool names should be unique",
+			Detail:   fmt.Sprintf("Worker pool '%v' is duplicated", w.Name),
+		})
+	}
+
+	return diagnostics
+}
+
+// Meta is part of Platform interface and returns common information about the platform configuration.
+func (c *config) Meta() platform.Meta {
+	nodes := 0
+	for _, workerpool := range c.WorkerPools {
+		nodes += workerpool.Count
+	}
+
+	return platform.Meta{
+		AssetDir:      c.AssetDir,
+		ExpectedNodes: nodes,
+		Managed:       true,
+	}
+}
+
+// Apply creates AKS infrastructure via Terraform.
+func (c *config) Apply(ex *terraform.Executor) error {
+	if err := c.Initialize(ex); err != nil {
+		return err
+	}
+
+	return ex.Apply()
+}
+
+// Destroy destroys AKS infrastructure via Terraform.
+func (c *config) Destroy(ex *terraform.Executor) error {
+	if err := c.Initialize(ex); err != nil {
+		return err
+	}
+
+	return ex.Destroy()
+}
+
+// Initialize creates Terrafrom files required for AKS.
+func (c *config) Initialize(ex *terraform.Executor) error {
+	assetDir, err := homedir.Expand(c.AssetDir)
+	if err != nil {
+		return err
+	}
+
+	terraformRootDir := terraform.GetTerraformRootDir(assetDir)
+
+	return createTerraformConfigFile(c, terraformRootDir)
+}
+
+// createTerraformConfigFiles create Terraform config files in given directory.
+func createTerraformConfigFile(cfg *config, terraformRootDir string) error {
+	t := template.Must(template.New("t").Parse(terraformConfigTmpl))
+
+	path := filepath.Join(terraformRootDir, "cluster.tf")
+
+	f, err := os.Create(path)
+	if err != nil {
+		return fmt.Errorf("failed to create file %q: %w", path, err)
+	}
+
+	util.AppendTags(&cfg.Tags)
+
+	if cfg.ClientSecret == "" {
+		cfg.ClientSecret = os.Getenv(clientSecretEnv)
+	}
+
+	if cfg.SubscriptionID == "" {
+		cfg.SubscriptionID = os.Getenv(subscriptionIDEnv)
+	}
+
+	if cfg.ClientID == "" {
+		cfg.ClientID = os.Getenv(clientIDEnv)
+	}
+
+	if cfg.TenantID == "" {
+		cfg.TenantID = os.Getenv(tenantIDEnv)
+	}
+
+	if err := t.Execute(f, cfg); err != nil {
+		return fmt.Errorf("failed to write template to file %q: %w", path, err)
+	}
+
+	if err := f.Close(); err != nil {
+		return fmt.Errorf("failed closing file %q: %w", path, err)
+	}
+
+	return nil
+}
diff --git a/pkg/platform/aks/template.go b/pkg/platform/aks/template.go
new file mode 100644
index 00000000..49755ecd
--- /dev/null
+++ b/pkg/platform/aks/template.go
@@ -0,0 +1,225 @@
+// Copyright 2020 The Lokomotive Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aks
+
+var terraformConfigTmpl = `{{- define "resource_group_name" -}}
+{{- if .ManageResourceGroup -}}
+azurerm_resource_group.aks.name
+{{- else -}}
+"{{ .ResourceGroupName }}"
+{{- end -}}
+{{- end -}}
+
+{{- define "client_id" -}}
+{{- if .ApplicationName -}}
+azuread_application.aks.application_id
+{{- else -}}
+"{{ .ClientID }}"
+{{- end -}}
+{{- end -}}
+
+{{- define "client_secret" -}}
+{{- if .ApplicationName -}}
+azuread_application_password.aks.value
+{{- else -}}
+"{{ .ClientSecret }}"
+{{- end -}}
+{{- end -}}
+
+locals {
+  subscription_id           = "{{ .SubscriptionID }}"
+  tenant_id                 = "{{ .TenantID }}"
+  application_name          = "{{ .ApplicationName }}"
+  location                  = "{{ .Location }}"
+  resource_group_name       = {{ template "resource_group_name" . }}
+  kubernetes_version        = "1.16.7"
+  cluster_name              = "{{ .ClusterName }}"
+  default_node_pool_name    = "{{ (index .WorkerPools 0).Name }}"
+  default_node_pool_vm_size = "{{ (index .WorkerPools 0).VMSize }}"
+  default_node_pool_count   = {{ (index .WorkerPools 0).Count  }}
+  client_id                 = {{ template "client_id" . }}
+  client_secret             = {{ template "client_secret" . }}
+}
+
+provider "azurerm" {
+  version = "2.2.0"
+
+  # https://github.com/terraform-providers/terraform-provider-azurerm/issues/5893
+  features {}
+}
+
+provider "local" {
+  version = "1.4.0"
+}
+
+{{- if .ApplicationName }}
+provider "azuread" {
+  version = "0.8.0"
+}
+
+provider "random" {
+  version = "2.2.1"
+}
+
+resource "azuread_application" "aks" {
+  name = local.application_name
+}
+
+resource "azuread_service_principal" "aks" {
+  application_id = azuread_application.aks.application_id
+
+  {{- if .Tags }}
+  tags = [
+    {{- range $k, $v := .Tags }}
+    "{{ $k }}={{ $v }}",
+    {{- end }}
+  ]
+  {{- end }}
+}
+
+resource "random_string" "password" {
+  length  = 16
+  special = true
+
+  override_special = "/@\" "
+}
+
+resource "azuread_application_password" "aks" {
+  application_object_id = azuread_application.aks.object_id
+  value                 = random_string.password.result
+  end_date_relative     = "86000h"
+}
+
+resource "azurerm_role_assignment" "aks" {
+  scope                = "/subscriptions/${local.subscription_id}"
+  role_definition_name = "Contributor"
+  principal_id         = azuread_service_principal.aks.id
+}
+{{- end }}
+
+{{- if .ManageResourceGroup }}
+resource "azurerm_resource_group" "aks" {
+  name     = "{{ .ResourceGroupName }}"
+  location = local.location
+
+  {{- if .Tags }}
+  tags = {
+    {{- range $k, $v := .Tags }}
+    "{{ $k }}" = "{{ $v }}"
+    {{- end }}
+  }
+  {{- end }}
+}
+{{- end }}
+
+resource "azurerm_kubernetes_cluster" "aks" {
+  name                = local.cluster_name
+  location            = local.location
+  resource_group_name = local.resource_group_name
+  kubernetes_version  = local.kubernetes_version
+  dns_prefix          = local.cluster_name
+
+  default_node_pool {
+    name       = local.default_node_pool_name
+    vm_size    = local.default_node_pool_vm_size
+    node_count = local.default_node_pool_count
+
+    {{- if (index .WorkerPools 0).Labels }}
+    node_labels = {
+      {{- range $k, $v := (index .WorkerPools 0).Labels }}
+      "{{ $k }}" = "{{ $v }}"
+      {{- end }}
+    }
+    {{- end }}
+
+    {{- if (index .WorkerPools 0).Taints }}
+    node_taints = [
+      {{- range (index .WorkerPools 0).Taints }}
+      "{{ . }}",
+      {{- end }}
+    ]
+    {{- end }}
+  }
+
+  role_based_access_control {
+    enabled = true
+  }
+
+  service_principal {
+    client_id     = local.client_id
+    client_secret = local.client_secret
+  }
+
+  network_profile {
+    network_plugin = "kubenet"
+    network_policy = "calico"
+  }
+
+  {{- if .Tags }}
+  tags = {
+    {{- range $k, $v := .Tags }}
+    "{{ $k }}" = "{{ $v }}"
+    {{- end }}
+  }
+  {{- end }}
+}
+
+{{ range $index, $pool := (slice .WorkerPools 1) }}
+resource "azurerm_kubernetes_cluster_node_pool" "worker-{{ $pool.Name }}" {
+  name                  = "{{ $pool.Name }}"
+  kubernetes_cluster_id = azurerm_kubernetes_cluster.aks.id
+  vm_size               = "{{ $pool.VMSize }}"
+  node_count            = "{{ $pool.Count }}"
+
+  {{- if $pool.Labels }}
+  node_labels = {
+    {{- range $k, $v := $pool.Labels }}
+    "{{ $k }}" = "{{ $v }}"
+    {{- end }}
+  }
+  {{- end }}
+
+
+  {{- if $pool.Taints }}
+  node_taints = [
+    {{- range $pool.Taints }}
+    "{{ . }}",
+    {{- end }}
+  ]
+  {{- end }}
+
+
+  {{- if $.Tags }}
+  tags = {
+    {{- range $k, $v := $.Tags }}
+    "{{ $k }}" = "{{ $v }}"
+    {{- end }}
+  }
+  {{- end }}
+}
+{{- end }}
+
+resource "local_file" "kubeconfig" {
+  sensitive_content = azurerm_kubernetes_cluster.aks.kube_config_raw
+  filename          = "../cluster-assets/auth/kubeconfig"
+}
+
+# Stub output which indicates, that Terraform ran at least once.
+# Used when checking, if we should ask user for confirmation when
+# applying changes to the cluster.
+output "initialized" {
+  value = true
+}
+`
diff --git a/pkg/platform/aws/aws.go b/pkg/platform/aws/aws.go
index 70e73b9c..1f1120e3 100644
--- a/pkg/platform/aws/aws.go
+++ b/pkg/platform/aws/aws.go
@@ -101,9 +101,17 @@ func NewConfig() *config {
 	}
 }
 
-// GetAssetDir returns asset directory path
-func (c *config) GetAssetDir() string {
-	return c.AssetDir
+// Meta is part of Platform interface and returns common information about the platform configuration.
+func (c *config) Meta() platform.Meta {
+	nodes := c.ControllerCount
+	for _, workerpool := range c.WorkerPools {
+		nodes += workerpool.Count
+	}
+
+	return platform.Meta{
+		AssetDir:      c.AssetDir,
+		ExpectedNodes: nodes,
+	}
 }
 
 func (c *config) Apply(ex *terraform.Executor) error {
@@ -212,15 +220,6 @@ func createTerraformConfigFile(cfg *config, terraformRootDir string) error {
 	return nil
 }
 
-func (c *config) GetExpectedNodes() int {
-	nodes := c.ControllerCount
-	for _, workerpool := range c.WorkerPools {
-		nodes += workerpool.Count
-	}
-
-	return nodes
-}
-
 // checkValidConfig validates cluster configuration.
 func (c *config) checkValidConfig() hcl.Diagnostics {
 	var diagnostics hcl.Diagnostics
diff --git a/pkg/platform/baremetal/baremetal.go b/pkg/platform/baremetal/baremetal.go
index a605aadf..fbd0276a 100644
--- a/pkg/platform/baremetal/baremetal.go
+++ b/pkg/platform/baremetal/baremetal.go
@@ -62,9 +62,12 @@ func (c *config) LoadConfig(configBody *hcl.Body, evalContext *hcl.EvalContext)
 	return gohcl.DecodeBody(*configBody, evalContext, c)
 }
 
-// GetAssetDir returns asset directory path
-func (c *config) GetAssetDir() string {
-	return c.AssetDir
+// Meta is part of Platform interface and returns common information about the platform configuration.
+func (c *config) Meta() platform.Meta {
+	return platform.Meta{
+		AssetDir:      c.AssetDir,
+		ExpectedNodes: len(c.ControllerMacs) + len(c.WorkerMacs),
+	}
 }
 
 func NewConfig() *config {
@@ -195,7 +198,3 @@ func createTerraformConfigFile(cfg *config, terraformPath string) error {
 	}
 	return nil
 }
-
-func (c *config) GetExpectedNodes() int {
-	return len(c.ControllerMacs) + len(c.WorkerMacs)
-}
diff --git a/pkg/platform/packet/packet.go b/pkg/platform/packet/packet.go
index 8361b689..c7e0f145 100644
--- a/pkg/platform/packet/packet.go
+++ b/pkg/platform/packet/packet.go
@@ -116,9 +116,17 @@ func NewConfig() *config {
 	}
 }
 
-// GetAssetDir returns asset directory path
-func (c *config) GetAssetDir() string {
-	return c.AssetDir
+// Meta is part of Platform interface and returns common information about the platform configuration.
+func (c *config) Meta() platform.Meta {
+	nodes := c.ControllerCount
+	for _, workerpool := range c.WorkerPools {
+		nodes += workerpool.Count
+	}
+
+	return platform.Meta{
+		AssetDir:      c.AssetDir,
+		ExpectedNodes: nodes,
+	}
 }
 
 func (c *config) Initialize(ex *terraform.Executor) error {
@@ -345,16 +353,6 @@ func clusterTarget(name, resource string) string {
 	return fmt.Sprintf("module.packet-%v.%v", name, resource)
 }
 
-func (c *config) GetExpectedNodes() int {
-	workers := 0
-
-	for _, wp := range c.WorkerPools {
-		workers += wp.Count
-	}
-
-	return c.ControllerCount + workers
-}
-
 // checkValidConfig validates cluster configuration.
 func (c *config) checkValidConfig() hcl.Diagnostics {
 	var diagnostics hcl.Diagnostics
diff --git a/pkg/platform/platform.go b/pkg/platform/platform.go
index c9d98a5f..4fe2ba7d 100644
--- a/pkg/platform/platform.go
+++ b/pkg/platform/platform.go
@@ -27,8 +27,14 @@ type Platform interface {
 	Apply(*terraform.Executor) error
 	Destroy(*terraform.Executor) error
 	Initialize(*terraform.Executor) error
-	GetAssetDir() string
-	GetExpectedNodes() int
+	Meta() Meta
+}
+
+// Meta is a generic information format about the platform.
+type Meta struct {
+	AssetDir      string
+	ExpectedNodes int
+	Managed       bool
 }
 
 // platforms is a collection where all platforms gets automatically registered


Test patch:
diff --git a/examples/aks-testing/cluster.lokocfg b/examples/aks-testing/cluster.lokocfg
new file mode 100644
index 00000000..22d3b64d
--- /dev/null
+++ b/examples/aks-testing/cluster.lokocfg
@@ -0,0 +1,78 @@
+variable "subscription_id" {}
+variable "tenant_id" {}
+variable "grafana_admin_password" {}
+variable "resource_group_name" {}
+variable "cert_manager_email" {}
+
+variable "manage_resource_group" {
+  default = true
+}
+
+variable "application_name" {
+  default = ""
+}
+
+variable "asset_dir" {
+  default = "./lokomotive-assets"
+}
+
+variable "cluster_name" {
+  default = "lokomotive-cluster"
+}
+
+variable "workers_count" {
+  default = 1
+}
+
+variable "workers_type" {
+  default = "Standard_D2_v2"
+}
+
+variable "location" {
+  default = "West Europe"
+}
+
+cluster "aks" {
+  asset_dir    = pathexpand(var.asset_dir)
+  cluster_name = var.cluster_name
+
+  subscription_id       = var.subscription_id
+  tenant_id             = var.tenant_id
+  location              = var.location
+  resource_group_name   = var.resource_group_name
+  application_name      = var.application_name
+  manage_resource_group = var.manage_resource_group
+
+  worker_pool "default" {
+    vm_size = var.workers_type
+    count   = var.workers_count
+  }
+}
+
+component "prometheus-operator" {
+  grafana_admin_password = var.grafana_admin_password
+  disable_webhooks       = true
+
+  monitor {
+    etcd                    = false
+    kube_controller_manager = false
+    kube_scheduler          = false
+    kube_proxy              = false
+    kubelet                 = false
+  }
+
+  coredns {
+    selector = {
+      "k8s-app" = "kube-dns",
+    }
+  }
+}
+
+component "cert-manager" {
+  email           = var.cert_manager_email
+  service_monitor = true
+}
+
+component "contour" {
+  service_monitor = true
+}
diff --git a/examples/aws-testing/cluster.lokocfg b/examples/aws-testing/cluster.lokocfg
index ae6f8940..f5fe7b0a 100644
--- a/examples/aws-testing/cluster.lokocfg
+++ b/examples/aws-testing/cluster.lokocfg
@@ -32,8 +32,8 @@ cluster "aws" {
   ssh_pubkeys      = var.ssh_public_keys
 
   worker_pool "my-wp-name" {
-    instance_type = var.workers_type
     count         = var.workers_count
+    instance_type = var.workers_type
     ssh_pubkeys   = var.ssh_public_keys
   }
 }
diff --git a/pkg/platform/aks/aks_test.go b/pkg/platform/aks/aks_test.go
new file mode 100644
index 00000000..eca21dec
--- /dev/null
+++ b/pkg/platform/aks/aks_test.go
@@ -0,0 +1,315 @@
+package aks
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/gohcl"
+	"github.com/hashicorp/hcl/v2/hclparse"
+
+	lokoconfig "github.com/kinvolk/lokomotive/pkg/config"
+)
+
+const (
+	testWorkerCount = 1
+)
+
+// createTerraformConfigFile()
+func TestCreateTerraformConfigFile(t *testing.T) {
+	tmpDir, err := ioutil.TempDir("", "lokoctl-tests-")
+	if err != nil {
+		t.Fatalf("creating tmp dir should succeed, got: %v", err)
+	}
+
+	defer func() {
+		if err := os.RemoveAll(tmpDir); err != nil {
+			t.Logf("failed to remove temp dir %q: %v", tmpDir, err)
+		}
+	}()
+
+	c := &config{
+		WorkerPools: []workerPool{
+			{
+				Name:   "foo",
+				VMSize: "bar",
+				Count:  testWorkerCount,
+			},
+		},
+	}
+
+	if err := createTerraformConfigFile(c, tmpDir); err != nil {
+		t.Fatalf("creating Terraform config files should succeed, got: %v", err)
+	}
+}
+
+func TestCreateTerraformConfigFileNoWorkerPools(t *testing.T) {
+	tmpDir, err := ioutil.TempDir("", "lokoctl-tests-")
+	if err != nil {
+		t.Fatalf("creating tmp dir should succeed, got: %v", err)
+	}
+
+	defer func() {
+		if err := os.RemoveAll(tmpDir); err != nil {
+			t.Logf("failed to remove temp dir %q: %v", tmpDir, err)
+		}
+	}()
+
+	c := &config{}
+
+	if err := createTerraformConfigFile(c, tmpDir); err == nil {
+		t.Fatalf("creating Terraform config files should fail if there is no worker pools defined")
+	}
+}
+
+func TestCreateTerraformConfigFileNonExistingPath(t *testing.T) {
+	c := &config{}
+
+	if err := createTerraformConfigFile(c, "/nonexisting"); err == nil {
+		t.Fatalf("creating Terraform config files in non-existing path should fail")
+	}
+}
+
+// Meta()
+func TestMeta(t *testing.T) {
+	assetDir := "foo"
+
+	moreWorkers := 3
+
+	c := &config{
+		AssetDir: assetDir,
+		WorkerPools: []workerPool{
+			{
+				Count: testWorkerCount,
+			},
+			{
+				Count: moreWorkers,
+			},
+		},
+	}
+
+	expectedNodes := 4
+	if e := c.Meta().ExpectedNodes; e != expectedNodes {
+		t.Errorf("Meta should count workers from all pools. Expected %d, got %d", expectedNodes, e)
+	}
+
+	if a := c.Meta().AssetDir; a != assetDir {
+		t.Errorf("Meta should return configured asset dir. Expected %q, got %q", assetDir, a)
+	}
+}
+
+// checkWorkerPoolNamesUnique()
+func TestCheckWorkerPoolNamesUniqueDuplicated(t *testing.T) {
+	c := &config{
+		WorkerPools: []workerPool{
+			{
+				Name: "foo",
+			},
+			{
+				Name: "foo",
+			},
+		},
+	}
+
+	if d := c.checkWorkerPoolNamesUnique(); !d.HasErrors() {
+		t.Fatalf("should return error when worker pools are duplicated")
+	}
+}
+
+func TestCheckWorkerPoolNamesUnique(t *testing.T) {
+	c := &config{
+		WorkerPools: []workerPool{
+			{
+				Name: "foo",
+			},
+			{
+				Name: "bar",
+			},
+		},
+	}
+
+	if d := c.checkWorkerPoolNamesUnique(); d.HasErrors() {
+		t.Fatalf("should not return errors when pool names are unique, got: %v", d)
+	}
+}
+
+// checkNotEmptyWorkers()
+func TestNotEmptyWorkersEmpty(t *testing.T) {
+	c := &config{}
+
+	if d := c.checkNotEmptyWorkers(); !d.HasErrors() {
+		t.Fatalf("should return error when there is no worker pool defined")
+	}
+}
+
+func TestNotEmptyWorkers(t *testing.T) {
+	c := &config{
+		WorkerPools: []workerPool{
+			{
+				Name: "foo",
+			},
+		},
+	}
+
+	if d := c.checkNotEmptyWorkers(); d.HasErrors() {
+		t.Fatalf("should not return errors when worker pool is not empty, got: %v", d)
+	}
+}
+
+// checkConfiguration()
+func TestCheckWorkerPoolNamesUniqueTest(t *testing.T) {
+	c := &config{
+		WorkerPools: []workerPool{
+			{
+				Name: "foo",
+			},
+			{
+				Name: "bar",
+			},
+		},
+	}
+
+	if d := c.checkWorkerPoolNamesUnique(); d.HasErrors() {
+		t.Fatalf("should not return errors when pool names are unique, got: %v", d)
+	}
+}
+
+// checkCredentials()
+func TestCheckCredentialsAppNameAndClientID(t *testing.T) {
+	c := &config{
+		ApplicationName: "foo",
+		ClientID:        "foo",
+	}
+
+	if d := c.checkCredentials(); !d.HasErrors() {
+		t.Fatalf("should give error if both ApplicationName and ClientID fields are defined")
+	}
+}
+
+func TestCheckCredentialsAppNameAndClientSecret(t *testing.T) {
+	c := &config{
+		ApplicationName: "foo",
+		ClientSecret:    "foo",
+	}
+
+	if d := c.checkCredentials(); !d.HasErrors() {
+		t.Fatalf("should give error if both ApplicationName and ClientID fields are defined")
+	}
+}
+
+func TestCheckCredentialsAppNameClientIDAndClientSecret(t *testing.T) {
+	c := &config{
+		ApplicationName: "foo",
+		ClientID:        "foo",
+		ClientSecret:    "foo",
+	}
+
+	expectedErrorCount := 2
+
+	if d := c.checkCredentials(); len(d) != expectedErrorCount {
+		t.Fatalf("should give errors for both conflicting ClientID and ClientSecret, got %v", d)
+	}
+}
+
+func TestCheckCredentialsRequireSome(t *testing.T) {
+	c := &config{}
+
+	if d := c.checkCredentials(); !d.HasErrors() {
+		t.Fatalf("should give error if both ApplicationName and ClientID fields are empty")
+	}
+}
+
+func TestCheckCredentialsRequireClientIDWithClientSecret(t *testing.T) {
+	c := &config{
+		ClientSecret: "foo",
+	}
+
+	if d := c.checkCredentials(); !d.HasErrors() {
+		t.Fatalf("should give error if ClientSecret is defined and ClientID is empty")
+	}
+}
+
+func TestCheckCredentialsReadClientSecretFromEnvironment(t *testing.T) {
+	if err := os.Setenv(clientSecretEnv, "1"); err != nil {
+		t.Fatalf("failed to set environment variable %q: %v", clientSecretEnv, err)
+	}
+
+	defer func() {
+		if err := os.Setenv(clientSecretEnv, ""); err != nil {
+			t.Logf("failed unsetting environment variable %q: %v", clientSecretEnv, err)
+		}
+	}()
+
+	c := &config{
+		ClientID: "foo",
+	}
+
+	if d := c.checkCredentials(); d.HasErrors() {
+		t.Fatalf("should read client secret from environment")
+	}
+}
+
+// LoadConfig()
+func loadConfigFromString(t *testing.T, c string) hcl.Diagnostics {
+	p := hclparse.NewParser()
+
+	f, d := p.ParseHCL([]byte(c), "x.lokocfg")
+	if d.HasErrors() {
+		t.Fatalf("parsing HCL should succeed, got: %v", d)
+	}
+
+	configBody := hcl.MergeFiles([]*hcl.File{f})
+
+	var rootConfig lokoconfig.RootConfig
+
+	if d := gohcl.DecodeBody(configBody, nil, &rootConfig); d.HasErrors() {
+		t.Fatalf("decoding root config should succeed, got: %v", d)
+	}
+
+	cc := &config{}
+
+	return cc.LoadConfig(&rootConfig.Cluster.Config, &hcl.EvalContext{})
+}
+
+func TestLoadConfig(t *testing.T) {
+	c := `
+cluster "aks" {
+  asset_dir           = "/fooo"
+  client_id           = "bar"
+  client_secret       = "foo"
+  cluster_name        = "mycluster"
+  resource_group_name = "test"
+  subscription_id     = "foo"
+  tenant_id           = "bar"
+
+  worker_pool "foo" {
+    count   = 1
+    vm_size = "foo"
+  }
+}
+`
+	if d := loadConfigFromString(t, c); d.HasErrors() {
+		t.Fatalf("valid config should not return error, got: %v", d)
+	}
+}
+
+func TestLoadConfigEmpty(t *testing.T) {
+	c := &config{}
+
+	if d := c.LoadConfig(nil, &hcl.EvalContext{}); !d.HasErrors() {
+		t.Fatalf("empty config should return error, got: %v", d)
+	}
+}
+
+func TestLoadConfigBadHCL(t *testing.T) {
+	c := `
+cluster "aks" {
+  not_defined_field = "doh"
+}
+`
+
+	if d := loadConfigFromString(t, c); !d.HasErrors() {
+		t.Fatalf("invalid HCL should return errors")
+	}
+}
diff --git a/test/components/cert-manager/cert-manager_test.go b/test/components/cert-manager/cert-manager_test.go
index ff53179c..3d538817 100644
--- a/test/components/cert-manager/cert-manager_test.go
+++ b/test/components/cert-manager/cert-manager_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build e2e
 
 package certmanager
diff --git a/test/components/contour/contour_test.go b/test/components/contour/contour_test.go
index c4546aeb..7b2545ef 100644
--- a/test/components/contour/contour_test.go
+++ b/test/components/contour/contour_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build e2e
 
 package contour
diff --git a/test/components/external-dns/external-dns_test.go b/test/components/external-dns/external-dns_test.go
index 7c1b2b8d..b57d3da9 100644
--- a/test/components/external-dns/external-dns_test.go
+++ b/test/components/external-dns/external-dns_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build e2e
 
 package externaldns
diff --git a/test/components/httpbin/httpbin_test.go b/test/components/httpbin/httpbin_test.go
index 985eb7f1..2aa179bc 100644
--- a/test/components/httpbin/httpbin_test.go
+++ b/test/components/httpbin/httpbin_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build e2e
 
 package httpbin
diff --git a/test/components/prometheus-operator/prometheus_operator_test.go b/test/components/prometheus-operator/prometheus_operator_test.go
index 2317394a..cdee0cff 100644
--- a/test/components/prometheus-operator/prometheus_operator_test.go
+++ b/test/components/prometheus-operator/prometheus_operator_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build e2e
 
 package prometheusoperator
diff --git a/test/components/util/util.go b/test/components/util/util.go
index 19633a0c..c052cab8 100644
--- a/test/components/util/util.go
+++ b/test/components/util/util.go
@@ -337,6 +337,9 @@ const (
 
 	// PlatformBaremetal is for Baremetal
 	PlatformBaremetal = "baremetal"
+
+	// PlatformAKS is for AKS.
+	PlatformAKS = "aks"
 )
 
 // IsPlatformSupported takes in the test object and the list of supported platforms. The function
diff --git a/test/monitoring/components_alerts_test.go b/test/monitoring/components_alerts_test.go
index 47351b8d..35bfad2e 100644
--- a/test/monitoring/components_alerts_test.go
+++ b/test/monitoring/components_alerts_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build poste2e
 
 package monitoring
diff --git a/test/monitoring/components_metrics_test.go b/test/monitoring/components_metrics_test.go
index ea2c8ecf..7e6d74f3 100644
--- a/test/monitoring/components_metrics_test.go
+++ b/test/monitoring/components_metrics_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build poste2e
 
 package monitoring
@@ -25,12 +25,18 @@ import (
 	"time"
 
 	v1 "github.com/prometheus/client_golang/api/prometheus/v1"
+	"k8s.io/apimachinery/pkg/util/wait"
 
 	testutil "github.com/kinvolk/lokomotive/test/components/util"
 )
 
 //nolint:funlen
 func testComponentsPrometheusMetrics(t *testing.T, v1api v1.API) {
+	selfHostedPlatforms := []testutil.Platform{
+		testutil.PlatformPacket,
+		testutil.PlatformAWS,
+	}
+
 	testCases := []struct {
 		componentName string
 		query         string
@@ -47,18 +53,22 @@ func testComponentsPrometheusMetrics(t *testing.T, v1api v1.API) {
 		{
 			componentName: "kube-scheduler",
 			query:         "scheduler_schedule_attempts_total",
+			platforms:     selfHostedPlatforms,
 		},
 		{
 			componentName: "kube-controller-manager",
 			query:         "workqueue_work_duration_seconds_bucket",
+			platforms:     selfHostedPlatforms,
 		},
 		{
 			componentName: "kube-proxy",
 			query:         "kubeproxy_sync_proxy_rules_duration_seconds_bucket",
+			platforms:     selfHostedPlatforms,
 		},
 		{
 			componentName: "kubelet",
 			query:         "kubelet_running_pod_count",
+			platforms:     selfHostedPlatforms,
 		},
 		{
 			componentName: "metallb",
@@ -68,12 +78,12 @@ func testComponentsPrometheusMetrics(t *testing.T, v1api v1.API) {
 		{
 			componentName: "contour",
 			query:         "contour_dagrebuild_timestamp",
-			platforms:     []testutil.Platform{testutil.PlatformPacket, testutil.PlatformAWS},
+			platforms:     []testutil.Platform{testutil.PlatformPacket, testutil.PlatformAWS, testutil.PlatformAKS},
 		},
 		{
 			componentName: "cert-manager",
 			query:         "certmanager_controller_sync_call_count",
-			platforms:     []testutil.Platform{testutil.PlatformPacket, testutil.PlatformAWS},
+			platforms:     []testutil.Platform{testutil.PlatformPacket, testutil.PlatformAWS, testutil.PlatformAKS},
 		},
 	}
 
@@ -88,51 +98,39 @@ func testComponentsPrometheusMetrics(t *testing.T, v1api v1.API) {
 
 			t.Logf("querying %q", tc.query)
 
-			const contextTimeout = 10
-
-			var err error
-
-			// This loop ensures that we try to query prometheus for the state of targets multiple times.
-			// This is the retry logic.
-			for i := 0; i < 20; i++ {
-				t.Logf("Running scrape test iteration #%d", i)
-
-				// Use function to be able to use defer.
-				err = func() error {
-					ctx, cancel := context.WithTimeout(context.Background(), contextTimeout*time.Second)
-					defer cancel()
+			if err := wait.PollImmediate(retryInterval, timeout, getMetricRetryFunc(t, v1api, tc.query)); err != nil {
+				t.Errorf("%v", err)
+			}
+		})
+	}
+}
 
-					results, warnings, err := v1api.Query(ctx, tc.query, time.Now())
-					if err != nil {
-						return fmt.Errorf("error querying Prometheus: %w", err)
-					}
+// getMetricRetryFunc returns a function which can be passed to wait.PollImmediate
+// checking if a given Prometheus query returns any result.
+func getMetricRetryFunc(t *testing.T, v1api v1.API, query string) wait.ConditionFunc {
+	return func() (done bool, err error) {
+		ctx, cancel := context.WithTimeout(context.Background(), contextTimeout*time.Second)
+		defer cancel()
 
-					if len(warnings) > 0 {
-						t.Logf("warnings: %v", warnings)
-					}
+		results, warnings, err := v1api.Query(ctx, query, time.Now())
+		if err != nil {
+			t.Logf("error querying Prometheus for metric %q: %v", query, err)
 
-					if len(results.String()) == 0 {
-						return fmt.Errorf("no metrics found")
-					}
+			return false, nil
+		}
 
-					t.Logf("found %d results for %s", len(strings.Split(results.String(), "\n")), tc.query)
+		if len(warnings) > 0 {
+			t.Logf("warnings: %v", warnings)
+		}
 
-					return nil
-				}()
+		if len(results.String()) == 0 {
+			t.Logf("no metrics found for query %q", query)
 
-				// If there is no errors, break the retry loop.
-				if err == nil {
-					break
-				}
+			return false, nil
+		}
 
-				// Wait a bit before next attempt.
-				time.Sleep(30 * time.Second) //nolint:gomnd
-			}
+		t.Logf("found %d results for %q", len(strings.Split(results.String(), "\n")), query)
 
-			// If there are still some errors after all retries, fail the test.
-			if err != nil {
-				t.Fatal(err)
-			}
-		})
+		return true, nil
 	}
 }
diff --git a/test/monitoring/monitoring_test.go b/test/monitoring/monitoring_test.go
index 1f004bdb..4ca5d61d 100644
--- a/test/monitoring/monitoring_test.go
+++ b/test/monitoring/monitoring_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build poste2e
 
 package monitoring
diff --git a/test/monitoring/scrape_targets_test.go b/test/monitoring/scrape_targets_test.go
index 9f15b5b5..9f74096f 100644
--- a/test/monitoring/scrape_targets_test.go
+++ b/test/monitoring/scrape_targets_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// +build aws packet
+// +build aws packet aks
 // +build poste2e
 
 package monitoring