Enforcing Tag Policies in Terraform Plan Checks
The specific bottleneck this page solves is timing: every tag-validation approach that runs after terraform apply — a scheduled sweep, an AWS Config rule, a Cloud Asset Inventory scan — catches a missing cost_center or malformed owner value only once the resource already exists and is already accruing cost. Remediation at that point means mutating live infrastructure, racing a human operator’s next terraform apply, or waiting out an evaluation delay measured in minutes. The cheaper failure mode is to never create the resource in the first place. terraform plan already computes the exact attribute values — including tags — that apply would set, and terraform show -json exposes that computed state as structured JSON before a single API call to a cloud provider happens. A plan-time check that parses this JSON, validates tags against a required-tag schema, and fails the CI job is a zero-cost rejection: the pull request does not merge, apply never runs, and no non-compliant resource is ever billed. This is the pre-provisioning complement to the continuous evaluation described in Tagging Policy Enforcement with AWS Config, and it lives inside the broader discipline covered by Enforcing Tag Policies in Terraform.
Root Cause & Failure Modes
terraform validate only checks configuration syntax and internal consistency — it has no concept of what a compliant cost_center value looks like, because tag content is a policy concern, not a language concern. Teams that skip a dedicated plan-time check end up relying on one of three approaches, and each breaks in a specific way:
- Post-apply scanning. Correct eventually, but the resource exists and bills for the gap between creation and the next sweep. On a busy account, that gap compounds across dozens of resources per day.
terraform planoutput grepped as text. Fragile against formatting changes between Terraform versions, and cannot distinguish a tag that is genuinely missing from one whose value is not yet known because it depends on another resource’s computed output.- Checking only the
tagsattribute. Providers that supportdefault_tagsat the provider block (the AWS provider is the common case) merge those defaults into a separatetags_allattribute — the resource’s owntagsblock can be missing a required key entirely whiletags_allsupplies it from the provider default, or vice versa. A naive check ontagsalone produces false positives against infrastructure that is actually compliant, and false negatives whentags_allnever gets checked at all.
There is a second, subtler failure mode buried in the plan JSON itself: after_unknown. When a tag value derives from a resource attribute Terraform cannot resolve until apply — an ARN, a generated ID, an output from a module not yet created — the plan marks that key true in after_unknown instead of giving a value in after. A check that treats every key absent from a fully-resolved value as a violation will block perfectly legitimate infrastructure. The correct design validates only what the plan actually knows, and treats the rest as deferred rather than failed.
Production Pipeline Architecture
The check runs as four phases inside a CI job, and it must run before the apply step in the same workflow, never after:
- Plan generation.
terraform plan -out=tfplancomputes the full graph of proposed changes, thenterraform show -json tfplanserializes it to machine-readable JSON — this is the only artifact the check touches; it never calls a cloud API directly. - Change filtering. Iterate
resource_changes[], keep onlymanaged-mode resources whosechange.actionsincludecreateorupdate(skipdeleteandno-op— a destroy should never be blocked by a tag policy), and resolve which attribute actually carries tags for that resource type (tagsfor most providers,labelsfor GCP-style resources). - Schema validation. For each surviving resource, scrub tag keys the plan marks as unknown-until-apply, then validate the remaining known tag map against a versioned required-tag schema with
jsonschema. This mirrors the same canonical schema —cost_center,owner,environment,application_id— used by the runtime validator in the parent Resource Tagging & Validation Pipelines engine, so a resource that passes plan-time review does not turn around and fail the post-apply sweep on day one. - Reporting and exit. Print each violation as a GitHub Actions annotation so it surfaces directly on the pull request diff view, then exit non-zero. A non-zero exit is what actually blocks the merge — the annotations are for the human, the exit code is for the pipeline.
This four-phase shape is structurally identical to the cost-budget gate in Blocking Terraform Plans That Exceed Cost Budgets — both parse the same terraform show -json artifact, both filter to in-scope resource changes, and both fail the job before apply runs. If your pipeline already has a cost-budget gate, add the tag-policy gate as a second, independent step against the same plan.json rather than a second terraform plan invocation — plan generation is the expensive part, validation is cheap.
Step-by-Step Python Implementation
"""
tag_plan_check.py — validate required resource tags against a Terraform
plan before apply, so non-compliant infrastructure never gets created.
Usage: python tag_plan_check.py plan.json
Exit code: 0 = compliant, 1 = one or more violations found.
"""
import argparse
import json
import logging
import sys
from dataclasses import dataclass
from typing import Any, Dict, List
import jsonschema
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("tag_plan_check")
# Same canonical model the post-apply validator enforces, so a resource that
# clears this gate does not immediately fail the next scheduled sweep.
SCHEMA_VERSION = "v3.1"
REQUIRED_TAG_SCHEMA: Dict[str, Any] = {
"type": "object",
"required": ["cost_center", "owner", "environment", "application_id"],
"properties": {
"cost_center": {"type": "string", "pattern": "^[A-Z0-9]{4,8}$"},
"owner": {"type": "string", "pattern": r"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"},
"environment": {"enum": ["dev", "staging", "prod", "dr"]},
"application_id": {"type": "string", "pattern": "^app-[a-z0-9-]{3,32}$"},
},
}
# Resource type prefixes whose tag-bearing attribute isn't named "tags".
TAG_ATTRIBUTE_OVERRIDES = {"google_": "labels"}
TERMINAL_ACTIONS = {"delete", "no-op", "read"}
@dataclass
class TagViolation:
address: str
resource_type: str
rule: str
detail: str
def resolve_tag_attribute(resource_type: str) -> str:
for prefix, attr in TAG_ATTRIBUTE_OVERRIDES.items():
if resource_type.startswith(prefix):
return attr
return "tags"
def load_plan(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def iter_taggable_changes(plan: Dict[str, Any]):
"""Yield only managed-resource changes that will create or mutate a
live resource. Destroys and no-ops are never subject to tag policy."""
for rc in plan.get("resource_changes", []):
if rc.get("mode") != "managed":
continue
actions = set(rc.get("change", {}).get("actions", []))
if not actions or actions.issubset(TERMINAL_ACTIONS):
continue
yield rc
def validate_resource(
rc: Dict[str, Any], validator: jsonschema.Draft7Validator
) -> List[TagViolation]:
address = rc["address"]
resource_type = rc["type"]
change = rc.get("change", {})
after = change.get("after") or {}
after_unknown = change.get("after_unknown") or {}
attr = resolve_tag_attribute(resource_type)
unknown_marker = after_unknown.get(attr)
if unknown_marker is True:
# The whole tag map is computed and unresolvable until apply (e.g.
# tags_all sourced entirely from a not-yet-created module output).
# Deferring here beats a false-positive block on legitimate infra.
logger.warning("%s: %s unresolved until apply, deferring", address, attr)
return []
tags = after.get(attr) or {}
# after_unknown can also be a per-key dict marking individual computed
# values. Scrub those before validation — a key Terraform legitimately
# cannot know yet is not the same violation as a key that is simply absent.
unknown_keys = unknown_marker if isinstance(unknown_marker, dict) else {}
known_tags = {k: v for k, v in tags.items() if not unknown_keys.get(k)}
violations = []
for error in validator.iter_errors(known_tags):
rule = ".".join(str(p) for p in error.path) or "schema"
violations.append(TagViolation(address, resource_type, rule, error.message))
return violations
def emit_annotation(v: TagViolation) -> None:
# GitHub Actions workflow-command syntax. The plan JSON carries no
# source file/line for a resource_changes entry, so the annotation is
# scoped to the resource address rather than a fabricated line number.
print(f"::error title=Tag Policy Violation::{v.address} ({v.resource_type}): {v.detail}")
def run(plan_path: str) -> int:
plan = load_plan(plan_path)
validator = jsonschema.Draft7Validator(REQUIRED_TAG_SCHEMA)
violations: List[TagViolation] = []
checked = 0
for rc in iter_taggable_changes(plan):
checked += 1
violations.extend(validate_resource(rc, validator))
logger.info(
"schema=%s checked=%d violations=%d", SCHEMA_VERSION, checked, len(violations)
)
for v in violations:
emit_annotation(v)
if violations:
print(f"::error::{len(violations)} resource(s) failed tag policy — see annotations above")
return 1
return 0
def main() -> None:
parser = argparse.ArgumentParser(
description="Validate Terraform plan resource tags before apply"
)
parser.add_argument("plan_json", help="Path to terraform show -json output")
args = parser.parse_args()
sys.exit(run(args.plan_json))
if __name__ == "__main__":
main()
Wire it into the plan job right after the plan is generated and before any apply step ever runs:
- name: Terraform plan
run: |
terraform init -input=false
terraform plan -input=false -out=tfplan
terraform show -json tfplan > plan.json
- name: Enforce tag policy on plan
run: python ci/tag_plan_check.py plan.json
Because the check step is separate from the plan step, a tag-policy failure shows up as its own red check on the pull request, distinct from a plan failure — reviewers see immediately which gate rejected the change.
Verification & Testing
Treat the schema and the unknown-handling logic as the two things most likely to regress silently:
- Fixture-driven exit codes. Keep a
plan.jsonfixture with one compliant and one non-compliantresource_changesentry side by side. Assertrun()returns1and that exactly oneTagViolationis emitted, keyed to the non-compliant resource’s address. - Unknown-value regression test. Add a fixture resource whose
after_unknown.tagsistrue(fully computed) and another whoseafter_unknown.tagsis a dict with one unknown key. Assert both cases produce zero violations for the unknown fields — a regression here silently starts blocking legitimate merges. - Destroy and no-op exclusion. Include a resource change with
actions: ["delete"]carrying deliberately non-compliant tags inbefore. Assertiter_taggable_changesnever yields it — tag policy has no opinion on infrastructure being removed. - Schema parity check. Diff
REQUIRED_TAG_SCHEMAandSCHEMA_VERSIONin this script against the runtime validator described in Tagging Policy Enforcement with AWS Config on a schedule (or better, import both from one shared package). A silent drift means plan-time and post-apply checks disagree on what compliant even means.
Common Pitfalls Checklist
- Validating
tagswhendefault_tagsonly populatetags_all. Decide up front which attribute is authoritative for your provider setup and validate that one consistently — usuallytags_allonce provider-level defaults are in play. - Failing on
after_unknownvalues. Treat a key marked unknown as deferred, not missing — otherwise every resource with a computed tag input blocks CI regardless of compliance. - Not filtering
delete/no-opactions. A destroy carrying stale, non-compliant tags inbeforewill fail the job if you validatechange.actionsindiscriminately. - Hardcoding the tag attribute name. GCP-family resources use
labels, nottags— resolve the attribute per resource-type prefix rather than assuming one name site-wide. - Letting the plan-time schema drift from the post-apply schema. Two different definitions of “compliant” means a resource can pass CI and still get flagged the next morning — pin both to one versioned source.
Frequently Asked Questions
Why not just rely on terraform validate for this?
terraform validate checks configuration syntax and internal type consistency — it has no concept of what a valid cost_center or owner value looks like, because tag content is a policy decision, not a language rule. It also runs before values are resolved, so it cannot see the final computed tag map the way a plan-time check on terraform show -json output can.
How do I handle tags that come from a provider’s default_tags block?
Provider-level default_tags (the AWS provider is the common case) get merged into the resource’s tags_all attribute, separately from the tags block a resource author writes. Validate whichever attribute is authoritative for your setup — tags_all once provider defaults are in use — so a resource is not flagged for a key the provider is already supplying.
What happens when a required tag’s value depends on another resource’s computed output?
The plan marks that key true (or the whole map true) in change.after_unknown instead of resolving it, because Terraform genuinely cannot know the value until apply. The check must scrub those keys before validating rather than treating them as missing, or it will block infrastructure that is actually going to be compliant once applied.
Should this run on every pull request or only right before apply?
Run it on every pull request that touches Terraform, immediately after the plan step and before any apply step in the same or a downstream workflow. Catching a violation at PR-review time is strictly cheaper than catching it at the merge-triggered apply, and it gives the author immediate, annotated feedback instead of a failed deploy later.
Related
- Enforcing Tag Policies in Terraform — the parent implementation covering IAM scoping, provider-level default tags, and the broader CI/CD gate design this check plugs into.
- Resource Tagging & Validation Pipelines — the four-stage discovery, validation, classification, and remediation model this plan-time check front-loads into the pre-provisioning boundary.
- Tagging Policy Enforcement with AWS Config — the continuous, post-apply evaluation layer that catches drift on resources this gate cannot see, such as manual console changes.
- Blocking Terraform Plans That Exceed Cost Budgets — a structurally identical plan-time gate that parses the same
terraform show -jsonartifact to enforce a cost ceiling instead of a tag schema. - Setting Up FinOps Governance Boundaries in Terraform — the governance-boundary patterns this tag gate enforces at merge time.
Up: Enforcing Tag Policies in Terraform · Home: Cloud Cost Optimization & FinOps Automation