Downloads a marketplace OS image onto an Azure Local cluster shared volume. The apply is a multi-gigabyte download. Targets
hashicorp/azurerm ~> 4.0.
- ☁️ Creates one
azurerm_stack_hci_marketplace_gallery_image(the keystonethis) — a marketplace OS image realised on your own hardware. - 🔴 Records the failure that surfaces in a different resource, days later: a wrong
hyperv_generationapplies 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'sversiontoimage_version, because Terraform reservesversionas a variable name; the rename is forced by the language.- 🔴 States that omitting
storage_path_iddoes 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.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
rg["terraform-azurerm-resource-group"]
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;
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.
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;
| 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.
| 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_generationapplies 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
versionhad to be renamed toimage_version—variable "version" {}is a hard parse error, sinceversionis amodule-block meta-argument. - 🔴 Creating this resource downloads several gigabytes through the Arc resource bridge. Raise
create. - 🔴 Omitting
storage_path_idis not an error — Azure selects a path round-robin. - 🔴
custom_location_idisMicrosoft.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_typeandhyperv_generationare closed sets —Windows/LinuxandV1/V2, notGen1/Gen2.⚠️ identifieris min 1 / max 1.⚠️ tagsis 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 onstorage_path_idnames the wrong resource — it says "Virtual Hard Disk". It forces a new gallery image.⚠️ lifecycleis not valid inside amoduleblock.
| 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.
- 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.AzureStackHCIandMicrosoft.ExtendedLocationregistered.- The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; this module declares none of these.
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
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
createtimeout is raised deliberately. This apply downloads the image; a Windows Server image is several gigabytes over whatever link the site has.
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 |
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 isV1/V2, notGen1/Gen2or1/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" — becausesource,versionandprovidersaremodule-block meta-arguments.
ℹ️ So the rename is forced by the language, not a stylistic divergence. The module maps
image_versiononto the provider'sversion, and theimage_versionoutput 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
identifierblock and a separate version argument, while the marketplace, the portal andaz vm imageall 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— andlatestis 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
falsehere 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_nameisnullrather 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 onstorage_path_idsays "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.
ℹ️
updateonly 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 areV2here 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_eachover several is a long apply. Raisecreateonce 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.
🔴
unverifiedis alwaystrue— a standing reminder, not a finding. Confirm the URN withaz vm image listbefore applying.
⚠️ A VM is absent deliberately.azurerm_stack_hci_virtual_machine_instanceis not yet authored in this library, and it is what would consume the image and the network together — and where a wronghyperv_generationwould finally surface.
| 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
}| 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.
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.
| 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. |
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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 tableAnd confirm there is room:
Get-Volume | Where-Object FileSystemLabel -like "UserStorage*" |
Select-Object FileSystemLabel, SizeRemainingAfter the apply:
az resource list --resource-type "Microsoft.AzureStackHCI/marketplaceGalleryImages" -o tableWhat 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.
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
| 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. |
azurerm_stack_hci_marketplace_gallery_imageprovider reference- Microsoft Learn — Create storage path for Azure Local — the round-robin placement behaviour.
- Microsoft Learn — What is Azure Arc resource bridge? — where custom locations come from.
- Prerequisite:
terraform-azurerm-stack-hci-storage-path— suppliesstorage_path_idfrom itsidoutput. Omitting it means round-robin placement. - Sibling:
terraform-azurerm-stack-hci-logical-network— the same custom location; what a VM uses alongside this image. - Sibling:
terraform-azurerm-stack-hci-deployment-setting— where the custom location above actually comes from; it is created by the deployment, not by Terraform. - Prerequisite:
terraform-azurerm-resource-group— suppliesresource_group_name. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."