Testing and Policy as Code
Catching mistakes before they reach production: formatting and validation, linters such as TFLint and ansible-lint, the test command, Molecule, security scanners, and policy as code with OPA.
A ladder of checks
Infrastructure code fails in expensive ways: a wrong plan can delete a database, and a playbook with a typo can stop nginx on every web server at once. Testing infrastructure therefore follows the same idea as the test pyramid for applications: many fast, cheap checks that run on every change, and fewer slow, realistic ones. Each rung catches a different kind of mistake.
| Rung | Catches | Needs |
|---|---|---|
| Format and validate | Untidy code, syntax errors, typos in argument names, wrong types, undeclared variables | Only the tool (and providers, for validate) |
| Lint | Valid code that is still wrong or risky: unused variables, short module names, file permissions not set | The linter and its rule plugins |
| Scan |
Insecure settings: public buckets, unencrypted disks, a
firewall rule open to 0.0.0.0/0
|
The scanner; works on files or plan JSON |
| Policy | Breaches of your own rules: missing owner tags, wrong regions, destroying protected resources | A plan (so state and credentials) plus the policies |
| Test | Logic errors: a template that renders the wrong file, a role that is not idempotent | Time; sometimes real resources or a test machine |
None of this replaces reading the plan. The checks make sure the plan a reviewer reads is worth reading; the reviewer still decides whether "1 to destroy" is intended. In a pipeline the whole ladder becomes a quality gate (see Infrastructure as Code in Pipelines).
Formatting and validation
tofu fmt rewrites files into the canonical style: two
spaces of indentation and aligned = signs. With
-check it changes nothing, prints the names of files
that are not formatted, and exits with a non-zero code — exactly
what a pipeline needs. Joost Bakker's new file for the nightly
billing export was typed in a hurry:
cat billing.tf
tofu fmt -check
tofu fmt -diff
tofu fmt -check
resource "local_file" "billing_cron" {
filename = "${path.module}/out/billing-export.cron"
content = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
file_permission="0640"
}
billing.tf
billing.tf
--- old/billing.tf
+++ new/billing.tf
@@ -1,5 +1,5 @@
resource "local_file" "billing_cron" {
-filename = "${path.module}/out/billing-export.cron"
- content = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
- file_permission="0640"
+ filename = "${path.module}/out/billing-export.cron"
+ content = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
+ file_permission = "0640"
}
The first -check printed billing.tf and
exited with code 3; -diff showed the change and rewrote
the file; the second -check printed nothing and exited
with 0. Add -recursive to include subdirectories such
as modules/. With Terraform the commands are the same:
terraform fmt -check.
tofu validate goes one step further: it loads the
configuration with the provider schemas (so it needs
tofu init first) and reports unknown arguments, wrong
types and references to things that do not exist. It does not
contact any cloud API and does not read the state, so it is safe to
run anywhere. The typical errors and their fixes are on
Troubleshooting Infrastructure as Code; a clean run prints one line:
Success! The configuration is valid.
Built-in linting in OpenTofu 1.13
As of October 2026, OpenTofu 1.13 has
experimental built-in lint rules, enabled with a
-lint flag on validate, plan,
apply and refresh. The rules include
core:no-type-variable (a variable without a type),
core:unused-variable,
core:unused-local and
core:count-instead-enabled (a
count = var.x ? 1 : 0 that could use
enabled in OpenTofu's lifecycle block).
Findings are warnings, and OpenTofu also warns that the feature is
experimental and may change in minor releases. Terraform has no
equivalent flag.
tofu validate -lint='all,!core:unused-local'
╷
│ Warning: Variable with no type (core:no-type-variable)
│
│ on variables.tf line 12:
│ 12: variable "legacy_port" {
│
│ Variable "legacy_port" has no type specified.
╵
…
Linters
A linter looks for code that works but is likely to cause trouble later. Infrastructure code has two well-known ones.
TFLint
TFLint is an open-source linter for
Terraform and OpenTofu configuration (MPL 2.0; v0.64.0 as of October
2026). Its rules come in plugins: a bundled
terraform ruleset for language-level problems, and
separate rulesets for AWS, Azure and Google Cloud that know, for
example, which virtual machine sizes exist — something
validate cannot check, because to the provider schema
an instance type is just a string. The configuration lives in
.tflint.hcl:
plugin "terraform" {
enabled = true
preset = "recommended"
}
Cloud rulesets are added as further plugin blocks with
a source and a pinned version;
tflint --init downloads them. Then
tflint (or tflint --recursive for a
repository with modules) runs the rules:
tflint --init
tflint
2 issue(s) found:
Warning: `legacy_port` variable has no type (terraform_typed_variables)
on variables.tf line 12:
12: variable "legacy_port" {
Warning: variable "legacy_port" is declared but not used (terraform_unused_declarations)
on variables.tf line 12:
12: variable "legacy_port" {
…
ansible-lint
ansible-lint checks playbooks,
roles and collections against the Ansible community's best
practices. It is a separate Python package (26.9.0 as of October
2026, calendar versioning) and supports only the last two major
versions of Ansible. Rules have names such as
fqcn[action-core] (use
ansible.builtin.copy, not copy),
no-changed-when (a command task that
always reports "changed"), risky-file-permissions,
yaml[truthy] and yaml[octal-values] (the
YAML traps from
Configuration Languages).
Profiles group the rules from lenient to strict:
min, basic, moderate,
safety, shared and
production.
Hamid Rahimi's quick playbook for the billing export settings:
- name: Billing export settings
hosts: db
gather_facts: false
tasks:
- name: Write the export settings
copy:
content: "format=csv\n"
dest: /etc/kestrel/billing-export.ini
ansible-lint billing.yml
fqcn[action-core]: Use FQCN for builtin module actions (copy). billing.yml:5 Use `ansible.builtin.copy` or `ansible.legacy.copy` instead. risky-file-permissions: File permissions unset or incorrect. billing.yml:5 Task/Handler: Write the export settings Read documentation for instructions on how to ignore specific rule violations. # Rule Violation Summary … Failed: 2 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'moderate'. Rating: 2/5 star
The fix is ansible.builtin.copy with
mode: "0640" (quoted). Without a mode, the file gets
whatever the remote user's umask gives, which may be readable by
everyone on db01.
Before any linter, Ansible itself can check that a playbook parses.
--syntax-check loads the playbook, its roles and its
includes without connecting to a host:
ansible-playbook site.yml --syntax-check
playbook: site.yml
The test command
Linters read code; tests run it. Since version 1.6, both OpenTofu
(tofu test) and Terraform (terraform test)
have a built-in test command. A
test file ends in
.tftest.hcl, usually sits in a
tests/ folder next to the module, and contains
run blocks. Each run block plans or applies the module
and checks assert conditions against the result. Ravi
Kumar wrote this test for the nginx-upstream module
that renders the portal's load-balancer configuration (the module
itself is on
Modules and Reuse):
tests/upstream.tftest.hcl
variables {
name = "kestrel_portal"
servers = ["192.0.2.11:8080", "192.0.2.12:8080"]
output_dir = "./test-out"
}
run "renders_one_line_per_server" {
command = plan
assert {
condition = length(regexall("server ", local_file.this.content)) == 2
error_message = "Expected one server line per backend."
}
assert {
condition = local_file.this.filename == "./test-out/kestrel_portal.conf"
error_message = "The file name must follow <output_dir>/<name>.conf."
}
}
run "rejects_empty_server_list" {
command = plan
variables {
servers = []
}
expect_failures = [var.servers]
}
run "writes_the_file" {
command = apply
assert {
condition = fileexists(output.path)
error_message = "The upstream file was not written."
}
}
-
The top-level
variablesblock gives every run the same inputs; a run can override them, as the second one does. -
command = plancreates nothing and is fast; it can check every value that is known at plan time. -
expect_failures = [var.servers]turns an expected error into a pass: the module's validation rule must reject an empty list. Testing that bad input is refused is as important as testing that good input works. -
command = applyreally creates the resources, andtofu testdestroys them again at the end. With a cloud provider that means real (billed) resources in a test account — and a cleanup to check if a test run is killed.
tofu test
tests/upstream.tftest.hcl... pass run "renders_one_line_per_server"... pass run "rejects_empty_server_list"... pass run "writes_the_file"... pass Success! 3 passed, 0 failed.
To see a failure, the first assertion was changed to expect three server lines. The failing run shows the values involved and a diff, and the command exits with code 1, which stops a pipeline:
tests/upstream.tftest.hcl... fail
run "renders_one_line_per_server"... fail
╷
│ Error: Test assertion failed
│
│ on tests/upstream.tftest.hcl line 11, in run "renders_one_line_per_server":
│ 11: condition = length(regexall("server ", local_file.this.content)) == 3
│ ├────────────────
│ │ local_file.this.content is "upstream kestrel_portal {\n server 192.0.2.11:8080;\n server 192.0.2.12:8080;\n}\n"
│ ├────────────────
│ │ Diff:
│ │ 2 -> 3
│
│ Expected one server line per backend.
╵
run "rejects_empty_server_list"... pass
run "writes_the_file"... pass
Failure! 2 passed, 1 failed.
Tests against a cloud do not always need the cloud. A
mock_provider "aws" {} block in a test file replaces
the real provider with one that invents values for computed
attributes, so a plan-and-assert test runs without credentials
(OpenTofu 1.8 and later, Terraform 1.7 and later). Mocks prove the
logic of the module, not that AWS accepts the result; keep a few
real apply tests in a sandbox account for that.
Testing Ansible roles with Molecule
Molecule is the testing framework
for Ansible roles, playbooks and collections (MIT license; 26.9.0 as
of October 2026, released together with ansible-lint). A
scenario describes a disposable test machine,
usually a container or a small virtual machine. The default
molecule test sequence is:
- dependency, cleanup, destroy: install required collections, remove leftovers of an earlier run;
- syntax, create, prepare: check the syntax, create the test machine, put prerequisites on it;
- converge: apply the role;
-
idempotence: apply it again and fail if
any task reports
changed; -
side_effect, verify: optionally
disturb the machine, then run checks (by default an Ansible
playbook with
asserttasks); - cleanup, destroy.
The idempotence step is the one that pays off most often. A role
that reports "changed" on every run hides real changes in noise and
restarts services for nothing. The check is easy to do by hand on a
test inventory: run the playbook twice and read the second
PLAY RECAP. For the Kestrel web servers the second run
looked like this:
PLAY RECAP ********************************************************************* db01 : ok=2 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 web01 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 web02 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 on every host is what Molecule's idempotence
step checks for you, on a clean machine, for every pull request. The
playbook itself and the first run are on
Playbooks and Idempotency.
Security scanners
A security scanner is static analysis with hundreds of built-in rules about insecure settings, the infrastructure counterpart of static application security testing. It complements the cloud's own misconfiguration scanning: the scanner finds the problem in the pull request, the cloud finds it after it exists.
Checkov (Apache 2.0, maintained by
Prisma Cloud at Palo Alto Networks; 3.3.25 as of October 2026) scans
Terraform and OpenTofu files, plan JSON, CloudFormation, Kubernetes
manifests, Dockerfiles and more. Each rule has an ID such as
CKV_AWS_21. A new bucket for the billing exports, with
nothing but a tag, fails several checks at once (versioning, access
logging, cross-region replication, KMS encryption, public access
block, …); the first finding:
resource "aws_s3_bucket" "billing_exports" {
bucket = "kestrel-billing-exports-test"
tags = {
owner = "joost.bakker"
}
}
checkov -d . --compact
terraform scan results: Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled" FAILED for resource: aws_s3_bucket.billing_exports File: /storage.tf:1-7 …
Some findings do not apply. Checkov accepts a skip comment inside the resource, and the comment must say why, so the next reviewer can judge whether the reason still holds:
resource "aws_s3_bucket" "billing_exports" {
#checkov:skip=CKV_AWS_144:Test exports are recreated nightly; no cross-region copy needed
bucket = "kestrel-billing-exports-test"
tags = {
owner = "joost.bakker"
}
}
Trivy, best known as a container image scanner, also scans infrastructure code; tfsec, once the popular Terraform scanner, has been folded into Trivy and its README asks users to move. If you run a scanner as a GitHub Action, pin it to a commit SHA: in March 2026 the version tags of the Trivy action were moved to malicious code (see container security and pinning actions). A scanner runs with the pipeline's permissions, which makes it an attractive supply chain attack target.
Policy as code
Scanners know general best practice. Every organization also has its
own rules: Kestrel Energy requires an owner tag on
every bucket and server, allows resources only in EU regions, and
never wants PostgreSQL on db01 reachable from the internet.
Policy as code means writing
such rules in a machine-readable language, keeping them in Git with
reviews and history, and evaluating them automatically on every
change — instead of in a wiki page that reviewers are supposed to
remember.
Open Policy Agent and Rego
Open Policy Agent (OPA) is a general-purpose policy engine, a graduated project of the Cloud Native Computing Foundation (CNCF); v1.21.1 as of October 2026. It takes a JSON document as input, evaluates policies against it, and returns a decision, so it acts as a policy decision point. Policies are written in Rego, OPA's declarative query language: a rule describes the conditions under which something is true, and every condition in the rule body must hold.
Policies should check the plan, not the HCL. In the
source files a value may be a variable, a module output or a
function call; in the plan JSON (tofu show -json tfplan) every value that is known has been worked out. The Kestrel policy
file:
policy/kestrel.rego
package kestrel.plan
tagged_types := {"aws_s3_bucket", "aws_instance", "aws_db_instance"}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
after := rc.change.after
after.from_port <= 5432
after.to_port >= 5432
"0.0.0.0/0" in after.cidr_blocks
msg := sprintf("%s opens PostgreSQL (5432) to the whole internet", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type in tagged_types
rc.change.after != null
not rc.change.after.tags.owner
msg := sprintf("%s has no owner tag", [rc.address])
}
-
deny contains msg if { … }adds a message to the setdenyfor every combination of values that makes all lines in the body true. Two rules with the same name add to the same set. -
some rc in input.resource_changesloops over every planned change;rc.change.afteris the resource as it will be after the apply (nullfor a deletion). -
not rc.change.after.tags.owneris true when the tag is missing, or when there are no tags at all.
Conftest (v0.71.1 as of October 2026) is a small command-line tool that runs Rego policies against configuration files, including plan JSON. In the pipeline, after the plan:
tofu show -json tfplan > tfplan.json
conftest test --policy policy/ --namespace kestrel.plan tfplan.json
FAIL - tfplan.json - kestrel.plan - aws_instance.web03 has no owner tag FAIL - tfplan.json - kestrel.plan - aws_security_group_rule.db01_postgres opens PostgreSQL (5432) to the whole internet 2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
The same policy, step by step
The logic of a policy is easier to see without Rego's syntax. This program applies the same two rules to a shortened plan JSON with four changes: a tagged bucket, an open firewall rule, an untagged server and a server being deleted.
import json
# A shortened excerpt of `tofu show -json tfplan` for a planned change
plan = json.loads("""
{
"resource_changes": [
{"address": "aws_s3_bucket.billing_exports",
"type": "aws_s3_bucket",
"change": {"actions": ["create"],
"after": {"bucket": "kestrel-billing-exports",
"tags": {"owner": "joost.bakker"}}}},
{"address": "aws_security_group_rule.db01_postgres",
"type": "aws_security_group_rule",
"change": {"actions": ["create"],
"after": {"from_port": 5432, "to_port": 5432,
"cidr_blocks": ["0.0.0.0/0"]}}},
{"address": "aws_instance.web03",
"type": "aws_instance",
"change": {"actions": ["create"],
"after": {"instance_type": "t4g.small", "tags": null}}},
{"address": "aws_instance.web_old",
"type": "aws_instance",
"change": {"actions": ["delete"], "after": null}}
]
}
""")
TAGGED_TYPES = {"aws_s3_bucket", "aws_instance", "aws_db_instance"}
def deny(change):
"""Return the policy violations for one resource change."""
after = change["change"]["after"]
if after is None: # being deleted: nothing to check
return []
problems = []
if change["type"] == "aws_security_group_rule":
opens_5432 = after["from_port"] <= 5432 <= after["to_port"]
if opens_5432 and "0.0.0.0/0" in after["cidr_blocks"]:
problems.append("opens PostgreSQL (5432) to the whole internet")
if change["type"] in TAGGED_TYPES and not (after.get("tags") or {}).get("owner"):
problems.append("has no owner tag")
return problems
violations = []
for change in plan["resource_changes"]:
for problem in deny(change):
violations.append(f"{change['address']} {problem}")
for line in sorted(violations):
print("DENY", line)
print(f"{len(violations)} violation(s): {'blocked' if violations else 'allowed'}")
DENY aws_instance.web03 has no owner tag DENY aws_security_group_rule.db01_postgres opens PostgreSQL (5432) to the whole internet 2 violation(s): blocked
The deleted server is not checked: there is nothing left to tag. Whether deleting it is allowed is a different policy, and a good one to have for databases.
Other policy engines
HCP Terraform, HashiCorp's hosted service, runs policies in its own language Sentinel or in OPA as a step between plan and apply; as of October 2026 both are included in its free tier. Cloud platforms have their own policy services that act on the account itself (AWS Service Control Policies, Azure Policy, Google Cloud Organization Policy — see guardrails). These are complementary: the pipeline policy gives fast feedback in the pull request, the cloud policy also stops someone who bypasses the pipeline.
Where each check runs
| Where | What to run | Why there |
|---|---|---|
| On the laptop, before a commit |
tofu fmt, tofu validate,
ansible-playbook --syntax-check, linters
|
Seconds; fixes before anyone else sees the change |
| Pipeline, every pull request |
The same again (laptops differ), scanners, plan, policy on
the plan JSON, tofu test, Molecule
|
Nobody can skip it; results sit next to the review |
| Pipeline, on a schedule |
Plan against production for drift; slow
apply tests in a sandbox account
|
Finds what changed outside the code |
The same checks also apply to network configuration; the network team's version of this ladder, with Batfish, is on Network Automation with Git.