Skip to content

Commit 09c2340

Browse files
authored
Ensure the analyzer and reporter can handle with action as a unique replace action. (#22)
* Ensure the analyzer and reporter can handle with action as a unique replace action. * Updating the changelog and bump version
1 parent fa6ddf0 commit 09c2340

File tree

9 files changed

+163
-17
lines changed

9 files changed

+163
-17
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## [0.2.1] - 2025-06-26
4+
5+
### 🛠 Enhancements
6+
7+
- Integrated Bandit security scanning into CI pipelines and resolved initial issues.
8+
- Added Ruff support to the Taskfile for consistent code linting and fixed the CLI smoke test failure.
9+
- Introduced a smoke test for the CLI to catch regressions early.
10+
- Added a link to the Discord community server for real-time support and discussion.
11+
12+
### 🐛 Bug Fixes
13+
14+
- Fixed handling of Terraform resources with a replace action so that the analyzer and reporter treat it uniquely.
15+
16+
---
17+
318
## [0.2.0] - 2024-06-15
419

520
### 🎨 Enhanced Output Formats and CLI Arguments

Taskfile.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ tasks:
1717
test:
1818
desc: Run all tests with pytest
1919
cmds:
20-
- . .venv/bin/activate && pytest
20+
- . .venv/bin/activate && poetry run pytest --cov=tfsumpy --cov-report=term && poetry run coverage report --fail-under=75
2121
deps: [install]
2222

2323
lint:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tfsumpy"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
requires-python = ">=3.10,<4.0"
55

66
[tool.poetry]

tests/plan/test_plan_analyzer.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,97 @@ def test_debug_logging(self, mock_debug, analyzer, sample_plan_json):
171171

172172
with patch("builtins.open", mock_open(read_data=plan_content)):
173173
analyzer.analyze(Mock(), plan_path="test.tfplan")
174-
assert mock_debug.called
174+
assert mock_debug.called
175+
176+
def test_replacement_detection(self, analyzer):
177+
"""Test detection of replacement (delete+create) and extraction of triggers."""
178+
plan_json = {
179+
"resource_changes": [
180+
{
181+
"address": "aws_instance.server",
182+
"type": "aws_instance",
183+
"change": {
184+
"actions": ["delete", "create"],
185+
"before": {"instance_type": "t2.micro"},
186+
"after": {"instance_type": "t2.small"},
187+
"replacement_triggered_by": ["instance_type"],
188+
"before_sensitive": {}
189+
}
190+
}
191+
]
192+
}
193+
changes = analyzer._parse_plan(json.dumps(plan_json))
194+
assert len(changes) == 1
195+
change = changes[0]
196+
assert change.action == "replace"
197+
assert change.replacement is True
198+
assert change.replacement_triggers == ["instance_type"]
199+
assert change.before == {"instance_type": "t2.micro"}
200+
assert change.after == {"instance_type": "t2.small"}
201+
202+
def test_replacement_no_triggers(self, analyzer):
203+
"""Test replacement detection when no replacement_triggered_by is present."""
204+
plan_json = {
205+
"resource_changes": [
206+
{
207+
"address": "aws_instance.server",
208+
"type": "aws_instance",
209+
"change": {
210+
"actions": ["delete", "create"],
211+
"before": {"instance_type": "t2.micro"},
212+
"after": {"instance_type": "t2.small"},
213+
"before_sensitive": {}
214+
}
215+
}
216+
]
217+
}
218+
changes = analyzer._parse_plan(json.dumps(plan_json))
219+
assert len(changes) == 1
220+
change = changes[0]
221+
assert change.action == "replace"
222+
assert change.replacement is True
223+
assert change.replacement_triggers == []
224+
225+
def test_regular_create_update_delete(self, analyzer):
226+
"""Test that create, update, and delete actions are not marked as replacement."""
227+
plan_json = {
228+
"resource_changes": [
229+
{
230+
"address": "aws_s3_bucket.data",
231+
"type": "aws_s3_bucket",
232+
"change": {
233+
"actions": ["create"],
234+
"before": None,
235+
"after": {"bucket": "test-bucket"},
236+
"before_sensitive": {}
237+
}
238+
},
239+
{
240+
"address": "aws_instance.server",
241+
"type": "aws_instance",
242+
"change": {
243+
"actions": ["delete"],
244+
"before": {"instance_type": "t2.micro"},
245+
"after": None,
246+
"before_sensitive": {}
247+
}
248+
},
249+
{
250+
"address": "aws_instance.web",
251+
"type": "aws_instance",
252+
"change": {
253+
"actions": ["update"],
254+
"before": {"instance_type": "t2.micro"},
255+
"after": {"instance_type": "t2.small"},
256+
"before_sensitive": {}
257+
}
258+
}
259+
]
260+
}
261+
changes = analyzer._parse_plan(json.dumps(plan_json))
262+
assert len(changes) == 3
263+
for c in changes:
264+
if c.action == "replace":
265+
assert False, "Should not detect replace for create, update, or delete"
266+
assert c.replacement is False
267+
assert c.replacement_triggers == []

tfsumpy/plan/analyzer.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,14 @@ def analyze(self, context: 'Context', **kwargs) -> AnalyzerResult:
5555
# Generate summary statistics
5656
change_counts = {'create': 0, 'update': 0, 'delete': 0}
5757
for change in changes:
58-
change_counts[change.action] += 1
58+
# For reporting, treat 'replace' as both a delete and a create
59+
if change.action == 'replace':
60+
change_counts['delete'] += 1
61+
change_counts['create'] += 1
62+
elif change.action in change_counts:
63+
change_counts[change.action] += 1
64+
else:
65+
change_counts[change.action] = 1 # fallback for unexpected actions
5966

6067
self.logger.info(f"Found {len(changes)} resource changes")
6168
self.logger.debug(f"Change breakdown: {change_counts}")
@@ -97,9 +104,10 @@ def _parse_plan(self, plan_content: str) -> List[ResourceChange]:
97104

98105
for change in resource_changes:
99106
# Extract change action
100-
action = change.get('change', {}).get('actions', ['no-op'])[0]
107+
actions = change.get('change', {}).get('actions', ['no-op'])
108+
action = actions[0] if actions else 'no-op'
101109
if action != 'no-op':
102-
self.logger.debug(f"Processing {action} change for {change.get('address', '')}")
110+
self.logger.debug(f"Processing {actions} change for {change.get('address', '')}")
103111

104112
# Extract module information
105113
address = change.get('address', '')
@@ -108,14 +116,22 @@ def _parse_plan(self, plan_content: str) -> List[ResourceChange]:
108116
# Get change details
109117
change_details = change.get('change', {})
110118

119+
# Detect replacement (Terraform: actions == ["delete", "create"])
120+
is_replacement = actions == ["delete", "create"]
121+
replacement_triggers = change_details.get('replacement_triggered_by', []) if is_replacement else []
122+
# For reporting, treat as 'replace' action
123+
action_display = 'replace' if is_replacement else action
124+
111125
changes.append(ResourceChange(
112-
action=action,
126+
action=action_display,
113127
resource_type=change.get('type', ''),
114128
identifier=self._sanitize_text(address),
115129
changes=change_details.get('before_sensitive', {}),
116130
module=module_name,
117131
before=change_details.get('before', {}),
118-
after=change_details.get('after', {})
132+
after=change_details.get('after', {}),
133+
replacement=is_replacement,
134+
replacement_triggers=replacement_triggers
119135
))
120136

121137
return changes

tfsumpy/plan/reporter.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ def _process_resources(self, resources: List[Dict], show_changes: bool = False,
6767
'identifier': resource['identifier'],
6868
'action': resource['action'],
6969
'provider': resource.get('provider', 'unknown'),
70-
'module': resource.get('module', 'root')
70+
'module': resource.get('module', 'root'),
71+
# Add replacement and triggers for reporting
72+
'replacement': resource.get('replacement', False),
73+
'replacement_triggers': resource.get('replacement_triggers', []),
7174
}
7275

7376
# Process changes if requested
@@ -102,7 +105,9 @@ def _process_resources(self, resources: List[Dict], show_changes: bool = False,
102105
'raw': {
103106
'before': resource.get('before', {}),
104107
'after': resource.get('after', {})
105-
}
108+
},
109+
# Add replacement triggers to details if present
110+
'replacement_triggers': resource.get('replacement_triggers', [])
106111
}
107112

108113
processed_resources.append(resource_data)
@@ -140,9 +145,9 @@ def _print_summary(self, report: Dict) -> None:
140145
"""Format the change summary section."""
141146
self._write(f"\n{self._colorize('Total Changes: ' + str(report['total_changes']), 'bold')}\n")
142147

143-
# Add change counts by type
148+
# Add change counts by type (do not show 'replace' in summary)
144149
for action in ['create', 'update', 'delete']:
145-
count = report['change_breakdown'][action]
150+
count = report['change_breakdown'].get(action, 0)
146151
self._write(f"{action.title()}: {count}\n")
147152

148153
def _print_resource_details(self, resources: list, show_changes: bool = False) -> None:
@@ -153,7 +158,8 @@ def _print_resource_details(self, resources: list, show_changes: bool = False) -
153158
action_colors = {
154159
'CREATE': 'green',
155160
'UPDATE': 'blue',
156-
'DELETE': 'red'
161+
'DELETE': 'red',
162+
'REPLACE': 'yellow',
157163
}
158164

159165
for resource in resources:
@@ -164,9 +170,14 @@ def _print_resource_details(self, resources: list, show_changes: bool = False) -
164170
f"\n{colored_action} {resource['resource_type']}: "
165171
f"{resource['identifier']}\n"
166172
)
167-
173+
# Show replacement triggers if this is a replacement
174+
if resource.get('replacement', False) and resource.get('replacement_triggers'):
175+
triggers = ', '.join(resource['replacement_triggers'])
176+
self._write(f" Replacement triggered by: {triggers}\n")
168177
if show_changes:
169178
self._print_attribute_changes(resource)
179+
# Ensure a newline at the end to avoid shell prompt artifacts
180+
self._write("\n")
170181

171182
def _print_attribute_changes(self, resource: Dict) -> None:
172183
"""Format attribute changes for a resource."""
@@ -182,7 +193,8 @@ def _print_attribute_changes(self, resource: Dict) -> None:
182193
symbol_colors = {
183194
'+': 'green', # create
184195
'~': 'blue', # update
185-
'-': 'red' # delete
196+
'-': 'red', # delete
197+
'-/+': 'yellow' # replace
186198
}
187199

188200
for attr in sorted(all_attrs - skip_attrs):
@@ -196,9 +208,12 @@ def _print_attribute_changes(self, resource: Dict) -> None:
196208
elif resource['action'] == 'delete':
197209
symbol = self._colorize('-', symbol_colors['-'])
198210
lines.append(f" {symbol} {attr} = {before_val}")
199-
else: # update
211+
elif resource['action'] == 'update':
200212
symbol = self._colorize('~', symbol_colors['~'])
201213
lines.append(f" {symbol} {attr} = {before_val} -> {after_val}")
214+
elif resource['action'] == 'replace':
215+
symbol = self._colorize('-/+', symbol_colors['-/+'])
216+
lines.append(f" {symbol} {attr} = {before_val} -> {after_val}")
202217

203218
self._write('\n'.join(lines))
204219

tfsumpy/reporters/base_reporter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class BaseReporter:
1414
'green': Fore.GREEN,
1515
'blue': Fore.BLUE,
1616
'red': Fore.RED,
17+
'yellow': Fore.YELLOW,
1718
'reset': Style.RESET_ALL,
1819
'bold': Style.BRIGHT
1920
}

tfsumpy/resource.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ class ResourceChange:
1111
changes: Dict[str, Any] = field(default_factory=dict)
1212
module: str = "root"
1313
before: Dict[str, Any] = field(default_factory=dict)
14-
after: Dict[str, Any] = field(default_factory=dict)
14+
after: Dict[str, Any] = field(default_factory=dict)
15+
replacement: bool = False
16+
replacement_triggers: list = field(default_factory=list)

tfsumpy/templates/plan_report.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
## Resource Changes
1010
{% for resource in resources %}
1111
### {{ resource.resource_type }}.{{ resource.identifier }}
12+
**Action:** {{ resource.action | upper }}
13+
{% if resource.replacement and resource.replacement_triggers %}
14+
- **Replacement triggered by:** {{ resource.replacement_triggers | join(', ') }}
15+
{% endif %}
1216
{% if resource.changes %}
1317
#### Changes:
1418
{% for change in resource.changes %}

0 commit comments

Comments
 (0)