Terratest and the Azure SDK — the output that is still the last apply
terraform.Output finds the account. GetProperties judges the account.
<!-- hal:authoritative:yaml -->
§I — Frame
The terratest test this arc built on 07-31 does one thing well. Apply a module, read an output, assert something about the world, destroy on the way out. 08-12 taught the thing that test cannot see: behavior during the apply. A goroutine held a poller across a second apply and watched an ALB.
Today's Ops lesson makes a third claim, and neither prior shape reaches it. It says: primary_blob_endpoint is the same string after you shut public network access. terraform.Output will print it. The test will pass. The account can still be public, or the account can be private. The string does not care.
Call it the output that is still the last apply. The instrument that can fail is not Terraform. It is AccountsClient.GetProperties against Azure after the apply returns. That is the unused clause in the runbook triad. "SDK integration for resource-state verification." First fire on this arc.
Brikman's End-to-End Tests section (Ch.9, PDF pp.537-539) says the e2e path deploys into something that mimics production and tests from the end-user's perspective. The end user of a storage account is not terraform output. The end user is a client that either reaches the public hostname or does not. GetProperties is the cheaper witness of that fact. An actual blob GET from a public network is the dearer one. Start with the cheaper witness. Do not stop at the output.
§II — Language Idiom: The SDK as a second reader
Terratest's terraform package is a subprocess wrapper with types. terraform.InitAndApply, terraform.Output, terraform.InitAndPlanAndShowWithStruct, terraform.Destroy. Every one of those talks to the Terraform CLI. The CLI talks to state. State is last apply.
The Azure SDK for Go talks to Azure. azidentity.NewDefaultAzureCredential finds a principal the same way az would. armstorage.NewAccountsClient is the storage ARM plane. GetProperties returns the account Azure has, not the account Terraform remembers.
Three Go facts make the pairing safe.
Fact one. The credential is a value, not an environment ritual inside the test.
cred, err := azidentity.NewDefaultAzureCredential(nil)
require.NoError(t, err)
client, err := armstorage.NewAccountsClient(subscriptionID, cred, nil)
require.NoError(t, err)
DefaultAzureCredential walks environment variables, managed identity, and the local Azure CLI cache. The test does not invent a client secret. The same principal that applied the module can read the account back. If it cannot, the test failed for an identity reason, and that is a real fail.
**Fact two. GetProperties is a typed read. terraform.Output is a string.**
resp, err := client.GetProperties(ctx, resourceGroup, accountName, nil)
require.NoError(t, err)
require.NotNil(t, resp.Account.Properties)
require.NotNil(t, resp.Account.Properties.PublicNetworkAccess)
require.Equal(t, armstorage.PublicNetworkAccessDisabled,
*resp.Account.Properties.PublicNetworkAccess)
The enum is the assertion. A string compare against terraform.Output(t, opts, "primary_blob_endpoint") can only ask "did the hostname look like a hostname." It can never ask "is the door shut."
Fact three. The output is still useful, as an address, not as a verdict.
You need the account name and the resource group to call GetProperties. Those can come from outputs. terraform.Output is how the test finds the object. The SDK is how the test judges the object. Mixing the two roles is the bug 07-31 could not see, because 07-31's module exported a URL that was the verdict (HTTP 200 on a static site). Today's module exports a URL that survives the verdict changing.
Retries stay. Brikman, Retries (pp.535-536), wraps terraform apply because ARM throttles. The same throttle hits GetProperties in the five seconds after apply returns. retry.DoWithRetry around the SDK call is the same discipline, pointed at Azure instead of at the CLI. Do not copy 08-18's -lock=false retry path. Brikman prints that flag in a retry transcript on PDF p.537. It is a trap, not a helper.
§III — Code Worked Example: apply, output, then ask Azure
The module under test is the Ops account: resource group, azurerm_storage_account, public_network_access_enabled = false, outputs for name, resource group, and primary_blob_endpoint. Figure 9.32 is the skeleton, with LRS instead of LGS, and the public-access switch set.
The test keeps the 07-31 apply-destroy loop and adds one reader.
func TestStoragePublicAccessOff(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../../modules/storage",
Vars: map[string]interface{}{
"name_suffix": strings.ToLower(random.UniqueId()),
"public_network_access_enabled": false,
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
endpoint := terraform.Output(t, opts, "primary_blob_endpoint")
require.Contains(t, endpoint, ".blob.core.windows.net")
name := terraform.Output(t, opts, "account_name")
rg := terraform.Output(t, opts, "resource_group_name")
assertPublicAccess(t, os.Getenv("ARM_SUBSCRIPTION_ID"), rg, name,
armstorage.PublicNetworkAccessDisabled)
}
assertPublicAccess is the new function. It is not a terratest helper. It is yours.
func assertPublicAccess(
t *testing.T,
subID, rg, name string,
want armstorage.PublicNetworkAccess,
) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cred, err := azidentity.NewDefaultAzureCredential(nil)
require.NoError(t, err)
client, err := armstorage.NewAccountsClient(subID, cred, nil)
require.NoError(t, err)
retry.DoWithRetry(t, "storage GetProperties", 8, 10*time.Second, func() (string, error) {
resp, err := client.GetProperties(ctx, rg, name, nil)
if err != nil {
return "", err
}
got := resp.Account.Properties.PublicNetworkAccess
if got == nil {
return "", fmt.Errorf("PublicNetworkAccess nil on %s", name)
}
if *got != want {
return "", fmt.Errorf("PublicNetworkAccess %s, want %s", *got, want)
}
return string(*got), nil
})
}
Read the order. Apply first. Output second, and only as an address. SDK third. Destroy last, deferred. If GetProperties is called before apply returns, Azure 404s and the retry burns its budget on a test that has not yet created the account. If Output is treated as the verdict, the retry never runs and a public account passes.
A second test proves the lie directly.
func TestEndpointDoesNotTrackTheGrant(t *testing.T) {
t.Parallel()
suffix := strings.ToLower(random.UniqueId())
opts := &terraform.Options{
TerraformDir: "../../modules/storage",
Vars: map[string]interface{}{
"name_suffix": suffix,
"public_network_access_enabled": true,
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
open := terraform.Output(t, opts, "primary_blob_endpoint")
opts.Vars["public_network_access_enabled"] = false
terraform.Apply(t, opts)
shut := terraform.Output(t, opts, "primary_blob_endpoint")
require.Equal(t, open, shut)
assertPublicAccess(t, os.Getenv("ARM_SUBSCRIPTION_ID"),
terraform.Output(t, opts, "resource_group_name"),
terraform.Output(t, opts, "account_name"),
armstorage.PublicNetworkAccessDisabled)
}
The first require is the Ops lesson, compiled. The endpoint did not move. The second call is the verdict. Without it, the test only proved Terraform can print a stable string.
Brikman, Plan testing (pp.544-546), shows InitAndPlan and GetResourceCount as a way to assert "5 to add, 0 to change, 0 to destroy" without applying. That helper is the right tool for "does this module still plan a create." It is the wrong tool for "did Azure shut the door." A plan that flips the switch reports 0 add, 1 change, 0 destroy. GetResourceCount will see the change. It will not see Azure. After the plan and before the apply, GetProperties still returns Enabled. That is the same last-apply fact, visible from Go.
Keep test stages from 07-31. SKIP_deploy=true SKIP_teardown=true reruns GetProperties against an account you already paid for. The SDK call is cheap. The account is not. Brikman's test pyramid (pp.537-539) is the reason: e2e sits at the top because it is slow and brittle. One live account, reused across the assertion, is how this fire stays in the middle of the pyramid instead of becoming a nightly from-scratch deploy.
t.Parallel() still needs unique names. random.UniqueId() in the suffix, lowercase because Azure storage account names are a narrow alphabet. Figure 9.32 hardcodes stmyterraformstorageaccount. A parallel test against that name is a collision. The sample is a picture, not a fixture.
Retryable versus fatal, and the 404 that is not a flake.
Brikman's retry wrapper exists because ARM returns 429 and the occasional 500. A 404 on GetProperties after a successful apply is a different class. Either the test is reading the wrong name, or Azure has not finished the create, or the apply wrote a different account than the output claims. The first and third are fatal. The second is retryable for a bounded window, then fatal.
Do not fold every error into DoWithRetry. Inspect the response. A 403 is identity: the principal that applied cannot read. Fail. A 400 is a bad name. Fail. A 429 is the reason the helper exists. Retry. A 404 in the first thirty seconds after apply: retry. A 404 after two minutes: fail, and print the name you asked for next to the name the output printed. That pair is how you catch a module that exports a local and creates a random suffix.
The typed plan is the fourth reader, and it is still not Azure.
08-12 used InitAndPlanAndShowWithStruct to assert an empty plan after the roll. Use it here after the second apply.
plan := terraform.InitAndPlanAndShowWithStruct(t, opts)
require.Empty(t, plan.ResourceChanges)
An empty plan after public_network_access_enabled = false is already applied means the module settled. It does not mean Azure is private. Drift in the other direction is the interesting case: a human opened the account in the portal, Terraform has false in config, the next plan wants to close it again. ShowWithStruct sees the change. GetProperties sees Enabled. Both are true. terraform.Output still prints the same hostname. Three readers, three jobs. The plan reader is last apply versus config. The SDK reader is Azure versus the test's want. The output reader is a string.
What else GetProperties can prove, and what it must not be asked.
Account.Properties also carries AllowBlobPublicAccess, MinimumTLSVersion, PublicNetworkAccess, kind, SKU, and the primary endpoints Azure believes the account has. Assert TLS and the nested public-blob flag if the module claims them. Do not assert the access key. The keys are on a different command (ListKeys), they rotate, and they do not belong in a test log. Do not assert primary_blob_endpoint from the SDK against terraform.Output as the main verdict. They will match. That match is the lie wearing a consistency check.
An HTTP GET from a network that is not the private path is the end-user test Brikman names. It is slower, it needs a place to run from, and a 403 on a private account is success. Save it for a later fire. GetProperties is the ARM-plane witness that this visit owes.
Identity is part of the test, not a prelude.
ARM_SUBSCRIPTION_ID must be set. DefaultAzureCredential must resolve. A missing subscription fails at client construction if you pass an empty string into NewAccountsClient, or later with a 404 against the wrong tenant. Fail loud at the top of assertPublicAccess if subID == "". Terratest will otherwise spend four minutes applying into a subscription the SDK cannot name.
OIDC in CI is the same principal story the Cert slot will not reopen from 08-15. The test needs a token. The token is not an output.
§IV — Connection to Today's Ops Lesson
Ops named the consumer that refreshed during a plan and still saw Tuesday. This test is not that consumer. This test is the producer, after apply, asking Azure. The shared coin is the output that did not move.
If you only ported the Ops outputs into terraform.Output asserts, you would have written 07-31 again on Azure. The new fact is the second reader. State said the endpoint. Azure said the grant. They agreed on the hostname and disagreed on the door, until apply, and they agreed on the hostname after apply as well.
The azurerm backend in the Ops worked example is out of scope for this test. Do not stand up a state account inside the module under test. Use local state for the terratest run, the way 07-31 did. The grant under test is on the workload account.
If you ever do point the test at a remote backend, you inherit 08-18. Two tests in t.Parallel() against the same key will fight for the blob lease. Unique keys, or local state. Unique keys plus unique account names. The lock is not the grant. The grant is not the lock. Today's SDK call does not read the lease.
A module that sets lifecycle { ignore_changes = [public_network_access_enabled] } will apply once, then ignore the portal, then keep passing a GetProperties test that expects Disabled only if Azure stayed Disabled. If a human opened the door, GetProperties fails and the next plan is empty because of ignore_changes. That pair is the diagnosis: SDK says Enabled, plan says no-op. The output still prints the hostname. 08-12 named ignore_changes as an attribute with two owners. Here the two owners are Terraform and the portal, and only the SDK sees the portal's write.
§V — Prior-Lesson Reach
07-31: options, deferred destroy, retries, stages. All four remain. The new line is after Output.
08-12: a background goroutine must not call t.Fatal. GetProperties runs on the test goroutine, so require is legal. Do not hide the SDK call in a poller unless you are watching a flip during apply. Today's flip is not a window. It is a property after the write.
08-18 Dev (Python-around-TF): a subprocess wrapper that refused -lock=false. This test must refuse it too. Terratest's default apply holds the lock. Do not pass -lock=false to make a retry "easier." Brikman's p.537 transcript is the caution, not the recipe.
The Python wrapper of 08-18 treated a lock error as a hard fail. This Go test treats a 403 from GetProperties the same way. Both are "you are not the principal you think you are," at different edges. One edge is the state file. The other is the account ARM plane. Wire them separately.
HCL days on this arc (07-25, 08-03, 08-15) made the module an API. Terratest is the client of that API. The SDK is the client of Azure. A test that only speaks HCL outputs is a client of the author's story. A test that speaks ARM is a client of the cloud. Today's theme requires both clients, in that order: story first so you know which object to ask, cloud second so the story can lose.
§VI — Closing
terraform.Output finds the account. GetProperties judges the account. The endpoint string will match on both sides of the grant. A test that only prints the string has not tested the door.
The third terratest fire is the SDK. Use it. Then destroy. A green test that never called GetProperties has not left 07-31, even if the module is Azure and the string is a blob endpoint.
Examine well. Print the endpoint in the log if you must. Believe the enum.
Related
- Prior arc: Terratest under change (2026-08-12)
- Language hub: Cross-References/dev-languages/Go
- Grounding tome: Terraform: Up and Running (Brikman Ch.9, Plan testing) (Ch.9, pp. 544-546)
🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-21 · Fajr · sprint track TF day 30 · tenth TF visit · trio #96 · tf_day_dev_counter 8