Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Gallery Application Version Terraform Module

Manages a VM Application version β€” the package, and the commands that run as root on every consuming VM (azurerm_gallery_application_version). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • πŸ“¦ The actual VM Application payload: a package URL, the commands that install and remove it, and the regions it replicates to.
  • πŸ”΄ The manage_action commands run with the VM agent's privileges β€” effectively root or SYSTEM β€” on every VM whose profile includes the parent application. This is a software-supply-chain surface, not a configuration record.
  • πŸ”΄ A SAS in the package URL is a credential in a query string, and it lands in Terraform state in plaintext.
  • πŸ”΄ And Azure lets you delete that SAS after replication β€” but any later update needs a valid one. A version untouched for a year can fail to update for a reason unrelated to the change.
  • πŸ”΄ Azure renames the downloaded package after the parent application, discarding the extension. package_file is the override.
  • πŸ”΄ exclude_from_latest exists twice β€” once for the whole version, once per target region. Same name, different scope.
  • ⚠️ Omitting manage_action.update is choosing a behaviour: Azure then runs the previous version's remove, then this version's install.
  • ⚠️ A failed install cannot be retried. Azure's only remedy is removing the application from the VM profile and adding it back β€” and the failure happens on the VM, so Terraform never reports it.

πŸ’‘ Why it matters: Almost nothing that can go wrong with this resource shows up in a plan. The commands are opaque strings to Terraform, the SAS's expiry is invisible, the filename rewrite happens on the VM, and a failed install produces no Azure error at all.

❀️ Support this project

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


πŸ—ΊοΈ Where this fits in the family

flowchart TB
  st["terraform-azurerm-storage-account holding the package blob. Under 2 GB, and 1 GB for a config file. A SAS URL here is A CREDENTIAL IN A QUERY STRING - it lands in Terraform state in plaintext, and sensitive redacts plan output rather than encrypting state."]
  sas["And the trap: Azure permits DELETING the SAS once replication finishes, but ANY LATER UPDATE NEEDS A VALID ONE. A version untouched for a year can fail to update for a reason unrelated to the change, and the error names the source URL rather than an expired credential."]
  app["terraform-azurerm-gallery-application. Pass its id - the ANCHORED check rejects the gallery's own id, which is the usual mistake since both look plausible."]
  ver["terraform-azurerm-gallery-application-version"]
  vm["EVERY VM WHOSE PROFILE INCLUDES THE APPLICATION. The install, remove and update commands run there with the VM agent's privileges - effectively root or SYSTEM - so this is a software-supply-chain surface, not a configuration record."]
  retry["A failed install CANNOT be retried in place: Azure's only remedy is removing the application from the VM profile and adding it back. And the failure happens on the VM, so Terraform never reports it. Write idempotent commands."]
  regions["target_regions, keyed by region name so a duplicate cannot be expressed. Replication to each is the slow part of the apply. Only Standard_ZRS is zone-redundant."]

  st -->|"source_artifact.media_link, sensitive"| ver
  st -->|"if SAS-signed"| sas
  sas -->|"emitted as a constant flag"| ver
  app -->|"gallery_application_id, force-new"| ver
  ver -->|"replicates to"| regions
  ver -->|"id, referenced by the application profile"| vm
  vm -->|"and if the install fails"| retry

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class ver mine;
  class vm,sas,retry keystone;
  class st,app,regions sib;
Loading

🧬 What this module builds

flowchart TB
  src["source_artifact renders the provider's source block, renamed because source is a RESERVED variable name - a module-block meta-argument, so variable source is a hard parse error. Marked sensitive; the URL is never emitted, only has_source_sas_token."]
  cmds["manage_action: install and remove REQUIRED, update optional. Each capped at 4096 characters. Emitted UN-REDACTED on purpose, following the same reasoning as a public key: the value exists to be executed and read, so hiding it would protect nothing while removing the only chance to review it."]
  upd["And omitting update is CHOOSING A BEHAVIOUR, not leaving a field blank: Azure then runs the PREVIOUS version's remove followed by this version's install - a brief uninstalled window, and a dependency on an older script still being correct."]
  pkg["package_file overrides Azure renaming the download after the parent application and stripping the extension. relies_on_default_package_filename reports whether you took the override."]
  regions["target_regions is a KEYED MAP, not a list, so a duplicate region cannot be expressed and the key IS the region name. Replica count 1 to 10 per region. total_replica_count and zone_redundant_target_regions are derived."]
  excl["exclude_from_latest EXISTS TWICE - once for the whole version, once per target region. Same name, different scope, and reading one when you meant the other is the easiest mistake here. So two differently-named outputs report them."]
  health["enable_health_check keeps the provider's false default, because the flag declares that the APPLICATION reports health. Defaulting it true would assert a capability the software may not have and break correct configurations."]
  this["azurerm_gallery_application_version.this"]
  root["runs_commands_as_root_on_target_vms: always true, and the module's most important output. Nothing in a plan distinguishes a resource that ships privileged code from one that sets a tag."]

  src -->|"required block"| this
  cmds -->|"required block"| this
  cmds -->|"and note"| upd
  pkg -->|"force-new"| this
  regions -->|"at least one required"| this
  excl -->|"two scopes, two outputs"| this
  health -->|"positively stated as reports_health"| this
  this -->|"emits"| root

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this mine;
  class health mine;
  class root,excl,upd keystone;
  class src,cmds,pkg,regions sib;
Loading

Resource inventory

Resource Count Notes
azurerm_gallery_application_version.this 1 The keystone.
source block 1 Required. Rendered from source_artifact.
manage_action block 1 Required. πŸ”΄ Code run as root.
target_region block 1..n Required, at least one.
timeouts block 0..1 Replication makes create slow.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Microsoft.Compute/galleries/applications/versions
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:

  • πŸ”΄ manage_action commands run as root or SYSTEM on every consuming VM (example 2).
  • πŸ”΄ A SAS in media_link is a credential in state, and an update needs a valid one (examples 3, 4).
  • πŸ”΄ Azure renames the package after the parent application and strips the extension (example 5).
  • πŸ”΄ exclude_from_latest exists at two scopes with one name (example 8).
  • ⚠️ manage_action.update is optional with a behavioural consequence (example 6).
  • ⚠️ A failed installation cannot be retried in place (example 11).
  • ⚠️ source, version and providers are reserved variable names β€” hence source_artifact (example 3).
  • ⚠️ Force-new: name, gallery_application_id, location, package_file, config_file, and the manage_action commands (example 10).
  • ⚠️ Azure limits: 10 replicas per region, 300 versions per region, 2 GB package, 1 GB config, 25 applications per VM, one version per application per VM, 4096 characters per command (example 7).
  • ⚠️ location is not automatically a target_region (example 7).
  • ⚠️ timeouts object-type conversion silently discards unknown keys.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy (example 12).

πŸ”‘ Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the version Contributor the gallery's resource group
Read the version Reader the version
Create versions under the application Microsoft.Compute/galleries/applications/versions/write the application
Read the package blob (Azure's own access) granted by public read or the SAS the storage account
Mint the SAS, if one is used Storage Account Contributor or a data-plane key holder the storage account

πŸ”΄ This is the permission worth restricting in the pair. A principal who can create versions can ship arbitrary code that runs as root or SYSTEM on every VM consuming the parent application. Creating the definition cannot do that, and no Azure role narrows what the commands may do (example 2).

πŸ”’ Plan access is close to credential access when a SAS is used, because the SAS sits in state in plaintext (example 4).

Azure Prerequisites

  • An existing Gallery Application, referenced by ID (example 10).
  • πŸ”’ A readable package blob β€” public-read or SAS-signed β€” under 2 GB, and under 1 GB for a config file (example 7).
  • πŸ”’ An encrypted, access-controlled state backend, if the URL carries a SAS (example 4).
  • ⚠️ A plan for the SAS's lifetime, because a later update requires a valid one (example 4).
  • The VM Agent on every consuming VM, able to receive goal states.
  • ⚠️ Headroom against Azure's limits (example 7).

πŸ“ Module Structure

terraform-azurerm-gallery-application-version/
β”œβ”€β”€ providers.tf   # required_version + the pinned azurerm provider. No provider block.
β”œβ”€β”€ variables.tf   # name, gallery_application_id, location, source_artifact, manage_action,
#                  # target_regions, package_file, config_file, exclude_from_latest,
#                  # enable_health_check, end_of_life_date, tags, timeouts
β”œβ”€β”€ main.tf        # the keystone `this` + its three required nested blocks
β”œβ”€β”€ outputs.tf     # id first, then replication, then the commands, then the derived and constant flags
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "gallery_app_v1" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-gallery-application-version.git?ref=v1.0.0"

  name = "1.0.0"

  # πŸ”΄ The application's ID, passed as an attribute so destroy ordering works (example 10).
  gallery_application_id = module.gallery_app.id
  location               = "eastus"

  # πŸ”’ A SAS here is a credential in state (example 4).
  source_artifact = {
    media_link = var.package_blob_url
  }

  # πŸ”΄ This runs as root on every consuming VM (example 2). Note the filename (example 5).
  manage_action = {
    install = "tar -xzf financeagent -C /opt && systemctl enable financeagent"
    remove  = "systemctl disable financeagent && rm -rf /opt/financeagent"
  }

  target_regions = {
    eastus = { regional_replica_count = 2 }
  }
}

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

πŸ”΄ Read example 2 before this reaches production. The two commands above are the most privileged thing in this configuration.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
name string caller β€” a dotted numeric version, force-new
gallery_application_id string terraform-azurerm-gallery-application output id
location string caller β€” the home region; not automatically a target
source_artifact object(...) πŸ”’ terraform-azurerm-storage-account blob URL β€” required, sensitive
manage_action object(...) caller β€” πŸ”΄ required; code run as root
target_regions map(object(...)) caller β€” required, keyed by region
package_file / config_file string caller β€” force-new; override Azure's renaming
exclude_from_latest bool caller β€” the staged-rollout control
enable_health_check bool caller β€” a declaration about the software
end_of_life_date string caller β€” RFC3339; a label only
tags map(string) caller β€” βœ… where package provenance lives
timeouts object(...) caller β€” replication is the slow part

Emits

Output Description Consumed by
id The version's Resource ID. VM / scale-set application profiles
name / gallery_application_id / location Identity. Force-new. review
target_regions / total_replica_count The fan-out, and its cost. cost review
zone_redundant_target_regions βœ… Derived β€” the Standard_ZRS regions. resilience review
location_is_a_target_region βœ… Derived. Worth a look when false. review
relies_on_default_package_filename πŸ”΄ Derived. install-command review
install_command / remove_command / update_command πŸ”΄ Deliberately un-redacted. security review
update_falls_back_to_remove_then_install ⚠️ Derived. change review
reports_health βœ… Positively stated. observability review
excluded_from_latest The whole-version gate. release review
regions_excluded_from_latest πŸ”΄ Derived β€” the other field of that name. release review
end_of_life_date A label, not an enforcement. lifecycle review
has_source_sas_token πŸ”’ Presence only. security review
update_requires_a_valid_source_sas πŸ”΄ Always true. runbook
runs_commands_as_root_on_target_vms πŸ”΄ Always true. security review
failed_install_cannot_be_retried_in_place Always true. runbook

πŸ”’ The package URL is never emitted β€” only has_source_sas_token.

πŸ“š Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "other_app_id" {
  description = "id of an existing other app that these examples reference but do not create."
  type        = string
}
1 Β· The three required blocks
module "gallery_app_v1" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-gallery-application-version.git?ref=v1.0.0"

  name                   = "1.0.0"
  gallery_application_id = module.gallery_app.id
  location               = "eastus"

  source_artifact = { media_link = var.package_blob_url }              # where the package is
  manage_action   = { install = "…", remove = "…" }                    # what to do with it
  target_regions  = { eastus = { regional_replica_count = 2 } }        # where to put it
}

ℹ️ Three required blocks, and each answers a different question: where the package is, what to run, and where to replicate. None has a safe default, so there is no empty call for this library's secure-by-default rule to harden β€” the module's contribution is making the consequences legible instead.

⚠️ target_regions needs at least one entry, and the version's own location does not count automatically (example 7).

πŸ”΄ Versions are the unit of rollout. name is force-new, so shipping a change means creating a new version rather than editing this one β€” which is what makes exclude_from_latest the staging mechanism (example 8).

πŸ’‘ Read this alongside the definition module. Neither is useful alone, and the filename behaviour lives in the seam between them (example 5).

2 Β· πŸ”΄ These commands run as root on every consuming VM
manage_action = {
  install = "mv financeagent /opt/financeagent && chmod +x /opt/financeagent && systemctl enable financeagent"
  remove  = "systemctl disable financeagent && rm -f /opt/financeagent"
  update  = "mv financeagent /opt/financeagent && systemctl restart financeagent"
}

πŸ”΄ This is arbitrary code executed with the VM agent's privileges β€” effectively root or SYSTEM β€” on every VM whose profile includes the parent application. It is not a configuration value, and no Azure role narrows what it may do.

πŸ”΄ Which makes this resource a software-supply-chain surface. The package URL and these strings together decide what runs privileged across a fleet, so review them the way you would review a deployment script:

output "read_this" {
  value = module.gallery_app_v1.runs_commands_as_root_on_target_vms # always true
}

βœ… The commands are emitted un-redacted, deliberately. This library treats them the way it treats a public key: the value exists to be executed and read, redacting it would protect nothing, and hiding it would remove the only chance a reviewer has to read the code before it runs:

output "install" { value = module.gallery_app_v1.install_command }
output "remove"  { value = module.gallery_app_v1.remove_command }

⚠️ Each command is capped at 4096 characters. For anything longer, put a script inside the package and invoke that β€” which is easier to review anyway.

⚠️ Make them idempotent. A failed install cannot be retried in place (example 11), so a command that fails halfway leaves manual work on every affected VM.

3 Β· πŸ”’ `source_artifact` β€” and why it is not called `source`
source_artifact = {
  media_link                 = var.package_blob_url
  default_configuration_link = var.config_blob_url # optional
}

ℹ️ source is a reserved variable name. It is a module-block meta-argument, so variable "source" {} is a hard parse error β€” "reserved for use by Terraform". Hence source_artifact, which renders the provider's source block unchanged. The same applies to version and providers.

πŸ”’ media_link may be a plain blob URL or a SAS URL, and a SAS URL is a credential in a query string. The variable is marked sensitive for that reason β€” which redacts plan output and does not encrypt state (example 4).

βœ… Prefer a blob Azure can read without a SAS, which removes the credential rather than protecting it better. That is a categorically stronger position than any handling advice this module can offer.

⚠️ Size ceilings Azure enforces and this module cannot see: 2 GB for the package, 1 GB for the configuration file.

⚠️ config_file is only meaningful alongside default_configuration_link, and the module does not enforce that pairing β€” the provider documents both as independently optional, and this library does not invent a constraint that could reject legal input.

4 Β· πŸ”΄ The SAS that expires, and the update that then fails
Day 1    apply with a SAS URL     -> replication completes, VMs install fine
Day 90   the SAS expires or is deleted -> deployed VMs keep working
Day 200  you change a target region    -> the update FAILS, pointing at the source URL

πŸ”΄ Azure explicitly permits deleting the SAS once replication finishes β€” but any subsequent update operation requires a valid one. So a version that has worked untouched for months can fail to update for a reason that has nothing to do with the change being made, and the error will name the source URL rather than an expired credential.

βœ… The module emits it as a constant, because the expiry is invisible in state, invisible in the plan, and inferable from nothing else:

output "before_you_edit_this" {
  value = module.gallery_app_v1.update_requires_a_valid_source_sas # always true
}

πŸ”’ sensitive = true redacts plan output. It does not encrypt state. The SAS sits in Terraform state in plaintext, so whoever can read the state holds access to the package blob β€” which makes the backend's access controls part of this resource's security.

βœ… The URL is never emitted. Only presence is, because re-emitting a credential copies it into every consuming configuration's state for no benefit:

output "has_sas" {
  value = module.gallery_app_v1.has_source_sas_token # true / false β€” never the token
}

⚠️ Note that flag is wrapped in nonsensitive() internally. Sensitivity is contagious even to a boolean derived from a sensitive value, and Terraform refuses to emit a sensitive bool at all β€” so a presence flag needs unwrapping explicitly.

5 Β· πŸ”΄ Azure renames your package and drops the extension
You upload:                   myagent.tar.gz
Parent application is called: financeagent
The VM receives:              financeagent      ← no extension
# ⚠️ Fails β€” that filename does not exist on the VM:
manage_action = { install = "tar -xzf myagent.tar.gz …", remove = "…" }

# βœ… Option A: reference the name Azure actually used
manage_action = { install = "tar -xzf financeagent …", remove = "…" }

# βœ… Option B: override the behaviour and keep your own filename
package_file  = "myagent.tar.gz"
manage_action = { install = "tar -xzf myagent.tar.gz …", remove = "…" }

πŸ”΄ This is the most surprising behaviour in the pair, and it fails on the VM rather than in the apply β€” so nothing in Terraform's output points at it.

βœ… The module reports which route you took, rather than silently setting package_file for you β€” that would change what lands on the VM without saying so:

output "renamed_by_azure" {
  value = module.gallery_app_v1.relies_on_default_package_filename # true = Azure's naming
}

βœ… Best practice is to interpolate the definition module's output so a rename cannot desynchronise the two:

install = "tar -xzf ${module.gallery_app.package_filename_on_vm} -C /opt"

⚠️ Commands run in the download directory, which differs between Linux and Windows β€” so assume the current directory and do not hard-code a path.

ℹ️ A configuration file gets the same treatment: Azure names it after the application with _config appended, unless config_file says otherwise.

6 · ⚠️ Omitting `update` is choosing a behaviour
# With an update command β€” a single in-place step:
manage_action = { install = "…", remove = "…", update = "…" }

# Without one β€” Azure runs the PREVIOUS version's remove, then this version's install:
manage_action = { install = "…", remove = "…" }

⚠️ This is not a tidy default; it is a different upgrade path. Without update, Azure performs an upgrade by running the previous version's remove command and then this version's install. That means a brief window where the application is not installed at all, and a dependency on an older version's script still being correct.

βœ… The module reports the choice, because omitting a field looks like leaving it blank:

output "upgrade_path" {
  value = module.gallery_app_v1.update_falls_back_to_remove_then_install # true = remove-then-install
}

πŸ”΄ Which is why remove_command matters more than it looks. An old, wrong remove script can break a future upgrade that has nothing else in common with it β€” so the remove command is part of your forward compatibility, not just your cleanup.

βœ… Supply update for anything stateful. A service that should not be stopped, a data directory that should not be deleted, or a configuration that should survive the upgrade all argue for it.

7 Β· Replication, limits, and the home region that is not a target
target_regions = {
  eastus  = { regional_replica_count = 3 }
  westus2 = { regional_replica_count = 1, storage_account_type = "Standard_ZRS" }
  # ⚠️ eastus is here because location = "eastus" does NOT add it automatically
}

ℹ️ Keyed by region rather than a list, so a region cannot appear twice and the key is the region name. The provider takes repeatable blocks; this shape makes a duplicate impossible to express.

⚠️ The version's location is not automatically a replication target. The module reports the condition rather than rejecting it, because Azure's expectation is undocumented in the provider:

output "home_is_replicated" {
  value = module.gallery_app_v1.location_is_a_target_region # false is worth a second look
}

⚠️ regional_replica_count must be 1–10. More replicas deploy faster in parallel and cost proportionally more storage β€” total_replica_count sums them, which is the number nobody computes by hand.

βœ… Only Standard_ZRS is zone-redundant. Standard_LRS is the provider's default and this module keeps it, because the choice is about cost and resilience rather than security:

output "zr" {
  value = module.gallery_app_v1.zone_redundant_target_regions # the ZRS regions
}

⚠️ Other Azure ceilings that bite: 300 versions per region across all applications, 25 applications per VM, one version of a given application per VM, 2 GB package, 1 GB config, 4096 characters per command. A pipeline publishing per commit reaches the version ceiling.

⚠️ Replication is the slow part of the apply. A wide fan-out with high replica counts genuinely takes time β€” raise timeouts.create rather than concluding it has hung.

8 Β· πŸ”΄ Two fields called `exclude_from_latest`
# The WHOLE version β€” out of `latest` everywhere:
exclude_from_latest = true

# And, separately, per region:
target_regions = {
  eastus  = { regional_replica_count = 2 }
  westus2 = { regional_replica_count = 1, exclude_from_latest = true } # ⚠️ different control
}

πŸ”΄ Same name, two scopes, and reading one when you meant the other is the easiest mistake on this resource. So the module emits them as two differently-named outputs, which makes the distinction unavoidable:

output "version_gate" { value = module.gallery_app_v1.excluded_from_latest }          # bool
output "region_gates" { value = module.gallery_app_v1.regions_excluded_from_latest }  # list of regions

βœ… The top-level field is the staged-rollout mechanism. Versions are force-new, so you cannot edit one to ship a change β€” you publish a new version excluded from latest, verify it against a pinned VM profile, and then flip the flag to false. That flip updates in place, so promotion is cheap and reversible.

⚠️ false on a brand-new version means anything tracking latest picks it up on the next VM operation. Convenient, and also how an unverified package reaches production.

ℹ️ The per-region field is for a partial rollout β€” publish everywhere, expose in one region first. Useful, and much less commonly what people mean.

⚠️ Remember a VM can hold only one version of an application, so staging happens across VMs rather than within one.

9 Β· Health reporting, and a default this library did *not* flip
# βœ… The default β€” matching the provider:
# enable_health_check = false

# βœ… Turn it on only if the application actually reports health:
enable_health_check = true

⚠️ This library usually defaults toward the safer setting, and here it does not β€” the reason is worth stating. The flag declares that the application implements health reporting. Turning it on for a package that does not report leaves the platform waiting on a signal that never arrives, so a default of true would break correct configurations. This library's secure-by-default rule does not extend to asserting a capability the software may not have.

βœ… Where the application does report, turn it on, because the failure mode is otherwise silent: a failed install produces no Azure error, and the only remedy is manual (example 11). Health reporting is the difference between knowing and not.

βœ… Emitted positively as reports_health, so there is no negated flag to misread:

output "health" {
  value = module.gallery_app_v1.reports_health
}

ℹ️ Not force-new, so it can be turned on later once the package learns to report.

10 Β· Force-new, and the parent reference
# πŸ”΄ Force-new:
name                   = "1.0.1"
gallery_application_id = var.other_app_id
location               = "westus2"
package_file           = "myagent.tar.gz"
config_file            = "myagent.conf"
manage_action          = { install = "…", remove = "…" } # the commands themselves

# βœ… Updates in place:
target_regions      = { eastus = { regional_replica_count = 4 } }
exclude_from_latest = false
enable_health_check = true
end_of_life_date    = "2028-01-01T00:00:00Z"
tags                = { built_from = "commit-def5678" }

βœ… The split is exactly right for a release process: the payload and its commands are immutable, while replication, promotion and lifecycle metadata are not. Which is what makes staged rollout cheap (example 8).

πŸ”΄ Pass the application's ID as an attribute, never a literal. The reference is what tells Terraform the application must be destroyed after this version β€” otherwise a destroy can attempt the application first, and Azure rejects it with "Can not delete resource before nested resources are deleted".

βœ… The check is anchored to the application, so the gallery's own ID is rejected β€” passing the gallery instead of the application is the usual mistake, and both look plausible:

gallery_application_id must be a full Gallery Application Resource ID ending in
/galleries/<gallery>/applications/<application>. It is anchored, so the GALLERY's own ID is
rejected β€” passing the gallery instead of the application is the usual mistake, and both look
plausible. Use `module.gallery_app.id`.

⚠️ The version name rejects the near misses and allows the unknown. The provider documents the format only by example (such as 1.0.0), so v1.0.0, 1.0.0-beta and latest are refused while an unusual numeric shape is permitted rather than guessed at.

11 · ⚠️ A failed install cannot be retried
Azure's only documented remedy:
  remove the application from the VM's profile
  then add it back

⚠️ There is no retry. And the failure occurs on the VM rather than in the apply, so Terraform will never tell you it happened β€” the version resource is created successfully whether or not a single VM installed it.

βœ… The module states it as a constant, since nothing else can:

output "no_retry" {
  value = module.gallery_app_v1.failed_install_cannot_be_retried_in_place # always true
}

βœ… Which is why the commands should be idempotent (example 2). A command that fails halfway leaves manual work on every affected VM, and re-running it must be safe.

βœ… And why health reporting matters where the application supports it (example 9) β€” it is the only automated signal that an install went wrong.

πŸ’‘ Test on one VM before flipping exclude_from_latest to false (example 8). A version excluded from latest can be pinned by a single test VM, which is the cheapest place to discover a broken install command.

12 Β· What a review should assert, and destroy behaviour
output "version_review" {
  value = {
    id        = module.gallery_app_v1.id
    install   = module.gallery_app_v1.install_command   # πŸ”΄ read this; it runs as root
    remove    = module.gallery_app_v1.remove_command    # πŸ”΄ and it affects future upgrades
    fallback  = module.gallery_app_v1.update_falls_back_to_remove_then_install
    renamed   = module.gallery_app_v1.relies_on_default_package_filename
    has_sas   = module.gallery_app_v1.has_source_sas_token
    replicas  = module.gallery_app_v1.total_replica_count
    zr        = module.gallery_app_v1.zone_redundant_target_regions
    home_repl = module.gallery_app_v1.location_is_a_target_region
    staged    = module.gallery_app_v1.excluded_from_latest
    per_region = module.gallery_app_v1.regions_excluded_from_latest
    health    = module.gallery_app_v1.reports_health
    # πŸ”΄ Prompts, not values:
    root_warning = module.gallery_app_v1.runs_commands_as_root_on_target_vms
    sas_warning  = module.gallery_app_v1.update_requires_a_valid_source_sas
    no_retry     = module.gallery_app_v1.failed_install_cannot_be_retried_in_place
  }
}

πŸ”΄ install and remove are the two lines a human must actually read. Everything else in this review is context; those are code that will run privileged across a fleet (example 2).

⚠️ renamed = true must be consistent with the install command (example 5) β€” a mismatch fails on the VM, silently.

Destroy behaviour

terraform destroy on this module:
  removes the VERSION      ->  and its replicas in every target region
  the application survives ->  and Azure requires versions gone before it can be deleted
  the package blob survives β€”  it was never owned here
  VMs referencing this version lose it on their next operation

⚠️ prevent_destroy is not available, because lifecycle is not valid inside a module block. A CanNotDelete management lock is the alternative.

Importing

terraform import 'module.gallery_app_v1.azurerm_gallery_application_version.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-gallery/providers/Microsoft.Compute/galleries/mygallery/applications/financeagent/versions/1.0.0"

πŸ”΄ Restate the manage_action commands and the package URL exactly, or the plan is a replacement β€” and if the SAS has since expired, that replacement will fail (example 4).

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-compute-gallery"
  location = "eastus"
}

# ── The gallery. ⚠️ Its name cannot contain dashes ───────────────────────────
module "gallery" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-shared-image-gallery.git?ref=v1.0.0"

  name                = "platform_gallery"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

# ── The application DEFINITION β€” carries no package, runs nothing ────────────
module "gallery_app" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-gallery-application.git?ref=v1.0.0"

  name              = "financeagent"
  gallery_id        = module.gallery.id
  location          = module.rg.location
  supported_os_type = "Linux"
  description       = "Finance backend agent. Unpacks to /opt/financeagent and enables a systemd unit."
  tags              = { owner = "platform-tools" }
}

# ── This module: the payload ────────────────────────────────────────────────
module "gallery_app_v1" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-gallery-application-version.git?ref=v1.0.0"

  name = "1.0.0"

  # πŸ”΄ The attribute reference β€” this is what orders the destroy (example 10).
  gallery_application_id = module.gallery_app.id
  location               = module.rg.location

  # πŸ”’ A SAS here is a credential in state, and a later update needs it valid (examples 3, 4).
  #    Prefer a blob Azure can read without one.
  source_artifact = {
    media_link = var.package_blob_url
  }

  # πŸ”΄ Runs as root on every consuming VM (example 2). Idempotent, because a failed
  #    install cannot be retried (example 11).
  # βœ… The filename comes from the definition module, so a rename cannot desynchronise
  #    the command from the file Azure actually writes (example 5).
  manage_action = {
    install = "tar -xzf ${module.gallery_app.package_filename_on_vm} -C /opt && systemctl enable --now financeagent"
    remove  = "systemctl disable --now financeagent || true; rm -rf /opt/financeagent"
    # βœ… Supplied because the service is stateful: without it Azure would run the
    #    PREVIOUS version's remove, then this install (example 6).
    update = "tar -xzf ${module.gallery_app.package_filename_on_vm} -C /opt && systemctl restart financeagent"
  }

  # ⚠️ eastus is listed explicitly β€” location does not add it (example 7).
  target_regions = {
    eastus  = { regional_replica_count = 3 }
    westus2 = { regional_replica_count = 1, storage_account_type = "Standard_ZRS" }
  }

  # βœ… Staged rollout: publish excluded, verify on a pinned VM, then flip to false
  #    in place (example 8).
  exclude_from_latest = true

  # ⚠️ Left false β€” this package does not report health yet (example 9).
  # enable_health_check = false

  end_of_life_date = "2027-12-31T00:00:00Z"

  # βœ… Provenance: the version name is only "1.0.0" (example 2).
  tags = {
    owner      = "platform-tools"
    built_from = "commit-abc1234"
  }
}

output "vm_application_version_id" {
  # This is what a VM or scale-set application profile references.
  value = module.gallery_app_v1.id
}

output "review_before_promotion" {
  value = {
    install  = module.gallery_app_v1.install_command
    remove   = module.gallery_app_v1.remove_command
    has_sas  = module.gallery_app_v1.has_source_sas_token
    renamed  = module.gallery_app_v1.relies_on_default_package_filename
    replicas = module.gallery_app_v1.total_replica_count
    staged   = module.gallery_app_v1.excluded_from_latest # βœ… true until verified
  }
}

πŸ”’ What the composition gets right: attribute references in both hops so destroy ordering works, install commands built from package_filename_on_vm rather than a repeated literal, an idempotent remove command, an explicit update command because the service is stateful, the home region listed explicitly as a target, zone-redundant storage in the secondary region, publication staged behind exclude_from_latest, build provenance in tags, and the commands exported for review before promotion.

⚠️ Note exclude_from_latest = true is the starting state. Promotion is flipping it to false, which updates in place β€” no new version required (example 8).

πŸ”΄ What still needs a human: reading the install and remove commands, deciding whether the package is trustworthy, and planning the SAS's lifetime against the next update (example 4).

πŸ“₯ Inputs

Input Type Default Notes
name string β€” Required. Force-new. Dotted numeric; near misses rejected.
gallery_application_id string β€” Required. Force-new. Anchored to the application.
location string β€” Required. Force-new. ⚠️ Not automatically a target.
source_artifact object(...) β€” Required, sensitive. πŸ”’ A SAS is a credential.
manage_action object(...) β€” Required. πŸ”΄ Runs as root. 4096 chars each.
target_regions map(object(...)) β€” Required, β‰₯1. Keyed by region. Replicas 1–10.
package_file string null Force-new. πŸ”΄ Overrides Azure's renaming.
config_file string null Force-new. Only meaningful with a config link.
exclude_from_latest bool false The staged-rollout control. In place.
enable_health_check bool false ⚠️ A declaration about the software (example 9).
end_of_life_date string null RFC3339. A label only.
tags map(string) {} βœ… Where provenance lives.
timeouts object(...) null Replication makes create slow.
Full schemas
variable "source_artifact" {
  type = object({
    media_link                 = string
    default_configuration_link = optional(string)
  })
  sensitive = true
  # ℹ️ Named source_artifact because `source` is RESERVED β€” a module-block meta-argument. Renders the provider's `source`
  # block unchanged. πŸ”’ A SAS here is a credential in a query string, and `sensitive` redacts plans without encrypting
  # state. πŸ”΄ Azure permits deleting the SAS after replication, but any later UPDATE needs a valid one. Examples 3–4.
  validation {
    condition     = can(regex("(?i)^https?://[^[:space:]]+$", trimspace(var.source_artifact.media_link)))
    error_message = "source_artifact.media_link must be an absolute http(s) URL to a readable blob … πŸ”΄ If the URL carries a SAS token, remember it is a credential …"
  }
}

variable "manage_action" {
  type = object({
    install = string
    remove  = string
    update  = optional(string)
  })
  # πŸ”΄ Arbitrary code run with the VM agent's privileges on every consuming VM. Each command capped at 4096 characters.
  # ⚠️ Omitting `update` means Azure runs the PREVIOUS version's remove, then this install. Examples 2 and 6.
}

variable "target_regions" {
  type = map(object({
    regional_replica_count = number
    exclude_from_latest    = optional(bool, false)
    storage_account_type   = optional(string, "Standard_LRS")
  }))
  # Keyed by region name so a duplicate cannot be expressed. β‰₯1 entry; replicas 1–10; storage_account_type one of
  # Standard_LRS / Premium_LRS / Standard_ZRS. πŸ”΄ Note the per-region exclude_from_latest is NOT the top-level one.
  # Examples 7 and 8.
}

🧾 Outputs

Output Description Sensitive
id The version's Resource ID. no
name / gallery_application_id / location Identity. Force-new. no
target_regions / total_replica_count The fan-out and its cost. no
zone_redundant_target_regions βœ… Derived β€” the Standard_ZRS regions. no
location_is_a_target_region βœ… Derived. no
relies_on_default_package_filename πŸ”΄ Derived. no
install_command / remove_command / update_command πŸ”΄ Un-redacted, deliberately. no
update_falls_back_to_remove_then_install ⚠️ Derived. no
reports_health βœ… Positively stated. no
excluded_from_latest The whole-version gate. no
regions_excluded_from_latest πŸ”΄ Derived β€” the per-region gate. no
end_of_life_date A label, not an enforcement. no
has_source_sas_token πŸ”’ Presence only. no
update_requires_a_valid_source_sas πŸ”΄ Always true. no
runs_commands_as_root_on_target_vms πŸ”΄ Always true. no
failed_install_cannot_be_retried_in_place Always true. no

πŸ”’ No package URL output exists. Not sensitive-marked β€” absent (example 4).

🧠 Architecture Notes

  • This is the half of the pair that ships software, and the module treats it accordingly. The manage_action commands are arbitrary code run with the VM agent's privileges on every VM consuming the parent application, and no Azure role narrows what they may do. runs_commands_as_root_on_target_vms is a constant true because nothing in a plan distinguishes a resource that ships privileged code from one that sets a tag.

  • The commands are emitted un-redacted on purpose, following the same reasoning this library applies to public keys: the value exists to be executed and read, redaction would protect nothing, and hiding it would remove the only chance a reviewer has to read the code before it runs. The permissions table says plainly that this β€” not the definition module β€” is the permission worth restricting.

  • The package URL is sensitive on the way in and absent on the way out. A SAS is a credential in a query string, and re-emitting it would copy it into every consuming configuration's state for no benefit. Only has_source_sas_token is published, wrapped in nonsensitive() because sensitivity is contagious even to a boolean derived from a sensitive value and Terraform refuses to emit a sensitive bool. The module also states that sensitive redacts plan output rather than encrypting state, so the marking does not imply protection it lacks.

  • update_requires_a_valid_source_sas describes a uniquely misleading failure. Azure permits deleting the SAS once replication completes, so everything keeps working β€” until an update is attempted months later, at which point the error names the source URL rather than an expired credential. Nothing in state records a SAS's expiry, which is exactly the case a constant output exists for.

  • relies_on_default_package_filename reports rather than fixes. The module could set package_file automatically, but that would silently change what lands on the VM. Naming the condition leaves the choice with the author while making the surprise visible β€” and the end-to-end example interpolates the definition module's output so a rename cannot desynchronise the command from the file.

  • exclude_from_latest is emitted as two differently-named outputs, because the provider has two fields with one name at two scopes. Mirroring the collision in the outputs would preserve the ambiguity; excluded_from_latest and regions_excluded_from_latest make it unavoidable.

  • enable_health_check keeps the provider's false default, and the module explains why this library's usual instinct does not apply. The flag declares that the application reports health; defaulting it on would assert a capability the software may not have and would break correct configurations. Where the application does report, the module recommends enabling it β€” because a failed install is otherwise entirely silent.

  • failed_install_cannot_be_retried_in_place is the reason the commands must be idempotent. The failure happens on the VM, Terraform never reports it, and Azure's only remedy is removing the application from the VM profile and adding it back.

  • target_regions is a keyed map rather than a list, per this library's preference for keyed collections: the key is the region, so a duplicate cannot be expressed. total_replica_count and zone_redundant_target_regions are derived because replica count is the resource's cost and speed dial, and only Standard_ZRS survives a zone loss.

  • Two pairings are deliberately left unenforced β€” location against target_regions, and config_file against default_configuration_link β€” because the provider documents neither as required. Both are reported instead, per this library's rule against inventing a constraint that could reject legal input.

  • The version name rejects the near misses and allows the unknown. The provider documents the format only by example, so v1.0.0, 1.0.0-beta and latest are refused while an unusual numeric shape is permitted rather than guessed at.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Privileged code shipped unreviewed commands emitted un-redacted + constant flag β€”
A credential in a URL sensitive input, no output β€”
False secrecy "redacts plans, does not encrypt state" stated β€”
Re-emitting a secret presence only, nonsensitive()-wrapped β€”
A SAS whose expiry breaks a future update constant flag emitted β€”
A filename rewrite that breaks installs derived flag; not silently fixed set package_file
Two fields with one name two differently-named outputs β€”
An upgrade path chosen by omission derived flag emitted β€”
Asserting a capability the software lacks enable_health_check left false, and said why set true, knowingly
A silent install failure constant flag + health-check guidance β€”
Duplicate regions keyed map makes them inexpressible β€”
Unexamined redundancy zone_redundant_target_regions emitted choose Standard_LRS, knowingly
An undocumented pairing reported, not enforced β€”
Wrong-resource IDs anchored to the application with $ β€”
A version format the provider has not closed near misses rejected, unknown allowed β€”
  • Before applying: read the install and remove commands. They run as root on every consuming VM.
  • Before applying: know that a SAS in the URL lands in state, and that a later update needs it valid.
  • Before writing commands: check the filename Azure will actually use.
  • Before promoting: publish with exclude_from_latest = true and verify on a pinned VM.
  • Before assuming success: a failed install is silent, and cannot be retried.

πŸš€ 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.
  • πŸ”΄ Review manage_action as code, not configuration (example 2).
  • πŸ”΄ Confirm the SAS is valid before any update, not just at creation (example 4).
  • πŸ”΄ Cross-check the install command against the on-VM filename (example 5).
  • πŸ”΄ Force-new: name, gallery_application_id, location, package_file, config_file, the commands (example 10).
  • βœ… In place: target_regions, exclude_from_latest, enable_health_check, end_of_life_date, tags.
  • ⚠️ Publish staged, then flip exclude_from_latest (example 8).
  • ⚠️ Replication is slow β€” raise timeouts.create for a wide fan-out (example 7).
  • ⚠️ Watch the 300-versions-per-region ceiling if a pipeline publishes per commit (example 7).
  • πŸ’‘ A CanNotDelete lock is the only destroy protection available (example 12).

πŸ§ͺ Testing

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

  • name is a dotted numeric version β€” v1.0.0, 1.0.0-beta and latest are rejected;
  • gallery_application_id is anchored to an application β€” the gallery's own ID is rejected;
  • location is non-empty;
  • source_artifact.media_link is an absolute http(s) URL, and the config link too when supplied;
  • manage_action.install and .remove are non-empty, and every command is within 4096 characters;
  • target_regions has at least one entry, every regional_replica_count is 1–10, every storage_account_type is one of the three legal values, and every key looks like a region name rather than a display name or an ID;
  • package_file and config_file, when supplied, are bare filenames with no directory separators;
  • end_of_life_date, when supplied, is a valid RFC3339 timestamp;
  • no package URL is emitted as an output at all;
  • 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. "v1.0.0" fires the version check, a gallery ID fires the anchored application check, a replica count of 11 fires the range check, "East US" as a key fires the region-name check, and "dir/file" fires the bare-filename check.

⚠️ And note a property of the harness: Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a check is missing.

πŸ”΄ What the gate deliberately does not attempt: whether the commands are safe, whether the SAS is currently valid, whether the blob is under 2 GB, whether location is among the target regions, or whether config_file has a configuration link to name. The last two are reported as derived flags instead, because the provider documents neither pairing as required.

What only plan and apply exercise:

  • whether the parent application exists;
  • whether the package blob is reachable, and within Azure's size limits;
  • whether the region and replica counts are available.

What no Terraform command checks at any stage:

  • πŸ”΄ whether the install command actually works on a VM β€” the resource succeeds regardless (example 11);
  • πŸ”΄ whether the SAS will still be valid at the next update (example 4);
  • πŸ”΄ whether the install command matches the filename Azure writes (example 5);
  • whether the package is what it claims to be.

πŸ’¬ Example Output

Outputs:

end_of_life_date                          = "2027-12-31T00:00:00Z"
excluded_from_latest                      = true
failed_install_cannot_be_retried_in_place = true
gallery_application_id                    = "/subscriptions/00000000-.../galleries/platform_gallery/applications/financeagent"
has_source_sas_token                      = true
id                                        = "/subscriptions/00000000-.../applications/financeagent/versions/1.0.0"
install_command                           = "tar -xzf financeagent -C /opt && systemctl enable --now financeagent"
location                                  = "eastus"
location_is_a_target_region               = true
name                                      = "1.0.0"
regions_excluded_from_latest              = []
relies_on_default_package_filename        = true
remove_command                            = "systemctl disable --now financeagent || true; rm -rf /opt/financeagent"
reports_health                            = false
runs_commands_as_root_on_target_vms       = true
target_regions                            = ["eastus", "westus2"]
total_replica_count                       = 4
update_command                            = "tar -xzf financeagent -C /opt && systemctl restart financeagent"
update_falls_back_to_remove_then_install  = false
update_requires_a_valid_source_sas        = true
zone_redundant_target_regions             = ["westus2"]

πŸ”΄ install_command and remove_command are the two lines to read. They run as root on every consuming VM (example 2) β€” and note the extensionless financeagent, consistent with relies_on_default_package_filename = true (example 5).

πŸ”’ has_source_sas_token = true with no URL anywhere in the output β€” presence only, by design (example 4).

βœ… excluded_from_latest = true with regions_excluded_from_latest = [] is the staged-rollout shape: the whole version is held back, and no region is individually gated (example 8).

βœ… update_falls_back_to_remove_then_install = false because an explicit update command was supplied (example 6).

⚠️ reports_health = false β€” the default, and honest for a package that does not report (example 9).

πŸ” Troubleshooting

Symptom Cause Fix
The install fails on the VM but the apply succeeded The failure is on the VM; Terraform never sees it. Remove and re-add the application (example 11).
The install command cannot find the package Azure renamed it and stripped the extension. Use the real filename, or package_file (example 5).
An update fails months after a working apply The source SAS expired or was deleted. Supply a valid SAS (example 4).
An upgrade briefly uninstalled the application No update command; Azure ran remove-then-install. Supply update (example 6).
An old version's remove script broke an upgrade The fallback path uses the previous version's remove. Supply update (example 6).
Plan rejects gallery_application_id The gallery's ID was passed, not the application's. module.gallery_app.id (example 10).
destroy fails with a nested-resources error A hard-coded ID removed the dependency edge. Pass attributes (example 10).
Plan rejects name v1.0.0, a pre-release suffix, or a word. Dotted numeric (example 10).
Plan rejects a regional_replica_count Outside 1–10. Azure's per-region limit (example 7).
Plan rejects a target_regions key A display name such as "East US", or an ID. Use "eastus" (example 7).
Plan rejects package_file It contains a directory separator. A bare filename (example 5).
The version is not picked up as latest exclude_from_latest is true somewhere. Check both scopes (example 8).
A new version reached production unverified exclude_from_latest was false. Publish staged (example 8).
Health always shows unknown enable_health_check is false, or the app does not report. See example 9.
The apply seems to hang Replication to many regions is slow. Raise timeouts.create (example 7).
Hit a limit publishing versions 300 versions per region, across all applications. Retire old versions (example 7).
A VM cannot hold two versions Azure allows one version per application per VM. Stage across VMs (example 8).
Wanted prevent_destroy lifecycle is not valid inside a module block. Use a CanNotDelete lock (example 12).

πŸ”— Related Docs

  • azurerm_gallery_application_version β€” provider documentation, including the block contracts of example 1 and the replica range of example 7.
  • VM Applications overview β€” the limits of example 7, the filename rewriting of example 5, the no-retry remedy of example 11, and the SAS-after-replication note of example 4.
  • Create and deploy a VM Application β€” how the definition and the version fit together (example 1).
  • Microsoft.Compute galleries/applications/versions template reference β€” the 4096-character command cap and the update fallback semantics (examples 2, 6).
  • Sibling modules: terraform-azurerm-gallery-application (the parent definition, and the source of the on-VM filename), terraform-azurerm-shared-image-gallery (two levels up), terraform-azurerm-storage-account (the package blob), terraform-azurerm-resource-group.
  • This module's SCOPE.md.

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."