In this post we are going to cover CI/CD, testing and policy for Terraform. These are 7 real Terraform interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: build a pipeline where every pull request shows a plan, and only approved merges apply to production.
Running Terraform from a laptop works for one person. On a team it means nobody knows what was applied, by whom, or from which code. Interviewers ask about pipelines, OIDC login, tests and policy because that is how a team makes Terraform safe and repeatable.
Part 9 of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT.
- The short answer
- Let's set up the project
- Question 1: Engineers run apply from their laptops. How do you move Terraform into a CI/CD pipeline?
- Question 2: Your manager wants every change reviewed before it hits production. How do plan files and pull request reviews work?
- Question 3: The pipeline stores a client secret to log in to Azure. How does OIDC workload identity federation remove it?
- Question 4: A teammate asks how to test Terraform code. What do terraform test, validate and fmt cover?
- Question 5: Security wants to block public storage accounts before they are created. How do policy as code and static checks help?
- Question 6: A provider upgrade changed behavior in production. How do you manage provider and Terraform version upgrades?
- Scenario 7: Build a pipeline where every pull request shows a plan, and only approved merges apply to production. What is your design?
- The full script
The short answer
Run Terraform only in a pipeline: fmt -check, validate, terraform test, then plan -out, and apply that saved plan after approval. Log in to Azure with OIDC workload identity federation, not a client secret. Block bad configs early with validation, tests and policy as code, keep Azure Policy as the guardrail, and upgrade pinned versions on purpose.
Let's set up the project
We use a small Terraform 1.17 project with three folders. platform uses local resources, terraform_data and local_file, so every plan and apply is real and nothing is created in Azure. lake and legacy hold real azurerm 5.11 code, checked with terraform validate and with terraform test using a mocked provider. Here are the starting files.
terraform {
required_version = "~> 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}variable "environment" {
type = string
default = "dev"
}
locals {
containers = toset(["landing", "raw", "curated"])
}
resource "terraform_data" "container" {
for_each = local.containers
input = "stsalesdata${var.environment}/${each.key}"
}
resource "local_file" "pipeline_config" {
filename = "${path.module}/out/nightly-load-${var.environment}.json"
content = jsonencode({
containers = sort(local.containers)
schedule = "0 2 * * *"
})
}terraform {
required_version = "~> 1.17.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.11"
}
}
# state in Azure Blob, read with Entra ID and the OIDC token
backend "azurerm" {
storage_account_name = "sttfstatesales"
container_name = "tfstate"
key = "sales-data/dev.terraform.tfstate"
use_oidc = true
use_azuread_auth = true
}
}
# client, tenant and subscription IDs come from ARM_* variables
provider "azurerm" {
use_oidc = true
features {}
}variable "environment" {
type = string
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "environment must be one of dev, test, prod."
}
}
variable "public_network_access" {
type = string
default = "Disabled"
validation {
condition = var.public_network_access != "Enabled"
error_message = "Public network access is not allowed."
}
}locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}mock_provider "azurerm" {}
variables {
environment = "dev"
}
run "dev_uses_lrs" {
command = plan
assert {
condition = (
azurerm_storage_account.lake.account_replication_type == "LRS"
)
error_message = "dev must use LRS"
}
}
run "prod_uses_grs" {
command = plan
variables {
environment = "prod"
}
assert {
condition = (
azurerm_storage_account.lake.account_replication_type == "GRS"
)
error_message = "prod must use GRS"
}
}terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.11"
}
}
}
provider "azurerm" {
features {}
}
# written for azurerm 4.x, now validated with 5.11
resource "azurerm_storage_account" "lake" {
name = "stsalesdatadev"
resource_group_name = "rg-sales-data-dev"
location = "westeurope"
account_tier = "Standard"
account_replication_type = "LRS"
is_hns_enabled = true
public_network_access_enabled = false
}
resource "azurerm_storage_container" "raw" {
name = "raw"
storage_account_name = azurerm_storage_account.lake.name
}Question 1: Engineers run apply from their laptops. How do you move Terraform into a CI/CD pipeline?
The same commands, but only in a pipeline, with its own identity: init, format check, validate, plan to a file, a review, then apply that exact file. Nobody applies from a laptop.
The flow looks like this:
- Pull request: code and tests
- Checks:
fmt -check,validate - Plan:
plan -out=tfplan - Review: approval
- Apply:
apply tfplan
Let's run the gates for real in the platform folder: terraform init, terraform fmt -check -recursive, terraform validate, and terraform plan -out=tfplan.
$ terraform init -input=false
Initializing the backend...
Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding hashicorp/local versions matching "~> 2.9"...
- Installing hashicorp/local v2.9.1...
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
$ terraform fmt -check -recursive
$ terraform validate
Success! The configuration is valid.
$ terraform plan -input=false -out=tfplan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# local_file.pipeline_config will be created
+ resource "local_file" "pipeline_config" {
+ content = jsonencode(
{
+ containers = [
+ "curated",
+ "landing",
+ "raw",
]
+ schedule = "0 2 * * *"
}
)
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./out/nightly-load-dev.json"
+ id = (known after apply)
}
# terraform_data.container["curated"] will be created
+ resource "terraform_data" "container" {
+ id = (known after apply)
+ input = "stsalesdatadev/curated"
+ output = (known after apply)
}
# terraform_data.container["landing"] will be created
+ resource "terraform_data" "container" {
+ id = (known after apply)
+ input = "stsalesdatadev/landing"
+ output = (known after apply)
}
# terraform_data.container["raw"] will be created
+ resource "terraform_data" "container" {
+ id = (known after apply)
+ input = "stsalesdatadev/raw"
+ output = (known after apply)
}
Plan: 4 to add, 0 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"Init succeeds, the format check prints nothing, validate says Success! The configuration is valid., and the plan is 4 to add, saved to tfplan.
Each gate can fail the build. The format check exits non-zero when a file needs formatting, validate fails on errors, and the next stage applies the saved plan, not a new one.
A few rules that go with it:
- State lock and a concurrency group: one run writes at a time, the next one waits
- One identity and one state per environment: dev, test, prod
- Engineers read, the pipeline applies: no laptop applies in production
- Break glass: for emergencies, and audited
Engineers keep read access in production. They read plans; the pipeline applies.
Question 2: Your manager wants every change reviewed before it hits production. How do plan files and pull request reviews work?
Plan to a file, post the plan for review, and after approval, apply exactly that file. What was reviewed is what runs.
The plan file is binary, so tools read it through terraform show -json. Let's list the action for each resource, and then count the deletes with a one-line jq check.
$ terraform show -json tfplan | jq -r '.resource_changes[] | "\(.change.actions | join(",")) \(.address)"'
create local_file.pipeline_config
create terraform_data.container["curated"]
create terraform_data.container["landing"]
create terraform_data.container["raw"]
$ terraform show -json tfplan | jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length'
0We get four create lines, and the delete count is 0. A pipeline can ask for an extra approval when that number is not zero.
Now we apply the reviewed plan file.
$ terraform apply -input=false tfplan | tail -n 1
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.There is no prompt, because the approval already happened: Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
What if we apply the same file again?
$ terraform apply -input=false tfplan
Error: Saved plan is stale
The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.Terraform refuses: Error: Saved plan is stale, because the state was changed after the plan was created. If someone else applied in between, you plan again and review again.
A plan file holds sensitive values in plain text, and backend settings. Keep it as a protected pipeline artifact with short retention, and post only the text plan (terraform show tfplan) in the pull request.
Question 3: The pipeline stores a client secret to log in to Azure. How does OIDC workload identity federation remove it?
The pipeline proves who it is with a short-lived token from GitHub, and Microsoft Entra ID trusts that token through a federated credential. No client secret to store, rotate or leak.
Here is the flow: GitHub issues a short-lived OIDC token, Entra ID checks it against a federated credential, and the job gets an access token.
First, in Entra ID, we add a federated credential to the app registration. It has the GitHub issuer, a subject that names the repository and the environment, and the audience api://AzureADTokenExchange.
{
"name": "sales-data-dev",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:contoso/sales-data:environment:dev",
"audiences": ["api://AzureADTokenExchange"]
}We create it with the Azure CLI:
az ad app federated-credential create \
--id "$APP_ID" --parameters credential.jsonThen the workflow. id-token: write lets the job request the token. ARM_USE_OIDC, with the client, tenant and subscription IDs, tells the azurerm provider and the backend to fetch the token themselves. The azure/login action is only for az CLI steps.
# .github/workflows/terraform.yml (the plan job)
permissions:
id-token: write # lets the job request an OIDC token
contents: read
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
jobs:
plan:
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.17.0
- run: terraform init -input=false
- run: terraform plan -input=false -out=tfplanIn the code, lake/providers.tf sets use_oidc = true in the provider. The backend sets use_oidc = true plus use_azuread_auth = true, so the state is read with Entra ID, not storage keys.
The subject must match exactly, with no wildcards, so I create one credential per GitHub environment.
Repositories created, renamed or transferred since July 15, 2026 send immutable subjects with owner and repo IDs (owner@id/repo@id). Check the subject first.
Question 4: A teammate asks how to test Terraform code. What do terraform test, validate and fmt cover?
Three layers. Format checks style, validate checks that the code is valid, and terraform test checks that it does what you meant.
| Command | Checks | Needs Azure? |
|---|---|---|
| terraform fmt -check | style and layout only | no |
| terraform validate | syntax, types, references, provider schema | no |
| terraform test | your assertions on plan or apply runs | no, with mock_provider |
Let's start with format. In the lake folder we add a badly formatted outputs.tf and run terraform fmt -check -diff, then terraform fmt.
output "storage_account_name" {
value = azurerm_storage_account.lake.name
description="Name of the lake storage account"
}$ terraform fmt -check -diff; echo "exit code: $?"
outputs.tf
--- old/outputs.tf
+++ new/outputs.tf
@@ -1,4 +1,4 @@
output "storage_account_name" {
-value = azurerm_storage_account.lake.name
- description="Name of the lake storage account"
+ value = azurerm_storage_account.lake.name
+ description = "Name of the lake storage account"
}
exit code: 3
$ terraform fmt
outputs.tfThe diff shows the change and the command prints exit code: 3, so the build fails. terraform fmt then fixes outputs.tf.
Validate checks syntax, types, references and the provider schema, with no credentials and no variable values. Let's put a typo in main.tf, date instead of data, and validate.
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.date.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}$ terraform validate
Error: Reference to undeclared resource
on main.tf line 13, in resource "azurerm_storage_account" "lake":
13: location = azurerm_resource_group.date.location
A managed resource "azurerm_resource_group" "date" has not been declared in
the root module.It catches it: Reference to undeclared resource, azurerm_resource_group.date.
Now we fix the typo, validate, and run terraform test. With mock_provider "azurerm", it needs no subscription and creates nothing.
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}$ terraform validate
Success! The configuration is valid.
$ terraform test
tests/lake.tftest.hcl... in progress
run "dev_uses_lrs"... pass
run "prod_uses_grs"... pass
tests/lake.tftest.hcl... tearing down
tests/lake.tftest.hcl... pass
Success! 2 passed, 0 failed.dev_uses_lrs and prod_uses_grs both pass: Success! 2 passed, 0 failed.
Then a teammate sets LRS everywhere to save money. We run the tests again.
locals {
replication = "LRS" # cheaper
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}$ terraform test
tests/lake.tftest.hcl... in progress
run "dev_uses_lrs"... pass
run "prod_uses_grs"... fail
Error: Test assertion failed
on tests/lake.tftest.hcl line 23, in run "prod_uses_grs":
23: condition = (
24: azurerm_storage_account.lake.account_replication_type == "GRS"
25: )
├────────────────
│ azurerm_storage_account.lake.account_replication_type is "LRS"
prod must use GRS
tests/lake.tftest.hcl... tearing down
tests/lake.tftest.hcl... fail
Failure! 1 passed, 1 failed.The prod test fails: account_replication_type is "LRS", and prod must use GRS.
The mistake is caught in the pull request, not in production.
Question 5: Security wants to block public storage accounts before they are created. How do policy as code and static checks help?
Stop it in three places: in the code, in the pipeline before apply, and in Azure itself.
In the code, the validation rule in lake/variables.tf rejects Enabled, so only Disabled or SecuredByPerimeter get through. A test proves the rule fires: it sets public_network_access = "Enabled" and expects the variable to fail. We run that test.
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}mock_provider "azurerm" {}
run "public_access_is_rejected" {
command = plan
variables {
environment = "dev"
public_network_access = "Enabled"
}
expect_failures = [var.public_network_access]
}$ terraform test -filter=tests/security.tftest.hcl
tests/security.tftest.hcl... in progress
run "public_access_is_rejected"... pass
tests/security.tftest.hcl... tearing down
tests/security.tftest.hcl... pass
Success! 1 passed, 0 failed.The plan fails as expected, so the test passes: run "public_access_is_rejected"... pass.
In the pipeline, Checkov, TFLint and Trivy, which replaced tfsec, scan the code. OPA with Conftest checks the plan JSON. HCP Terraform adds Sentinel, OPA, and native Terraform policy, which needs Terraform 1.17, where the -policies flag is now generally available.
And Azure Policy with a deny effect blocks the request at Azure Resource Manager, at apply time, even from the portal. That is the guardrail, not the first check.
| Layer | Tools | Runs on |
|---|---|---|
| In the code | variable validation, preconditions, terraform test | every plan |
| Static scan | Checkov, TFLint, Trivy (tfsec moved to Trivy) | the code, in the PR |
| Policy on the plan | OPA / Conftest; HCP: Sentinel, OPA, Terraform policy | plan JSON, before apply |
| Azure Policy (deny) | Azure Resource Manager | every request, at apply |
Fail fast in the pull request, keep Azure Policy as the safety net, and keep the rules in step, so a plan that passes doesn't fail in Azure.
Question 6: A provider upgrade changed behavior in production. How do you manage provider and Terraform version upgrades?
Pin both, upgrade on purpose, and test the upgrade like any other change.
| Pin | Where |
|---|---|
| Terraform | required_version + pipeline |
| Providers | version constraint |
| Exact build | .terraform.lock.hcl in Git |
Terraform gets required_version in the code and the same version in the pipeline. Providers get a version constraint, plus the lock file in Git, with the exact version every run uses.
Let's say local 2.9.1 is the release that broke production. A pull request excludes it with "~> 2.9, != 2.9.1", but the lock file still selects it. We run terraform plan in platform.
terraform {
required_version = "~> 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9, != 2.9.1"
}
}
}$ terraform plan -input=false
Error: Inconsistent dependency lock file
The following dependency selections recorded in the lock file are
inconsistent with the current configuration:
- provider registry.terraform.io/hashicorp/local: locked version selection 2.9.1 doesn't match the
updated version constraints "~> 2.9, != 2.9.1"
To update the locked dependency selections to match a changed configuration,
run:
terraform init -upgradeThe plan stops: Error: Inconsistent dependency lock file, and it tells us to run terraform init -upgrade. You do that in a branch, and the lock file change is reviewed in the pull request.
Then read the upgrade guide. The legacy folder was written for azurerm 4. Let's validate it with 5.11.
$ terraform validate
Warning: Argument is deprecated
with azurerm_storage_account.lake,
on main.tf line 22, in resource "azurerm_storage_account" "lake":
22: public_network_access_enabled = false
`public_network_access_enabled` has been deprecated in favour of
`public_network_access` and will be removed in v6.0 of the AzureRM provider
Error: Missing required argument
on main.tf line 25, in resource "azurerm_storage_container" "raw":
25: resource "azurerm_storage_container" "raw" {
The argument "storage_account_id" is required, but no definition was found.
Error: Unsupported argument
on main.tf line 27, in resource "azurerm_storage_container" "raw":
27: storage_account_name = azurerm_storage_account.lake.name
An argument named "storage_account_name" is not expected here.It fails: storage_account_id is required on the storage container, and storage_account_name is not expected any more. And there is a warning: public_network_access_enabled is deprecated and will be removed in v6.0.
I upgrade one provider at a time, plan in dev and test first, and look for unexpected changes before prod. And I fix deprecation warnings while they are still warnings.
Scenario 7: Build a pipeline where every pull request shows a plan, and only approved merges apply to production. What is your design?
Two workflows, two identities.
On a pull request: format check, validate, tests, and a plan for each environment it touches, posted to the pull request. The plan identity can read Azure and use the state, but it can't change resources. Branch protection on main means the pull request needs an approval and green checks before it can merge.
On merge, the workflow plans again from main and saves the plan file. A GitHub environment with required reviewers pauses the run. After approval, the apply job applies that saved file, with its own apply identity.
apply: # on main, after the plan-prod job
needs: plan-prod
runs-on: ubuntu-latest
environment: prod # required reviewers approve here
env:
ARM_CLIENT_ID: ${{ vars.AZURE_APPLY_CLIENT_ID }}
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.17.0
- uses: actions/download-artifact@v8
with:
name: tfplan
- run: terraform init -input=false
- run: terraform apply -input=false tfplanplan -detailed-exitcode tells the pipeline if there is anything to do: 0 for no changes, 1 for an error, 2 for changes. First we put the provider constraint back to "~> 2.9" and plan.
terraform {
required_version = "~> 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}$ terraform plan -detailed-exitcode -input=false > plan.txt; echo "exit code: $?"
exit code: 0
$ grep -E "No changes|# .* (will be|must be)|^Plan:" plan.txt
No changes. Your infrastructure matches the configuration.exit code: 0 and No changes. Now a pull request adds a gold container, which also rewrites the config file.
variable "environment" {
type = string
default = "dev"
}
locals {
containers = toset(["landing", "raw", "curated", "gold"])
}
resource "terraform_data" "container" {
for_each = local.containers
input = "stsalesdata${var.environment}/${each.key}"
}
resource "local_file" "pipeline_config" {
filename = "${path.module}/out/nightly-load-${var.environment}.json"
content = jsonencode({
containers = sort(local.containers)
schedule = "0 2 * * *"
})
}$ terraform plan -detailed-exitcode -input=false > plan.txt; echo "exit code: $?"
exit code: 2
$ grep -E "No changes|# .* (will be|must be)|^Plan:" plan.txt
# local_file.pipeline_config must be replaced
# terraform_data.container["gold"] will be created
Plan: 2 to add, 0 to change, 1 to destroy.exit code: 2: the gold container will be created, and local_file.pipeline_config must be replaced, so Plan: 2 to add, 0 to change, 1 to destroy.
A nightly -refresh-only plan uses the same exit code to detect drift: 2 means drift.
If another run changed the state after the plan, apply refuses the stale plan, so the run plans again. One state per environment, and the lock makes sure only one run writes at a time.
Key points to remember
- Pipeline gates: fmt -check, validate, test, plan -out, apply that file
- show -json for tools; a stale plan is refused; protect plan files
- OIDC: id-token: write, ARM_USE_OIDC, a federated credential per environment
- terraform test with mock_provider; validation, scanners, policy, Azure Policy
- Pin versions, commit the lock file, upgrade one provider at a time
The full script
Here is a Python script that writes all the files and runs every command in order. You need Terraform 1.17 on your PATH; run it in an empty folder.
Show the full script that writes the files and runs every step
# ========================================================================
# Terraform Interview Q&A #9 - CI/CD, testing and policy
# TechBrothersIT. Names and sample values are invented for teaching.
# Every step below was run with Terraform 1.17.0; the outputs in the video and the blog post are the real outputs.
# Providers used: hashicorp/random, null, local, time (they create nothing in any cloud). Azure and Databricks
# code in this series is checked with terraform validate only: nothing is created in a real subscription.
# To run it yourself (Linux, macOS or WSL; some steps use grep / cat):
# 1. Install Terraform 1.17 from developer.hashicorp.com/terraform/install and put it on your PATH.
# 2. python Terraform_Interview_QA_09_Code.py in an empty folder (terraform init downloads the providers).
# ========================================================================
import os, subprocess
def write(path, text):
if text is None:
if os.path.exists(path): os.remove(path)
return
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text.strip("\n") + "\n")
def run(cmd, expect_error=False, allow_error=False, env=None):
print("\n$ " + cmd, flush=True)
rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
if rc != 0 and not (expect_error or allow_error):
raise SystemExit("stopped: this command was expected to succeed")
# Step 0: the starting files
write('platform/versions.tf', """\
terraform {
required_version = "~> 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}
""")
write('platform/main.tf', """\
variable "environment" {
type = string
default = "dev"
}
locals {
containers = toset(["landing", "raw", "curated"])
}
resource "terraform_data" "container" {
for_each = local.containers
input = "stsalesdata${var.environment}/${each.key}"
}
resource "local_file" "pipeline_config" {
filename = "${path.module}/out/nightly-load-${var.environment}.json"
content = jsonencode({
containers = sort(local.containers)
schedule = "0 2 * * *"
})
}
""")
write('lake/providers.tf', """\
terraform {
required_version = "~> 1.17.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.11"
}
}
# state in Azure Blob, read with Entra ID and the OIDC token
backend "azurerm" {
storage_account_name = "sttfstatesales"
container_name = "tfstate"
key = "sales-data/dev.terraform.tfstate"
use_oidc = true
use_azuread_auth = true
}
}
# client, tenant and subscription IDs come from ARM_* variables
provider "azurerm" {
use_oidc = true
features {}
}
""")
write('lake/variables.tf', """\
variable "environment" {
type = string
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "environment must be one of dev, test, prod."
}
}
variable "public_network_access" {
type = string
default = "Disabled"
validation {
condition = var.public_network_access != "Enabled"
error_message = "Public network access is not allowed."
}
}
""")
write('lake/main.tf', """\
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}
""")
write('lake/tests/lake.tftest.hcl', """\
mock_provider "azurerm" {}
variables {
environment = "dev"
}
run "dev_uses_lrs" {
command = plan
assert {
condition = (
azurerm_storage_account.lake.account_replication_type == "LRS"
)
error_message = "dev must use LRS"
}
}
run "prod_uses_grs" {
command = plan
variables {
environment = "prod"
}
assert {
condition = (
azurerm_storage_account.lake.account_replication_type == "GRS"
)
error_message = "prod must use GRS"
}
}
""")
write('legacy/main.tf', """\
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.11"
}
}
}
provider "azurerm" {
features {}
}
# written for azurerm 4.x, now validated with 5.11
resource "azurerm_storage_account" "lake" {
name = "stsalesdatadev"
resource_group_name = "rg-sales-data-dev"
location = "westeurope"
account_tier = "Standard"
account_replication_type = "LRS"
is_hns_enabled = true
public_network_access_enabled = false
}
resource "azurerm_storage_container" "raw" {
name = "raw"
storage_account_name = azurerm_storage_account.lake.name
}
""")
run('cd lake && terraform fmt -check -recursive')
run('cd lake && terraform init -backend=false -input=false')
run('cd legacy && terraform init -input=false')
# Step 1: The pipeline gates: init, fmt -check, validate, plan -out
run('cd platform && terraform init -input=false')
run('cd platform && terraform fmt -check -recursive')
run('cd platform && terraform validate')
run('cd platform && terraform plan -input=false -out=tfplan')
# Step 2: The plan file as JSON: actions per resource, and a delete count for the pipeline
run('cd platform && terraform show -json tfplan | jq -r \'.resource_changes[] | "\\(.change.actions | join(",")) \\(.address)"\'')
run('cd platform && terraform show -json tfplan | jq \'[.resource_changes[] | select(.change.actions | index("delete"))] | length\'')
# Step 3: Apply the reviewed plan file (no prompt)
run('cd platform && terraform apply -input=false tfplan | tail -n 1')
# Step 4: Apply the same plan file again: the state changed, so it is stale
run('cd platform && terraform apply -input=false tfplan', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 5: fmt -check -diff on a badly formatted file, then terraform fmt fixes it
write('lake/outputs.tf', """\
output "storage_account_name" {
value = azurerm_storage_account.lake.name
description="Name of the lake storage account"
}
""")
run('cd lake && terraform fmt -check -diff; echo "exit code: $?"')
run('cd lake && terraform fmt')
# Step 6: validate catches a typo in a reference
write('lake/main.tf', """\
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.date.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}
""")
run('cd lake && terraform validate', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 7: Fixed: validate, then terraform test with mock_provider "azurerm"
write('lake/main.tf', """\
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}
""")
run('cd lake && terraform validate')
run('cd lake && terraform test')
# Step 8: A teammate sets LRS everywhere: the prod test fails
write('lake/main.tf', """\
locals {
replication = "LRS" # cheaper
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}
""")
run('cd lake && terraform test', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 9: A test proves public network access is rejected
write('lake/main.tf', """\
locals {
replication = var.environment == "prod" ? "GRS" : "LRS"
}
resource "azurerm_resource_group" "data" {
name = "rg-sales-data-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "lake" {
name = "stsalesdata${var.environment}"
resource_group_name = azurerm_resource_group.data.name
location = azurerm_resource_group.data.location
account_tier = "Standard"
account_replication_type = local.replication
is_hns_enabled = true
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
public_network_access = var.public_network_access
}
""")
write('lake/tests/security.tftest.hcl', """\
mock_provider "azurerm" {}
run "public_access_is_rejected" {
command = plan
variables {
environment = "dev"
public_network_access = "Enabled"
}
expect_failures = [var.public_network_access]
}
""")
run('cd lake && terraform test -filter=tests/security.tftest.hcl')
# Step 10: A PR excludes the release that broke prod, but the lock file still selects it
write('platform/versions.tf', """\
terraform {
required_version = "~> 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9, != 2.9.1"
}
}
}
""")
run('cd platform && terraform plan -input=false', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 11: azurerm 4.x-style code validated against azurerm 5.11
run('cd legacy && terraform validate', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 12: No changes: plan -detailed-exitcode returns 0
write('platform/versions.tf', """\
terraform {
required_version = "~> 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}
""")
run('cd platform && terraform plan -detailed-exitcode -input=false > plan.txt; echo "exit code: $?"')
run('cd platform && grep -E "No changes|# .* (will be|must be)|^Plan:" plan.txt')
# Step 13: A pull request adds a gold container: plan -detailed-exitcode returns 2
write('platform/main.tf', """\
variable "environment" {
type = string
default = "dev"
}
locals {
containers = toset(["landing", "raw", "curated", "gold"])
}
resource "terraform_data" "container" {
for_each = local.containers
input = "stsalesdata${var.environment}/${each.key}"
}
resource "local_file" "pipeline_config" {
filename = "${path.module}/out/nightly-load-${var.environment}.json"
content = jsonencode({
containers = sort(local.containers)
schedule = "0 2 * * *"
})
}
""")
run('cd platform && terraform plan -detailed-exitcode -input=false > plan.txt; echo "exit code: $?"')
run('cd platform && grep -E "No changes|# .* (will be|must be)|^Plan:" plan.txt')
Part 10: Real-World Manager Scenarios
The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run with Terraform 1.17.0; IDs, timestamps and durations are masked. Azure and Databricks code is checked with terraform validate. Part of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official Terraform documentation (developer.hashicorp.com/terraform) for anything you quote in an interview.



No comments:
Post a Comment
Note: Only a member of this blog may post a comment.