Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Kubernetes Cluster Extension Terraform Module

Installs a managed cluster extension — Flux/GitOps, Dapr, Azure ML, backup, observability agents — onto an existing AKS cluster as an ARM child, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • ⚙️ Creates one azurerm_kubernetes_cluster_extension, named this.
  • 🔗 Attaches to an existing AKS managed cluster by Resource ID — this module never creates a cluster.
  • 🎯 Enforces Microsoft's published rule that a pinned version and a release train are mutually exclusive, by grouping both into one release object so the rule is a parse-time error rather than a comment.
  • 🧭 Derives the installation scope the provider has no argument for, from which namespace field is set.
  • 🔐 Marks configuration_protected_settings sensitive itself, because the provider's schema does not — and emits only its presence and count, never a key name.
  • 🛒 Renders the optional Marketplace plan block from a single object variable.
  • 📤 Emits the extension's assigned identity, which is the principal a caller actually grants RBAC to.

💡 Why it matters: an extension is the supported way to get a platform workload into a cluster without operating a Helm release yourself — Azure tracks its version, upgrades it on a release train, and shows it in ARM. The cost of that convenience is a map(string) payload nothing validates until the extension itself reads it, and a set of force-new fields broad enough that a namespace change is a destroy. This module makes the rules that are checkable into type errors, and states plainly which mistakes it cannot catch.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  RG["terraform-azurerm-resource-group"]
  AKS["terraform-azurerm-kubernetes-cluster"]
  EXT["terraform-azurerm-kubernetes-cluster-extension"]
  TARB["terraform-azurerm-kubernetes-cluster-trusted-access-role-binding"]
  SG["terraform-azurerm-kubernetes-cluster-deployment-safeguard"]
  RA["terraform-azurerm-role-assignments"]
  KV["a secret store, read out of band"]
  ARC["terraform-azurerm-arc-kubernetes-cluster -- owns the Arc twin of this resource"]

  RG -->|"resource_group_name"| AKS
  AKS -->|"id as cluster_id"| EXT
  AKS -->|"id as kubernetes_cluster_id"| TARB
  AKS -->|"id as kubernetes_cluster_id"| SG
  KV -->|"values as configuration_protected_settings"| EXT
  EXT -->|"installs_into_namespace feeds excluded_namespaces"| SG
  EXT -->|"aks_assigned_identity_principal_id"| RA
  ARC -.->|"same ARM type, different Terraform resource, for connectedClusters"| EXT

  style EXT fill:#0078D4,stroke:#004578,color:#ffffff
  style AKS fill:#004578,stroke:#00335c,color:#ffffff
  style RG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style TARB fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style SG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style RA fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style KV fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style ARC fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

The extension is a leaf that hangs off a cluster. It consumes the cluster's id, and it feeds two things onward: the namespace it installs into (so admission control can exclude it) and the identity Azure assigns it (so RBAC can be granted to it). The dashed edge is the one that catches people out — azurerm_arc_kubernetes_cluster_extension is the same ARM type reached through a different Terraform resource, for Arc-connected clusters.


🧬 What this module builds

flowchart TB
  IN["required: name, cluster_id, extension_type"]
  REL["release object: extension_version XOR release_train -- grouped so Microsoft's published mutual exclusion is enforceable. Setting NEITHER means auto-upgrade on, latest Stable."]
  NS["namespaces object: release_namespace (cluster scope) or target_namespace (namespace scope) -- the provider has NO scope argument, so the scope is IMPLIED"]
  CFG["configuration_settings -- public, retrievable, editable in place"]
  PROT["configuration_protected_settings -- the schema does NOT mark this sensitive; THIS MODULE DOES. A partial update DELETES what you omit."]
  PLAN["plan object -- Marketplace publisher, product and name, all force-new"]
  THIS["azurerm_kubernetes_cluster_extension.this"]
  HELM["workloads deployed into the cluster namespace"]
  DERIVED["derived: version_is_pinned, auto_upgrade_minor_version_effective, release_train_effective, implied_installation_scope, installs_into_namespace, protected_setting_count"]
  NOTCHECKED["cannot be checked here: whether the extension_type exists, whether a settings key is one it reads, and whether the type supports the chosen scope"]

  IN --> THIS
  REL --> THIS
  NS --> THIS
  CFG --> THIS
  PROT -->|"never emitted -- presence and count only"| THIS
  PLAN --> THIS
  THIS -->|"installs"| HELM
  THIS --> DERIVED
  THIS -.->|"no validation can reach these"| NOTCHECKED

  style THIS fill:#0078D4,stroke:#004578,color:#ffffff
  style PROT fill:#004578,stroke:#00335c,color:#ffffff
  style IN fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style REL fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style NS fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style CFG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style PLAN fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style HELM fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style DERIVED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style NOTCHECKED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading
Resource Count Notes
azurerm_kubernetes_cluster_extension.this 1 The keystone.
nested plan block 0 or 1 Schema max = 1, so it is one optional object variable, not a map.
nested timeouts block 0 or 1 The universal tail. All four operations are supported.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None here. The caller configures the provider, its authentication, and the mandatory features {} block.
Resource azurerm_kubernetes_cluster_extension
ARM type Microsoft.KubernetesConfiguration/extensions

Schema notes that bite

  • 🔴 Force-new: name, cluster_id, extension_type, release_namespace, target_namespace, and every field of plan. That is everything except the two settings maps, the version and the train. Changing the namespace destroys and recreates the extension — for a stateful extension that is data loss, not downtime.
  • 🔴 A pinned version and a release train cannot coexist. Microsoft documents that pinning a version turns autoUpgradeMinorVersion off, and that a release train "can't be used when autoUpgradeMinorVersion is set to false". The provider exposes no auto_upgrade_minor_version argument, so the posture is implied by whether you pinned. This module rejects setting both and emits auto_upgrade_minor_version_effective.
  • 🔴 configuration_protected_settings is not marked sensitive in the provider's schema. The equivalent argument on azurerm_stack_hci_extension is. Same concept, one marked and one not — this module marks it itself.
  • 🔴 A partial update to either settings map replaces the whole map. Keys you omit are removed from the extension. There is no merge.
  • ⚠️ release_namespace and target_namespace are both optional and computed. Azure may populate whichever the extension type defaults to, so a plan that set neither can come back with one filled in. Setting both is accepted by the schema, meaningless in the platform, and rejected here.
  • ⚠️ The ARM namespace is Microsoft.KubernetesConfiguration, not Microsoft.ContainerService. A custom RBAC role scoped only to ContainerService cannot manage the extension, and an Azure Policy definition must target the KubernetesConfiguration type.
  • ⚠️ version is a reserved Terraform variable name. variable "version" {} is a hard parse error — it is a module-block meta-argument. The input is release.extension_version; the provider's argument name is unchanged.
  • ⚠️ aks_assigned_identity is a computed list that can be empty. Index it with try(...[0], null).
  • ⚠️ The parent reference is cluster_id here — but kubernetes_cluster_id on the trusted-access-role-binding and deployment-safeguard siblings of the same cluster. Do not infer it from a sibling.
  • ℹ️ current_version drifts on its own as a release train advances. It is an observation, not a setting.

Force-new facts come from the provider's documentation markdown. The binary schema carries no force-new marker at all, so it cannot be the source for them; shapes, types and the sensitivity marks do come from the schema.


🔑 Required Azure RBAC Roles / Permissions

Role Scope Why
Azure Kubernetes Service Contributor the AKS cluster Create, update, delete the extension.
A role granting Microsoft.KubernetesConfiguration/extensions/* the AKS cluster The extension is under a different resource provider from the cluster. A custom role written against Microsoft.ContainerService/* alone will not manage it.
Reader the AKS cluster Refresh and plan.
Whatever the extension needs at runtime its own targets Granted to the extension's identity, not the Terraform identity — e.g. AcrPull on a registry for Flux.

🔒 Plan access is not credential access here. Azure never echoes the protected settings, so a Reader can refresh without learning them. That is the favourable direction, and it does not hold for every resource in this library — so it is worth stating rather than assuming.


Azure Prerequisites

  • Microsoft.KubernetesConfiguration registered on the subscription. Separate from Microsoft.ContainerService — a subscription that creates AKS clusters happily can still reject an extension.
  • An existing AKS managed cluster. There is no create-if-absent path.
  • An extension_type that exists and supports the cluster. Some types need a minimum Kubernetes version, a Linux node pool, or a specific node OS. Nothing in Terraform can check this — it fails at apply, from Azure.
  • For a Marketplace offer: the offer's terms accepted on the subscription, and the plan identifiers matching the offer exactly.
  • Cluster egress to whatever the extension's own pods need to reach.

📁 Module Structure

terraform-azurerm-kubernetes-cluster-extension/
├── providers.tf   # required_version + the pinned azurerm; no provider block
├── variables.tf   # 9 deeply-typed inputs, 20 validations
├── main.tf        # locals deriving the implied posture + the single keystone resource
├── outputs.tf     # 35 outputs: id first, then the derived facts, then six constants
├── README.md      # this file
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore     # the canonical library ignore file

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "flux_extension" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"
}

That is the whole minimum call: three required arguments. Leaving release unset is deliberate and is the safer choice — auto-upgrade stays on and the extension follows the latest Stable release.

⚠️ The features {} block is mandatory on the provider and belongs entirely to your root module. This module declares no provider.


🔌 Cross-Module Contract

Consumes

Input Type Typical source
cluster_id string module.aks.id
name string caller
extension_type string the publisher's registered type
release object caller — one field or neither, never both
namespaces object caller — the field you set implies the scope
configuration_settings map(string) caller / composition
configuration_protected_settings map(string), sensitive a secret store, read out of band
plan object the Marketplace offer
timeouts object caller

Emits

Output Consumed by
id anything referencing the extension
installs_into_namespace terraform-azurerm-kubernetes-cluster-deployment-safeguard → excluded_namespaces
aks_assigned_identity_principal_id terraform-azurerm-role-assignments → principal_id
version_is_pinned, auto_upgrade_minor_version_effective patching posture review
has_protected_settings, protected_setting_count secret review, without disclosure

📚 Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "platform_vault_id" {
  description = "id of an existing platform vault that these examples reference but do not create."
  type        = string
}

variable "registry_id" {
  description = "id of an existing registry that these examples reference but do not create."
  type        = string
}
1 · The minimum call
module "flux" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"
}

💡 Three required arguments and nothing else. release unset means auto-upgrade on, latest Stable — check auto_upgrade_minor_version_effective and you will see true.

2 · Following a release train
module "dapr" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "dapr"
  cluster_id     = module.aks.id
  extension_type = "Microsoft.Dapr"

  release = {
    release_train = "Stable"
  }
}

ℹ️ Naming Stable explicitly is identical in effect to omitting release altogether — the module emits release_train_effective = "Stable" either way. Be explicit when you want the intent visible in review.

3 · Pinning a version, and what it costs
module "pinned" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"

  release = {
    extension_version = "1.9.1"
  }
}

🔒 Pinning turns auto-upgrade off, so the extension stops receiving security patches until you bump this string. The module reports it: version_is_pinned = true and auto_upgrade_minor_version_effective = false. That is sometimes exactly right — a reproducible platform build — and sometimes an extension frozen at a vulnerable release. Nothing here can tell which, which is why it is reported rather than prevented.

4 · What you cannot do — a version and a train together
# THIS FAILS AT PARSE TIME, BY DESIGN.
module "contradiction" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"

  release = {
    extension_version = "1.9.1"
    release_train     = "Stable"
  }
}

⚠️ Microsoft documents that a release train "can't be used when autoUpgradeMinorVersion is set to false", and that pinning a version sets exactly that. The two are mutually exclusive. This is why both fields live in one release object: a validation {} block may only reference its own variable, so a rule spanning two fields is only enforceable when the fields share one variable.

5 · Cluster scope, via a release namespace
module "cluster_scoped" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"

  namespaces = {
    release_namespace = "flux-system"
  }
}

ℹ️ The provider has no scope argument — Microsoft's CLI does (--scope cluster). Setting release_namespace is what expresses cluster scope, and the module emits implied_installation_scope = "cluster" so the choice is legible without knowing that convention.

6 · Namespace scope, via a target namespace
module "namespace_scoped" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "team-a-agent"
  cluster_id     = module.aks.id
  extension_type = "Contoso.SomeAgent"

  namespaces = {
    target_namespace = "team-a"
  }
}

🔒 Namespace scope confines the extension to one namespace. Both namespace fields are force-new — moving this to team-b later destroys and recreates the extension. Decide before the first apply.

7 · Public settings, which are safe to read back
module "with_settings" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"

  configuration_settings = {
    "multiTenancy.enforce"                       = "true"
    "image-automation-controller.enabled"        = "true"
    "helm-controller.detectDrift"                = "true"
  }
}

ℹ️ Azure returns these on a GET, so the module emits them in full — the values, the sorted key list and the count. There is nothing to withhold. But nothing validates them either: a misspelled key is accepted, stored, and silently ignored by the extension. See settings_are_interpreted_by_the_extension_not_validated_by_azure.

8 · Protected settings, handled properly
data "azurerm_key_vault_secret" "git_token" {
  name         = "flux-git-token"
  key_vault_id = var.platform_vault_id
}

module "flux_private_repo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"

  configuration_protected_settings = {
    "git.token" = data.azurerm_key_vault_secret.git_token.value
  }
}

🔒 Three things to be clear about. One: the provider's schema does not mark this argument sensitive; this module does. Two: sensitive = true redacts plan output and does not encrypt state — that value is in your state file in plaintext, so the control that matters is a remote backend with encryption and access control, never a local state file in a repository. Three: the module emits has_protected_settings and protected_setting_count and never a key name, because git.token tells a reader what kind of credential is present.

9 · The partial-update trap
# BEFORE: two protected settings.
configuration_protected_settings = {
  "git.username" = var.git_username
  "git.token"    = data.azurerm_key_vault_secret.git_token.value
}

# AFTER, rotating only the token — DO NOT DO THIS:
configuration_protected_settings = {
  "git.token" = data.azurerm_key_vault_secret.git_token.value
}

⚠️ The second form deletes git.username from the extension. Terraform sends the map it holds and Azure replaces the whole map; there is no merge. Rotating one key means declaring all of them. This is the single easiest way to break a working extension while believing you changed one value.

10 · A Marketplace offer
module "partner_extension" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "contoso-backup"
  cluster_id     = module.aks.id
  extension_type = "Contoso.BackupAgent"

  plan = {
    publisher = "contoso"
    product   = "contoso-backup-aks"
    name      = "standard"
  }
}

⚠️ Every field of plan is force-new, and all three of these are required by the schema when the block is present. They must match the offer exactly, and the offer's terms must already be accepted on the subscription — a mismatch is an apply-time rejection from Azure, not something this module can catch. plan_identifier emits the publisher/product/name triple so it can be diffed against the Marketplace listing by eye.

11 · Granting the extension's identity what it needs
module "flux" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"
}

module "flux_acr_pull" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = var.registry_id

  role_assignments = {
    flux_pull = {
      role_definition_name = "AcrPull"
      principal_id         = module.flux.aks_assigned_identity_principal_id
      principal_type       = "ServicePrincipal"
    }
  }
}

💡 This is the failure mode worth internalising. The extension installs perfectly without this grant, and then its pods cannot pull from the registry — so the error surfaces later, inside the cluster, far from the Terraform run that caused it. has_aks_assigned_identity is available as a precondition if you want the composition to say so rather than passing null into a role assignment.

12 · Rejecting the plausible wrong cluster ID
# All three of these FAIL AT PARSE TIME, each with a message naming what you actually passed.

cluster_id = "/subscriptions/.../providers/Microsoft.Kubernetes/connectedClusters/arc-cluster"
# -> an Arc cluster. Same ARM type for the extension, DIFFERENT Terraform resource.

cluster_id = "/subscriptions/.../providers/Microsoft.ContainerService/fleets/fleet-prod"
# -> a fleet. Shares the Microsoft.ContainerService prefix, which is exactly why it is easy to transpose.

cluster_id = "/subscriptions/.../managedClusters/aks-east/agentPools/system"
# -> a node pool, not the cluster.

🔒 The regex is anchored at both ends, so a parent ID and a child ID cannot both satisfy it. Each rejection names the resource you actually supplied, because "invalid cluster ID" does not help someone who believes they passed a cluster.

13 · Several extensions on one cluster, with for_each
locals {
  extensions = {
    flux = { type = "microsoft.flux", namespace = "flux-system" }
    dapr = { type = "Microsoft.Dapr", namespace = "dapr-system" }
  }
}

module "cluster_extensions" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"
  for_each = local.extensions

  name           = each.key
  cluster_id     = module.aks.id
  extension_type = each.value.type

  namespaces = {
    release_namespace = each.value.namespace
  }
}

ℹ️ for_each over a keyed map, never count — adding a third extension must not re-index the first two. Each instance is an independent ARM child; they have no ordering relationship with each other, only with the cluster.

14 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

locals {
  # Declared once here rather than read back from the resource-group module, which
  # accepts tags as an input and does not emit them.
  platform_tags = {
    environment = "production"
    owner       = "platform-engineering"
  }
}

module "platform_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-platform-eastus"
  location = "eastus"

  tags = local.platform_tags
}

module "aks" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster.git?ref=v1.0.0"

  name                = "aks-platform-eastus"
  resource_group_name = module.platform_rg.name
  location            = module.platform_rg.location
  dns_prefix          = "aks-platform"

  # The deployment-safeguard sibling below requires the Azure Policy add-on.
  azure_policy_enabled = true

  default_node_pool = {
    name       = "system"
    node_count = 3
    vm_size    = "Standard_D4s_v5"
  }

  identity = {
    type = "SystemAssigned"
  }

  tags = local.platform_tags
}

data "azurerm_key_vault_secret" "git_token" {
  name         = "flux-git-token"
  key_vault_id = var.platform_vault_id
}

module "flux_extension" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"

  name           = "flux"
  cluster_id     = module.aks.id
  extension_type = "microsoft.flux"

  # Neither a version nor a train: auto-upgrade on, latest Stable.
  release = {}

  namespaces = {
    release_namespace = "flux-system"
  }

  configuration_settings = {
    "multiTenancy.enforce" = "true"
  }

  configuration_protected_settings = {
    "git.token" = data.azurerm_key_vault_secret.git_token.value
  }
}

# The extension's own identity needs registry access at runtime.
module "flux_acr_pull" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = var.platform_registry_id

  role_assignments = {
    flux_pull = {
      role_definition_name = "AcrPull"
      principal_id         = module.flux_extension.aks_assigned_identity_principal_id
      principal_type       = "ServicePrincipal"
    }
  }
}

# Admission control over the same cluster, excluding the extension's own namespace --
# a platform namespace held to workload rules will fail to start.
module "safeguards" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"

  kubernetes_cluster_id = module.aks.id
  level                 = "Warn"

  excluded_namespaces = [
    module.flux_extension.installs_into_namespace,
  ]
}

output "extension_posture" {
  value = {
    id             = module.flux_extension.id
    scope          = module.flux_extension.implied_installation_scope
    namespace      = module.flux_extension.installs_into_namespace
    version_pinned = module.flux_extension.version_is_pinned
    auto_upgrade   = module.flux_extension.auto_upgrade_minor_version_effective
    train          = module.flux_extension.release_train_effective
    secret_count   = module.flux_extension.protected_setting_count
  }
}

💡 Three wirings in this composition are the whole point of the module's outputs. The cluster's id becomes cluster_id. installs_into_namespace becomes an admission-control exclusion — because the namespace an extension lands in is chosen here, and hard-coding "flux-system" in two modules is how they drift apart. aks_assigned_identity_principal_id becomes a role assignment, because the extension's identity is not the Terraform identity and nothing grants it automatically.

⚠️ Note level = "Warn" on the safeguard module, not the default. Start in Warn, read what it would have blocked, then move to Enforce — and read that module's own notes on why Enforce is not a guarantee.


📥 Inputs

Required (3): name, cluster_id, extension_type.

The two grouped objects (2): release (version XOR train), namespaces (which field you set implies the scope).

Settings (2): configuration_settings, configuration_protected_settings (sensitive).

Marketplace + tail (2): plan, timeouts.

There is deliberately no tags, location or resource_group_name — the resource has no such arguments.

Full input schemas
variable "name" {
  type = string
  # Force-new. Rejects a Resource ID passed where a name was wanted.
}

variable "cluster_id" {
  type = string
  # Force-new. Anchored at both ends to .../Microsoft.ContainerService/managedClusters/<name>.
  # Rejects: connectedClusters (Arc), fleets, /agentPools/ children.
}

variable "extension_type" {
  type = string
  # Force-new. Non-empty, no whitespace. The set of legal values is not published,
  # so only the probable mistakes are checked.
}

variable "release" {
  type = object({
    extension_version = optional(string)
    release_train     = optional(string)
  })
  default = {}
  # AT MOST ONE. Setting neither is valid and is the safer default.
  # `extension_version` rather than `version`, which Terraform reserves.
}

variable "namespaces" {
  type = object({
    release_namespace = optional(string)
    target_namespace  = optional(string)
  })
  default = {}
  # Both force-new. At most one -- release_namespace implies cluster scope,
  # target_namespace implies namespace scope. Both are RFC 1123 labels.
}

variable "configuration_settings" {
  type    = map(string)
  default = {}
  # Public. Azure returns these on a GET; emitted in full.
}

variable "configuration_protected_settings" {
  type      = map(string)
  default   = {}
  sensitive = true
  # The PROVIDER'S SCHEMA DOES NOT MARK THIS SENSITIVE. This module does.
  # A partial update deletes what you omit.
}

variable "plan" {
  type = object({
    name           = string
    product        = string
    publisher      = string
    promotion_code = optional(string)
    version        = optional(string)
  })
  default = null
  # Every field force-new. Schema max = 1, so one object rather than a map.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

⚠️ Object-type conversion silently discards extra keys. A misspelled key inside release, namespaces or plan is dropped without error — it is the one place a wrong input produces no signal at all. Check the derived outputs (version_is_pinned, implied_installation_scope, plan_identifier) against what you intended.


🧾 Outputs

Output Description
id The extension's Resource ID.
name The extension name.
cluster_id / cluster_name / cluster_resource_group The parent, echoed and decomposed.
extension_type The installed type.
current_version Computed — the version Azure actually installed.
extension_version The version you requested, or null.
version_is_pinned Whether a version was pinned.
auto_upgrade_minor_version_effective Derived — true unless a version was pinned.
release_train The train as supplied.
release_train_effective Stable when omitted; null when a version is pinned, because a train does not apply.
follows_the_preview_train Whether the effective train is a preview train.
release_namespace / target_namespace As supplied. Both computed in the schema, so Azure may fill one in.
implied_installation_scope cluster, namespace, ambiguous or unspecified.
both_namespaces_are_set_so_the_scope_is_ambiguous The rejected shape, reported for completeness.
installs_into_namespace The single namespace the workload lands in, or "".
configuration_settings / configuration_setting_keys / configuration_setting_count / has_configuration_settings The public settings, in full.
has_protected_settings / protected_setting_count 🔒 Presence and count only — never a key name.
uses_marketplace_plan / plan_identifier Whether a plan was supplied, and its publisher/product/name.
aks_assigned_identity Computed — the identity list as the provider returns it.
aks_assigned_identity_principal_id Computed — the principal to grant RBAC to.
has_aks_assigned_identity Computed — whether an identity was assigned.
settings_are_interpreted_by_the_extension_not_validated_by_azure Constant. A failure-timing table with two Never rows.
protected_settings_are_in_state_in_plaintext_and_sensitive_does_not_encrypt_them Constant.
auto_upgrade_cannot_be_disabled_without_pinning_a_version Constant.
this_resource_has_no_tags_location_or_resource_group_name Constant.
the_arm_type_is_a_child_of_the_cluster The ARM type string — note the KubernetesConfiguration namespace.
secure_by_default_applies_partly_here_and_this_is_where Constant. Where the rule applies and where it cannot.

No secret is emitted, in any form, including a key list.


🧠 Architecture Notes

Almost everything is force-new. name, cluster_id, extension_type, both namespace fields and every field of plan force replacement; only the settings maps, the version and the train are updatable in place. The practical consequence is that the interesting decisions — which namespace, which type — are effectively permanent, and a change of mind is a destroy. For a stateful extension that is data loss. Decide before the first apply.

The version posture is implied, not stated, and the module refuses to guess. The provider has no auto_upgrade_minor_version argument at all, yet Microsoft's rule ties auto-upgrade to whether a version was pinned. So auto_upgrade_minor_version_effective is derived: true unless release.extension_version is set. Note the deliberate asymmetry in release_train_effective — it is Stable when you set nothing, and null when you pinned a version. null here means "not applicable", not "unset", and conflating those two would misreport the posture.

This module does not flip the version default, and that is a considered exception. This suite's rule is that an enum with a permissive member defaults to the locked-down one. Here the platform's own default — auto-upgrade on, latest Stable — is already the safer choice for a security-relevant extension, and pinning is the risky act. Pinning is sometimes correct and sometimes freezes an extension at a vulnerable release, and nothing visible to Terraform distinguishes those cases. So the module reports the choice through version_is_pinned rather than making it.

The settings maps are the largest unverifiable surface in the module. They are map(string) blobs whose keys and values mean something only to the extension's own controller. Azure accepts an unrecognised key, stores it, and returns it; the extension ignores it. There is no error, at plan, at apply, or afterwards — which is why settings_are_interpreted_by_the_extension_not_validated_by_azure exists as a constant output with a failure-timing table whose decisive rows read Never. A constant output is a weak device and it is used here because the failure it describes produces no signal whatsoever.

Sensitivity is handled by the module because the provider does not handle it. The schema leaves configuration_protected_settings unmarked while marking the same concept on azurerm_stack_hci_extension. Marking it here has a consequence worth knowing: sensitivity is contagious, including to scalars — length(var.x) > 0 on a sensitive map is itself a sensitive bool that Terraform refuses to emit. Both derived flags are therefore unwrapped with nonsensitive(), which doubles as a guard, since it errors if its argument was not sensitive in the first place. What is deliberately not unwrapped is any key name: the count is enough to review against, and git.token describes what kind of credential is held.

The grouped-variable technique has a boundary, and it is stated rather than hidden. release and namespaces exist as objects because a validation {} block may only reference its own variable, so a documented rule spanning two fields is enforceable only when those fields share a variable. But the next question — is the namespace this extension installs into one the cluster's admission control excludes? — compares against a different module's input. No validation here can reach it. installs_into_namespace is emitted for the composition to wire instead. Folding an unrelated field into namespaces to force that check would misrepresent the schema.

for_each, never count. Where a caller installs several extensions on one cluster, the map key is the stable identity; adding a third extension must not re-index the first two.

One coalesce note, because it is a real trap. installs_into_namespace is written as an explicit three-way conditional rather than coalesce(a, b, ""). coalesce rejects an empty string as a candidate and errors with "no non-null, non-empty-string arguments" when both namespaces are unset — which is a perfectly legal call. The conditional is the only safe form.


🧱 Design Principles

Concern This module's default (empty call) Opt-out (you must type it)
Auto-upgrade / patching on — release empty means auto-upgrade and latest Stable pin release.extension_version, which turns auto-upgrade off
Release train Stable set release.release_train = "Preview"
Secrets no secret accepted — configuration_protected_settings defaults to {} supply values, read from a secret store out of band
Secret disclosure presence and count only; keys and values never emitted none — there is no opt-out, by design
Marketplace plan absent supply the plan object
Installation scope unspecified, letting the extension type default set one namespace field
Namespace naming RFC 1123 labels enforced none — Kubernetes' rule, not this module's

🔒 Where secure-by-default cannot apply, and why. Three arguments are required, so there is no empty call to make safe. This suite's rule for that case is to compensate three ways, and all three are met: the checkable rules are enforced (the version/train exclusion, the release-train near-miss, RFC 1123 labels); the restrictive fact is named inside each error message (that an Arc ID belongs to a different Terraform resource, that a fleet ID shares a resource-provider prefix, that pinning stops patches); and every unverifiable decision is emitted rather than assumed. See secure_by_default_applies_partly_here_and_this_is_where.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module by tag — ?ref=v1.0.0 — never a branch. This library is plan-only: a human applies from CI.

# Review the derived posture before applying anything.
terraform plan -out=tfplan
terraform show -json tfplan | jq '.planned_values.outputs'

🧪 Testing

The offline gate is init -backend=false → validate → fmt -check. That proves the HCL parses, the types line up, and the formatting is canonical.

What only a root-module terraform console proves: that each validation {} block actually fires. terraform validate on a calling configuration does not run root-variable validations; terraform console does. Feed it a deliberately bad .tfvars and read the line numbers.

echo 'var.name' | terraform console -var-file=bad.tfvars

⚠️ Terraform skips a validation whose referenced variable has already failed, so a config with six deliberate faults may report four errors. A short error list is not proof a check is missing — fix the dependency and re-run. Suppression is per-variable, which is why proving 20 checks takes several fixture files rather than one.

What only a real plan against Azure exercises: whether the extension_type exists, whether it supports this cluster, whether a settings key is one the extension reads, and whether the Marketplace plan matches an offer whose terms are accepted. None of that is visible offline.


💬 Example Output

id                                   = "/subscriptions/.../resourceGroups/rg-platform-eastus/providers/Microsoft.ContainerService/managedClusters/aks-platform-eastus/providers/Microsoft.KubernetesConfiguration/extensions/flux"
name                                 = "flux"
cluster_name                         = "aks-platform-eastus"
cluster_resource_group               = "rg-platform-eastus"
extension_type                       = "microsoft.flux"
current_version                      = "1.9.2"
extension_version                    = null
version_is_pinned                    = false
auto_upgrade_minor_version_effective = true
release_train                        = null
release_train_effective              = "Stable"
follows_the_preview_train            = false
release_namespace                    = "flux-system"
target_namespace                     = null
implied_installation_scope           = "cluster"
installs_into_namespace              = "flux-system"
configuration_setting_keys           = ["multiTenancy.enforce"]
configuration_setting_count          = 1
has_protected_settings               = true
protected_setting_count              = 1
uses_marketplace_plan                = false
plan_identifier                      = null
aks_assigned_identity_principal_id   = "8f3c1d92-4b7e-4a1f-9c2d-6e5b7a8f0d31"
has_aks_assigned_identity            = true

ℹ️ current_version = "1.9.2" against extension_version = null is the normal, healthy shape when following a release train: Azure chose the version, and it will change again on its own. Do not treat that as drift.


🔍 Troubleshooting

Symptom Cause Fix
release may set extension_version OR release_train, never both Both fields set. Microsoft forbids the combination — pinning turns auto-upgrade off, and a train requires it on. Choose one, or set neither for auto-upgrade on the latest Stable.
namespaces may set release_namespace OR target_namespace, never both Both set. The provider has no scope argument, so the field you set is the scope; setting both is meaningless. Set release_namespace for cluster scope or target_namespace for namespace scope.
Cluster ID rejected, message naming connectedClusters An Arc cluster ID. The same ARM extension type is reached through a different Terraform resource. Use azurerm_arc_kubernetes_cluster_extension (owned by terraform-azurerm-arc-kubernetes-cluster) for Arc clusters.
Cluster ID rejected, message naming fleets A fleet ID. It shares the Microsoft.ContainerService prefix, which is why the two transpose so easily. Pass a managedClusters ID. A fleet is not a cluster.
Cluster ID rejected, message naming agentPools A node pool's ID — the cluster's ID with a child segment appended. Truncate at the cluster: module.aks.id.
Apply fails: extension type not found / not supported The type does not exist, or does not support this cluster's Kubernetes version, node OS or pool layout. Unverifiable offline. Confirm the published type name and its prerequisites, then re-apply.
Apply fails: Microsoft.KubernetesConfiguration not registered A subscription that creates AKS clusters can still lack this separate provider registration. Register it, then re-apply.
Custom RBAC role cannot manage the extension The role was written against Microsoft.ContainerService/*. The extension is under Microsoft.KubernetesConfiguration. Add Microsoft.KubernetesConfiguration/extensions/* to the role.
Extension installs, then its pods fail at runtime The extension's own identity has no permission on what it needs — a registry, a storage account. Distinct from the Terraform identity. Grant a role to aks_assigned_identity_principal_id. See example 11.
A setting you added has no effect The key is not one the extension reads. Azure accepts, stores and returns it; the extension ignores it. No error is ever produced. Check the extension type's documented settings. Nothing in Terraform can catch this.
A key vanished from the extension after an update A partial settings map. Terraform sends what it holds and Azure replaces the whole map — omitted keys are deleted. Declare every key on every change. See example 9.
Plan shows the namespace changing, and a replacement Both namespace fields are force-new. Accept the replacement knowingly, or leave the namespace alone. For a stateful extension, back up first.
A key you misspelled inside release or plan was ignored Object-type conversion silently discards extra keys — the one input error with no signal at all. Check version_is_pinned / plan_identifier against what you intended.
Output refers to sensitive values A derived flag over the protected settings was not unwrapped. Sensitivity is contagious even to a bool. Wrap in nonsensitive(). The module already does this for its own two flags.
Extension appears in ARM but not via cluster-side tooling The extension's own pods failed to start — an image pull, a resource limit, a scheduling constraint. ARM records the extension, not its workload's health. Inspect the workload in the target namespace; ARM state is not workload health.

🔗 Related Docs

  • azurerm_kubernetes_cluster_extension — the provider resource.
  • Cluster extensions for AKS — the platform documentation, including the release-train and auto-upgrade rules.
  • SCOPE.md — this module's cross-module contract.
  • Siblings: terraform-azurerm-kubernetes-cluster (the parent), terraform-azurerm-kubernetes-cluster-deployment-safeguard, terraform-azurerm-kubernetes-cluster-trusted-access-role-binding, terraform-azurerm-role-assignments, terraform-azurerm-arc-kubernetes-cluster (which owns the Arc-connected twin of this resource).

💙 "Infrastructure as Code should be standardized, consistent, and secure."