mirror of
https://github.com/github/awesome-copilot.git
synced 2026-02-22 19:35:13 +00:00
Merge pull request #620 from torumakabe/add-terraform-azurerm-set-diff-analyzer
Add terraform-azurerm-set-diff-analyzer skill
This commit is contained in:
48
skills/terraform-azurerm-set-diff-analyzer/SKILL.md
Normal file
48
skills/terraform-azurerm-set-diff-analyzer/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
74
skills/terraform-azurerm-set-diff-analyzer/scripts/.gitignore
vendored
Normal file
74
skills/terraform-azurerm-set-diff-analyzer/scripts/.gitignore
vendored
Normal file
@@ -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/
|
||||
205
skills/terraform-azurerm-set-diff-analyzer/scripts/README.md
Normal file
205
skills/terraform-azurerm-set-diff-analyzer/scripts/README.md
Normal file
@@ -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
|
||||
940
skills/terraform-azurerm-set-diff-analyzer/scripts/analyze_plan.py
Executable file
940
skills/terraform-azurerm-set-diff-analyzer/scripts/analyze_plan.py
Executable file
@@ -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()
|
||||
Reference in New Issue
Block a user