Gate a Terraform plan with tfpolicy without HCP Terraform
The tfpolicy CLI reads hand-written .policytest.hcl mocks and nothing else. It has no command that takes a plan and judges it, and terraform plan has no policy flag, so out of the box the only place a policy is enforced against real configuration is HCP Terraform.
This guide builds a harness that closes that gap. A plan is converted into mocks, and those mocks are fed to tfpolicy test, so a non-compliant configuration produces a non-zero exit in your own pipeline.
Working implementation, including the test suite: github.com/gcbikram/azure-tf-policy-test.
How the harness fits together
The dashed nodes are built during the run and thrown away. Everything in the top group is committed.
The generated mocks carry no expect_failure, so each one asserts that the resource satisfies every policy. A violation in the tfvars becomes a violation in the plan, becomes a failing mock, becomes a non-zero exit.
Keep the hand-written mocks. They assert intent ("TLS 1.1 must be rejected") and survive a rewrite of your examples. The generated ones assert what the configuration currently produces, which is what catches a provider or module upgrade changing the plan shape underneath a policy.
Before you start
- Terraform 1.16 or later, which is where the policy framework integration landed
- The
tfpolicybinary from releases.hashicorp.com/tfpolicy - Python 3.7 or later, since the generator relies on dictionaries preserving insertion order
- Credentials for your provider, because producing a plan means talking to it
The generator needs nothing outside the standard library, so there is no pip install step in CI.
Step 1: add the generator
Save this as tools/generate_policy_tests.py. The underscore in the filename matters if you want to unit test it later, since a hyphenated name cannot be imported.
#!/usr/bin/env python3
"""Generate tfpolicy mocks from a Terraform plan."""
import argparse
import json
import os
import re
import sys
# Keys safe to write bare in an HCL object. Deliberately excludes the hyphen:
# a bare `cost-centre = "x"` can parse as a subtraction rather than a key, and
# Azure tag names carry hyphens constantly. Quoting is always safe, so anything
# outside this set gets quoted.
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Terraform addresses contain dots, brackets and quotes. Mock labels cannot.
UNSAFE_LABEL_CHARS = re.compile(r"[^A-Za-z0-9_]+")
def hcl_string(value):
"""Render a Python string as an HCL quoted string.
The interpolation sequences matter here. A plan can legitimately contain a
literal ${ (an ARM expression, a shell snippet in user data), and writing it
raw would make tfpolicy try to interpolate it.
"""
out = value.replace("\\", "\\\\").replace('"', '\\"')
out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
out = out.replace("${", "$${").replace("%{", "%%{")
return f'"{out}"'
def hcl_key(key):
return key if IDENT.match(key) else hcl_string(key)
def render(value, indent=1):
"""Render a JSON-decoded value as HCL."""
pad = " " * indent
inner = " " * (indent + 1)
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return json.dumps(value)
if isinstance(value, str):
return hcl_string(value)
if isinstance(value, list):
if not value:
return "[]"
items = [f"{inner}{render(v, indent + 1)}" for v in value]
return "[\n" + ",\n".join(items) + f"\n{pad}]"
if isinstance(value, dict):
if not value:
return "{}"
items = [
f"{inner}{hcl_key(k)} = {render(v, indent + 1)}"
for k, v in value.items()
]
return "{\n" + "\n".join(items) + f"\n{pad}}}"
raise TypeError(f"cannot render {type(value).__name__} as HCL")
class Pruner:
"""Walks change.after alongside after_unknown and after_sensitive.
Three things get removed, and each for a different reason:
Unknown values are computed at apply time. The plan carries them as null in
`after` with a flag in `after_unknown`, so emitting them would assert that an
attribute is explicitly null when it is really "not decided yet".
Sensitive values must never reach a committed test fixture. A generated mock
is a file in Git, and a plan can carry connection strings and keys.
Nulls are dropped by default only because a real plan carries every unset
optional attribute, which buries the interesting ones. See --keep-nulls.
"""
def __init__(self, keep_nulls=False, redact_sensitive=True):
self.keep_nulls = keep_nulls
self.redact_sensitive = redact_sensitive
self.dropped_unknown = 0
self.dropped_sensitive = 0
self.dropped_null = 0
def prune(self, value, unknown=False, sensitive=False):
"""Return (value, drop). drop=True means omit the key entirely."""
if unknown is True:
self.dropped_unknown += 1
return None, True
if sensitive is True:
self.dropped_sensitive += 1
if self.redact_sensitive:
return "(sensitive value withheld by the generator)", False
return None, True
if isinstance(value, dict):
out = {}
for key, item in value.items():
sub_u = unknown.get(key, False) if isinstance(unknown, dict) else False
sub_s = sensitive.get(key, False) if isinstance(sensitive, dict) else False
pruned, drop = self.prune(item, sub_u, sub_s)
if not drop:
out[key] = pruned
return out, False
if isinstance(value, list):
out = []
for index, item in enumerate(value):
sub_u = unknown[index] if isinstance(unknown, list) and index < len(unknown) else False
sub_s = sensitive[index] if isinstance(sensitive, list) and index < len(sensitive) else False
pruned, drop = self.prune(item, sub_u, sub_s)
if not drop:
out.append(pruned)
return out, False
if value is None and not self.keep_nulls:
self.dropped_null += 1
return None, True
return value, False
def display_path(path):
"""A path safe to embed in the generated file's header.
The header tells the next person how to regenerate, so it must not bake in
an absolute path from whichever machine ran the generator. That would make
the committed file differ per developer and produce noise in the drift check.
"""
try:
relative = os.path.relpath(path)
except ValueError:
# Windows raises when the path is on a different drive from the cwd.
return os.path.basename(path)
if relative.startswith(".."):
return os.path.basename(path)
return relative.replace(os.sep, "/")
def warn_unresolvable_targets(out_path, targets):
"""Targets are relative to the generated file, not the working directory.
Getting that wrong produces a file that only fails later, inside tfpolicy,
with an error that does not mention this script.
"""
out_dir = os.path.dirname(os.path.abspath(out_path))
for target in targets:
if os.path.isabs(target):
continue
resolved = os.path.normpath(os.path.join(out_dir, target))
if not os.path.exists(resolved):
print(
f"warning: target {target!r} does not resolve to a file from "
f"{out_dir}. Targets are relative to the generated file, not the "
f"current directory.",
file=sys.stderr,
)
def label_for(address, used):
"""Turn a Terraform address into a unique, valid HCL label."""
label = UNSAFE_LABEL_CHARS.sub("_", address).strip("_").lower()
if not label or not label[0].isalpha() and label[0] != "_":
label = f"r_{label}"
candidate = label
suffix = 2
while candidate in used:
candidate = f"{label}_{suffix}"
suffix += 1
used.add(candidate)
return candidate
def select_changes(plan, types, include_actions):
for change in plan.get("resource_changes", []):
if change.get("mode") != "managed":
continue
actions = change.get("change", {}).get("actions", [])
if not any(action in include_actions for action in actions):
continue
if types and change.get("type") not in types:
continue
yield change
def main():
parser = argparse.ArgumentParser(
description="Generate tfpolicy .policytest.hcl mocks from terraform show -json output."
)
parser.add_argument("--plan", required=True,
help="Path to JSON from 'terraform show -json tfplan'.")
parser.add_argument("--out", required=True,
help="Path of the .policytest.hcl file to write.")
parser.add_argument("--targets", required=True, nargs="+",
help="Policy files this test targets, relative to the output file.")
parser.add_argument("--types", nargs="*", default=None,
help="Only emit these resource types. Default: all managed resources.")
parser.add_argument("--expect-failure", action="store_true",
help="Mark every generated mock expect_failure.")
parser.add_argument("--actions", nargs="*", default=["create", "update"],
help="Plan actions to include. Default: create update.")
parser.add_argument("--keep-nulls", action="store_true",
help="Keep attributes the plan reports as null.")
parser.add_argument("--drop-sensitive", action="store_true",
help="Omit sensitive attributes instead of replacing them with a placeholder.")
args = parser.parse_args()
try:
with open(args.plan, encoding="utf-8") as handle:
plan = json.load(handle)
except FileNotFoundError:
sys.exit(f"error: no such plan file: {args.plan}")
except json.JSONDecodeError as exc:
sys.exit(f"error: {args.plan} is not valid JSON: {exc}")
if "resource_changes" not in plan:
sys.exit(
"error: no resource_changes in the plan JSON. "
"Generate it with 'terraform show -json tfplan', not 'terraform plan -json'."
)
pruner = Pruner(keep_nulls=args.keep_nulls, redact_sensitive=not args.drop_sensitive)
used_labels = set()
blocks = []
for change in select_changes(plan, args.types, set(args.actions)):
after = change["change"].get("after")
if after is None:
continue
attrs, drop = pruner.prune(
after,
change["change"].get("after_unknown", {}),
change["change"].get("after_sensitive", {}),
)
if drop or not attrs:
continue
label = label_for(change["address"], used_labels)
body = [f'resource "{change["type"]}" "{label}" {{']
body.append(f' # Generated from {change["address"]}')
if args.expect_failure:
body.append(" expect_failure = true")
body.append("")
body.append(f" attrs = {render(attrs, 1)}")
body.append("}")
blocks.append("\n".join(body))
if not blocks:
sys.exit(
"error: the plan produced no mocks. Nothing matched the requested "
f"types {args.types} and actions {args.actions}."
)
warn_unresolvable_targets(args.out, args.targets)
targets = ", ".join(hcl_string(t) for t in args.targets)
header = [
"# Generated by tools/generate_policy_tests.py. Do not edit by hand.",
"#",
"# Regenerate with:",
"# terraform show -json tfplan > plan.json",
f"# python tools/generate_policy_tests.py --plan plan.json --out {display_path(args.out)} \\",
f"# --targets {' '.join(args.targets)}",
"",
"policytest {",
f" targets = [{targets}]",
"}",
"",
]
with open(args.out, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(header))
handle.write("\n")
handle.write("\n\n".join(blocks))
handle.write("\n")
print(f"wrote {len(blocks)} mock(s) to {args.out}")
if pruner.dropped_unknown:
print(f" omitted {pruner.dropped_unknown} value(s) unknown at plan time")
if pruner.dropped_sensitive:
action = "omitted" if args.drop_sensitive else "redacted"
print(f" {action} {pruner.dropped_sensitive} sensitive value(s)")
if pruner.dropped_null:
print(f" dropped {pruner.dropped_null} null attribute(s); use --keep-nulls to retain")
if __name__ == "__main__":
main()
Four behaviours in there are load-bearing rather than incidental.
newline="\n" on the output file. CI compares the committed file byte for byte, so CRLF from a Windows developer would fail the check for everyone on Linux.
The empty-selection branch exits non-zero instead of writing an empty file. Both tfpolicy test and tfpolicy validate exit 0 when they find no files, so a harness that can silently produce nothing inherits a gate that cannot fail.
Sensitive values are replaced, not written. Generated mocks get committed, and a plan carries access keys.
Unknown values are omitted rather than written as null, because the plan reports "not decided yet" and null in a mock asserts "explicitly unset".
Step 2: run the gate locally
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
python tools/generate_policy_tests.py --plan plan.json --out policy-tests/generated.policytest.hcl --targets ../policies/storage_account_azapi.policy.hcl
tfpolicy test --policies=policies/ --tests=policy-tests/
A compliant plan exits 0. A non-compliant one exits 1 and prints the policy's own error message alongside the value it compared:
Error: Storage account 'stavmgen0001' sets minimumTlsVersion to 'TLS1_1'...
attrs.body.properties.minimumTlsVersion is "TLS1_1"
--targets resolves relative to the generated file, not your working directory. The generator warns when a target does not resolve, because otherwise the mistake surfaces much later as a tfpolicy error that never mentions the script.
Step 3: place the gate in the pipeline
The gate must consume the same plan file the apply will consume. Re-planning at apply time can produce a different result from the one the gate approved, because the world moved in between.
The two credential-free jobs run first so a formatting error never burns a login or a state lock. Keeping policy free of cloud credentials also means it still runs on pull requests from forks.
In GitHub Actions the gate is one step:
gate:
name: Policy gate
runs-on: ubuntu-latest
needs: plan
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
terraform_wrapper: false
- name: Install tfpolicy
run: |
set -euo pipefail
base="https://releases.hashicorp.com/tfpolicy/${TFPOLICY_VERSION}"
zip="tfpolicy_${TFPOLICY_VERSION}_linux_amd64.zip"
curl -fsSL -o "$zip" "$base/$zip"
curl -fsSL -o SHA256SUMS "$base/tfpolicy_${TFPOLICY_VERSION}_SHA256SUMS"
sha256sum -c --ignore-missing SHA256SUMS
unzip -q "$zip"
install -m 0755 tfpolicy /usr/local/bin/tfpolicy
- name: Download the plan
uses: actions/download-artifact@v4
with:
name: tfplan
path: ${{ env.WORKING_DIR }}
- name: Generate mocks from the plan and evaluate them
run: |
set -euo pipefail
terraform -chdir="${WORKING_DIR}" show -json tfplan > plan.json
python3 tools/generate_policy_tests.py \
--plan plan.json \
--out policy-tests/generated.policytest.hcl \
--targets ../policies/storage_account_azapi.policy.hcl
tfpolicy test --policies=policies/ --tests=policy-tests/
Pin TFPOLICY_VERSION explicitly and verify the download against the published checksums. There is no package manager distribution for a beta binary, so the pipeline fetches an executable over the network on every run and then executes it.
Add gate to the apply job's needs: so a failed gate blocks the apply.
Failure cases you will hit
Unknown values fail closed. Anything computed at apply time is omitted from the mock. If a policy-relevant attribute comes from a random_string or another resource's output, it disappears, and a fail-closed policy then rejects it. This is the most likely source of a confusing red build. Either compute the value earlier so it is known at plan time, or scope the policy so it does not depend on a computed attribute.
Module policies are not covered. The generator reads resource_changes, so it emits resource mocks only. A module_policy checking meta.source and meta.version is not evaluated by this path. Plan JSON carries version_constraint rather than a resolved version, so covering it properly means reading .terraform/modules/modules.json after init and normalising the source, which prefixes registry modules with registry.terraform.io/.
The gate needs a successful plan. A configuration that fails to plan produces no JSON, so the gate never runs. Make sure the plan job's failure is what blocks the pipeline in that case, not a skipped gate reported as success.
It is a CI step, not enforcement. Anyone who can edit the workflow can delete the job. Requiring the workflow through repository rulesets closes that for pull requests, and Azure Policy remains the control that covers applies happening outside CI entirely.
Confirm the harness is not vacuous
A gate that cannot fail is worse than no gate, because the green check is persuasive. Prove yours fails before you trust it.
Take a copy of a compliant plan, break one value the policy checks, and run the chain against it:
python -c "import json; p=json.load(open('plan.json')); p['resource_changes'][0]['change']['after']['min_tls_version']='TLS1_1'; json.dump(p, open('plan-bad.json','w'))"
python tools/generate_policy_tests.py --plan plan-bad.json --out /tmp/gate/bad.policytest.hcl --targets ../policies/storage_account_azurerm.policy.hcl
tfpolicy test --policies=policies/ --tests=/tmp/gate/
That must exit 1 and name the attribute. If it exits 0, the mocks are not reaching the policy, and the most common reason is a --targets path that does not resolve from the generated file.
Do this again after any change to the directory layout or the CI invocation.