From 1b9ff37cf973aa873cbbf270e13c7457eeb2050d Mon Sep 17 00:00:00 2001 From: Toru Makabe <993850+torumakabe@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:13:01 +0900 Subject: [PATCH] Add terraform-azurerm-set-diff-analyzer skill Add a new skill that analyzes Terraform plan JSON output for AzureRM Provider to distinguish between false-positive diffs (order-only changes in Set-type attributes) and actual resource changes. This skill helps users identify 'noise' in terraform plan output caused by Azure API returning Set elements in different order, making plan reviews easier and reducing confusion in CI/CD pipelines. Bundled assets: - references/azurerm_set_attributes.json - references/azurerm_set_attributes.md - scripts/analyze_plan.py --- docs/README.skills.md | 1 + .../SKILL.md | 37 + .../references/azurerm_set_attributes.json | 154 +++ .../references/azurerm_set_attributes.md | 145 +++ .../scripts/.gitignore | 74 ++ .../scripts/README.md | 205 ++++ .../scripts/analyze_plan.py | 940 ++++++++++++++++++ 7 files changed, 1556 insertions(+) create mode 100644 skills/terraform-azurerm-set-diff-analyzer/SKILL.md create mode 100644 skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.json create mode 100644 skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.md create mode 100644 skills/terraform-azurerm-set-diff-analyzer/scripts/.gitignore create mode 100644 skills/terraform-azurerm-set-diff-analyzer/scripts/README.md create mode 100755 skills/terraform-azurerm-set-diff-analyzer/scripts/analyze_plan.py diff --git a/docs/README.skills.md b/docs/README.skills.md index 45a9d055..5c8ecde1 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -46,6 +46,7 @@ Skills differ from other primitives by supporting bundled assets (scripts, code | [refactor](../skills/refactor/SKILL.md) | Surgical code refactoring to improve maintainability without changing behavior. Covers extracting functions, renaming variables, breaking down god functions, improving type safety, eliminating code smells, and applying design patterns. Less drastic than repo-rebuilder; use for gradual improvements. | None | | [scoutqa-test](../skills/scoutqa-test/SKILL.md) | This skill should be used when the user asks to "test this website", "run exploratory testing", "check for accessibility issues", "verify the login flow works", "find bugs on this page", or requests automated QA testing. Triggers on web application testing scenarios including smoke tests, accessibility audits, e-commerce flows, and user flow validation using ScoutQA CLI. IMPORTANT: Use this skill proactively after implementing web application features to verify they work correctly - don't wait for the user to ask for testing. | None | | [snowflake-semanticview](../skills/snowflake-semanticview/SKILL.md) | Create, alter, and validate Snowflake semantic views using Snowflake CLI (snow). Use when asked to build or troubleshoot semantic views/semantic layer definitions with CREATE/ALTER SEMANTIC VIEW, to validate semantic-view DDL against Snowflake via CLI, or to guide Snowflake CLI installation and connection setup. | None | +| [terraform-azurerm-set-diff-analyzer](../skills/terraform-azurerm-set-diff-analyzer/SKILL.md) | 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. | `references/azurerm_set_attributes.json`
`references/azurerm_set_attributes.md`
`scripts/.gitignore`
`scripts/README.md`
`scripts/analyze_plan.py` | | [vscode-ext-commands](../skills/vscode-ext-commands/SKILL.md) | Guidelines for contributing commands in VS Code extensions. Indicates naming convention, visibility, localization and other relevant attributes, following VS Code extension development guidelines, libraries and good practices | None | | [vscode-ext-localization](../skills/vscode-ext-localization/SKILL.md) | Guidelines for proper localization of VS Code extensions, following VS Code extension development guidelines, libraries and good practices | None | | [web-design-reviewer](../skills/web-design-reviewer/SKILL.md) | This skill enables visual inspection of websites running locally or remotely to identify and fix design issues. Triggers on requests like "review website design", "check the UI", "fix the layout", "find design problems". Detects issues with responsive design, accessibility, visual consistency, and layout breakage, then performs fixes at the source code level. | `references/framework-fixes.md`
`references/visual-checklist.md` | diff --git a/skills/terraform-azurerm-set-diff-analyzer/SKILL.md b/skills/terraform-azurerm-set-diff-analyzer/SKILL.md new file mode 100644 index 00000000..bfae51ff --- /dev/null +++ b/skills/terraform-azurerm-set-diff-analyzer/SKILL.md @@ -0,0 +1,37 @@ +--- +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. + +## 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 +``` + +## 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 diff --git a/skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.json b/skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.json new file mode 100644 index 00000000..d16b6cc4 --- /dev/null +++ b/skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.json @@ -0,0 +1,154 @@ +{ + "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 + } + } +} diff --git a/skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.md b/skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.md new file mode 100644 index 00000000..9ee52639 --- /dev/null +++ b/skills/terraform-azurerm-set-diff-analyzer/references/azurerm_set_attributes.md @@ -0,0 +1,145 @@ +# 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 diff --git a/skills/terraform-azurerm-set-diff-analyzer/scripts/.gitignore b/skills/terraform-azurerm-set-diff-analyzer/scripts/.gitignore new file mode 100644 index 00000000..61a44ca6 --- /dev/null +++ b/skills/terraform-azurerm-set-diff-analyzer/scripts/.gitignore @@ -0,0 +1,74 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/skills/terraform-azurerm-set-diff-analyzer/scripts/README.md b/skills/terraform-azurerm-set-diff-analyzer/scripts/README.md new file mode 100644 index 00000000..ff007a24 --- /dev/null +++ b/skills/terraform-azurerm-set-diff-analyzer/scripts/README.md @@ -0,0 +1,205 @@ +# 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 diff --git a/skills/terraform-azurerm-set-diff-analyzer/scripts/analyze_plan.py b/skills/terraform-azurerm-set-diff-analyzer/scripts/analyze_plan.py new file mode 100755 index 00000000..cd593dd7 --- /dev/null +++ b/skills/terraform-azurerm-set-diff-analyzer/scripts/analyze_plan.py @@ -0,0 +1,940 @@ +#!/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()