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.
- ⚙️ Creates one
azurerm_kubernetes_cluster_extension, namedthis. - 🔗 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
releaseobject 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_settingssensitive itself, because the provider's schema does not — and emits only its presence and count, never a key name. - 🛒 Renders the optional Marketplace
planblock 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.
If this module saves you time:
- ⭐ Star the repository — it genuinely helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
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
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.
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
| 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. |
| 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 |
- 🔴 Force-new:
name,cluster_id,extension_type,release_namespace,target_namespace, and every field ofplan. 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
autoUpgradeMinorVersionoff, and that a release train "can't be used when autoUpgradeMinorVersion is set to false". The provider exposes noauto_upgrade_minor_versionargument, so the posture is implied by whether you pinned. This module rejects setting both and emitsauto_upgrade_minor_version_effective. - 🔴
configuration_protected_settingsis not marked sensitive in the provider's schema. The equivalent argument onazurerm_stack_hci_extensionis. 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_namespaceandtarget_namespaceare 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 isMicrosoft.KubernetesConfiguration, notMicrosoft.ContainerService. A custom RBAC role scoped only to ContainerService cannot manage the extension, and an Azure Policy definition must target the KubernetesConfiguration type.⚠️ versionis a reserved Terraform variable name.variable "version" {}is a hard parse error — it is amodule-block meta-argument. The input isrelease.extension_version; the provider's argument name is unchanged.⚠️ aks_assigned_identityis a computed list that can be empty. Index it withtry(...[0], null).⚠️ The parent reference iscluster_idhere — butkubernetes_cluster_idon the trusted-access-role-binding and deployment-safeguard siblings of the same cluster. Do not infer it from a sibling.- ℹ️
current_versiondrifts 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.
| 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
Readercan 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.
Microsoft.KubernetesConfigurationregistered on the subscription. Separate fromMicrosoft.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_typethat 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
planidentifiers matching the offer exactly. - Cluster egress to whatever the extension's own pods need to reach.
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
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.
⚠️ Thefeatures {}block is mandatory on the provider and belongs entirely to your root module. This module declares no provider.
| 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 |
| 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 |
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.
releaseunset means auto-upgrade on, latestStable— checkauto_upgrade_minor_version_effectiveand you will seetrue.
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
Stableexplicitly is identical in effect to omittingreleasealtogether — the module emitsrelease_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 = trueandauto_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 onereleaseobject: avalidation {}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
scopeargument — Microsoft's CLI does (--scope cluster). Settingrelease_namespaceis what expresses cluster scope, and the module emitsimplied_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-blater 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 = trueredacts 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 emitshas_protected_settingsandprotected_setting_countand never a key name, becausegit.tokentells 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 deletesgit.usernamefrom 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 ofplanis 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_identifieremits thepublisher/product/nametriple 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_identityis available as apreconditionif you want the composition to say so rather than passingnullinto 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_eachover a keyed map, nevercount— 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
idbecomescluster_id.installs_into_namespacebecomes 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_idbecomes a role assignment, because the extension's identity is not the Terraform identity and nothing grants it automatically.
⚠️ Notelevel = "Warn"on the safeguard module, not the default. Start inWarn, read what it would have blocked, then move toEnforce— and read that module's own notes on whyEnforceis not a guarantee.
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 insiderelease,namespacesorplanis 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.
| 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.
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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'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.
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"againstextension_version = nullis 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.
| 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. |
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."

