Attaches one host pool to one scaling plan, and says whether the plan actually scales it. Targets
hashicorp/azurerm ~> 4.0.
- 🔗 Manages
azurerm_virtual_desktop_scaling_plan_host_pool_association— the attachment that puts a host pool under a plan. - 👻 No such record exists in Azure. It is a Terraform fiction over one field of the scaling plan.
- 🔀 Names the overlap: the scaling plan module's own
host_poolblock writes the same list. - 🤐 Reports the quiet half-applied state — attached and not scaled looks configured and does nothing.
- ♻️ Names the read-modify-write, and the lost attachment that disappears from state with no error.
- 🧭 Anchors both Resource IDs to their own type, because they are trivially transposed.
💡 Why it matters: two arguments and a boolean, and almost none of it behaves the way the resource name suggests. It creates no Azure object, mutates a record it does not own, shares that field with a sibling module, needs write permission on the parent, and heals silently when something else overwrites it.
If this module saved you time:
- ⭐ Star the repository — it is the cheapest signal that this work is worth continuing.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
HP["terraform-azurerm-virtual-desktop-host-pool"]
SP["terraform-azurerm-virtual-desktop-scaling-plan"]
ASSOC["terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association"]
RG -->|"name to resource_group_name"| SP
SP -->|"id to scaling_plan_id"| ASSOC
HP -->|"id to host_pool_id"| ASSOC
ASSOC -->|"PATCHES the plan hostPoolReferences list"| SP
SP -.->|"its own host_pool block writes the SAME list -- use one or the other"| ASSOC
classDef self fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
class ASSOC self
class SP keystone
class RG,HP ext
Note the edge that points back at the scaling plan: this resource does not merely reference it, it patches its hostPoolReferences list. The dotted edge is the warning — the scaling plan module carries its own host_pool block that writes the very same field, and its update sends that list wholesale.
flowchart TB
V1["scaling_plan_id -- the record this MUTATES"]
V2["host_pool_id -- appended to its list, must be Pooled"]
V3["enabled -- REQUIRED, the only in-place field"]
V4["timeouts -- all four, unlike its sibling attachment"]
THIS["azurerm_virtual_desktop_scaling_plan_host_pool_association.this"]
O1["id -- SYNTHETIC, exists only in Terraform state"]
O2["no_such_record_exists_in_azure"]
O3["the_scaling_plan_module_writes_the_same_list"]
O4["attached_but_not_scaled"]
O5["a_lost_attachment_vanishes_from_state_without_an_error"]
V1 --> THIS
V2 --> THIS
V3 --> THIS
V4 --> THIS
THIS --> O1
THIS --> O2
THIS --> O3
THIS --> O4
THIS --> O5
classDef self fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
class THIS keystone
class O1 self
class V1,V2,V3,V4,O2,O3,O4,O5 ext
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_virtual_desktop_scaling_plan_host_pool_association |
1 (this) |
Creates no Azure object. The provider reads the scaling plan, appends to its list, and patches it back; the Terraform ID is synthetic. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module. The caller configures provider "azurerm" { features {} }, including authentication. |
Schema notes that bite — each verified against the live provider schema and the resource's own source:
- 🔴 No attachment object exists in Azure. Create appends the host pool to the scaling plan's
hostPoolReferencesand PATCHes the plan; update rewrites one entry's enabled flag; delete filters it out. Each also re-sends the plan'sSchedulesto preserve them. The ID is composed from the two sides and appears nowhere in the portal. - 🔴 The scaling plan module writes the same list, and its update sends that list wholesale — so an update there overwrites whatever attachments created. Use one approach or the other.
- 🔴 It is a read-modify-write on shared state. The provider takes internal locks on the plan's and the pool's names, which serialises operations within a run and cannot see anything outside it.
- 🔴 A missing attachment is removed from state silently. The read matches the host pool inside the plan's list and, not finding it, clears the ID and returns success.
⚠️ The host pool must be POOLED, because the plan's type is hard-coded. The pool'stypelives on a different resource, so nothing in Terraform checks it.⚠️ enabledis required and is the only in-place field. Both Resource IDs are force-new.- ℹ️ This attachment HAS an update function, so its
timeoutscarries all four keys — unlikeazurerm_virtual_desktop_workspace_application_group_association, which has none. Two attachment resources in one family, twotimeoutsshapes. - ℹ️ Each deadline covers two API calls, because create, update and delete each GET the plan and then PATCH it.
- ℹ️ The import guard is a list-membership check, not a GET, and the comparison is case-insensitive.
- ℹ️ The two sides need not share a resource group or subscription, and there are no
tags, nolocationand noresource_group_name.
Least privilege, at the smallest scope that works.
| Scope | Permission | Why |
|---|---|---|
| The scaling plan | Microsoft.DesktopVirtualization/scalingPlans/read |
Every operation begins by reading the plan's current list. |
| The scaling plan | Microsoft.DesktopVirtualization/scalingPlans/write |
The permission that surprises. Creating, enabling, disabling or destroying an attachment PATCHes the SCALING PLAN — there is no attachment object to write. |
| The host pool | Microsoft.DesktopVirtualization/hostPools/read |
The pool's ID is validated and recorded. |
| — | Built-in fit: Desktop Virtualization Contributor at the resource group. | There is no narrower built-in that grants scaling plan write. |
🔒 No credential of any kind is accepted or emitted by this module.
🔴 Granting this is granting scaling plan write. A principal that can create attachments can also change the plan's schedules, its time zone and its exclusion tag — because all of those are writes to the same record. The Azure permission model cannot express "may only append to one list".
- The
Microsoft.DesktopVirtualizationresource provider registered in the subscription. - An existing scaling plan. The provider fails with
<plan> was not foundrather than waiting; referencing the scaling-plan module'sidgives Terraform the ordering for free. - A POOLED host pool. The scaling plan's host-pool type is hard-coded to
Pooled, so aPersonalpool cannot be scaled on this provider line. - The Azure Virtual Desktop service principal granted rights to start and stop the session hosts, made outside this module. Without it the attachment exists and the plan does nothing.
terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association/
├── providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf # 4 inputs, 2 validations, deeply-typed with the schema in the descriptions
├── main.tf # one keystone `this`; all four timeout keys, unlike its sibling attachment
├── outputs.tf # 29 outputs: the synthetic id first, then both sides, then the posture facts
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id
host_pool_id = module.avd_host_pool.id
enabled = true
}
⚠️ Do not also sethost_poolon the scaling plan module — both write the same field.
ℹ️ The caller configures the provider, its authentication, and the mandatory
features {}block.
Consumes
| Input | Type | Source module |
|---|---|---|
scaling_plan_id |
string |
terraform-azurerm-virtual-desktop-scaling-plan → id |
host_pool_id |
string |
terraform-azurerm-virtual-desktop-host-pool → id |
enabled |
bool (required) |
caller |
timeouts |
object (all four) |
caller |
Emits
| Output | Consumed by |
|---|---|
id |
state inspection only — it is synthetic |
scaling_is_enabled_for_this_host_pool, attached_but_not_scaled |
review |
scaling_plan_name, host_pool_name |
reporting |
attachment_spans_resource_groups, attachment_spans_subscriptions |
review |
| the posture constants | human readers |
1 · The whole module
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id
host_pool_id = module.avd_host_pool.id
enabled = true
}ℹ️ Two IDs and a boolean. What it does is another matter — see the rest of this library.
2 · The two IDs transposed — refused
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_host_pool.id # rejected at plan
host_pool_id = module.avd_scaling.id # also wrong
enabled = true
}🔴 Both arguments are the same shape, adjacent in every example, and differ only in the type segment of the Resource ID. Each is anchored to its own type so the transposition fails at plan rather than at the service.
3 · What this actually does to the scaling plan
output "read_this_first" {
value = {
synthetic = module.avd_scaling_attach.id
fiction = module.avd_scaling_attach.no_such_record_exists_in_azure # true
rmw = module.avd_scaling_attach.this_is_a_read_modify_write_on_the_scaling_plan # true
}
}🔴 There is no attachment object in Azure. The provider GETs the plan, appends the host pool to its
hostPoolReferenceslist, and PATCHes the plan back — re-sending the plan's schedules to preserve them. The ID in state is composed from the two sides and will not be found in the portal or accepted as an RBAC scope.
4 · Attached and not scaled
module "avd_scaling_attach_pilot" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id
host_pool_id = var.pilot_host_pool_id
enabled = false
}
output "check_this" {
value = module.avd_scaling_attach_pilot.attached_but_not_scaled # true
}🔴 With
enabled = falsethe host pool appears in the plan's list and in the portal, and no schedule ever starts or stops it. That is how a plan is rolled out gradually — and exactly how one is left half-applied for months. The flag is required by the provider, so the module cannot default it; it emits the choice instead.
5 · Enabling it later is the only in-place change
enabled = true # was false -- an in-place patch on the plan's listℹ️ Both Resource IDs are force-new, so repointing either side destroys the attachment and creates another. Only
enabledupdates in place — which is also why this resource has an update function and its sibling attachment does not.
6 · Several host pools under one plan
locals {
scaled_pools = {
prod = { id = module.avd_host_pool.id, enabled = true }
pilot = { id = var.pilot_host_pool_id, enabled = false }
}
}
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
for_each = local.scaled_pools
scaling_plan_id = module.avd_scaling.id
host_pool_id = each.value.id
enabled = each.value.enabled
}💡 Safe as written: each of these rewrites the same list, and the provider serialises them with internal locks on the plan's and the pool's names for the duration of the run. Keying on a stable identifier keeps
for_eachkeys stable when a pool is added or removed.
7 · Where the read-modify-write bites
# Two separate Terraform configurations, each attaching a different host pool
# to the SAME scaling plan, applied concurrently.
#
# Provider locks are per-process, so they do not see each other. Each reads the
# plan's list, appends its own entry, and writes back -- and the second write is
# based on a list that did not include the first.🔴 Whichever write lands last wins, and the loser's attachment simply disappears. Keep every attachment for one plan in one configuration, or serialise the runs. The same applies to a portal edit or a script touching the same field.
8 · The failure that produces no error
output "watch_for_this" {
value = module.avd_scaling_attach.a_lost_attachment_vanishes_from_state_without_an_error # true
}🔴 If the host pool is no longer in the plan's list, the read clears the resource from state and returns success. The attachment reappears as a create on the next plan — self-healing if you apply regularly, and completely invisible if you do not.
9 · Sharing one field with the scaling plan module
module "avd_scaling" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan.git?ref=v1.0.0"
name = "sp-prod"
resource_group_name = module.rg.name
location = module.rg.location
time_zone = "Eastern Standard Time"
schedule = { /* ... */ }
# host_pool deliberately OMITTED -- omission adopts existing references
# rather than clearing them, which is what makes this pattern safe.
}
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id
host_pool_id = module.avd_host_pool.id
enabled = true
}
⚠️ Settinghost_poolon the plan and using this resource is two Terraform resources owning one field, and the plan's update sends its list wholesale. Omitting the block is safe because it is optional-and-computed and the provider sends nothing for an absent block.
10 · The host pool must be Pooled
module "avd_host_pool" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-host-pool.git?ref=v1.0.0"
name = "hp-prod"
resource_group_name = module.rg.name
location = module.rg.location
type = "Pooled" # a Personal pool cannot be scaled
load_balancer_type = "BreadthFirst"
}🔴 The scaling plan's host-pool type is hard-coded to
Pooledby the provider, and the SDK enum has exactly one member. The pool'stypelives on a different resource, so nothing in Terraform checks the pairing — the mismatch is the service's to refuse at apply.
11 · An attachment that spans resource groups
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id # rg-avd-eastus
host_pool_id = var.shared_host_pool_id # rg-avd-shared
enabled = true
}
output "note" {
value = module.avd_scaling_attach.attachment_spans_resource_groups # true
}ℹ️ Entirely legitimate — the attachment carries both full Resource IDs. Surfaced because a reviewer looking at one resource group sees only half of this relationship.
12 · Timeouts, with four keys — unlike its sibling
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id
host_pool_id = module.avd_host_pool.id
enabled = true
timeouts = {
create = "30m"
read = "5m"
update = "30m" # exists HERE, and does not exist on the workspace association
delete = "30m"
}
}
⚠️ This resource has a real update function becauseenabledchanges in place, so all four keys exist.azurerm_virtual_desktop_workspace_application_group_associationhas no update at all and no update timeout — and Terraform's object-type conversion discards an undeclared key in silence, so the difference matters. Each deadline here covers two API calls.
13 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-avd-eastus"
location = "eastus"
}
module "avd_host_pool" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-host-pool.git?ref=v1.0.0"
name = "hp-prod"
resource_group_name = module.rg.name
location = module.rg.location
type = "Pooled"
load_balancer_type = "BreadthFirst"
preferred_app_group_type = "Desktop"
}
module "avd_scaling" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan.git?ref=v1.0.0"
name = "sp-prod"
resource_group_name = module.rg.name
location = module.rg.location
time_zone = "Eastern Standard Time"
friendly_name = "Production scaling"
schedule = {
weekdays = {
name = "weekdays"
days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
ramp_up_start_time = "07:00"
ramp_up_load_balancing_algorithm = "BreadthFirst"
peak_start_time = "09:00"
peak_load_balancing_algorithm = "DepthFirst"
ramp_down_start_time = "18:00"
ramp_down_load_balancing_algorithm = "DepthFirst"
ramp_down_minimum_hosts_percent = 10
ramp_down_capacity_threshold_percent = 90
ramp_down_force_logoff_users = false
ramp_down_stop_hosts_when = "ZeroSessions"
ramp_down_wait_time_minutes = 30
ramp_down_notification_message = "This host will shut down once your session ends."
off_peak_start_time = "20:00"
off_peak_load_balancing_algorithm = "DepthFirst"
}
}
# host_pool omitted on purpose -- the attachment below owns that list.
}
module "avd_scaling_attach" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-scaling-plan-host-pool-association.git?ref=v1.0.0"
scaling_plan_id = module.avd_scaling.id
host_pool_id = module.avd_host_pool.id
enabled = true
}
output "scaling" {
value = {
plan = module.avd_scaling.id
pool = module.avd_host_pool.id
attachment = module.avd_scaling_attach.id # synthetic
actually_on = module.avd_scaling_attach.scaling_is_enabled_for_this_host_pool # true
uncovered = module.avd_scaling.days_covered_by_no_schedule # ["Saturday", "Sunday"]
}
}🔒 Every reference is a real output of a real sibling. Note the comment on the plan: omitting its
host_poolblock is what lets this attachment own the list without the two fighting.
The attachment — scaling_plan_id, host_pool_id (both required, both force-new)
The switch — enabled (required; the only in-place field)
Tail — timeouts (all four keys; no tags, no location, no resource_group_name)
Full input schemas
variable "scaling_plan_id" { type = string }
# Anchored to .../Microsoft.DesktopVirtualization/scalingPlans/<plan>. FORCE-NEW.
# THIS is the record the resource mutates.
variable "host_pool_id" { type = string }
# Anchored to .../Microsoft.DesktopVirtualization/hostPools/<pool>. FORCE-NEW.
# Must be a POOLED pool -- unchecked here, because the type lives elsewhere.
variable "enabled" { type = bool }
# REQUIRED. Attached-and-disabled is a real state, and it looks configured.
variable "timeouts" { # all FOUR keys -- this attachment has an update function
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Description | Notes |
|---|---|---|
id |
The synthetic Terraform ID. | Not an RBAC scope; absent from the portal. |
scaling_plan_id, host_pool_id, enabled |
Configuration, as reported. | |
scaling_plan_name, scaling_plan_resource_group_name |
Parsed. | |
host_pool_name, host_pool_resource_group_name |
Parsed. | |
subscription_id |
The plan's. | Read with attachment_spans_subscriptions. |
scaling_is_enabled_for_this_host_pool |
The required choice, surfaced. | Conditional. |
attached_but_not_scaled |
The quiet half-applied state. | Conditional. |
attachment_spans_resource_groups, attachment_spans_subscriptions |
Both legitimate. | Conditional. |
no_such_record_exists_in_azure |
Constant true. | The first thing to understand. |
the_scaling_plan_module_writes_the_same_list |
Constant true. | The overlap. |
this_is_a_read_modify_write_on_the_scaling_plan |
Constant true. | |
a_lost_attachment_vanishes_from_state_without_an_error |
Constant true. | The quietest failure. |
the_host_pool_must_be_pooled |
Constant true. | Unchecked by Terraform. |
attached_and_disabled_is_a_real_state |
Constant true. | |
only_the_enabled_flag_changes_in_place |
Constant true. | |
this_attachment_has_an_update_unlike_its_sibling |
Constant true. | Two timeouts shapes. |
each_timeout_covers_two_api_calls |
Constant true. | |
the_import_guard_is_a_list_membership_check |
Constant true. | |
the_rbac_needed_is_write_on_the_scaling_plan |
Constant true. | |
force_new_fields, fields_that_can_change_after_creation, fields_azure_returns_on_read |
Lifecycle summary. | |
this_resource_supports_no_azure_resource_tags |
Constant true. | |
the_two_ids_are_in_the_same_subscription |
Derived. | Reported, not refused. |
🔴 autoscale_needs_the_host_pool_to_carry_a_non_default_session_limit |
Constant true. | The likeliest reason an enabled plan does nothing. |
validations_stop_below_the_formula_ceiling_here |
Constant true. | Six of eight, deliberately. |
no_secret_is_accepted_or_emitted_by_this_module |
Constant true. |
🔒 No output is sensitive, because an attachment between two records carries no credential.
The resource is a fiction, and everything else follows. Azure has no attachment object. The provider GETs the scaling plan, appends the host pool to its hostPoolReferences list, and PATCHes the plan back — re-sending the plan's schedules to preserve them. Enable, disable and destroy do the same in different forms. The ID in Terraform state is composed from the two Resource IDs, is not something the portal will show you, and is not usable as an RBAC scope.
So the scaling plan is shared mutable state — and it has two owners. Every attachment against one plan rewrites the same field, and so does the scaling plan module's own host_pool block, whose update sends the list wholesale. Within a single Terraform run the provider serialises attachments with internal locks; across concurrent runs, or against a portal edit, whichever write lands last wins. The two approaches coexist safely only because the plan's block is optional-and-computed and the provider sends nothing for it when absent — omitting it adopts existing references rather than clearing them.
And a lost attachment goes quietly. The read looks for the host pool inside the plan's list; if it is not there, the provider clears the resource from state and returns success. No error, no warning. The attachment comes back as a create on the next plan — self-healing for anyone who applies regularly, and entirely invisible for anyone who does not.
Attached is not the same as scaled. enabled is required, so this module cannot default it, and false produces a host pool that appears in the plan's list and in the portal and is never touched by a schedule. That is a genuinely useful state for a gradual rollout and a genuinely easy one to leave behind, which is why the module emits attached_but_not_scaled positively rather than expecting a reviewer to negate a reassurance.
The permission is the giveaway. This module needs scalingPlans/write — not write on an attachment, because there is none. A principal granted it can also change the plan's schedules, its time zone and its exclusion tag. The RBAC table says so rather than implying a narrower grant than exists.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Transposed IDs | Refused. Each is anchored to its own resource type. | — |
enabled |
Not defaultable — the provider makes it required. The module emits the choice both ways. | Type the value you mean. |
| Shared state | Reported, not hidden. The read-modify-write, the shared list and the silent disappearance are constant outputs. | — |
| Host pool type | Reported. Pooled-only is unchecked by Terraform. | — |
| Secrets | None accepted, none emitted. | — |
🔒 There is nothing to harden here — the module's contribution is telling you what it does, which is not what its name suggests.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the source at a tag — ?ref=v1.0.0 — never a branch. This module is authored and verified plan-only; a human applies from CI.
What validate and fmt cover, with no credentials:
- Both anchored Resource-ID checks, which refuse a workspace ID and an application group ID on either argument and catch the two being transposed.
- HCL syntax and formatting.
What only plan or apply reaches:
- Whether the scaling plan exists — the provider fails outright if it does not.
- Whether the host pool is of type
Pooled. - Whether a concurrent writer has changed the plan's list.
- Whether the Azure Virtual Desktop service principal can start and stop the session hosts.
🔴 Nothing offline can tell you the attachment survived. A lost one leaves no error, only an absence — so the check that matters is applying again and reading the plan.
Outputs:
scaling = {
"actually_on" = true
"attachment" = "/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-avd-eastus/providers/Microsoft.DesktopVirtualization/scalingPlans/sp-prod|/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-avd-eastus/providers/Microsoft.DesktopVirtualization/hostPools/hp-prod"
"plan" = "/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-avd-eastus/providers/Microsoft.DesktopVirtualization/scalingPlans/sp-prod"
"pool" = "/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-avd-eastus/providers/Microsoft.DesktopVirtualization/hostPools/hp-prod"
"uncovered" = ["Saturday", "Sunday"]
}
attached_but_not_scaled = false
attachment_spans_resource_groups = false
| Symptom | Cause | Fix |
|---|---|---|
scaling_plan_id must be a scaling plan Resource ID… |
The host pool's ID was passed. | The two arguments are the same shape and adjacent; each is anchored so the transposition fails here. |
host_pool_id must be a host pool Resource ID… |
A scaling plan, workspace or application group ID. | All four sit under Microsoft.DesktopVirtualization and differ only in the type segment. |
<plan> was not found at apply |
The scaling plan does not exist yet. | Reference the scaling-plan module's id rather than a hard-coded string — that is what orders the work. |
| Apply fails with an already-exists error | The host pool is already in the plan's list. | The import guard is a membership check, not a GET. Import the attachment or remove the duplicate. |
| The service refuses the attachment | The host pool is Personal; scaling plans are Pooled-only on this provider line. |
Use a Pooled host pool. Nothing in Terraform checks this. |
| The attachment disappeared and Terraform reported nothing | The read removes it from state silently when the pool is no longer in the plan's list. | Something else rewrote the plan — most likely the plan module's own host_pool block. Use one approach or the other. |
| Attachments vanish when two pipelines run together | Concurrent read-modify-write on the same list; provider locks are per-process. | Serialise the runs, or consolidate them into one configuration. |
| The plan is attached and no host is ever started or stopped | enabled is false, or the AVD service principal lacks rights on the VMs. |
Check attached_but_not_scaled, then the service principal's role assignment. |
A timeouts.update value has no effect on a different AVD attachment |
The workspace-to-application-group association has no update operation, and Terraform discards undeclared keys silently. | On THIS resource all four keys exist. Check which attachment you are configuring. |
The attachment's id is rejected as an RBAC scope |
It is synthetic — a composite of the two Resource IDs that exists only in Terraform state. | Use the scaling plan's or the host pool's id. |
azurerm_virtual_desktop_scaling_plan_host_pool_associationazurerm_virtual_desktop_scaling_plan- Autoscale scaling plans for Azure Virtual Desktop
- Sibling modules:
terraform-azurerm-virtual-desktop-scaling-plan,terraform-azurerm-virtual-desktop-host-pool,terraform-azurerm-resource-group - This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."