Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Virtual Desktop Scaling Plan–Host Pool Attachment Terraform Module

Attaches one host pool to one scaling plan, and says whether the plan actually scales it. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 🔗 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_pool block 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.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

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.

✅ Provider / Versions

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 hostPoolReferences and PATCHes the plan; update rewrites one entry's enabled flag; delete filters it out. Each also re-sends the plan's Schedules to 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's type lives on a different resource, so nothing in Terraform checks it.
  • ⚠️ enabled is required and is the only in-place field. Both Resource IDs are force-new.
  • ℹ️ This attachment HAS an update function, so its timeouts carries all four keys — unlike azurerm_virtual_desktop_workspace_application_group_association, which has none. Two attachment resources in one family, two timeouts shapes.
  • ℹ️ 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, no location and no resource_group_name.

🔑 Required Azure RBAC Roles / Permissions

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".


Azure Prerequisites

  • The Microsoft.DesktopVirtualization resource provider registered in the subscription.
  • An existing scaling plan. The provider fails with <plan> was not found rather than waiting; referencing the scaling-plan module's id gives Terraform the ordering for free.
  • A POOLED host pool. The scaling plan's host-pool type is hard-coded to Pooled, so a Personal pool 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.

📁 Module Structure

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

⚙️ Quick Start

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 set host_pool on the scaling plan module — both write the same field.

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block.


🔌 Cross-Module Contract

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

📚 Example Library

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 hostPoolReferences list, 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 = false the 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 enabled updates 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_each keys 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
}

⚠️ Setting host_pool on 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 Pooled by the provider, and the SDK enum has exactly one member. The pool's type lives 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 because enabled changes in place, so all four keys exist. azurerm_virtual_desktop_workspace_application_group_association has 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_pool block is what lets this attachment own the list without the two fighting.


📥 Inputs

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
}

🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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.


🚀 Runbook

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

Pin 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.


🧪 Testing

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.


💬 Example Output

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

🔍 Troubleshooting

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.

🔗 Related Docs


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