Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure System Center VMM Availability Set Terraform Module

Creates an availability set in VMM for Arc-enabled virtual machines, so VMs placed in it are kept apart by VMM's placement engine (azurerm_system_center_virtual_machine_manager_availability_set). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Creates

🧩 Overview

  • 🧱 Creates an availability set in VMM — the one module in this family that creates rather than projects.
  • ⚠️ It takes the VMM server's Resource ID, where its three sibling projection modules take an inventory item. That asymmetry is the single most common mistake in this family, and it is caught at plan time here.
  • 🔗 Membership is not expressed here. A VM joins through the virtual-machine-instance module, so this module cannot place anything in the set — or report whether anything is in it.
  • 🔁 Everything except tags is force-new, including name — and here the name is not cosmetic.
  • ⚠️ Renaming detaches every VM in the set, and that detachment does not appear in this module's plan.
  • 🚫 No claim is made about resilience. A set across a single host separates nothing.
  • 🔒 No secret is accepted and none is emitted.

💡 Why it matters: Five inputs, one of which differs from its near-identical siblings in a way that fails only at apply. The other thing worth knowing is what this module cannot tell you: whether the set has members, and whether VMM can honour it.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

flowchart LR
  bridge["OUT OF BAND, BEFORE ANY TERRAFORM: Microsoft's onboarding script deploys the Arc RESOURCE BRIDGE and creates the CUSTOM LOCATION. No module in this suite does this. It takes up to 30 minutes and needs 32 GB RAM, 4 vCPUs and 100 GB of disk reserved in VMM."]
  cred["THE VMM CREDENTIAL: Microsoft REQUIRES a full VMM administrator that is ALSO a local administrator on the VMM server, on every node if VMM is highly available. There is NO scoped VMM role. It is FORCE-NEW, so Terraform has NO rotation path, and it is in state in PLAINTEXT."]
  rg["terraform-azurerm-resource-group"]
  server["terraform-azurerm-system-center-virtual-machine-manager-server: THE ANCHOR. Registers the VMM server and emits the custom_location_id every sibling needs."]
  inv["the inventory-items DATA SOURCE, by inventory_type Cloud, VirtualMachineTemplate, VirtualNetwork or VirtualMachine. This is how you find the ids the projection modules take. VMM objects created moments ago may not be listed yet."]
  cloud["terraform-azurerm-system-center-virtual-machine-manager-cloud: PROJECTS an existing VMM placement scope"]
  tmpl["terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-template: PROJECTS an existing VMM template"]
  vnet["terraform-azurerm-system-center-virtual-machine-manager-virtual-network: PROJECTS an existing VMM VM NETWORK. NOT an Azure virtual network: no address space, no subnets, no peering."]
  asym["THE ASYMMETRY THAT CATCHES EVERYONE: the three PROJECTION modules take an INVENTORY ITEM id because their VMM object already exists. The availability set takes the VMM SERVER id, because Azure CREATES it in VMM."]
  avset["terraform-azurerm-system-center-virtual-machine-manager-availability-set: the ONE module here that CREATES an object in VMM"]
  arc["terraform-azurerm-arc-machine with kind SCVMM: the prerequisite for BOTH modules below, and the thing to put TAGS on, since neither of them has any"]
  inst["terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-instance: an EXTENSION of the Arc machine, always named default, one per machine"]
  modes["TWO MUTUALLY EXCLUSIVE MODES: set template_id to DEPLOY a new VM, or inventory_item_id to ADOPT one that exists. The provider makes BOTH optional, so picking NEITHER passes validation and fails at apply. Enforced here at plan time."]
  disk["AND THE SERVICE INJECTS A DISK YOU NEVER DECLARED, IDE bus, from the template. The provider's own fix is ignore_changes on storage_disk, but lifecycle is NOT VALID inside a module block, so the CALLER CANNOT WRITE IT. This module sets it: disks apply at creation and are then NOT reconciled."]
  destroy["AND A DESTROY MAY DELETE THE REAL VM. The API distinguishes remove-from-Azure from delete-from-host; THE PROVIDER EXPOSES NEITHER SWITCH and documents which it does. prevent_destroy is also unavailable to a module caller. Use a CanNotDelete lock and an approval gate."]
  agent["terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-instance-guest-agent: installs the connected machine agent IN THE GUEST"]
  peer["A PEER, NOT A CHILD: it takes the ARC MACHINE id, the same input the instance takes, NOT the instance's id. So NO attribute links them, Terraform CANNOT order them, and you need an explicit depends_on. Exactly one per VM, always named default."]
  gcred["A SECOND, SEPARATE SECRET: GUEST OS credentials, owned by a different team from the VMM administrator account. Also force-new, also plaintext in state. Not using this module is the only way to keep them out."]
  rp["AND THIS resource ALONE does not auto-register Microsoft.ScVmm: run az provider register --namespace Microsoft.ScVmm"]

  bridge -->|"custom_location_id"| server
  cred -->|"username and password"| server
  rg -->|"resource_group_name, location"| server
  server -->|"id"| inv
  inv -->|"inventory item ids"| cloud
  inv -->|"inventory item ids"| tmpl
  inv -->|"inventory item ids"| vnet
  server -->|"custom_location_id and location to ALL SIX siblings"| cloud
  asym -->|"read this first"| avset
  server -->|"id, NOT an inventory item"| avset
  cloud -->|"cloud_id, placement"| inst
  tmpl -->|"template_id, mode 1"| inst
  vnet -->|"virtual_network_id per interface"| inst
  avset -->|"id, and MEMBERSHIP is expressed HERE not there"| inst
  arc -->|"id as scoped_resource_id"| inst
  modes -->|"validated at plan"| inst
  disk -->|"decided inside the module"| inst
  destroy -->|"unmitigable from here"| inst
  arc -->|"the SAME id as scoped_resource_id"| agent
  peer -->|"depends_on required"| agent
  gcred -->|"username and password"| agent
  rp -->|"prerequisite"| agent
  inst -.->|"must exist first, but NO reference exists"| agent

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class server keystone;
  class cloud,tmpl,vnet,avset,inst,agent me;
  class bridge,cred,rg,inv,asym,arc,modes,disk,destroy,peer,gcred,rp sib;
Loading

🧬 What this module builds

flowchart TB
  asym["READ THIS FIRST: THIS MODULE TAKES THE VMM SERVER's RESOURCE ID, not an inventory item id. Its three near-identical siblings - cloud, virtual-machine-template, virtual-network - all take an INVENTORY ITEM in the same position."]
  why["and the reason is the whole point of the module: THIS IS THE ONE MODULE IN THE FAMILY THAT CREATES AN OBJECT IN VMM. There is no inventory item to reference, because the availability set does not exist until Azure makes it."]
  check["so the provider accepts an inventory item id here and fails at apply, and this module rejects it AT PLAN TIME with an error message that names the distinction - reaching for an inventory-items lookup is the natural mistake after writing the other three"]
  flag["and creates_an_object_in_vmm is emitted as a constant true, the deliberate counterpart to the projection modules' projects_an_existing_vmm_object. Read side by side, the pair answers the only question that matters before a destroy: does this remove a RECORD or remove INFRASTRUCTURE?"]
  name["EVERY ARGUMENT EXCEPT tags IS FORCE-NEW, INCLUDING name - and here the name is NOT cosmetic, because it is the set's name in VMM too. Renaming destroys the set and creates a new one."]
  detach["which DETACHES EVERY VM currently placed in it - and because membership lives on the instance side, THAT DETACHMENT DOES NOT APPEAR IN THIS MODULE'S PLAN"]
  member["MEMBERSHIP IS NOT EXPRESSED HERE AT ALL. A VM joins through the virtual-machine-instance module's availability_set_ids input, so this module cannot place anything in the set and cannot report whether anything is in it."]
  empty["so an EMPTY SET is a valid, silent outcome: nothing in this module or its plan indicates whether any VM ever joined"]
  nores["AND NO CLAIM IS MADE ABOUT RESILIENCE. The set is a placement REQUEST to VMM. Whether VMM can honour it depends on host count and other constraints this module cannot see - a set across a single host separates nothing."]
  this["terraform-azurerm-system-center-virtual-machine-manager-availability-set"]
  keystone["azurerm_system_center_virtual_machine_manager_availability_set.this"]

  asym -->|"because"| why
  why -->|"so"| check
  check -->|"and"| flag
  flag -->|"validated"| this
  name -->|"consequence"| detach
  detach -->|"lifecycle"| this
  member -->|"so"| empty
  empty -->|"and"| nores
  nores -->|"scope"| this
  this -->|"creates the set in VMM"| keystone

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class keystone keystone;
  class asym,why,check,flag,name,detach,member,empty,nores sib;
Loading

Resource inventory

Resource Count Notes
azurerm_system_center_virtual_machine_manager_availability_set.this 1 The keystone. Creates an object in VMM.
timeouts block 0..1 All four operations.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Microsoft.ScVmm, API version 2023-10-07
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite — confirmed against the live provider schema and its documentation:

  • It takes system_center_virtual_machine_manager_server_id, not ..._server_inventory_item_id. The provider accepts an inventory item ID and fails at apply.
  • Every argument except tags is force-new, including name.
  • The name is the set's name in VMM, not just in Azure — this resource creates the set.
  • A destroy removes the availability set from the VMM fabric, not just an Azure record.
  • Membership is not on this resource. It lives on the VM instance's system_center_virtual_machine_manager_availability_set_ids.
  • location must match the custom location's region, and the mismatch surfaces only at apply.
  • ARM resource type: Microsoft.ScVmm/availabilitySets.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy.

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Creating this resource Azure Arc SCVMM Administrator the subscription or resource group holding the VMM server resource
Using the set when provisioning a VM (later, possibly a different identity) Azure Arc SCVMM VM Contributor the subscription or resource group where VMs are provisioned

Contributor or Owner at the same scope also work, and are broader than needed.

ℹ️ Creating the set is an administrator action; using it is not. That split is what lets a platform team define placement policy while an app team provisions into it.

Azure Prerequisites

  • The Microsoft.ScVmm resource provider registered on the subscription.
  • The VMM server already registered with Azure and reporting connected.
  • Enough hosts in the placement scope to make the set meaningful. VMM's host inventory is invisible to a plan, and a set across a single host separates nothing.
  • A resource group in the same region as the custom location.

📁 Module Structure

terraform-azurerm-system-center-virtual-machine-manager-availability-set/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # name, resource_group_name, location, custom_location_id,
│                  # system_center_virtual_machine_manager_server_id, tags, timeouts
├── main.tf        # the keystone, one resource
├── outputs.tf     # id first, then placement, then creates_an_object_in_vmm
├── README.md      # this document
├── SCOPE.md       # the cross-module contract and the asymmetry rationale
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "vmm_avset_app" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-availability-set.git?ref=v1.0.0"

  name                = "vmmas-app"
  resource_group_name = module.rg.name

  # Both from the VMM server module — one source of truth.
  location           = module.vmm.location
  custom_location_id = module.vmm.custom_location_id

  # ⚠️ The VMM SERVER's id — NOT an inventory item. See example 1.
  system_center_virtual_machine_manager_server_id = module.vmm.id

  tags = { tier = "app" }
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

⚠️ Creating the set is only half the job — a VM has to join it (example 3).

🔌 Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group → name
location string the VMM server module → location
custom_location_id string the VMM server module → custom_location_id
system_center_virtual_machine_manager_server_id string the VMM server module → id (not an inventory item)

Emits

Output Description Consumed by
id The availability set's Resource ID. the virtual-machine-instance module's system_center_virtual_machine_manager_availability_set_ids
name The set's name — in VMM as well as Azure. Force-new. review
resource_group_name / location / custom_location_id Placement. Force-new. review
system_center_virtual_machine_manager_server_id The VMM server. Force-new. review
creates_an_object_in_vmm Always true. destroy-behaviour review

No secret is accepted and none is emitted.

📚 Example Library

Values these examples reference but do not create are declared inputs:

variable "scoped_resource_id" {
  description = "scoped resource id of an existing resource these examples reference."
  type        = string
}
1 · ⚠️ The input that differs from its siblings
# ✅ THIS module — the VMM SERVER's Resource ID.
system_center_virtual_machine_manager_server_id = module.vmm.id
# ✅ Its three siblings — an INVENTORY ITEM id.
system_center_virtual_machine_manager_server_inventory_item_id = local.vmm_clouds["Production Cloud"]
# ❌ The natural mistake after writing the other three. Rejected at plan.
system_center_virtual_machine_manager_server_id = local.vmm_clouds["Production Cloud"]
Error: Invalid value for variable

  system_center_virtual_machine_manager_server_id must be a complete VMM server ID
  of the form /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.ScVmm/
  vmmServers/<name>, MATCHING THAT CAPITALISATION EXACTLY — note
  `Microsoft.ScVmm` and `vmmServers`, which almost nothing spells the way you would
  guess. The provider parses it with a case-sensitive segment parser. An inventory
  item ID is the other common mistake here — that is what the cloud, template and
  virtual-network modules take, not this one.

💡 Why the difference exists: the cloud, template and VM network already exist in VMM, so they are addressed by the inventory item that describes them. An availability set does not exist yet — Azure creates it in VMM on your behalf — so there is nothing to look up, and the module takes the server it should be created on.

ℹ️ The provider accepts the wrong ID and fails at apply. This module rejects it at plan time with an error that names the distinction, because in isolation the check is unremarkable and in this family it is the one most likely to save an apply.

💡 If you find yourself reaching for an inventory-items data source here, that is the tell.

2 · 🧱 This module creates; its siblings project
PROJECTION modules (cloud, virtual-machine-template, virtual-network):
  the VMM object already exists  ->  destroy removes the AZURE RECORD only
  output: projects_an_existing_vmm_object = true

THIS module:
  Azure creates the object in VMM  ->  destroy removes it FROM THE VMM FABRIC
  output: creates_an_object_in_vmm = true

⚠️ A destroy here is not a metadata operation. It removes an availability set from VMM, which changes placement for anything still referencing it.

💡 The two constant outputs exist as a matched pair. Read side by side in a state review they answer the only question that matters before a destroy: does this remove a record, or remove infrastructure?

ℹ️ A CanNotDelete lock is a reasonable precaution once VMs are placed in the set:

resource "azurerm_management_lock" "avset_no_delete" {
  name       = "vmmas-app-no-delete"
  scope      = module.vmm_avset_app.id
  lock_level = "CanNotDelete"
  notes      = "Deleting this set changes placement for the VMs in it"
}

⚠️ A caller cannot add prevent_destroy — lifecycle is not valid inside a module block — so the lock is the available control.

3 · 🔗 Membership is expressed on the VM, not here
module "vmm_avset_app" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-availability-set.git?ref=v1.0.0"
  custom_location_id           = var.custom_location_id
  location                     = "eastus2"
  name                         = "vmm-avset-app-example"
  resource_group_name          = "rg-example"
  system_center_virtual_machine_manager_server_id = var.system_center_virtual_machine_manager_server_id
}

# FRAGMENT: only the membership argument is shown -- the instance's own required inputs
# (scoped_resource_id, custom_location_id, infrastructure) are in that module's Quick Start.
module "vm_instance_01" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-instance.git?ref=v1.0.0"

  # ⚠️ THIS is where a VM joins the set.
  system_center_virtual_machine_manager_availability_set_ids = [module.vmm_avset_app.id]
  # ...
}

ℹ️ The provider places VMs from the instance side. So this module has no membership input, cannot place anything, and cannot report what is in the set.

💡 Why no membership input here? Adding one would mean either a second write path to the same relationship — two places that can disagree — or a synthetic input that does not reflect the API. This suite documents the split instead.

⚠️ An empty set is a valid, silent outcome. Creating the set and forgetting to place anything in it produces no error, no warning and no plan difference. Check is_in_availability_set on the VM modules, not here.

ℹ️ Membership is a list on the VM, so one VM can belong to more than one set.

4 · A set that separates two VMs
module "vmm_avset_web" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-availability-set.git?ref=v1.0.0"

  name                                            = "vmmas-web"
  resource_group_name                             = module.rg.name
  location                                        = module.vmm.location
  custom_location_id                              = module.vmm.custom_location_id
  system_center_virtual_machine_manager_server_id = module.vmm.id
}

module "vm_web_01" {
  # ...
  system_center_virtual_machine_manager_availability_set_ids = [module.vmm_avset_web.id]
}

module "vm_web_02" {
  # ...
  system_center_virtual_machine_manager_availability_set_ids = [module.vmm_avset_web.id]
}

💡 The point of the set is the second VM. One VM in a set achieves nothing; the set is a request that VMM keep its members apart.

⚠️ And VMM has to be able to honour it. A placement scope with one host cannot separate anything, and this module cannot see the host inventory. Nothing here validates or reports that.

ℹ️ Which is why no output on this module — and no wording in this document — describes the set as delivering availability.

5 · 🔁 The name is not cosmetic
Force-new: name  resource_group_name  location  custom_location_id
           system_center_virtual_machine_manager_server_id
Not force-new: tags

⚠️ Unlike the projection modules, this module's name is the object's name in VMM. The projections name an Azure record; this names a real availability set.

🔴 So renaming it destroys the set and creates a new one — which detaches every VM currently in it. And because membership lives on the instance side, that detachment does not appear in this module's plan. You will see one resource replaced; you will not see the VMs that lost their placement guarantee.

💡 Decide the name before the first apply, and treat a rename as a placement change requiring a review of every VM that references the set.

ℹ️ VMM naming conventions are worth following here, because this name shows up in VMM's own tooling, not just in Azure.

6 · Several sets for a tiered application
locals {
  tiers = ["web", "app", "db"]
}

module "vmm_avset" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-availability-set.git?ref=v1.0.0"
  for_each = toset(local.tiers)

  name                                            = "vmmas-${each.key}"
  resource_group_name                             = module.rg.name
  location                                        = module.vmm.location
  custom_location_id                              = module.vmm.custom_location_id
  system_center_virtual_machine_manager_server_id = module.vmm.id

  tags = { tier = each.key }
}

💡 One set per tier is the usual shape: separating web servers from each other is what protects the tier, and separating a web server from a database server is not what a set is for.

ℹ️ for_each over a set of strings keeps the keys stable — adding a fourth tier does not disturb the first three.

⚠️ Each set is force-new on name, so "vmmas-${each.key}" means renaming a tier destroys and recreates that set (example 5).

7 · `location` and `custom_location_id` come from one place
# ✅
location           = module.vmm.location
custom_location_id = module.vmm.custom_location_id
# ⚠️ Works until someone changes one and not the other.
location           = "eastus"
custom_location_id = "/subscriptions/.../customLocations/cl-vmm-dc1"

ℹ️ The region must match the custom location's region, and the mismatch is only detected at apply.

💡 The VMM server module emits both precisely so a composition states them once. Six modules in this family need the same pair.

⚠️ custom_location_id is shape-validated — it must be a Microsoft.ExtendedLocation/customLocations Resource ID. A resource-bridge ID is the usual substitute and the provider accepts it before failing at apply.

8 · Tags reach the Azure record
tags = {
  tier  = "app"
  owner = "platform-virtualization"
}

ℹ️ The only non-force-new field on this resource — so tags are the one thing here that can be corrected without a replacement.

⚠️ They tag the Azure resource. They do not reach VMM, and they do not reach the VMs placed in the set.

💡 Two of the seven modules in this family have no tags at all — the VM instance and the guest agent. Tag their Arc machines instead.

9 · What a review should assert
output "avset_posture" {
  value = {
    id      = module.vmm_avset_app.id
    name    = module.vmm_avset_app.name                     # the VMM name too
    creates = module.vmm_avset_app.creates_an_object_in_vmm  # always true
    server  = module.vmm_avset_app.system_center_virtual_machine_manager_server_id
  }
}

# ⚠️ Membership is asserted on the VMs, not here -- these are the two instance modules from the
#    example above, each carrying this set's id.
# FRAGMENT: the instances' own required inputs are in that module's Quick Start.
module "vm_web_01" {
  source                                                     = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-instance.git?ref=v1.0.0"
  system_center_virtual_machine_manager_availability_set_ids = [module.vmm_avset_web.id]
}

# FRAGMENT: as above.
module "vm_web_02" {
  source                                                     = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-instance.git?ref=v1.0.0"
  system_center_virtual_machine_manager_availability_set_ids = [module.vmm_avset_web.id]
}

output "avset_members" {
  value = {
    web_01 = module.vm_web_01.is_in_availability_set
    web_02 = module.vm_web_02.is_in_availability_set
  }
}

ℹ️ creates is a constant true, and it is in the outputs so that "what does a destroy of this actually remove?" has a documented answer.

⚠️ The second output is the important one. This module cannot tell you whether the set has members; only the VM modules can. A set with no members is the failure mode that produces no error.

💡 Even both outputs together do not establish resilience — that depends on VMM's host count and placement rules (example 4).

10 · Importing a set created from the portal
terraform import 'module.vmm_avset_app.azurerm_system_center_virtual_machine_manager_availability_set.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-vmm/providers/Microsoft.ScVmm/availabilitySets/vmmas-app"

⚠️ Check name and system_center_virtual_machine_manager_server_id first after importing. Both are force-new, so a mismatch proposes a replacement — which would detach every VM in the set (example 5).

💡 Confirm an empty plan before adding anything else. An import that plans a replacement on an availability set with live members is the worst combination in this family.

ℹ️ Import does not tell you what is in the set. Check the VM modules' is_in_availability_set outputs, or VMM itself.

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

data "azurerm_client_config" "current" {}

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

  name     = "rg-vmm-dc1"
  location = "eastus"
}

module "kv" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  name                = "kv-vmm-dc1"
  resource_group_name = module.rg.name
  location            = module.rg.location
  tenant_id           = data.azurerm_client_config.current.tenant_id
}

data "azurerm_key_vault_secret" "vmm_password" {
  name         = "vmm-arc-svc-password"
  key_vault_id = module.kv.id
}

# ── The anchor ────────────────────────────────────────────────────────────────
module "vmm" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-server.git?ref=v1.0.0"

  name                = "vmm-contoso-dc1"
  resource_group_name = module.rg.name
  location            = module.rg.location
  custom_location_id  = var.custom_location_id
  fqdn                = "vmm.contoso.com"
  username            = var.vmm_admin_username
  password            = data.azurerm_key_vault_secret.vmm_password.value
}

# ── Projections take an INVENTORY ITEM… ───────────────────────────────────────
data "azurerm_system_center_virtual_machine_manager_inventory_items" "clouds" {
  inventory_type                                  = "Cloud"
  system_center_virtual_machine_manager_server_id = module.vmm.id
}

locals {
  vmm_clouds = { for i in data.azurerm_system_center_virtual_machine_manager_inventory_items.clouds.inventory_items : i.name => i.id }
}

module "vmm_cloud_prod" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-cloud.git?ref=v1.0.0"

  name                                                           = "vmmcloud-prod"
  resource_group_name                                            = module.rg.name
  location                                                       = module.vmm.location
  custom_location_id                                             = module.vmm.custom_location_id
  system_center_virtual_machine_manager_server_inventory_item_id = local.vmm_clouds["Production Cloud"]
}

# ── …while THIS module takes the SERVER id (example 1) ────────────────────────
module "vmm_avset" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-availability-set.git?ref=v1.0.0"
  for_each = toset(["web", "app"])

  name                = "vmmas-${each.key}"
  resource_group_name = module.rg.name
  location            = module.vmm.location
  custom_location_id  = module.vmm.custom_location_id

  system_center_virtual_machine_manager_server_id = module.vmm.id

  tags = { tier = each.key }
}

# ── Two VMs, so the set has something to separate (example 4) ─────────────────
module "vm_arc" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-arc-machine.git?ref=v1.0.0"
  for_each = toset(["vm-web-01", "vm-web-02"])

  name                = each.key
  resource_group_name = module.rg.name
  location            = module.rg.location
  kind                = "SCVMM"

  tags = { tier = "web" }
}

module "vm_web" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-system-center-virtual-machine-manager-virtual-machine-instance.git?ref=v1.0.0"
  for_each = toset(["vm-web-01", "vm-web-02"])

  scoped_resource_id = module.vm_arc[each.key].id
  custom_location_id = module.vmm.custom_location_id

  infrastructure = {
    system_center_virtual_machine_manager_template_id               = var.vmm_template_id
    system_center_virtual_machine_manager_cloud_id                  = module.vmm_cloud_prod.id
    system_center_virtual_machine_manager_virtual_machine_server_id = module.vmm.id
    checkpoint_type                                                 = "Production"
  }

  hardware = { cpu_count = 2, memory_in_mb = 4096 }

  # ⚠️ MEMBERSHIP IS EXPRESSED HERE, not in the availability-set module.
  system_center_virtual_machine_manager_availability_set_ids = [module.vmm_avset["web"].id]

  timeouts = { create = "2h" }
}

resource "azurerm_management_lock" "avset_no_delete" {
  for_each = module.vmm_avset

  name       = "${each.value.name}-no-delete"
  scope      = each.value.id
  lock_level = "CanNotDelete"
  notes      = "Deleting this set changes placement for the VMs in it"
}

output "avset_posture" {
  value = {
    for k, m in module.vmm_avset : k => {
      id      = m.id
      name    = m.name
      creates = m.creates_an_object_in_vmm # always true
    }
  }
}

# ⚠️ The membership check that the availability-set module cannot answer.
output "avset_members" {
  value = { for k, m in module.vm_web : k => m.is_in_availability_set }
}

🔒 What the composition gets right: the server ID here and an inventory item ID on the projection, both regional values wired from the anchor, CanNotDelete locks over sets with live members, two VMs so the set separates something, and a membership output taken from the VM modules rather than assumed.

⚠️ What no plan will tell you: whether the placement scope has more than one host, whether VMM can honour the separation alongside its other constraints, or whether every VM you expected actually joined.

💡 The membership output is the one to keep. A set with no members produces no error anywhere (example 3).

12 · Everything this module deliberately does not do
NOT in scope, and why:

  membership            expressed on the VM instance; two write paths to one
                        relationship would be able to disagree
  VMM placement rules   fabric configuration; not readable or writable here
  host inventory        invisible to a plan, so no validation can depend on it
  a resilience claim    would require the two above
  the VMM object's
  prior existence       there is none — this module creates it (example 2)

💡 The pattern across this family: where a constraint cannot be checked at plan time, it is documented and no fake validation is written for it. A check that looked authoritative and was not would be worse than none.

ℹ️ What is validated: the server ID's shape (example 1), the custom location's shape, and non-empty names. All three catch real, common mistakes.

⚠️ What is documented but unenforceable: host count, VMM placement behaviour, and whether the set has members. Nothing in this module pretends otherwise.

📥 Inputs

Input Type Default Notes
name string — Required. Force-new. ⚠️ The set's name in VMM too.
resource_group_name string — Required. Force-new.
location string — Required. Force-new. Must match the custom location's region.
custom_location_id string — Required. Force-new. Shape-validated.
system_center_virtual_machine_manager_server_id string — Required. Force-new. ⚠️ The server ID, shape-validated — see example 1.
tags map(string) {} The only non-force-new field.
timeouts object(...) null All four operations.
Full schemas
variable "system_center_virtual_machine_manager_server_id" {
  type = string
  # The check that exists because of the FAMILY, not because of the field: three
  # near-identical siblings take an inventory item in this position.
  validation {
    condition     = can(regex("^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft[.]ScVmm/vmmServers/[^/]+$", var.system_center_virtual_machine_manager_server_id))
    error_message = "system_center_virtual_machine_manager_server_id must be a complete VMM server ID ... MATCHING THAT CAPITALISATION EXACTLY ... An inventory item ID is the other common mistake here …"
  }
}

variable "custom_location_id" {
  type = string
  validation {
    condition     = can(regex("(?i)/providers/Microsoft\\.ExtendedLocation/customLocations/", var.custom_location_id))
    error_message = "custom_location_id must be a Microsoft.ExtendedLocation/customLocations Resource ID."
  }
}

🧾 Outputs

Output Description Kind
id The Resource ID of the availability set Passthrough
name The name of the availability set Passthrough
resource_group_name The resource group holding the availability set Passthrough
location The region of the availability set Passthrough
custom_location_id The custom location backing the VMM environment Passthrough
system_center_virtual_machine_manager_server_id The VMM server this set belongs to Passthrough
creates_an_object_in_vmm Always true, and emitted as the counterpart to the projection modules' projects_an_existing_vmm_object: destroying this resource removes an availability set that Azure created in VMM, not just an Azure representation Constant
every_argument_except_tags_is_force_new Always true Constant
creates_the_object_in_vmm_rather_than_projecting_one Always true, and it is the opposite of the three sibling modules in this family Constant
requires_a_healthy_arc_resource_bridge Always true, and it is the dependency most likely to make an apply fail for reasons that look unrelated Constant

No secret is accepted and none is emitted. Membership is not an output here — it is on the VM modules.

🧠 Architecture Notes

  • The server-ID validation exists because of the family, not because of the field. In isolation, a shape check on one Resource ID is unremarkable. In a family where three near-identical siblings take a different ID in the same position, it is the check most likely to save an apply — so its error message names the distinction rather than restating the pattern.

  • creates_an_object_in_vmm is a constant true, designed as half of a pair. The three projection modules emit projects_an_existing_vmm_object. Read side by side in a state review, the pair answers the question that matters before a destroy: does this remove a record, or remove infrastructure?

  • The name field is called out as non-cosmetic because the projection modules trained the opposite intuition. Here the name reaches VMM, and a rename detaches every member — a consequence that does not appear in this module's plan, because membership lives elsewhere.

  • Membership is deliberately absent. The provider places VMs from the instance side. A membership input here would be either a second write path to one relationship — two places able to disagree — or a synthetic input that does not reflect the API. The split is documented instead, in both directions: this module says membership is elsewhere, and the VM module says the set cannot report its members.

  • An empty set is acknowledged as a silent failure mode. Creating a set and forgetting to place anything in it produces no error and no plan difference, so the review pattern points at the VM modules' is_in_availability_set rather than at anything here.

  • No claim is made about resilience, in the code or the prose. The set is a placement request to VMM; whether VMM can honour it depends on host count and other constraints this module cannot see. A set across a single host separates nothing, so no output is named or described as though it guaranteed availability.

  • Where a constraint cannot be checked, no fake validation is written. Host count, VMM placement behaviour and membership are all documented as unenforceable. A check that looked authoritative and was not would be worse than none.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Wrong-ID pastes an inventory item ID rejected at plan, with the family difference named —
Destroy semantics creates_an_object_in_vmm emitted; a management lock recommended destroy anyway, knowingly
Rename surprise the VMM-name consequence and the invisible detachment documented rename, knowingly
Membership confusion documented in both directions; the review pattern points at the VM modules —
Resilience honesty no output or wording claims availability —
Region mismatch both regional values wired from the VMM server module in every example hard-code literals, knowingly
Custom-location pastes resource-bridge and resource-group IDs rejected at plan —
Unenforceable constraints documented, with no fake validation written —
Secrets none accepted, none emitted —
  • Before the first apply: settle the name — it reaches VMM and a rename detaches members.
  • Before assuming the set works: check the VMs' is_in_availability_set, not this module.
  • Before claiming resilience: confirm the placement scope has more than one host.
  • Before a destroy: this removes an object from VMM, not just an Azure record.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag — ?ref=v1.0.0 — never a branch.
  • Plan-only from here. A human applies from CI.
  • ⚠️ A destroy removes the availability set from VMM. Use a CanNotDelete lock once VMs are placed in it; prevent_destroy is not available to a module caller.
  • ⚠️ A rename is a replacement, and it detaches every member without showing them in the plan.
  • ℹ️ Only tags updates in place.
  • ℹ️ Creating the set reaches through to VMM, so allow more time than a pure control-plane operation.
  • ℹ️ Membership lives on the VM instance module. Creating the set is half the job.

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • system_center_virtual_machine_manager_server_id is a complete vmmServers/<name> ID, anchored at both ends and matched case-sensitively (Microsoft.ScVmm, vmmServers) — an inventory item ID is rejected, and so is a lower-cased one, which is what the provider's own parser does;
  • custom_location_id is a customLocations Resource ID;
  • name, resource_group_name and location are not blank;
  • the module declares no provider block.

💡 These were proved by evaluating the conditions in terraform console inside the module — which does fire root-module variable validations, unlike terraform validate on a calling configuration. An inventory item ID in the server field fails; the VMM server module's id passes.

What only plan and apply exercise:

  • whether the resource group, custom location and VMM server exist;
  • whether the region matches the custom location's;
  • whether VMM accepts the set.

What no Terraform command checks at any stage:

  • whether the placement scope contains more than one host — and therefore whether the set can separate anything;
  • whether VMM can honour the separation alongside its other placement constraints;
  • whether any VM ever joined the set — membership is on the VM instance resource;
  • whether the name follows your VMM naming conventions, which matters because it reaches VMM.

💬 Example Output

Outputs:

creates_an_object_in_vmm                        = true
custom_location_id                              = "/subscriptions/00000000-.../providers/Microsoft.ExtendedLocation/customLocations/cl-vmm-dc1"
id                                              = "/subscriptions/00000000-.../resourceGroups/rg-vmm-dc1/providers/Microsoft.ScVmm/availabilitySets/vmmas-app"
location                                        = "eastus"
name                                            = "vmmas-app"
resource_group_name                             = "rg-vmm-dc1"
system_center_virtual_machine_manager_server_id = "/subscriptions/00000000-.../providers/Microsoft.ScVmm/vmmServers/vmm-contoso-dc1"

⚠️ creates_an_object_in_vmm = true is the destroy warning, in the outputs (example 2).

⚠️ Note what is not here: members. No output on this module can tell you whether the set contains anything (example 3).

💡 system_center_virtual_machine_manager_server_id running from /subscriptions/… to /vmmServers/vmm-contoso-dc1 — complete, with nothing appended, and capitalised exactly so — is what a correct value looks like (example 1).

🔍 Troubleshooting

Symptom Cause Fix
Plan rejects the server ID An inventory item ID was supplied. Use the VMM server module's id (example 1).
Apply fails saying the parent was not found The right shape, wrong VMM server. Wire it from the VMM server module.
Plan rejects custom_location_id A resource-bridge or resource-group ID was supplied. Use the customLocations ID (example 7).
Apply fails on the region location does not match the custom location's region. Wire both from the VMM server module (example 7).
The set exists but nothing is in it Membership is expressed on the VM. Set ..._availability_set_ids on the VM module (example 3).
Expected this module to report members It cannot — placement is on the instance side. Check the VMs' is_in_availability_set (example 9).
VMs in the set landed on the same host VMM could not honour the separation. A fabric question — check host count and placement rules (example 4).
A rename destroyed the set name is force-new and reaches VMM. Expected; treat a rename as a placement change (example 5).
VMs lost their placement guarantee after a rename Detachment does not appear in this module's plan. Review every VM referencing the set (example 5).
Wanted prevent_destroy lifecycle is not valid inside a module block. Use a CanNotDelete lock (example 2).
An imported resource proposes a replacement name or the server ID differs. Correct the configuration first (example 10).
Apply fails with the provider not registered Microsoft.ScVmm is not registered. az provider register --namespace Microsoft.ScVmm.

🔗 Related Docs

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