Terratest CopyTerraformFolderToTemp — the access that is not a public IP
The copy is not the module. HTTP from the runner is not the guest. Empty AccessConfigs is the pass.
<!-- hal:authoritative:yaml -->
§I — Frame
The terratest test this arc built on 07-31 does one thing well. Point TerraformDir at a module, apply, read an output, destroy on the way out. 08-12 taught the thing that test cannot see: behavior during the apply. 08-21 taught the thing terraform.Output cannot see: an Azure property the hostname does not carry.
Today's Ops lesson makes a fourth claim, and none of those three shapes is the leftover. It says: two tests against the same folder, in parallel, will overwrite each other's .terraform directory and state. Brikman writes the collision in one heading: RUNNING TESTS IN PARALLEL IN THE SAME FOLDER (Ch.9, printed pp.517-518). The helper that breaks the collision is test_structure.CopyTerraformFolderToTemp. Unused on this arc.
The module those copies apply is a GCP VM with no access_config on a subnet whose private_ip_google_access is true. Coin of the day stays the access that is not a public IP. The Go fact is narrower. The copy is not the module. The runner's HTTP client is not the guest.
08-12's HTTP poller from the test process to an ALB was the right direction for a load balancer. PGA is egress from a VM that has no public address. http_helper from the laptop to storage.googleapis.com proves the laptop. Leave it.
08-21's GetProperties judged an account the output could not see. Today's output.pga can see the subnet flag. The flag is not the NIC. gcp.FetchInstance has to show empty AccessConfigs. That is not GetProperties versus an endpoint string. That is a copy, an apply, and an absence.
§II — Language Idiom: the copy, then the empty NIC
Terratest's terraform package is a subprocess wrapper with types. InitAndApply, Output, Destroy. Every one of those talks to a working directory. Two tests that share that directory are two processes fighting over one .terraform and one state file. Brikman says the easiest fix is to copy the folder to a unique temporary directory and run Terraform there, and that relative module paths inside the copy still work.
Three Go facts make the pairing safe.
Fact one. Copy first. Then build Options on the copy.
func TestPrivateVMHasNoPublicIP(t *testing.T) {
t.Parallel()
tmpDir := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/pga-vm")
unique := random.UniqueId()
opts := &terraform.Options{
TerraformDir: tmpDir,
Vars: map[string]interface{}{
"name_suffix": unique,
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
}
t.Parallel() without the copy is the collision Brikman named. t.Parallel() after the copy is the point of the helper. random.UniqueId() still namespaces the GCP objects so two copies do not try to own one instance name. The copy solves the local disk. The unique id solves the cloud. You need both. A unique id pointing two tests at one TerraformDir still lets terraform init race.
The copy and the unique id solve different collisions. Disk collision is .terraform and terraform.tfstate in one folder. Cloud collision is two google_compute_instance resources asking GCP for the same name in the same project. Brikman (Ch.9, pp.517-518) names the disk fix. He leaves the cloud name to you. random.UniqueId() is that half. Keep both. Drop the copy and init races. Drop the suffix and apply returns 409. The 409 log talks like Terraform. The object is GCP.
Do not pass the original examples/pga-vm as TerraformDir "because this test is the only one." The next test in the package will not know that. The helper is cheap. Use it.
Brikman places that heading immediately before Integration Tests, which confuses the altitude. Copying the folder is not an integration test. An integration test, in his terms, deploys several modules and checks they work together (Ch.9, pp.518-521: mysql then hello-world-app). A unit test deploys one module. Today's PGA VM example is one module. The copy is a disk isolation trick that unit tests need as soon as two of them share a folder. If you wait to copy until you have two modules, the first parallel unit tests have already raced.
Relative paths inside the copy still work because the helper rewrites them. A module that does source = "../../modules/pga-vm" from examples/pga-vm will still find modules/pga-vm after the copy. A homemade os.MkdirTemp plus cp -R will not. Use the named helper. Do not invent a copier that drops the parent and then fails at init with a missing module.
The helper's two arguments are why a homemade copy fails. Root, then relative folder. Copying only examples/pga-vm into a temp dir drops modules/pga-vm. Init then asks for a source that is no longer a sibling. Brikman says relative module paths inside the copy still work. They work because the helper copies enough of the tree and rewrites the relative sources. Trust that. Do not cp -R examples/pga-vm $TMP and then wonder why init cannot find ../../modules.
The temp directory is the test's. Leave it. Terratest cleans it. Do not os.RemoveAll the copy before Destroy finishes. Destroy talks to the copy's state. Delete the directory first and Destroy has no state file and the GCP VM remains. Order is apply, assert, Destroy, then the helper's cleanup.
files.CopyTerraformFolderToTemp exists as a sibling. Brikman names test_structure.CopyTerraformFolderToTemp. Prefer the one the tome names. If a stem on the Pro exam says "tests in parallel against the same folder," the answer is a unique working directory, not -lock=false, not a workspace in the real account, not "run them serial in CI and call it done."
**Fact two. gcp.FetchInstance reads GCP. terraform.Output reads state.**
project := terraform.Output(t, opts, "project")
zone := terraform.Output(t, opts, "zone")
name := terraform.Output(t, opts, "instance_name")
inst := gcp.FetchInstance(t, project, zone, name)
require.NotEmpty(t, inst.NetworkInterfaces)
require.Empty(t, inst.NetworkInterfaces[0].AccessConfigs)
instance_name is a pointer. It is last-apply of the name. It is not last-apply of the NIC. 08-21 already spent "the output that is still the last apply" on an Azure hostname. Do not spend it again as the headline. The headline today is that you fetched the instance from a copy, and the public door is absent.
gcp.GetPublicIpOfInstance wants a natIP. On this shirt that helper is the wrong success path. A function that requires a public IP will fail when the coin is true, or it will pass when you left access_config {} in the file. Fetch the instance. Count AccessConfigs. Zero is the pass.
A 403 from FetchInstance is not empty AccessConfigs. 403 means the test principal cannot read the instance. Fail it. Do not retry 403 into a pass. A missing NIC is not empty AccessConfigs either. A VM with no network interface is a different bug. require.NotEmpty on the NIC slice, then require.Empty on AccessConfigs. Two asserts. One object.
An empty natIP on an AccessConfig that still exists is a public door with no number yet. Fail it. The pass is zero AccessConfigs. A blank address on a door you still opened is a wait, or a miss. GCP will fill natIP after the NIC settles. A late empty string is a public door in flight. Wait for the instance. Then count the slice. Do not poll the public internet. Do not poll a blank address into a pass.
The subnet flag can ride along:
pga := terraform.Output(t, opts, "pga")
require.Equal(t, "true", pga)
That line is a pointer, same as the name. Keep it. Do not let it replace the NIC assertion. Ops already showed both outputs surviving enable_public_ip=true.
**Fact three. HTTP from t is the wrong vantage.**
url := "https://storage.googleapis.com"
http_helper.HttpGetWithRetry(t, url, nil, 200, 5, 5*time.Second)
That call never entered the VPC. It never used PGA. It never saw the guest. 08-12 used http_helper because the ALB was the thing under test and the runner was a client. Today's guest is the client. The runner is not. If you need a positive that Google APIs work, you need a command inside the VM (IAP SSH, a startup script that writes a GCS object, an Ops Agent). That is a later fire. This fire stops at empty AccessConfigs plus a copy that did not trample a sibling test.
Brikman's Integration Tests skeleton (Ch.9, pp.518-521) still holds: t.Parallel(), defer terraform.Destroy, InitAndApply, then validate. 07-31 spent Options, retries, stages as the first test. This fire does not rebuild stages. It inserts the copy between t.Parallel() and Options.TerraformDir.
§III — Code Worked Example: two tests, one example folder
A package that means the coin:
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/gcp"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/test_structure"
"github.com/stretchr/testify/require"
)
func applyPrivateVM(t *testing.T, public bool) *terraform.Options {
t.Helper()
tmpDir := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/pga-vm")
opts := &terraform.Options{
TerraformDir: tmpDir,
Vars: map[string]interface{}{
"name_suffix": random.UniqueId(),
"enable_public_ip": public,
},
}
terraform.InitAndApply(t, opts)
return opts
}
func TestPGAVMHasNoPublicIP(t *testing.T) {
t.Parallel()
opts := applyPrivateVM(t, false)
defer terraform.Destroy(t, opts)
inst := gcp.FetchInstance(t,
terraform.Output(t, opts, "project"),
terraform.Output(t, opts, "zone"),
terraform.Output(t, opts, "instance_name"),
)
require.Empty(t, inst.NetworkInterfaces[0].AccessConfigs)
}
func TestPublicVMHasAccessConfig(t *testing.T) {
t.Parallel()
opts := applyPrivateVM(t, true)
defer terraform.Destroy(t, opts)
inst := gcp.FetchInstance(t,
terraform.Output(t, opts, "project"),
terraform.Output(t, opts, "zone"),
terraform.Output(t, opts, "instance_name"),
)
require.NotEmpty(t, inst.NetworkInterfaces[0].AccessConfigs)
}
Two tests. Same example folder. Each copies. Each can t.Parallel(). The false case is the coin. The true case is the control so a broken Fetch cannot green both. enable_public_ip is Ops's dynamic access_config. Cert will read that optional object with one() and try(). This test does not read it through Terraform. It reads it through GCP.
defer terraform.Destroy still runs on fail. 07-31 taught that. Keep it on the Options that pointed at the copy. Destroying the original example folder's state is the bug the copy exists to prevent. If you copy and then Destroy the original path, you have made a mess in the module under test. Destroy the copy.
name_suffix must be in the module's variables. The copy does not invent uniqueness in GCP. Two copies that both create google_compute_instance.app named app will collide on the live project even if their state files are distinct. Unique working directory plus unique object names. Drop either half and the parallel run fails for a reason that looks like Terraform and is actually GCP.
Two tests in one package is why the helper exists on this fire. A single test against a private folder can skip the copy and still pass. The package will not stay single. Copy first. The next person who adds t.Parallel() should inherit isolation, not discover the race in CI.
The control test (enable_public_ip = true) exists so a stub Fetch that always returns empty cannot green the package. If both tests only asked output.pga, both would print true and both would pass. The public control must see a non-empty AccessConfigs. That is the only reason to apply the illegal join on purpose. Do not make the control your default example. Ops's default stays false.
Brikman's integration test also injects BackendConfig so a hardcoded S3 backend does not overwrite staging state (pp.521-523). Today's module can use a local backend in the example folder. If it does not, add BackendConfig on the copy. That paragraph is adjacent, not today's spend. 08-18 already owned the lock. Do not reopen DynamoDB.
§IV — Connection to Today's Ops Lesson
Ops wrote private_ip_google_access = true and omitted access_config unless a boolean said otherwise. The two describes were the review. This test automates the second describe. It does not automate a curl. It does not treat output.pga as the verdict.
If Ops had left access_config {} in the default, TestPGAVMHasNoPublicIP fails. That is the test doing its job. If Dev HTTP-gets the bucket URL from the runner, both the public and private cases can pass and the coin is untested.
The copy is what lets you run the control and the coin in one go test. Without it, the two applies fight. With it, they are two guests. PGA is a guest path. Parallel tests that share a working directory are not guests. They are roommates with one key.
Ops also named four illegal joins. The test package only automates two of them: PGA true with public IP absent (coin), PGA true with public IP present (control). Join two, PGA false and no public IP, is a black hole for Google APIs and needs a guest-side command this fire refused. Join three is the HTTP-from-the-laptop mistake. Join four is PSC. Do not author a google_compute_forwarding_rule in the example folder to make FetchInstance look busy. The example under test is the Ops root, copied.
§V — Prior-Lesson Reach
07-31: options, deferred destroy, retries, stages. All four remain. The new line is before Options: copy the folder.
08-12: HTTP during apply, empty-plan idempotence. Do not hide FetchInstance in a poller. There is no window. The NIC is a property after apply. Do not import http_helper for PGA.
08-21: Azure SDK GetProperties versus terraform.Output of a hostname. The unused triad clause that day was SDK integration. Today's unused clause is the parallel-folder copy. FetchInstance is a GCP helper, not an ARM client. Do not rebuild the enum-versus-hostname argument. Rebuild the working directory.
08-18 Dev (Python-around-TF) refused -lock=false. This test refuses it too. Terratest's default apply holds the lock inside the copy. Two copies, two locks, two states. That is legal. Two tests, one directory, one lock, is the collision.
HCL days on this arc (07-25, 08-03, 08-15, 08-24) made the module an API. Terratest is the client of that API. GCP is the client of the NIC. A test that only speaks HCL outputs is a client of the author's story. A test that speaks the NIC is a client of the cloud. A test that copies first is a client that can run next to another client. Today's theme requires all three, in that order: copy, story, NIC.
§VI — Closing
CopyTerraformFolderToTemp is the copy that is not the module. gcp.FetchInstance is the read that can see a missing natIP. http_helper from the runner is a third process that never entered the VPC.
The fourth terratest fire is the copy plus an absence. Use both. Then destroy the copy. A green test that pointed TerraformDir at the example folder and printed output.pga has not left 07-31, even if the module is GCP and the flag is true.
Retries stay where 07-31 put them: on apply errors that GCP marks retryable, and on a short wait after apply before FetchInstance if the NIC is still propagating. They do not belong on HTTP to the public internet. They do not belong on a 403. A late empty AccessConfigs after you set enable_public_ip=true is a fail, not a retry budget. The control must see a public door. The coin must not.
Examine well. Print the instance name in the log if you must. Believe the empty AccessConfigs.
Related
- Prior arc: Terratest Azure GetProperties (2026-08-21)
- Language hub: Cross-References/dev-languages/Go
- Grounding tome: Terraform: Up and Running (Brikman Ch.9, CopyTerraformFolderToTemp) (Ch.9, pp. 517-518; Integration Tests pp. 518-521 referenced)