Home Blog Week 3 — The Pin Held. The Default Moved Anyway.
Azure Weekly Lab Azure Terraform

Week 3 — The Pin Held. The Default Moved Anyway.

A version pin protects a caller from a module changing underneath them. It does not protect them from choosing to upgrade — and one of the two breaking changes in this week's 2.0.0 produces no error at all.

Azure Verified Modules HCP Terraform Private Registry Semantic Versioning Azure Storage Terraform
Azure Platform Engineering Lab · Week 3 of 52

Why — The Problem This Solves

Weeks 1 and 2 built governance: a landing zone with a deny, then policies that repair what they find. Both assumed somebody was creating resources correctly in the first place. This week is about that somebody — the platform team who hands other teams a module and then has to live with every decision baked into it.

Microsoft publishes Azure Verified Modules, and they are good. What they are not is yours. AVM ships a correct storage account; a platform team needs their storage account — the same resource with the arguments already decided, the tag their policy enforces already applied, and diagnostics already wired to the workspace the estate actually reads. The gap between those two is a wrapper module, and the wrapper is the easy part.

The hard part is distribution. Point ten teams at ../../modules/storage-baseline and every one of them is on whatever is in the working tree right now. A relative path has no version. So the question "which version of the storage module is the payments team running" has no answer — not a hard one, no answer — and the day you fix a default, you have changed every consumer simultaneously and told none of them.

A private registry is not a convenience. It is the thing that makes a version pin mean something. Without it, "pinned" is a sentence in a README. With it, a caller writes version = "1.0.0" and gets exactly that content forever — and the interesting question becomes what happens when you publish 2.0.0 next to it.

What You Need to Know — Skills & Tools

  • Azure Verified Modules — Microsoft's maintained Terraform modules. Worth knowing that they are built on the AzAPI provider, not azurerm: the azurerm in the registry address is a namespace label, not a dependency. Consuming one puts azapi, modtm and random into your lock file whatever your own code uses.
  • HCP Terraform's private registry — publishing by API rather than a VCS connection, which is what lets a monorepo publish a single subdirectory without splitting each module into its own repository.
  • Semantic versioning as a contract — and specifically the difference between a breaking change that fails loudly and one that does not fail at all.
  • Exact pins versus ranges, and why the right answer differs depending on whether you are the module or the caller.
  • A module's version must be a literal. It cannot be a variable or a -var flag. Staging an upgrade is a code change and a commit, never a runtime switch — which is precisely the property that makes a pin worth anything.

Architecture — How It Fits Together

Diagram: the week 3 module factory, showing storage-baseline wrapping an exactly pinned AVM module, publish.sh building a tarball from a git tag and pushing it to the HCP private registry as versions 1.0.0 and 2.0.0, two consumers in one state file pinned to different majors, and the loud and silent breaking changes each version difference produces

Three pieces. A wrapper module in the lab's monorepo. A publish script that turns a git tag into a registry version. And a single Terraform configuration holding two consumers of that module, pinned to two different majors — which is not a contrived arrangement, it is the permanent condition of any platform team that owns a module and has no authority to make everyone upgrade on the same day.

The wrapper pins AVM exactly, and that is deliberate asymmetry

The wrapper pins Azure/avm-res-storage-storageaccount/azurerm at exactly 0.10.0 — not ~> 0.10. That is a different decision from the one a consumer of the wrapper makes, and the reason is that a wrapper's whole job is to be the stable thing. If it floated, a caller pinned to storage-baseline 1.0.0 could still have their storage account change underneath them, which is the exact outcome pinning was supposed to prevent. AVM is also pre-1.0, where semver permits a minor bump to break.

One workspace ID in, two diagnostic settings out

A storage account emits no logs of its own — only the Transaction metric. The read, write and delete audit events belong to the blob service, a separate ARM resource at .../blobServices/default with its own diagnostic setting. Asking for allLogs on the account is accepted and produces nothing, which is the sort of thing discovered during an incident rather than during a deploy. The wrapper takes one workspace ID and creates both settings, so no caller has to know that.

How We Built It — Step by Step

1. The module, published from a tag. publish.sh builds the tarball with git archive <tag>:<path> — never from the working tree. A registry version is immutable, so a tarball built from a dirty checkout publishes a version matching no commit, and the only correction available is deleting a version other people may already be pinned to.

2. Two versions, one breaking change of each kind. 2.0.0 carries exactly two differences from 1.0.0, chosen to be opposites:

ChangeHow it reaches a caller
Loudlog_analytics_workspace_id becomes requiredMissing required argument, at plan. Nothing deploys
Silentshared_access_key_enabled defaults truefalseNo plan error, no apply error. Whatever authenticated with a shared key stops working at runtime

3. Two consumers, deployed in stages. Stage one creates app_a on 1.0.0. Stage two adds app_b on 2.0.0 beside it and never touches the first block again.

Azure portal resource group blade showing two storage accounts and a Log Analytics workspace

The plan for stage two is the evidence, and it is a claim about a plan rather than about a deployment — so the script saves the plan, reports on it, and only then applies:

create   module.app_b[0]...azapi_resource.this
create   module.app_b[0]...containers["data"]
create   module.app_b[0]...diagnostic_setting_blob
create   module.app_b[0]...diagnostic_setting_storage_account

module.app_a changes in this plan: 0

Four resources created, every one of them app_b. A new major landed in the same state file and the consumer pinned to the old one did not move. That is what the pin buys.

Challenges — What Actually Went Wrong

The silent break is only visible on deployed resources

Both accounts came from the same module, through the same code path, and neither call site mentions shared_access_key_enabled at all. Here is 1.0.0:

Azure portal storage account configuration blade with Allow storage account key access set to Enabled

And 2.0.0, with every other setting on the blade identical:

Azure portal storage account configuration blade with Allow storage account key access set to Disabled

Nothing in either configuration says so. No plan flagged it. This is the half of a major bump that a range constraint hides completely — and a team that writes ~> 2.0 after upgrading has re-armed it for 2.1.0.

An empty module published, and every signal said success

The first publish uploaded a valid 45-byte tarball containing nothing. git archive <tag>:<path> resolves the path relative to the current directory, and the script ran from the week directory with a repo-root-relative path — so it did not error. It resolved to an empty tree. The upload returned 200, the version reached status ok, and a consumer would have met the result as a module with no inputs rather than as a failure.

Worse, the obvious guard does not work: git rev-parse -q --verify "<tag>^{tree}:<path>" still returns a hash for the missed path. The only honest check is counting the files in the tarball before uploading, which the script now does.

A pending version blocks its own retry

That failed publish created the 1.0.0 version record before it broke. The version then sat at status pending, and the retry was refused — "already exists, registry versions are immutable". A version awaiting an upload it will never receive has to be deleted through the API before republishing. Safe here only because nothing was pinned to it yet, which will not be true the second time this happens.

AVM nests deeply enough to hit Windows MAX_PATH

The AVM storage module pulls avm-utl-interfaces once per sub-resource, producing paths like .terraform/modules/app_a.storage_account.queues.role_assignments.interfaces/.git/objects/pack/pack-<sha>.pack. The clone fails with Filename too long — and it fails with core.longpaths=true set and the Windows LongPathsEnabled registry value already 0x1. The module cache is relocatable and the configuration is not, so the fix is to move TF_DATA_DIR somewhere short rather than to move the repository.

A validation that failed on correct resources

Two checks reported failures against resources that were perfectly correct. az renders JSON booleans Python-style — True and False, capitalised — and the script compared against lowercase "false". It is the same shape as a check that passes because the lookup broke, inverted: a check that fails because the comparison did.

Security — Controls at Every Layer

  • The baseline is not optional. TLS 1.2 minimum, HTTPS-only transport, and anonymous blob access disabled are decided in the module, not passed in by callers. A caller cannot forget them because there is no argument to forget.
  • 2.0.0 turns shared keys off by default — the flipped default that makes this week's point also happens to be the more secure posture. A caller who genuinely needs keys can still ask for them, explicitly, in their own code rather than inheriting it.
  • The tag the estate enforces is applied at creation. Every account carries cost-center, so week 2's remediation policy never has to touch anything this module made.
  • Diagnostics reach a workspace — account metrics and blob service audit logs, and in 2.0.0 there is no way to opt out by omission.
  • One honest exception: public_network_access_enabled is true. The alternative is a private endpoint, and a private endpoint without a linked private DNS zone resolves to the public IP and fails in a way that looks like a firewall problem. The zone estate is week 5; the module moves when it exists.

Cost

Two Standard LRS storage accounts and one Log Analytics workspace at PerGB2018 with 30-day retention. Nothing in this week has a per-hour price — no compute, no gateway, no firewall — so the bill is driven entirely by data, and this week stored and ingested essentially none. Pennies while running.

Cleanup

Two kinds of thing were created and only one of them should go. The consumers — a resource group, a workspace, two storage accounts — cost money and prove nothing further, so they are deleted.

The registry versions are kept. They cost nothing, they are the week's actual output, and deleting a published version is the one destructive act a module registry does not forgive: every configuration pinned to it stops initialising, including configurations owned by people who were never asked. The cleanup script takes a --registry flag to remove them and does not do it by default.

References

Key Takeaways

  • A relative module path has no version. Every consumer is on HEAD, and "which version is this team running" has no answer. That is the problem a private registry solves, and it is not really about convenience.
  • The pin protects the caller who does nothing. A new major landed in the same state file and the 1.0.0 consumer's plan showed zero changes.
  • The dangerous breaking change is the one that does not break anything. A required input fails loudly at plan and nobody gets hurt. A flipped default applies cleanly and surfaces in production, in whatever was still using the old behaviour.
  • Pin exactly in the module, and exactly in the caller. A range is a standing agreement to skip reading the diff, and the diff is the actual safety mechanism.
  • An immutable registry is unforgiving in both directions. It will not let you fix a bad version, and it will not let you retry a failed publish without deleting what the failure left behind.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent