Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Local (Stack HCI) Marketplace Gallery Image Terraform Module

Downloads a marketplace OS image onto an Azure Local cluster shared volume. The apply is a multi-gigabyte download. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • ☁️ Creates one azurerm_stack_hci_marketplace_gallery_image (the keystone this) — a marketplace OS image realised on your own hardware.
  • 🔴 Records the failure that surfaces in a different resource, days later: a wrong hyperv_generation applies cleanly and produces a VM that will not boot.
  • 🧮 Emits image_urn — publisher:offer:sku:version — because that is the form every other tool speaks while this resource splits it across four arguments.
  • ⚠️ Renames the provider's version to image_version, because Terraform reserves version as a variable name; the rename is forced by the language.
  • 🔴 States that omitting storage_path_id does not fail: Azure picks a storage path round-robin.

💡 Why it matters: none of the marketplace coordinates can be verified from Terraform, and the failures land at three different moments — plan, apply, and first boot. This module is explicit about which is which.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  bridge["Azure Arc resource bridge"]
  cl["Custom location  Microsoft.ExtendedLocation/customLocations  THE parent reference"]
  market["Azure Marketplace  publisher, offer, sku and version  unverifiable from here"]
  sp["terraform-azurerm-stack-hci-storage-path  omit it and Azure picks round-robin"]
  img["terraform-azurerm-stack-hci-marketplace-gallery-image"]
  lnet["terraform-azurerm-stack-hci-logical-network"]
  download["A multi-gigabyte download onto a cluster shared volume"]
  vm["An Azure Local virtual machine  boots only if hyperv_generation matches"]

  rg -->|"name and location"| cl
  bridge -->|"creates"| cl
  cl -->|"id as custom_location_id"| img
  cl -->|"id as custom_location_id"| sp
  cl -->|"id as custom_location_id"| lnet
  market -.->|"identifier plus image_version, four strings"| img
  sp -->|"id as storage_path_id, optional"| img
  img -->|"apply performs"| download
  img --> vm
  lnet --> vm

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#00243c,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class img this;
  class cl keystone;
  class rg,bridge,market,sp,lnet,download,vm neutral;
Loading

The dotted edge from the marketplace is where all the risk sits: four strings that must describe a real image, and nothing in Azure Resource Manager to check them against. The edge from the storage path is optional — omit it and Azure chooses a path itself.


🧬 What this module builds

flowchart TB
  n["name, resource_group_name, location  force-new"]
  cl["custom_location_id  ExtendedLocation, rejects the CLUSTER id by name"]
  os["os_type  Windows or Linux, a CLOSED set"]
  gen["hyperv_generation  V1 or V2, NOT Gen1/Gen2"]
  ver["image_version  RENAMED from the provider's version, which Terraform reserves"]
  ident["identifier  exactly one block  publisher, offer, sku"]
  sp["storage_path_id  optional  rejects a CUSTOM LOCATION id by name"]
  tags["tags  the ONLY in-place edit"]

  res["azurerm_stack_hci_marketplace_gallery_image.this"]

  urn["image_urn  publisher:offer:sku:version, the form every other tool speaks"]
  alias["version_looks_like_an_alias  a HEURISTIC, reported not rejected"]
  rr["uses_explicit_storage_path  false means Azure chose round-robin"]
  unver["the_marketplace_coordinates_cannot_be_verified_here  a wrong generation boots nothing"]
  dl["creating_this_image_downloads_it_over_your_own_link"]

  n --> res
  cl --> res
  os --> res
  gen --> res
  ver --> res
  ident --> res
  sp --> res
  tags --> res
  ident --> urn
  ver --> urn
  ver --> alias
  sp --> rr
  res --> unver
  res --> dl

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class res this;
  class n,cl,os,gen,ver,ident,sp,tags,urn,alias,rr,unver,dl neutral;
Loading
Resource Cardinality Purpose
azurerm_stack_hci_marketplace_gallery_image.this single, many per instance One marketplace image, downloaded to a shared volume.

Ten arguments plus timeouts. The identifier block is modelled as one object, because the provider permits exactly one.


✅ Provider / Versions

Item Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module. The caller configures provider "azurerm" { features {} }, auth and subscription.
Module type Standalone — one resource, no children.
ARM type Microsoft.AzureStackHCI/marketplaceGalleryImages

Schema notes that bite:

  • 🔴 A wrong hyperv_generation applies cleanly and produces a VM that will not boot. The failure appears in a different resource, later, with nothing pointing back here.
  • 🔴 The provider's version had to be renamed to image_version — variable "version" {} is a hard parse error, since version is a module-block meta-argument.
  • 🔴 Creating this resource downloads several gigabytes through the Arc resource bridge. Raise create.
  • 🔴 Omitting storage_path_id is not an error — Azure selects a path round-robin.
  • 🔴 custom_location_id is Microsoft.ExtendedLocation/customLocations, not the Stack HCI cluster.
  • 🔴 The product is now Azure Local; ARM and Terraform still say Stack HCI. A rename, not a deprecation.
  • ⚠️ os_type and hyperv_generation are closed sets — Windows/Linux and V1/V2, not Gen1/Gen2.
  • ⚠️ identifier is min 1 / max 1.
  • ⚠️ tags is the only argument that is not force-new — a version bump is a new resource and a fresh download.
  • ⚠️ The provider's force-new note on storage_path_id names the wrong resource — it says "Virtual Hard Disk". It forces a new gallery image.
  • ⚠️ lifecycle is not valid inside a module block.

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the image Azure Stack HCI Administrator, or Contributor the resource group
Reference the custom location Contributor or Reader on it the custom location — often another resource group
Reference a storage path Reader the storage path
Read the image Reader the image

⚠️ The custom location is created by the Arc resource bridge, so it commonly lives in a different resource group. Permission is needed at its scope too.

🔒 Nothing here is a credential. Marketplace coordinates are public identifiers; no argument or output is marked sensitive.

ℹ️ No Azure role covers the download itself. Reaching the marketplace is the resource bridge's network path, not an authorization question.


Azure Prerequisites

  • A deployed and Arc-registered Azure Local instance, with a custom location.
  • Room on the target volume for a multi-gigabyte image.
  • Outbound network from the resource bridge to the marketplace.
  • The exact marketplace coordinates, confirmed with az vm image list.
  • Microsoft.AzureStackHCI and Microsoft.ExtendedLocation registered.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; this module declares none of these.

📁 Module Structure

terraform-azurerm-stack-hci-marketplace-gallery-image/
├── providers.tf     # required_version + pinned azurerm; no provider block
├── variables.tf     # 11 inputs, 17 validations
├── main.tf          # the keystone `this` + URN assembly and ID parsing
├── outputs.tf       # id first, then what the module cannot verify
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "gallery_image" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-marketplace-gallery-image.git?ref=v1.0.0"

  name                = "img-ws2022"
  resource_group_name = module.rg.name
  location            = "eastus2"
  custom_location_id  = var.custom_location_id

  os_type           = "Windows"
  hyperv_generation = "V2"
  image_version     = "20348.2655.240905"

  identifier = {
    publisher = "microsoftwindowsserver"
    offer     = "windowsserver"
    sku       = "2022-datacenter-azure-edition-hotpatch"
  }

  # storage_path_id is optional and omitted here -- which means Azure picks a storage
  # path ROUND-ROBIN rather than failing. Examples 7 and 12 wire it explicitly.

  tags = { environment = "prod" }

  timeouts = { create = "3h" }
}

🔴 The create timeout is raised deliberately. This apply downloads the image; a Windows Server image is several gigabytes over whatever link the site has.


🔌 Cross-Module Contract

Consumes

Input Type Source
name / resource_group_name / location string caller / terraform-azurerm-resource-group — force-new
custom_location_id string the Arc resource bridge, via var.* — force-new
os_type / hyperv_generation string caller — closed sets, force-new
image_version string caller — renamed from the provider's version, force-new
identifier object(...) caller — exactly one, force-new
storage_path_id string terraform-azurerm-stack-hci-storage-path output id — optional, force-new
tags map(string) caller — the only in-place edit
timeouts object(...) caller — raise create

Emits

Output Description Consumed by
id The Resource ID under Microsoft.AzureStackHCI/marketplaceGalleryImages. an Azure Local VM
name / resource_group_name / location / custom_location_id Configuration echo. Force-new. review
os_type / hyperv_generation / image_version / identifier The image's identity. Force-new. review
storage_path_id The path, or null when Azure chose one. review
tags The effective tags. review
image_urn Derived: publisher:offer:sku:version. az vm image list comparison
custom_location_name / custom_location_resource_group Derived from the parent ID. inventory
storage_path_name Derived. null when omitted. review
uses_explicit_storage_path Derived. false means round-robin. composition review
version_looks_like_an_alias_rather_than_an_explicit_version Derived heuristic. Reported, not rejected. review
storage_path_selection_is_round_robin_when_this_is_omitted Always true. composition design
the_marketplace_coordinates_cannot_be_verified_here Always true. pre-apply review
creating_this_image_downloads_it_over_your_own_link Always true. capacity / timeout review
this_is_an_arc_projected_resource_so_azure_state_can_diverge_from_the_hardware Always true. operational review
every_argument_except_tags_is_force_new Always true. change review
the_product_is_now_called_azure_local_but_arm_and_terraform_still_say_stack_hci The ARM type. design review
secure_by_default_has_nothing_to_act_on_here Always true. design review

📚 Example Library

1 · A pinned Windows Server image
module "gallery_image" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-marketplace-gallery-image.git?ref=v1.0.0"

  name                = "img-ws2022"
  resource_group_name = module.rg.name
  location            = "eastus2"
  custom_location_id  = var.custom_location_id

  os_type           = "Windows"
  hyperv_generation = "V2"
  image_version     = "20348.2655.240905"

  identifier = {
    publisher = "microsoftwindowsserver"
    offer     = "windowsserver"
    sku       = "2022-datacenter-azure-edition-hotpatch"
  }
}

ℹ️ Seven required arguments, four of which describe one marketplace image.

2 · 🔴 `hyperv_generation` — the failure that lands somewhere else
# This APPLIES CLEANLY and produces a VM that will not boot.
module "gallery_image" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-marketplace-gallery-image.git?ref=v1.0.0"

  name                = "img-ws2022"
  resource_group_name = module.rg.name
  location            = "eastus2"
  custom_location_id  = var.custom_location_id

  os_type           = "Windows"
  hyperv_generation = "V1" # WRONG for a Gen2 image
  image_version     = "20348.2655.240905"

  identifier = {
    publisher = "microsoftwindowsserver"
    offer     = "windowsserver"
    sku       = "2022-datacenter-azure-edition-hotpatch"
  }
}

🔴 Nothing here fails. The image downloads, the resource is created, and the mismatch surfaces at first boot of a VM built from it — a different resource, potentially days later, with nothing pointing back to this module.

⚠️ It is V1/V2, not Gen1/Gen2 or 1/2, which is how the same concept is written elsewhere in Azure. A modern marketplace image is almost always Generation 2.

3 · ⚠️ Why the input is `image_version`
# The provider's argument is `version`. This module's input cannot be.
image_version = "20348.2655.240905"

⚠️ variable "version" {} is a hard parse error — "The variable name "version" is reserved due to its special meaning inside module blocks" — because source, version and providers are module-block meta-arguments.

ℹ️ So the rename is forced by the language, not a stylistic divergence. The module maps image_version onto the provider's version, and the image_version output reads the provider's attribute back.

4 · 🧮 `image_urn` — the form every other tool speaks
output "compare_against_the_marketplace" {
  value = module.gallery_image.image_urn
}
compare_against_the_marketplace = "microsoftwindowsserver:windowsserver:2022-datacenter-azure-edition-hotpatch:20348.2655.240905"
# The same four parts, in the same order.
az vm image list --all \
  --publisher microsoftwindowsserver \
  --offer windowsserver \
  --sku 2022-datacenter-azure-edition-hotpatch \
  --query "[].version" -o tsv

💡 This resource splits the URN across an identifier block and a separate version argument, while the marketplace, the portal and az vm image all speak the four-part form. Reassembling it by hand is needless work, and getting the order wrong is easy.

⚠️ It is a convenience, not a verified identifier. The module cannot confirm the combination exists.

5 · ⚠️ None of the coordinates can be verified
output "read_before_applying" {
  value = module.gallery_image.the_marketplace_coordinates_cannot_be_verified_here
}
What is wrong When it fails
Publisher, offer, SKU or version does not exist At apply, when the bridge tries to download.
os_type contradicts the SKU Accepted; surfaces as an odd VM.
hyperv_generation contradicts the image At first boot of a VM — a different resource, later.

🔴 There is no published list to validate against, and inventing one would reject images Microsoft adds later. So the module checks that the fields are present and non-empty and stops there.

💡 The three failure moments are why this is worth stating. "It applied" means only the download succeeded.

6 · The version alias heuristic
# Reported, not rejected.
image_version = "latest"
output "probably_wrong" {
  value = module.gallery_image.version_looks_like_an_alias_rather_than_an_explicit_version
}
probably_wrong = true

ℹ️ A marketplace gallery image is versioned explicitly — a real value looks like 20348.2655.240905 — and latest is the predictable mistake because that alias works in several other Azure image contexts.

⚠️ This is a heuristic and it is labelled one where it is emitted. The provider publishes no format, and this module cannot confirm the resource refuses the word — so rejecting a value that turns out to be legal would be worse than flagging one that is probably wrong.

ℹ️ The flag deliberately does not try to validate the version's shape. Publishers version images to their own schemes, and a dotted-numeric pattern would reject the first one that does something else.

7 · 🔴 Storage path placement, and what omitting it means
# Deterministic -- the image lands on the path this id names. Wire the storage-path
# module's `id` output; example 12 shows the full declaration.
storage_path_id = var.image_storage_path_id
# Omitted -- Azure picks a path round-robin.
# storage_path_id = ...
output "placement" {
  value = {
    explicit = module.gallery_image.uses_explicit_storage_path
    path     = module.gallery_image.storage_path_name
  }
}

🔴 Microsoft documents the round-robin behaviour: omit the parameter and the system chooses from those available on the cluster. So a false here means the image landed somewhere valid but unpredictable, which defeats a deliberate storage layout — and it is invisible in the plan, because the argument is simply absent.

⚠️ storage_path_name is null rather than a placeholder when omitted, because "Azure will choose one" is a genuinely different answer from "a path called something".

8 · The two closed value sets
# Rejected -- lower case.
os_type = "windows"

# Rejected -- this is not how the provider spells it.
hyperv_generation = "Gen2"

# Accepted.
os_type           = "Windows"
hyperv_generation = "V2"

ℹ️ Both are enumerated by the provider as complete sets rather than as examples, so an unrecognised value is rejected outright rather than allowed through under this suite's near-miss rule. Reading the documentation's verb is what decides between the two treatments.

9 · The ID conventions this module rejects
# Rejected by name -- the CLUSTER, in custom_location_id.
custom_location_id = ".../providers/Microsoft.AzureStackHCI/clusters/hci-prod"

# Rejected by name -- a CUSTOM LOCATION, in storage_path_id.
storage_path_id = ".../providers/Microsoft.ExtendedLocation/customLocations/cl-hci-prod"

# Accepted.
custom_location_id = ".../providers/Microsoft.ExtendedLocation/customLocations/cl-hci-prod"
storage_path_id    = ".../providers/Microsoft.AzureStackHCI/storagePaths/sp-images"

ℹ️ Two arguments take two different namespaces, and swapping them is the natural mistake given every other ID on this resource. Each rejects the other's by name.

⚠️ The provider's force-new note on storage_path_id says "forces a new Azure Stack HCI Virtual Hard Disk" — copied text from a neighbouring resource. It forces a new marketplace gallery image.

10 · 🔴 Raise the `create` timeout
module "gallery_image" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-marketplace-gallery-image.git?ref=v1.0.0"

  name                = "img-ws2022"
  resource_group_name = module.rg.name
  location            = "eastus2"
  custom_location_id  = var.custom_location_id
  os_type             = "Windows"
  hyperv_generation   = "V2"
  image_version       = "20348.2655.240905"

  identifier = {
    publisher = "microsoftwindowsserver"
    offer     = "windowsserver"
    sku       = "2022-datacenter-azure-edition-hotpatch"
  }

  timeouts = {
    create = "3h"
    delete = "1h"
  }
}

🔴 The apply downloads an OS image onto a cluster shared volume through the Arc resource bridge — several gigabytes over the site's link. This is by a wide margin the slowest operation in the Azure Local family.

⚠️ A failed download is not always a configuration error. A timeout, a proxy or an unreachable resource bridge all present as an apply failure while the Terraform is correct.

ℹ️ update only ever governs a tag change, since every other argument is force-new.

11 · Several images on one instance
variable "images" {
  type = map(object({
    os_type   = string
    publisher = string
    offer     = string
    sku       = string
    version   = string
  }))
  default = {
    ws2022 = {
      os_type   = "Windows"
      publisher = "microsoftwindowsserver"
      offer     = "windowsserver"
      sku       = "2022-datacenter-azure-edition-hotpatch"
      version   = "20348.2655.240905"
    }
    ubuntu2204 = {
      os_type   = "Linux"
      publisher = "canonical"
      offer     = "0001-com-ubuntu-server-jammy"
      sku       = "22_04-lts-gen2"
      version   = "22.04.202409060"
    }
  }
}

module "storage_path" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-storage-path.git?ref=v1.0.0"

  name                = "sp-images"
  resource_group_name = module.rg.name
  location            = "eastus2"
  custom_location_id  = var.custom_location_id
  path                = "C:\\ClusterStorage\\UserStorage_2\\images"
}

module "gallery_images" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-marketplace-gallery-image.git?ref=v1.0.0"
  for_each = var.images

  name                = "img-${each.key}"
  resource_group_name = module.rg.name
  location            = "eastus2"
  custom_location_id  = var.custom_location_id

  os_type           = each.value.os_type
  hyperv_generation = "V2"
  image_version     = each.value.version

  identifier = {
    publisher = each.value.publisher
    offer     = each.value.offer
    sku       = each.value.sku
  }

  storage_path_id = module.storage_path.id
  timeouts        = { create = "3h" }
}

output "image_urns" {
  value = { for k, m in module.gallery_images : k => m.image_urn }
}

⚠️ Both are V2 here because both SKUs are Generation 2 — the Ubuntu SKU says so in its name. Do not assume; check per image.

💡 Each image is a separate download, so a for_each over several is a long apply. Raise create once in the shared arguments.

12 · 🏗️ End-to-end composition — a storage path, two images and a network
provider "azurerm" {
  features {}
}

variable "location" {
  type    = string
  default = "eastus2"
}

variable "custom_location_id" {
  type        = string
  description = "The custom location created by the Arc resource bridge during Azure Local onboarding."
}

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

  name     = "rg-azure-local-prod"
  location = var.location

  tags = { environment = "prod" }
}

# Images on their own volume, so a large download cannot crowd out workloads.
module "image_storage_path" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-storage-path.git?ref=v1.0.0"

  name                = "sp-images"
  resource_group_name = module.rg.name
  location            = var.location
  custom_location_id  = var.custom_location_id

  # Doubled backslashes are mandatory in HCL.
  path = "C:\\ClusterStorage\\UserStorage_2\\images"

  tags = { environment = "prod", purpose = "images" }
}

# THIS MODULE.
module "gallery_image" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-marketplace-gallery-image.git?ref=v1.0.0"

  name                = "img-ws2022"
  resource_group_name = module.rg.name
  location            = var.location
  custom_location_id  = var.custom_location_id

  os_type           = "Windows"
  hyperv_generation = "V2"
  image_version     = "20348.2655.240905"

  identifier = {
    publisher = "microsoftwindowsserver"
    offer     = "windowsserver"
    sku       = "2022-datacenter-azure-edition-hotpatch"
  }

  # Deterministic placement -- without this, Azure chooses round-robin.
  storage_path_id = module.image_storage_path.id

  tags     = { environment = "prod" }
  timeouts = { create = "3h" }
}

module "logical_network" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stack-hci-logical-network.git?ref=v1.0.0"

  name                = "lnet-prod"
  resource_group_name = module.rg.name
  location            = var.location
  custom_location_id  = var.custom_location_id
  virtual_switch_name = "ConvergedSwitch(management)"

  dns_servers = ["10.220.32.16"]

  subnet = {
    ip_allocation_method = "Static"
    address_prefix       = "10.220.32.0/24"
    ip_pool              = [{ start = "10.220.32.18", end = "10.220.32.38" }]
    route = {
      address_prefix      = "0.0.0.0/0"
      next_hop_ip_address = "10.220.32.1"
    }
  }

  tags = { environment = "prod" }
}

output "azure_local_review" {
  value = {
    image_urn        = module.gallery_image.image_urn
    unverified       = module.gallery_image.the_marketplace_coordinates_cannot_be_verified_here
    placement_fixed  = module.gallery_image.uses_explicit_storage_path
    version_is_alias = module.gallery_image.version_looks_like_an_alias_rather_than_an_explicit_version
    image_volume     = module.image_storage_path.cluster_shared_volume
    reserved_volume  = module.image_storage_path.path_is_on_the_reserved_infrastructure_volume
    network_gateway  = module.logical_network.default_gateway
  }
}

💡 Images get their own volume on purpose. A multi-gigabyte download onto the workload volume competes with the VMs already running there.

🔴 unverified is always true — a standing reminder, not a finding. Confirm the URN with az vm image list before applying.

⚠️ A VM is absent deliberately. azurerm_stack_hci_virtual_machine_instance is not yet authored in this library, and it is what would consume the image and the network together — and where a wrong hyperv_generation would finally surface.


📥 Inputs

Input Type Default Notes
name string — Required. Force-new.
resource_group_name string — Required. Force-new.
location string — Required. Force-new. Lowercase, no spaces.
custom_location_id string — Required. Microsoft.ExtendedLocation. Rejects the cluster ID.
os_type string — Required. Windows / Linux, closed.
hyperv_generation string — Required. V1 / V2, closed. Not Gen2.
image_version string — Required. Renamed from the provider's reserved version.
identifier object(...) — Required, exactly one. Force-new.
storage_path_id string null Omit and Azure picks round-robin. Force-new.
tags map(string) {} The only in-place edit.
timeouts object(...) null Raise create — this downloads an image.
Full schemas
variable "name" { type = string }
variable "resource_group_name" { type = string }
variable "location" { type = string }

# Anchored to Microsoft.ExtendedLocation/customLocations; the cluster ID is rejected by name.
variable "custom_location_id" { type = string }

# Closed sets, enforced.
variable "os_type" { type = string }           # Windows | Linux
variable "hyperv_generation" { type = string }  # V1 | V2

# Renamed because Terraform reserves `version` as a variable name.
variable "image_version" { type = string }

# One object -- the provider permits exactly one identifier block.
variable "identifier" {
  type = object({
    publisher = string
    offer     = string
    sku       = string
  })
}

# Anchored to Microsoft.AzureStackHCI/storagePaths; a custom location ID is rejected by name.
variable "storage_path_id" {
  type    = string
  default = null
}

variable "tags" {
  type    = map(string)
  default = {}
}

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

🧾 Outputs

Output Type Notes
id string Microsoft.AzureStackHCI/marketplaceGalleryImages.
name / resource_group_name / location / custom_location_id string All force-new.
os_type / hyperv_generation / image_version string Force-new.
identifier object Force-new.
storage_path_id string Or null — meaning Azure chose one.
tags map(string) The only in-place edit.
image_urn string Derived: publisher:offer:sku:version.
custom_location_name / custom_location_resource_group string Derived.
storage_path_name string Derived. null when omitted.
uses_explicit_storage_path bool Derived. false means round-robin.
version_looks_like_an_alias_rather_than_an_explicit_version bool Derived heuristic.
storage_path_selection_is_round_robin_when_this_is_omitted bool Always true.
the_marketplace_coordinates_cannot_be_verified_here bool Always true.
creating_this_image_downloads_it_over_your_own_link bool Always true.
this_is_an_arc_projected_resource_so_azure_state_can_diverge_from_the_hardware bool Always true.
every_argument_except_tags_is_force_new bool Always true.
the_product_is_now_called_azure_local_but_arm_and_terraform_still_say_stack_hci string The ARM type.
secure_by_default_has_nothing_to_act_on_here bool Always true.

🔒 Nothing is sensitive. Marketplace coordinates are public identifiers.


🧠 Architecture Notes

The most consequential mistake this resource permits surfaces in a different resource. hyperv_generation must match the image the identifier names, and nothing in Terraform or Azure Resource Manager can check that it does. A wrong value downloads successfully, creates the resource successfully, and produces a VM that will not boot — a failure that appears at first boot of a different resource, potentially days later, with nothing pointing back here. That is why the module emits the_marketplace_coordinates_cannot_be_verified_here and enumerates the three distinct moments at which a coordinate error can land: plan (never), apply (a coordinate that does not exist), and first boot (a generation or OS-type mismatch).

None of the marketplace coordinates are verifiable, and inventing a check would be worse than admitting it. There is no published list of publishers, offers, SKUs and versions this module could validate against, and hard-coding one would reject images Microsoft adds later. So identifier is checked only for non-empty fields, and image_version only for presence. What the module does instead is emit image_urn — the four-part publisher:offer:sku:version form that the marketplace, the portal and az vm image list all speak — so a reviewer can compare directly against a real listing rather than reassembling four separate outputs by hand.

latest is reported rather than rejected, and the distinction is deliberate. A marketplace gallery image is versioned explicitly, and latest is the predictable mistake because that alias works in several other Azure image contexts. But the provider publishes no format and this module cannot confirm the resource refuses the word — so refusing a value that turns out to be legal would be worse than flagging one that is probably wrong. The flag is a labelled heuristic, and it deliberately does not attempt to validate the version's shape: publishers version to their own schemes, and a dotted-numeric pattern would reject the first one that does something else.

The input had to be renamed, and the language forced it. variable "version" {} is a hard parse error — "The variable name "version" is reserved due to its special meaning inside module blocks" — because source, version and providers are module-block meta-arguments. So the module's input is image_version, mapped onto the provider's version argument, with the reason stated in the variable's own description so the divergence does not read as arbitrary. The image_version output reads the provider's attribute back.

Omitting storage_path_id does not fail. Microsoft documents that when the parameter is not supplied, the system chooses a storage path round-robin from those available on the cluster. So the argument is the difference between deterministic and distributed placement, not between working and broken — and a composition that creates storage paths to control a layout must reference one here, or the layout is aspirational. The selection is also per-resource: choosing a path for a VM places its OS disk and configuration files, while its data disks are placed by their own selections. storage_path_name returns null when omitted rather than a placeholder, because "Azure will choose one" is a different answer from "a path called something".

The apply is a download, which changes how it should be operated. Creating this resource pulls a multi-gigabyte OS image through the Arc resource bridge onto a cluster shared volume — by a wide margin the slowest operation in the Azure Local family and the one most likely to exceed a default timeout. It also consumes space on the target volume, and with storage_path_id omitted, the volume that fills is whichever round-robin selected. A failed download is frequently not a configuration error at all: a timeout, a proxy, or an unreachable bridge all present as an apply failure while the Terraform is correct.

Two ID namespaces meet on this resource and each rejects the other's. custom_location_id is Microsoft.ExtendedLocation/customLocations; storage_path_id is Microsoft.AzureStackHCI/storagePaths. Given every other ID here, swapping them is the natural mistake, so both are anchored and each names the wrong value explicitly. Worth noting separately: the provider's force-new note on storage_path_id says changing it "forces a new Azure Stack HCI Virtual Hard Disk to be created" — copied text from a neighbouring resource. It forces a new marketplace gallery image.

Two value sets are closed and enforced as such. os_type is Windows or Linux; hyperv_generation is V1 or V2 — not Gen1/Gen2 or 1/2, which is how the same concept is written elsewhere in Azure. Both are enumerated by the provider as complete sets rather than as examples, and reading that verb is what decides between enforcing a closed set and applying this suite's near-miss rule.

Finally, tags is the only argument that is not force-new, which is the right model for immutable content. A version bump is a new resource and a fresh download, not an update — so version changes are planned work, and the old image should be kept until every VM built from it has been rebuilt. And as an Arc-projected resource, its ARM record can outlive the image on the volume: reconcile against the instance rather than trusting state alone.


🧱 Design Principles

Concern This module's position Why
hyperv_generation mismatch A constant output naming where it fails. It boots nothing, in a different resource, later.
Marketplace coordinates Not validated; image_urn emitted instead. No published list; a hard-coded one would go stale.
latest Reported as a labelled heuristic. The provider publishes no format.
The version's shape Deliberately unchecked. Publishers use their own schemes.
image_version naming Renamed, with the reason in the description. version is a reserved variable name.
Round-robin placement A constant output. Omitting the path is not an error.
storage_path_name null when omitted. "Azure chose one" is not "a path called something".
The download A constant output; create raised in examples. It is the slowest operation in the family.
The two ID namespaces Each rejects the other's by name. Swapping them is the natural mistake.
os_type / hyperv_generation Closed sets, enforced. Enumerated as complete, not exemplary.
The provider's wrong doc note Contradicted explicitly. It names the wrong resource.
tags Carried; noted as the only in-place edit. The force-new set is per-resource, never assumed.
Secrets None. Nothing marked sensitive. Marketplace coordinates are public.

🚀 Runbook

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

Pin with ?ref=v1.0.0 — never a branch. Plan-only; a human applies from CI.

Before the first apply — confirm the coordinates exist:

az vm image list --all \
  --publisher microsoftwindowsserver \
  --offer windowsserver \
  --sku 2022-datacenter-azure-edition-hotpatch \
  --query "[].{version:version, urn:urn}" -o table

And confirm there is room:

Get-Volume | Where-Object FileSystemLabel -like "UserStorage*" |
  Select-Object FileSystemLabel, SizeRemaining

After the apply:

az resource list --resource-type "Microsoft.AzureStackHCI/marketplaceGalleryImages" -o table

🧪 Testing

What the offline gate proves: that the configuration parses against the pinned provider, that formatting is canonical, and — via terraform console with variables files — that all 17 validations fire on the input each targets. No validation on this module references another variable, so validation suppression is not a factor.

One finding came from the gate itself rather than from a test. variable "version" failed to parse with "The variable name "version" is reserved due to its special meaning inside module blocks", which is why the input is image_version. The governing standard predicted it; terraform validate confirmed it in one run.

Proved with a failing value each: Resource IDs in name and resource_group_name, and both empty; location empty and "East US 2" producing two errors; the cluster's Resource ID and a truncated path in custom_location_id; a lower-cased os_type; hyperv_generation = "Gen2"; a custom location ID and a cluster ID in storage_path_id; a blank and a Resource ID inside identifier; and image_version empty and as a Resource ID.

Every derived value was printed rather than reasoned about, in both directions. With a pinned version and an explicit path: image_urn assembled correctly in publisher-offer-sku-version order, uses_explicit_storage_path = true, storage_path_name = "sp-workloads", and the alias flag false. With "Latest" and no path: the alias flag true — confirming the check is case-insensitive — uses_explicit_storage_path = false, and storage_path_name = null rather than a placeholder. The custom location's name and resource group were parsed from two different IDs.

What only an apply exercises: whether the coordinates exist, whether the bridge can reach the marketplace, and whether the volume has room.

What no Terraform run exercises at all: whether hyperv_generation and os_type match the image — which surfaces only when a VM built from it tries to boot.


💬 Example Output

Outputs:

creating_this_image_downloads_it_over_your_own_link = true
custom_location_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-arc-bridge/providers/Microsoft.ExtendedLocation/customLocations/cl-hci-prod"
custom_location_name = "cl-hci-prod"
custom_location_resource_group = "rg-arc-bridge"
every_argument_except_tags_is_force_new = true
hyperv_generation = "V2"
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-azure-local-prod/providers/Microsoft.AzureStackHCI/marketplaceGalleryImages/img-ws2022"
identifier = {
  "offer" = "windowsserver"
  "publisher" = "microsoftwindowsserver"
  "sku" = "2022-datacenter-azure-edition-hotpatch"
}
image_urn = "microsoftwindowsserver:windowsserver:2022-datacenter-azure-edition-hotpatch:20348.2655.240905"
image_version = "20348.2655.240905"
location = "eastus2"
name = "img-ws2022"
os_type = "Windows"
secure_by_default_has_nothing_to_act_on_here = true
storage_path_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-azure-local-prod/providers/Microsoft.AzureStackHCI/storagePaths/sp-images"
storage_path_name = "sp-images"
storage_path_selection_is_round_robin_when_this_is_omitted = true
tags = {
  "environment" = "prod"
}
the_marketplace_coordinates_cannot_be_verified_here = true
the_product_is_now_called_azure_local_but_arm_and_terraform_still_say_stack_hci = "Microsoft.AzureStackHCI/marketplaceGalleryImages"
this_is_an_arc_projected_resource_so_azure_state_can_diverge_from_the_hardware = true
uses_explicit_storage_path = true
version_looks_like_an_alias_rather_than_an_explicit_version = false

🔍 Troubleshooting

Symptom Cause Fix
os_type must be "Windows" or "Linux" Wrong case. Capitalise exactly.
hyperv_generation must be "V1" or "V2" Gen2 or 2. Use V2.
custom_location_id is an Azure Local (Stack HCI) CLUSTER Resource ID The cluster. Use the ExtendedLocation ID.
storage_path_id is a CUSTOM LOCATION Resource ID The two IDs swapped. Use a storagePaths ID.
image_version must not be empty Omitted. Give an explicit version.
Invalid variable name … version is reserved A caller wrote version =. The input is image_version.
Apply times out The download is large. Raise the create timeout.
Apply fails: image not found The coordinates do not exist. Confirm with az vm image list.
Apply fails describing Azure, but the config is right The resource bridge is unreachable. Check the appliance VM.
Apply fails: insufficient space The target volume is full. Expand it, or set storage_path_id.
The image landed on an unexpected volume storage_path_id was omitted. Azure chose round-robin; wire a path.
A VM built from the image will not boot hyperv_generation mismatch. Recreate the image with the right generation.
Plan replaces the image after a version bump Everything but tags is force-new. Expected; it is a fresh download.
az resource list finds nothing for "Azure Local" The namespace is still AzureStackHCI. Search the old name.

🔗 Related Docs


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