Manages a VM Application version β the package, and the commands that run as root on every consuming VM (
azurerm_gallery_application_version). Targetshashicorp/azurerm ~> 4.0.
- π¦ The actual VM Application payload: a package URL, the commands that install and remove it, and the regions it replicates to.
- π΄ The
manage_actioncommands 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_fileis the override. - π΄
exclude_from_latestexists twice β once for the whole version, once per target region. Same name, different scope. β οΈ Omittingmanage_action.updateis choosing a behaviour: Azure then runs the previous version'sremove, then this version'sinstall.β οΈ 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.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub.
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
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. |
| 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_actioncommands run as root or SYSTEM on every consuming VM (example 2). - π΄ A SAS in
media_linkis 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_latestexists at two scopes with one name (example 8). β οΈ manage_action.updateis optional with a behavioural consequence (example 6).β οΈ A failed installation cannot be retried in place (example 11).β οΈ source,versionandprovidersare reserved variable names β hencesource_artifact(example 3).β οΈ Force-new:name,gallery_application_id,location,package_file,config_file, and themanage_actioncommands (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).β οΈ locationis not automatically atarget_region(example 7).β οΈ timeoutsobject-type conversion silently discards unknown keys.lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy(example 12).
| 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).
- 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).
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
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.
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 |
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.
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_regionsneeds at least one entry, and the version's ownlocationdoes not count automatically (example 7).
π΄ Versions are the unit of rollout.
nameis force-new, so shipping a change means creating a new version rather than editing this one β which is what makesexclude_from_latestthe 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
}βΉοΈ
sourceis a reserved variable name. It is amodule-block meta-argument, sovariable "source" {}is a hard parse error β "reserved for use by Terraform". Hencesource_artifact, which renders the provider'ssourceblock unchanged. The same applies toversionandproviders.
π
media_linkmay be a plain blob URL or a SAS URL, and a SAS URL is a credential in a query string. The variable is markedsensitivefor 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_fileis only meaningful alongsidedefault_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 = trueredacts 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 innonsensitive()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_filefor 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
_configappended, unlessconfig_filesays 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. Withoutupdate, Azure performs an upgrade by running the previous version'sremovecommand and then this version'sinstall. 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_commandmatters 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
updatefor 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'slocationis 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_countmust be 1β10. More replicas deploy faster in parallel and cost proportionally more storage βtotal_replica_countsums them, which is the number nobody computes by hand.
β Only
Standard_ZRSis zone-redundant.Standard_LRSis 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 β raisetimeouts.createrather 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 tofalse. That flip updates in place, so promotion is cheap and reversible.
β οΈ falseon a brand-new version means anything trackinglatestpicks 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 oftruewould 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
destroycan 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), sov1.0.0,1.0.0-betaandlatestare 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_latesttofalse(example 8). A version excluded fromlatestcan 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
}
}π΄
installandremoveare 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 = truemust 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_destroyis not available, becauselifecycleis not valid inside amoduleblock. ACanNotDeletemanagement 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_actioncommands 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_vmrather than a repeated literal, an idempotent remove command, an explicitupdatecommand because the service is stateful, the home region listed explicitly as a target, zone-redundant storage in the secondary region, publication staged behindexclude_from_latest, build provenance in tags, and the commands exported for review before promotion.
β οΈ Noteexclude_from_latest = trueis the starting state. Promotion is flipping it tofalse, 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).
| 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. |
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 |
|
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.
}| 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 |
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).
-
This is the half of the pair that ships software, and the module treats it accordingly. The
manage_actioncommands 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_vmsis a constanttruebecause 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
sensitiveon 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. Onlyhas_source_sas_tokenis published, wrapped innonsensitive()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 thatsensitiveredacts plan output rather than encrypting state, so the marking does not imply protection it lacks. -
update_requires_a_valid_source_sasdescribes 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_filenamereports rather than fixes. The module could setpackage_fileautomatically, 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_latestis 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_latestandregions_excluded_from_latestmake it unavoidable. -
enable_health_checkkeeps the provider'sfalsedefault, 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_placeis 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_regionsis 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_countandzone_redundant_target_regionsare derived because replica count is the resource's cost and speed dial, and onlyStandard_ZRSsurvives a zone loss. -
Two pairings are deliberately left unenforced β
locationagainsttarget_regions, andconfig_fileagainstdefault_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-betaandlatestare refused while an unusual numeric shape is permitted rather than guessed at.
| 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 = trueand verify on a pinned VM. - Before assuming success: a failed install is silent, and cannot be retried.
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_actionas 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 flipexclude_from_latest(example 8).β οΈ Replication is slow β raisetimeouts.createfor a wide fan-out (example 7).β οΈ Watch the 300-versions-per-region ceiling if a pipeline publishes per commit (example 7).- π‘ A
CanNotDeletelock is the only destroy protection available (example 12).
terraform validate and terraform fmt -check are the offline gate. They confirm:
nameis a dotted numeric version βv1.0.0,1.0.0-betaandlatestare rejected;gallery_application_idis anchored to an application β the gallery's own ID is rejected;locationis non-empty;source_artifact.media_linkis an absolutehttp(s)URL, and the config link too when supplied;manage_action.installand.removeare non-empty, and every command is within 4096 characters;target_regionshas at least one entry, everyregional_replica_countis 1β10, everystorage_account_typeis one of the three legal values, and every key looks like a region name rather than a display name or an ID;package_fileandconfig_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
providerblock.
π‘ These were proved by evaluating the conditions in
terraform consoleinside the module β which does fire root-module variable validations, unliketerraform validateon a calling configuration."v1.0.0"fires the version check, a gallery ID fires the anchored application check, a replica count of11fires 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
locationis among the target regions, or whetherconfig_filehas 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.
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_commandandremove_commandare the two lines to read. They run as root on every consuming VM (example 2) β and note the extensionlessfinanceagent, consistent withrelies_on_default_package_filename = true(example 5).
π
has_source_sas_token = truewith no URL anywhere in the output β presence only, by design (example 4).
β
excluded_from_latest = truewithregions_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 = falsebecause an explicitupdatecommand was supplied (example 6).
β οΈ reports_health = falseβ the default, and honest for a package that does not report (example 9).
| 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). |
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/versionstemplate 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."