Installation
Download and extract to your repository:
.github/skills/terraform-azurerm-set-diff-analyzer/ Extract the ZIP to .github/skills/ in your repo. The folder name must match terraform-azurerm-set-diff-analyzer for Copilot to auto-discover it.
Skill Files (5)
SKILL.md 2.1 KB
---
name: terraform-azurerm-set-diff-analyzer
description: Analyze Terraform plan JSON output for AzureRM Provider to distinguish between false-positive diffs (order-only changes in Set-type attributes) and actual resource changes. Use when reviewing terraform plan output for Azure resources like Application Gateway, Load Balancer, Firewall, Front Door, NSG, and other resources with Set-type attributes that cause spurious diffs due to internal ordering changes.
license: MIT
---
# Terraform AzureRM Set Diff Analyzer
A skill to identify "false-positive diffs" in Terraform plans caused by AzureRM Provider's Set-type attributes and distinguish them from actual changes.
## When to Use
- `terraform plan` shows many changes, but you only added/removed a single element
- Application Gateway, Load Balancer, NSG, etc. show "all elements changed"
- You want to automatically filter false-positive diffs in CI/CD
## Background
Terraform's Set type compares by position rather than by key, so when adding or removing elements, all elements appear as "changed". This is a general Terraform issue, but it's particularly noticeable with AzureRM resources that heavily use Set-type attributes like Application Gateway, Load Balancer, and NSG.
These "false-positive diffs" don't actually affect the resources, but they make reviewing terraform plan output difficult.
## Prerequisites
- Python 3.8+
If Python is unavailable, install via your package manager (e.g., `apt install python3`, `brew install python3`) or from [python.org](https://www.python.org/downloads/).
## Basic Usage
```bash
# 1. Generate plan JSON output
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
# 2. Analyze
python scripts/analyze_plan.py plan.json
```
## Troubleshooting
- **`python: command not found`**: Use `python3` instead, or install Python
- **`ModuleNotFoundError`**: Script uses only standard library; ensure Python 3.8+
## Detailed Documentation
- [scripts/README.md](scripts/README.md) - All options, output formats, exit codes, CI/CD examples
- [references/azurerm_set_attributes.md](references/azurerm_set_attributes.md) - Supported resources and attributes
references/
azurerm_set_attributes.json 5.0 KB
{
"metadata": {
"description": "AzureRM Provider Set-type attribute definitions",
"lastUpdated": "2026-01-28",
"source": "Terraform Registry documentation and AzureRM Provider source code"
},
"resources": {
"azurerm_application_gateway": {
"backend_address_pool": "name",
"backend_http_settings": "name",
"custom_error_configuration": "status_code",
"frontend_ip_configuration": "name",
"frontend_port": "name",
"gateway_ip_configuration": "name",
"http_listener": "name",
"probe": "name",
"private_link_configuration": "name",
"redirect_configuration": "name",
"request_routing_rule": "name",
"rewrite_rule_set": {
"_key": "name",
"rewrite_rule": {
"_key": "name",
"condition": "variable",
"request_header_configuration": "header_name",
"response_header_configuration": "header_name"
}
},
"ssl_certificate": "name",
"ssl_profile": "name",
"trusted_client_certificate": "name",
"trusted_root_certificate": "name",
"url_path_map": {
"_key": "name",
"path_rule": {
"_key": "name",
"paths": null
}
}
},
"azurerm_lb": {
"frontend_ip_configuration": "name"
},
"azurerm_lb_backend_address_pool": {
"backend_address": "name"
},
"azurerm_lb_rule": {
"backend_address_pool_ids": null
},
"azurerm_firewall": {
"ip_configuration": "name",
"management_ip_configuration": "name",
"virtual_hub": null
},
"azurerm_firewall_policy_rule_collection_group": {
"application_rule_collection": {
"_key": "name",
"rule": {
"_key": "name",
"protocols": null,
"destination_fqdns": null
}
},
"network_rule_collection": {
"_key": "name",
"rule": {
"_key": "name",
"destination_addresses": null,
"destination_ports": null
}
},
"nat_rule_collection": {
"_key": "name",
"rule": "name"
}
},
"azurerm_frontdoor": {
"backend_pool": {
"_key": "name",
"backend": "address"
},
"backend_pool_health_probe": "name",
"backend_pool_load_balancing": "name",
"frontend_endpoint": "name",
"routing_rule": "name"
},
"azurerm_cdn_frontdoor_origin_group": {
"health_probe": null,
"load_balancing": null
},
"azurerm_network_security_group": {
"security_rule": "name"
},
"azurerm_route_table": {
"route": "name"
},
"azurerm_virtual_network": {
"subnet": "name"
},
"azurerm_virtual_network_gateway": {
"ip_configuration": "name",
"vpn_client_configuration": {
"_key": null,
"root_certificate": "name",
"revoked_certificate": "name",
"radius_server": "address"
},
"policy_group": "name"
},
"azurerm_virtual_network_gateway_connection": {
"ipsec_policy": null
},
"azurerm_nat_gateway": {
"public_ip_address_ids": null,
"public_ip_prefix_ids": null
},
"azurerm_private_endpoint": {
"ip_configuration": "name",
"private_dns_zone_group": "name",
"private_service_connection": "name"
},
"azurerm_api_management": {
"additional_location": "location",
"certificate": "encoded_certificate",
"hostname_configuration": {
"_key": null,
"management": "host_name",
"portal": "host_name",
"developer_portal": "host_name",
"proxy": "host_name",
"scm": "host_name"
}
},
"azurerm_storage_account": {
"network_rules": null,
"blob_properties": null
},
"azurerm_key_vault": {
"network_acls": null
},
"azurerm_cosmosdb_account": {
"geo_location": "location",
"capabilities": "name",
"virtual_network_rule": "id"
},
"azurerm_kubernetes_cluster": {
"default_node_pool": null
},
"azurerm_kubernetes_cluster_node_pool": {
"node_labels": null,
"node_taints": null
}
}
}
azurerm_set_attributes.md 3.9 KB
# AzureRM Set-Type Attributes Reference
This document explains the overview and maintenance of `azurerm_set_attributes.json`.
> **Last Updated**: January 28, 2026
## Overview
`azurerm_set_attributes.json` is a definition file for attributes treated as Set-type in the AzureRM Provider.
The `analyze_plan.py` script reads this JSON to identify "false-positive diffs" in Terraform plans.
### What are Set-Type Attributes?
Terraform's Set type is a collection that **does not guarantee order**.
Therefore, when adding or removing elements, unchanged elements may appear as "changed".
This is called a "false-positive diff".
## JSON File Structure
### Basic Format
```json
{
"resources": {
"azurerm_resource_type": {
"attribute_name": "key_attribute"
}
}
}
```
- **key_attribute**: The attribute that uniquely identifies Set elements (e.g., `name`, `id`)
- **null**: When there is no key attribute (compare entire element)
### Nested Format
When a Set attribute contains another Set attribute:
```json
{
"rewrite_rule_set": {
"_key": "name",
"rewrite_rule": {
"_key": "name",
"condition": "variable",
"request_header_configuration": "header_name"
}
}
}
```
- **`_key`**: The key attribute for that level's Set elements
- **Other keys**: Definitions for nested Set attributes
### Example: azurerm_application_gateway
```json
"azurerm_application_gateway": {
"backend_address_pool": "name", // Simple Set (key is name)
"rewrite_rule_set": { // Nested Set
"_key": "name",
"rewrite_rule": {
"_key": "name",
"condition": "variable"
}
}
}
```
## Maintenance
### Adding New Attributes
1. **Check Official Documentation**
- Search for the resource in [Terraform Registry](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs)
- Verify the attribute is listed as "Set of ..."
- Some resources like `azurerm_application_gateway` have Set attributes noted explicitly
2. **Check Source Code (more reliable)**
- Search for the resource in [AzureRM Provider GitHub](https://github.com/hashicorp/terraform-provider-azurerm)
- Confirm `Type: pluginsdk.TypeSet` in the schema definition
- Identify attributes within the Set's `Schema` that can serve as `_key`
3. **Add to JSON**
```json
"azurerm_new_resource": {
"set_attribute": "key_attribute"
}
```
4. **Test**
```bash
# Verify with an actual plan
python3 scripts/analyze_plan.py your_plan.json
```
### Identifying Key Attributes
| Common Key Attribute | Usage |
|---------------------|-------|
| `name` | Named blocks (most common) |
| `id` | Resource ID reference |
| `location` | Geographic location |
| `address` | Network address |
| `host_name` | Hostname |
| `null` | When no key exists (compare entire element) |
## Related Tools
### analyze_plan.py
Analyzes Terraform plan JSON to identify false-positive diffs.
```bash
# Basic usage
terraform show -json plan.tfplan | python3 scripts/analyze_plan.py
# Read from file
python3 scripts/analyze_plan.py plan.json
# Use custom attribute file
python3 scripts/analyze_plan.py plan.json --attributes /path/to/custom.json
```
## Supported Resources
Please refer to `azurerm_set_attributes.json` directly for currently supported resources:
```bash
# List resources
jq '.resources | keys' azurerm_set_attributes.json
```
Key resources:
- `azurerm_application_gateway` - Backend pools, listeners, rules, etc.
- `azurerm_firewall_policy_rule_collection_group` - Rule collections
- `azurerm_frontdoor` - Backend pools, routing
- `azurerm_network_security_group` - Security rules
- `azurerm_virtual_network_gateway` - IP configuration, VPN client configuration
## Notes
- Attribute behavior may differ depending on Provider/API version
- New resources and attributes need to be added as they become available
- Defining all levels of deeply nested structures improves accuracy
scripts/
README.md 5.2 KB
# Terraform AzureRM Set Diff Analyzer Script
A Python script that analyzes Terraform plan JSON and identifies "false-positive diffs" in AzureRM Set-type attributes.
## Overview
AzureRM Provider's Set-type attributes (such as `backend_address_pool`, `security_rule`, etc.) don't guarantee order, so when adding or removing elements, all elements appear as "changed". This script distinguishes such "false-positive diffs" from actual changes.
### Use Cases
- As an **Agent Skill** (recommended)
- As a **CLI tool** for manual execution
- For automated analysis in **CI/CD pipelines**
## Prerequisites
- Python 3.8 or higher
- No additional packages required (uses only standard library)
## Usage
### Basic Usage
```bash
# Read from file
python analyze_plan.py plan.json
# Read from stdin
terraform show -json plan.tfplan | python analyze_plan.py
```
### Options
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
| `--format` | `-f` | Output format (markdown/json/summary) | markdown |
| `--exit-code` | `-e` | Return exit code based on changes | false |
| `--quiet` | `-q` | Suppress warnings | false |
| `--verbose` | `-v` | Show detailed warnings | false |
| `--ignore-case` | - | Compare values case-insensitively | false |
| `--attributes` | - | Path to custom attribute definition file | (built-in) |
| `--include` | - | Filter resources to analyze (can specify multiple) | (all) |
| `--exclude` | - | Filter resources to exclude (can specify multiple) | (none) |
### Exit Codes (with `--exit-code`)
| Code | Meaning |
|------|---------|
| 0 | No changes, or order-only changes |
| 1 | Actual Set attribute changes |
| 2 | Resource replacement (delete + create) |
| 3 | Error |
## Output Formats
### Markdown (default)
Human-readable format for PR comments and reports.
```bash
python analyze_plan.py plan.json --format markdown
```
### JSON
Structured data for programmatic processing.
```bash
python analyze_plan.py plan.json --format json
```
Example output:
```json
{
"summary": {
"order_only_count": 3,
"actual_set_changes_count": 1,
"replace_count": 0
},
"has_real_changes": true,
"resources": [...],
"warnings": []
}
```
### Summary
One-line summary for CI/CD logs.
```bash
python analyze_plan.py plan.json --format summary
```
Example output:
```
๐ข 3 order-only | ๐ก 1 set changes
```
## CI/CD Pipeline Usage
### GitHub Actions
```yaml
name: Terraform Plan Analysis
on:
pull_request:
paths:
- '**.tf'
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init & Plan
run: |
terraform init
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
- name: Analyze Set Diff
run: |
python path/to/analyze_plan.py plan.json --format markdown > analysis.md
- name: Comment PR
uses: marocchino/sticky-pull-request-comment@v2
with:
path: analysis.md
```
### GitHub Actions (Gate with Exit Code)
```yaml
- name: Analyze and Gate
run: |
python path/to/analyze_plan.py plan.json --exit-code --format summary
# Fail on exit code 2 (resource replacement)
continue-on-error: false
```
### Azure Pipelines
```yaml
- task: TerraformCLI@0
inputs:
command: 'plan'
commandOptions: '-out=plan.tfplan'
- script: |
terraform show -json plan.tfplan > plan.json
python scripts/analyze_plan.py plan.json --format markdown > $(Build.ArtifactStagingDirectory)/analysis.md
displayName: 'Analyze Plan'
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)/analysis.md'
artifactName: 'plan-analysis'
```
### Filtering Examples
Analyze only specific resources:
```bash
python analyze_plan.py plan.json --include application_gateway --include load_balancer
```
Exclude specific resources:
```bash
python analyze_plan.py plan.json --exclude virtual_network
```
## Interpreting Results
| Category | Meaning | Recommended Action |
|----------|---------|-------------------|
| ๐ข Order-only | False-positive diff, no actual change | Safe to ignore |
| ๐ก Actual change | Set element added/removed/modified | Review the content, usually in-place update |
| ๐ด Resource replacement | delete + create | Check for downtime impact |
## Custom Attribute Definitions
By default, uses `references/azurerm_set_attributes.json`, but you can specify a custom definition file:
```bash
python analyze_plan.py plan.json --attributes /path/to/custom_attributes.json
```
See `references/azurerm_set_attributes.md` for the definition file format.
## Limitations
- Only AzureRM resources (`azurerm_*`) are supported
- Some resources/attributes may not be supported
- Comparisons may be incomplete for attributes containing `after_unknown` (values determined after apply)
- Comparisons may be incomplete for sensitive attributes (they are masked)
## Related Documentation
- [SKILL.md](../SKILL.md) - Usage as an Agent Skill
- [azurerm_set_attributes.md](../references/azurerm_set_attributes.md) - Attribute definition reference
analyze_plan.py 30.2 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Terraform Plan Analyzer for AzureRM Set-type Attributes
Analyzes terraform plan JSON output to distinguish between:
- Order-only changes (false positives) in Set-type attributes
- Actual additions/deletions/modifications
Usage:
terraform show -json plan.tfplan | python analyze_plan.py
python analyze_plan.py plan.json
python analyze_plan.py plan.json --format json --exit-code
For CI/CD pipeline usage, see README.md in this directory.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
# Exit codes for --exit-code option
EXIT_NO_CHANGES = 0
EXIT_ORDER_ONLY = 0 # Order-only changes are not real changes
EXIT_SET_CHANGES = 1 # Actual Set attribute changes
EXIT_RESOURCE_REPLACE = 2 # Resource replacement (most severe)
EXIT_ERROR = 3
# Default path to the external attributes JSON file (relative to this script)
DEFAULT_ATTRIBUTES_PATH = (
Path(__file__).parent.parent / "references" / "azurerm_set_attributes.json"
)
# Global configuration
class Config:
"""Global configuration for the analyzer."""
ignore_case: bool = False
quiet: bool = False
verbose: bool = False
warnings: List[str] = []
CONFIG = Config()
def warn(message: str) -> None:
"""Add a warning message."""
CONFIG.warnings.append(message)
if CONFIG.verbose:
print(f"Warning: {message}", file=sys.stderr)
def load_set_attributes(path: Optional[Path] = None) -> Dict[str, Dict[str, Any]]:
"""Load Set-type attributes from external JSON file."""
attributes_path = path or DEFAULT_ATTRIBUTES_PATH
try:
with open(attributes_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("resources", {})
except FileNotFoundError:
warn(f"Attributes file not found: {attributes_path}")
return {}
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in attributes file: {e}", file=sys.stderr)
sys.exit(EXIT_ERROR)
# Global variable to hold loaded attributes (initialized in main)
AZURERM_SET_ATTRIBUTES: Dict[str, Any] = {}
def get_attr_config(attr_def: Any) -> tuple:
"""
Parse attribute definition and return (key_attr, nested_attrs).
Attribute definition can be:
- str: simple key attribute (e.g., "name")
- None/null: no key attribute
- dict: nested structure with "_key" and nested attributes
"""
if attr_def is None:
return (None, {})
if isinstance(attr_def, str):
return (attr_def, {})
if isinstance(attr_def, dict):
key_attr = attr_def.get("_key")
nested_attrs = {k: v for k, v in attr_def.items() if k != "_key"}
return (key_attr, nested_attrs)
return (None, {})
@dataclass
class SetAttributeChange:
"""Represents a change in a Set-type attribute."""
attribute_name: str
path: str = (
"" # Full path for nested attributes (e.g., "rewrite_rule_set.rewrite_rule")
)
order_only_count: int = 0
added: List[str] = field(default_factory=list)
removed: List[str] = field(default_factory=list)
modified: List[tuple] = field(default_factory=list)
nested_changes: List["SetAttributeChange"] = field(default_factory=list)
# For primitive sets (string/number arrays)
is_primitive: bool = False
primitive_added: List[Any] = field(default_factory=list)
primitive_removed: List[Any] = field(default_factory=list)
@dataclass
class ResourceChange:
"""Represents changes to a single resource."""
address: str
resource_type: str
actions: List[str] = field(default_factory=list)
set_changes: List[SetAttributeChange] = field(default_factory=list)
other_changes: List[str] = field(default_factory=list)
is_replace: bool = False
is_create: bool = False
is_delete: bool = False
@dataclass
class AnalysisResult:
"""Overall analysis result."""
resources: List[ResourceChange] = field(default_factory=list)
order_only_count: int = 0
actual_set_changes_count: int = 0
replace_count: int = 0
create_count: int = 0
delete_count: int = 0
other_changes_count: int = 0
warnings: List[str] = field(default_factory=list)
def get_element_key(element: Dict[str, Any], key_attr: Optional[str]) -> str:
"""Extract the key value from a Set element."""
if key_attr and key_attr in element:
val = element[key_attr]
if CONFIG.ignore_case and isinstance(val, str):
return val.lower()
return str(val)
# Fall back to hash of sorted items for elements without a key attribute
return str(hash(json.dumps(element, sort_keys=True)))
def normalize_value(val: Any) -> Any:
"""Normalize values for comparison (treat empty string and None as equivalent)."""
if val == "" or val is None:
return None
if isinstance(val, list) and len(val) == 0:
return None
# Normalize numeric types (int vs float)
if isinstance(val, float) and val.is_integer():
return int(val)
return val
def normalize_for_comparison(val: Any) -> Any:
"""Normalize value for comparison, including case-insensitive option."""
val = normalize_value(val)
if CONFIG.ignore_case and isinstance(val, str):
return val.lower()
return val
def values_equivalent(before_val: Any, after_val: Any) -> bool:
"""Check if two values are effectively equivalent."""
return normalize_for_comparison(before_val) == normalize_for_comparison(after_val)
def compare_elements(
before: Dict[str, Any], after: Dict[str, Any], nested_attrs: Dict[str, Any] = None
) -> tuple:
"""
Compare two elements and return (simple_diffs, nested_set_attrs).
simple_diffs: differences in non-Set attributes
nested_set_attrs: list of (attr_name, before_val, after_val, attr_def) for nested Sets
"""
nested_attrs = nested_attrs or {}
simple_diffs = {}
nested_set_attrs = []
all_keys = set(before.keys()) | set(after.keys())
for key in all_keys:
before_val = before.get(key)
after_val = after.get(key)
# Check if this is a nested Set attribute
if key in nested_attrs:
if before_val != after_val:
nested_set_attrs.append((key, before_val, after_val, nested_attrs[key]))
elif not values_equivalent(before_val, after_val):
simple_diffs[key] = {"before": before_val, "after": after_val}
return (simple_diffs, nested_set_attrs)
def analyze_primitive_set(
before_list: Optional[List[Any]],
after_list: Optional[List[Any]],
attr_name: str,
path: str = "",
) -> SetAttributeChange:
"""Analyze changes in a primitive Set (string/number array)."""
full_path = f"{path}.{attr_name}" if path else attr_name
change = SetAttributeChange(
attribute_name=attr_name, path=full_path, is_primitive=True
)
before_set = set(before_list) if before_list else set()
after_set = set(after_list) if after_list else set()
# Apply case-insensitive comparison if configured
if CONFIG.ignore_case:
before_normalized = {v.lower() if isinstance(v, str) else v for v in before_set}
after_normalized = {v.lower() if isinstance(v, str) else v for v in after_set}
else:
before_normalized = before_set
after_normalized = after_set
removed = before_normalized - after_normalized
added = after_normalized - before_normalized
if removed:
change.primitive_removed = list(removed)
if added:
change.primitive_added = list(added)
# Elements that exist in both (order change only)
common = before_normalized & after_normalized
if common and not removed and not added:
change.order_only_count = len(common)
return change
def analyze_set_attribute(
before_list: Optional[List[Dict[str, Any]]],
after_list: Optional[List[Dict[str, Any]]],
key_attr: Optional[str],
attr_name: str,
nested_attrs: Dict[str, Any] = None,
path: str = "",
after_unknown: Optional[Dict[str, Any]] = None,
) -> SetAttributeChange:
"""Analyze changes in a Set-type attribute, including nested Sets."""
full_path = f"{path}.{attr_name}" if path else attr_name
change = SetAttributeChange(attribute_name=attr_name, path=full_path)
nested_attrs = nested_attrs or {}
before_list = before_list or []
after_list = after_list or []
# Handle non-list values (single element)
if not isinstance(before_list, list):
before_list = [before_list] if before_list else []
if not isinstance(after_list, list):
after_list = [after_list] if after_list else []
# Check if this is a primitive set (non-dict elements)
has_primitive_before = any(
not isinstance(e, dict) for e in before_list if e is not None
)
has_primitive_after = any(
not isinstance(e, dict) for e in after_list if e is not None
)
if has_primitive_before or has_primitive_after:
# Handle primitive sets
return analyze_primitive_set(before_list, after_list, attr_name, path)
# Build maps keyed by the key attribute
before_map: Dict[str, Dict[str, Any]] = {}
after_map: Dict[str, Dict[str, Any]] = {}
# Detect duplicate keys
for e in before_list:
if isinstance(e, dict):
key = get_element_key(e, key_attr)
if key in before_map:
warn(f"Duplicate key '{key}' in before state for {full_path}")
before_map[key] = e
for e in after_list:
if isinstance(e, dict):
key = get_element_key(e, key_attr)
if key in after_map:
warn(f"Duplicate key '{key}' in after state for {full_path}")
after_map[key] = e
before_keys = set(before_map.keys())
after_keys = set(after_map.keys())
# Find removed elements
for key in before_keys - after_keys:
display_key = key if key_attr else "(element)"
change.removed.append(display_key)
# Find added elements
for key in after_keys - before_keys:
display_key = key if key_attr else "(element)"
change.added.append(display_key)
# Compare common elements
for key in before_keys & after_keys:
before_elem = before_map[key]
after_elem = after_map[key]
if before_elem == after_elem:
# Exact match - this is just an order change
change.order_only_count += 1
else:
# Content changed - check for meaningful differences
simple_diffs, nested_set_list = compare_elements(
before_elem, after_elem, nested_attrs
)
# Process nested Set attributes recursively
for nested_name, nested_before, nested_after, nested_def in nested_set_list:
nested_key, sub_nested = get_attr_config(nested_def)
nested_change = analyze_set_attribute(
nested_before,
nested_after,
nested_key,
nested_name,
sub_nested,
full_path,
)
if (
nested_change.order_only_count > 0
or nested_change.added
or nested_change.removed
or nested_change.modified
or nested_change.nested_changes
or nested_change.primitive_added
or nested_change.primitive_removed
):
change.nested_changes.append(nested_change)
if simple_diffs:
# Has actual differences in non-nested attributes
display_key = key if key_attr else "(element)"
change.modified.append((display_key, simple_diffs))
elif not nested_set_list:
# Only null/empty differences - treat as order change
change.order_only_count += 1
return change
def analyze_resource_change(
resource_change: Dict[str, Any],
include_filter: Optional[List[str]] = None,
exclude_filter: Optional[List[str]] = None,
) -> Optional[ResourceChange]:
"""Analyze a single resource change from terraform plan."""
resource_type = resource_change.get("type", "")
address = resource_change.get("address", "")
change = resource_change.get("change", {})
actions = change.get("actions", [])
# Skip if no change or not an AzureRM resource
if actions == ["no-op"] or not resource_type.startswith("azurerm_"):
return None
# Apply filters
if include_filter:
if not any(f in resource_type for f in include_filter):
return None
if exclude_filter:
if any(f in resource_type for f in exclude_filter):
return None
before = change.get("before") or {}
after = change.get("after") or {}
after_unknown = change.get("after_unknown") or {}
before_sensitive = change.get("before_sensitive") or {}
after_sensitive = change.get("after_sensitive") or {}
# Determine action type
is_create = actions == ["create"]
is_delete = actions == ["delete"]
is_replace = "delete" in actions and "create" in actions
result = ResourceChange(
address=address,
resource_type=resource_type,
actions=actions,
is_replace=is_replace,
is_create=is_create,
is_delete=is_delete,
)
# Skip detailed Set analysis for create/delete (all elements are new/removed)
if is_create or is_delete:
return result
# Get Set attributes for this resource type
set_attrs = AZURERM_SET_ATTRIBUTES.get(resource_type, {})
# Analyze Set-type attributes
analyzed_attrs: Set[str] = set()
for attr_name, attr_def in set_attrs.items():
before_val = before.get(attr_name)
after_val = after.get(attr_name)
# Warn about sensitive attributes
if attr_name in before_sensitive or attr_name in after_sensitive:
if before_sensitive.get(attr_name) or after_sensitive.get(attr_name):
warn(
f"Attribute '{attr_name}' in {address} contains sensitive values (comparison may be incomplete)"
)
# Skip if attribute is not present or unchanged
if before_val is None and after_val is None:
continue
if before_val == after_val:
continue
# Only analyze if it's a list (Set in Terraform) or has changed
if not isinstance(before_val, list) and not isinstance(after_val, list):
continue
# Parse attribute definition for key and nested attrs
key_attr, nested_attrs = get_attr_config(attr_def)
# Get after_unknown for this attribute
attr_after_unknown = after_unknown.get(attr_name)
set_change = analyze_set_attribute(
before_val,
after_val,
key_attr,
attr_name,
nested_attrs,
after_unknown=attr_after_unknown,
)
# Only include if there are actual findings
if (
set_change.order_only_count > 0
or set_change.added
or set_change.removed
or set_change.modified
or set_change.nested_changes
or set_change.primitive_added
or set_change.primitive_removed
):
result.set_changes.append(set_change)
analyzed_attrs.add(attr_name)
# Find other (non-Set) changes
all_keys = set(before.keys()) | set(after.keys())
for key in all_keys:
if key in analyzed_attrs:
continue
if key.startswith("_"): # Skip internal attributes
continue
before_val = before.get(key)
after_val = after.get(key)
if before_val != after_val:
result.other_changes.append(key)
return result
def collect_all_changes(set_change: SetAttributeChange, prefix: str = "") -> tuple:
"""
Recursively collect order-only and actual changes from nested structure.
Returns (order_only_list, actual_change_list)
"""
order_only = []
actual = []
display_name = (
f"{prefix}{set_change.attribute_name}" if prefix else set_change.attribute_name
)
has_actual_change = (
set_change.added
or set_change.removed
or set_change.modified
or set_change.primitive_added
or set_change.primitive_removed
)
if set_change.order_only_count > 0 and not has_actual_change:
order_only.append((display_name, set_change))
elif has_actual_change:
actual.append((display_name, set_change))
# Process nested changes
for nested in set_change.nested_changes:
nested_order, nested_actual = collect_all_changes(nested, f"{display_name}.")
order_only.extend(nested_order)
actual.extend(nested_actual)
return (order_only, actual)
def format_set_change(change: SetAttributeChange, indent: int = 0) -> List[str]:
"""Format a single SetAttributeChange for output."""
lines = []
prefix = " " * indent
# Handle primitive sets
if change.is_primitive:
if change.primitive_added:
lines.append(f"{prefix}**Added:**")
for item in change.primitive_added:
lines.append(f"{prefix} - {item}")
if change.primitive_removed:
lines.append(f"{prefix}**Removed:**")
for item in change.primitive_removed:
lines.append(f"{prefix} - {item}")
if change.order_only_count > 0:
lines.append(f"{prefix}**Order-only:** {change.order_only_count} elements")
return lines
if change.added:
lines.append(f"{prefix}**Added:**")
for item in change.added:
lines.append(f"{prefix} - {item}")
if change.removed:
lines.append(f"{prefix}**Removed:**")
for item in change.removed:
lines.append(f"{prefix} - {item}")
if change.modified:
lines.append(f"{prefix}**Modified:**")
for item_key, diffs in change.modified:
lines.append(f"{prefix} - {item_key}:")
for diff_key, diff_val in diffs.items():
before_str = json.dumps(diff_val["before"], ensure_ascii=False)
after_str = json.dumps(diff_val["after"], ensure_ascii=False)
lines.append(f"{prefix} - {diff_key}: {before_str} โ {after_str}")
if change.order_only_count > 0:
lines.append(f"{prefix}**Order-only:** {change.order_only_count} elements")
# Format nested changes
for nested in change.nested_changes:
if (
nested.added
or nested.removed
or nested.modified
or nested.nested_changes
or nested.primitive_added
or nested.primitive_removed
):
lines.append(f"{prefix}**Nested attribute `{nested.attribute_name}`:**")
lines.extend(format_set_change(nested, indent + 1))
return lines
def format_markdown_output(result: AnalysisResult) -> str:
"""Format analysis results as Markdown."""
lines = ["# Terraform Plan Analysis Results", ""]
lines.append(
'Analyzes AzureRM Set-type attribute changes and identifies order-only "false-positive diffs".'
)
lines.append("")
# Categorize changes (including nested)
order_only_changes: List[tuple] = []
actual_set_changes: List[tuple] = []
replace_resources: List[ResourceChange] = []
create_resources: List[ResourceChange] = []
delete_resources: List[ResourceChange] = []
other_changes: List[tuple] = []
for res in result.resources:
if res.is_replace:
replace_resources.append(res)
elif res.is_create:
create_resources.append(res)
elif res.is_delete:
delete_resources.append(res)
for set_change in res.set_changes:
order_only, actual = collect_all_changes(set_change)
for name, change in order_only:
order_only_changes.append((res.address, name, change))
for name, change in actual:
actual_set_changes.append((res.address, name, change))
if res.other_changes:
other_changes.append((res.address, res.other_changes))
# Section: Order-only changes (false positives)
lines.append("## ๐ข Order-only Changes (No Impact)")
lines.append("")
if order_only_changes:
lines.append(
"The following changes are internal reordering of Set-type attributes only, with no actual resource changes."
)
lines.append("")
for address, name, change in order_only_changes:
lines.append(
f"- `{address}`: **{name}** ({change.order_only_count} elements)"
)
else:
lines.append("None")
lines.append("")
# Section: Actual Set changes
lines.append("## ๐ก Actual Set Attribute Changes")
lines.append("")
if actual_set_changes:
for address, name, change in actual_set_changes:
lines.append(f"### `{address}` - {name}")
lines.append("")
lines.extend(format_set_change(change))
lines.append("")
else:
lines.append("None")
lines.append("")
# Section: Resource replacements
lines.append("## ๐ด Resource Replacement (Caution)")
lines.append("")
if replace_resources:
lines.append(
"The following resources will be deleted and recreated. This may cause downtime."
)
lines.append("")
for res in replace_resources:
lines.append(f"- `{res.address}`")
else:
lines.append("None")
lines.append("")
# Section: Warnings
if result.warnings:
lines.append("## โ ๏ธ Warnings")
lines.append("")
for warning in result.warnings:
lines.append(f"- {warning}")
lines.append("")
return "\n".join(lines)
def format_json_output(result: AnalysisResult) -> str:
"""Format analysis results as JSON."""
def set_change_to_dict(change: SetAttributeChange) -> dict:
d = {
"attribute_name": change.attribute_name,
"path": change.path,
"order_only_count": change.order_only_count,
"is_primitive": change.is_primitive,
}
if change.added:
d["added"] = change.added
if change.removed:
d["removed"] = change.removed
if change.modified:
d["modified"] = [{"key": k, "diffs": v} for k, v in change.modified]
if change.primitive_added:
d["primitive_added"] = change.primitive_added
if change.primitive_removed:
d["primitive_removed"] = change.primitive_removed
if change.nested_changes:
d["nested_changes"] = [set_change_to_dict(n) for n in change.nested_changes]
return d
def resource_to_dict(res: ResourceChange) -> dict:
return {
"address": res.address,
"resource_type": res.resource_type,
"actions": res.actions,
"is_replace": res.is_replace,
"is_create": res.is_create,
"is_delete": res.is_delete,
"set_changes": [set_change_to_dict(c) for c in res.set_changes],
"other_changes": res.other_changes,
}
output = {
"summary": {
"order_only_count": result.order_only_count,
"actual_set_changes_count": result.actual_set_changes_count,
"replace_count": result.replace_count,
"create_count": result.create_count,
"delete_count": result.delete_count,
"other_changes_count": result.other_changes_count,
},
"has_real_changes": (
result.actual_set_changes_count > 0
or result.replace_count > 0
or result.create_count > 0
or result.delete_count > 0
or result.other_changes_count > 0
),
"resources": [resource_to_dict(r) for r in result.resources],
"warnings": result.warnings,
}
return json.dumps(output, indent=2, ensure_ascii=False)
def format_summary_output(result: AnalysisResult) -> str:
"""Format analysis results as a single-line summary."""
parts = []
if result.order_only_count > 0:
parts.append(f"๐ข {result.order_only_count} order-only")
if result.actual_set_changes_count > 0:
parts.append(f"๐ก {result.actual_set_changes_count} set changes")
if result.replace_count > 0:
parts.append(f"๐ด {result.replace_count} replacements")
if not parts:
return "โ
No changes detected"
return " | ".join(parts)
def analyze_plan(
plan_json: Dict[str, Any],
include_filter: Optional[List[str]] = None,
exclude_filter: Optional[List[str]] = None,
) -> AnalysisResult:
"""Analyze a terraform plan JSON and return results."""
result = AnalysisResult()
resource_changes = plan_json.get("resource_changes", [])
for rc in resource_changes:
res = analyze_resource_change(rc, include_filter, exclude_filter)
if res:
result.resources.append(res)
# Count statistics
if res.is_replace:
result.replace_count += 1
elif res.is_create:
result.create_count += 1
elif res.is_delete:
result.delete_count += 1
if res.other_changes:
result.other_changes_count += len(res.other_changes)
for set_change in res.set_changes:
order_only, actual = collect_all_changes(set_change)
result.order_only_count += len(order_only)
result.actual_set_changes_count += len(actual)
# Add warnings from global config
result.warnings = CONFIG.warnings.copy()
return result
def determine_exit_code(result: AnalysisResult) -> int:
"""Determine exit code based on analysis results."""
if result.replace_count > 0:
return EXIT_RESOURCE_REPLACE
if (
result.actual_set_changes_count > 0
or result.create_count > 0
or result.delete_count > 0
):
return EXIT_SET_CHANGES
return EXIT_NO_CHANGES
def parse_args() -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Analyze Terraform plan JSON for AzureRM Set-type attribute changes.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic usage
python analyze_plan.py plan.json
# From stdin
terraform show -json plan.tfplan | python analyze_plan.py
# CI/CD with exit code
python analyze_plan.py plan.json --exit-code
# JSON output for programmatic processing
python analyze_plan.py plan.json --format json
# Summary for CI logs
python analyze_plan.py plan.json --format summary
Exit codes (with --exit-code):
0 - No changes or order-only changes
1 - Actual Set attribute changes
2 - Resource replacement detected
3 - Error
""",
)
parser.add_argument(
"plan_file",
nargs="?",
help="Path to terraform plan JSON file (reads from stdin if not provided)",
)
parser.add_argument(
"--format",
"-f",
choices=["markdown", "json", "summary"],
default="markdown",
help="Output format (default: markdown)",
)
parser.add_argument(
"--exit-code",
"-e",
action="store_true",
help="Return exit code based on change severity",
)
parser.add_argument(
"--quiet",
"-q",
action="store_true",
help="Suppress warnings and verbose output",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Show detailed warnings and debug info",
)
parser.add_argument(
"--ignore-case",
action="store_true",
help="Ignore case when comparing string values",
)
parser.add_argument(
"--attributes", type=Path, help="Path to custom attributes JSON file"
)
parser.add_argument(
"--include",
action="append",
help="Only analyze resources matching this pattern (can be repeated)",
)
parser.add_argument(
"--exclude",
action="append",
help="Exclude resources matching this pattern (can be repeated)",
)
return parser.parse_args()
def main():
"""Main entry point."""
global AZURERM_SET_ATTRIBUTES
args = parse_args()
# Configure global settings
CONFIG.ignore_case = args.ignore_case
CONFIG.quiet = args.quiet
CONFIG.verbose = args.verbose
CONFIG.warnings = []
# Load Set attributes from external JSON
AZURERM_SET_ATTRIBUTES = load_set_attributes(args.attributes)
# Read plan input
if args.plan_file:
try:
with open(args.plan_file, "r") as f:
plan_json = json.load(f)
except FileNotFoundError:
print(f"Error: File not found: {args.plan_file}", file=sys.stderr)
sys.exit(EXIT_ERROR)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON: {e}", file=sys.stderr)
sys.exit(EXIT_ERROR)
else:
try:
plan_json = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON from stdin: {e}", file=sys.stderr)
sys.exit(EXIT_ERROR)
# Check for empty plan
resource_changes = plan_json.get("resource_changes", [])
if not resource_changes:
if args.format == "json":
print(
json.dumps(
{
"summary": {},
"has_real_changes": False,
"resources": [],
"warnings": [],
}
)
)
elif args.format == "summary":
print("โ
No changes detected")
else:
print("# Terraform Plan Analysis Results\n")
print("No resource changes detected.")
sys.exit(EXIT_NO_CHANGES)
# Analyze the plan
result = analyze_plan(plan_json, args.include, args.exclude)
# Format output
if args.format == "json":
output = format_json_output(result)
elif args.format == "summary":
output = format_summary_output(result)
else:
output = format_markdown_output(result)
print(output)
# Determine exit code
if args.exit_code:
sys.exit(determine_exit_code(result))
if __name__ == "__main__":
main()
License (MIT)
View full license text
MIT License Copyright GitHub, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.