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 plan output 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 tags attribute. Providers that support default_tags at the provider block (the AWS provider is the common case) merge those defaults into a separate tags_all attribute — the resource’s own tags block can be missing a required key entirely while tags_all supplies it from the provider default, or vice versa. A naive check on tags alone produces false positives against infrastructure that is actually compliant, and false negatives when tags_all never 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:

  1. Plan generation. terraform plan -out=tfplan computes the full graph of proposed changes, then terraform show -json tfplan serializes it to machine-readable JSON — this is the only artifact the check touches; it never calls a cloud API directly.
  2. Change filtering. Iterate resource_changes[], keep only managed-mode resources whose change.actions include create or update (skip delete and no-op — a destroy should never be blocked by a tag policy), and resolve which attribute actually carries tags for that resource type (tags for most providers, labels for GCP-style resources).
  3. 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.
  4. 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.json fixture with one compliant and one non-compliant resource_changes entry side by side. Assert run() returns 1 and that exactly one TagViolation is emitted, keyed to the non-compliant resource’s address.
  • Unknown-value regression test. Add a fixture resource whose after_unknown.tags is true (fully computed) and another whose after_unknown.tags is 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 in before. Assert iter_taggable_changes never yields it — tag policy has no opinion on infrastructure being removed.
  • Schema parity check. Diff REQUIRED_TAG_SCHEMA and SCHEMA_VERSION in 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 tags when default_tags only populate tags_all. Decide up front which attribute is authoritative for your provider setup and validate that one consistently — usually tags_all once provider-level defaults are in play.
  • Failing on after_unknown values. 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-op actions. A destroy carrying stale, non-compliant tags in before will fail the job if you validate change.actions indiscriminately.
  • Hardcoding the tag attribute name. GCP-family resources use labels, not tags — 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.

Up: Enforcing Tag Policies in Terraform · Home: Cloud Cost Optimization & FinOps Automation