Skip to content

Commit 73f32d2

Browse files
authored
FEAT: Add Diff to compare environments.
2 parents 4713fce + 6ece0fc commit 73f32d2

5 files changed

Lines changed: 393 additions & 99 deletions

File tree

Makefile

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,40 @@
11
.PHONY: install test lint format build clean all help render-example check-env
22

33
# Default target
4-
all: check-env format lint test build
4+
all: check-env format lint test build ## Run all checks and build
55

6-
help:
6+
help: ## Show this help message
77
@echo 'Usage: make [target]'
88
@echo ''
99
@echo 'Targets:'
1010
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
1111

12-
check-env:
12+
check-env: ## Check if uv is installed
1313
@command -v uv >/dev/null 2>&1 || { echo >&2 "Error: 'uv' is not installed. Please install it from https://github.com/astral-sh/uv"; exit 1; }
1414

15-
install: check-env
15+
install: check-env ## Install dependencies
1616
uv sync
1717

18-
test: check-env
18+
test: check-env ## Run tests
1919
uv run pytest
2020

21-
lint: check-env
21+
lint: check-env ## Run linter with auto-fix
2222
uv run ruff check --fix .
2323

24-
format: check-env
24+
format: check-env ## Format code
2525
uv run ruff format .
2626

27-
build: check-env install
27+
build: check-env install ## Build distribution packages
2828
uv build
2929

30-
clean:
30+
clean: ## Clean build artifacts and caches
3131
rm -rf dist/
3232
rm -rf .pytest_cache/
3333
rm -rf .ruff_cache/
3434
find . -type d -name "__pycache__" -exec rm -rf {} +
3535

36-
render-example: check-env
36+
render-example: check-env ## Render example template for dev environment
3737
uv run sam-render examples/template.yml --config examples/samconfig.toml --env dev
38+
39+
render-example-compare: check-env ## Compare dev and stag environments
40+
uv run sam-render examples/template.yml --config examples/samconfig.toml --env dev --env2 stag

README.md

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
# SAM Template Renderer
1+
# **SAM Template Renderer**
22

33
A lightweight Python tool to parse, resolve, and render AWS SAM and CloudFormation templates locally.
44

5-
This tool is designed to help debug complex template logic—specifically Mappings, Conditions, and Substitutions—without needing to deploy to AWS. It resolves intrinsic functions locally and outputs the final "rendered" YAML.
5+
This tool is designed to help debug complex template logic—specifically **Mappings**, **Conditions**, and **Substitutions**—without needing to deploy to AWS. It resolves intrinsic functions locally and outputs the final "rendered" YAML.
66

7-
## Features
7+
## **Features**
88

9-
- Intrinsic Function Resolution: Evaluates `Fn::FindInMap`, `Fn::If`, `Fn::Sub`, `Fn::Join`, `Fn::Select`, `Fn::Split`, and more locally.
10-
- Logic Handling: fully supports boolean logic (`Fn::And`, `Fn::Or`, `Fn::Not`, `Fn::Equals`) to correctly evaluate Condition blocks.
11-
- SAM Config Support: Parses `samconfig.toml` to apply environment-specific `parameter_overrides` automatically.
12-
- Custom YAML Tags: Handles short-form CloudFormation tags (e.g., `!Ref`, `!Sub`, `!GetAtt`) without parsing errors.
13-
- Hybrid Resolution: Mocks runtime values (like Resource IDs) but can optionally fetch real `Fn::ImportValue` data from AWS if a profile is provided.
14-
- Extended Syntax: Supports custom 4th-argument "DefaultValue" syntax for `Fn::FindInMap`.
9+
* **Intrinsic Function Resolution:** Evaluates `Fn::FindInMap`, `Fn::If`, `Fn::Sub`, `Fn::Join`, `Fn::Select`, `Fn::Split`, and more locally.
10+
* **Logic Handling:** Fully supports boolean logic (`Fn::And`, `Fn::Or`, `Fn::Not`, `Fn::Equals`) to correctly evaluate Condition blocks.
11+
* **Environment Diffing:** Compare the rendered output of two different environments (e.g., dev vs prod) to visualize configuration differences.
12+
* **Dynamic References:** Resolves `{{resolve:secretsmanager:...}}` patterns when an AWS profile is active.
13+
* **SAM Config Support:** Parses `samconfig.toml` to apply environment-specific parameter_overrides automatically.
14+
* **Custom YAML Tags:** Handles short-form CloudFormation tags (e.g., `!Ref`, `!Sub`, `!GetAtt`) without parsing errors.
15+
* **Hybrid Resolution:** Mocks runtime values (like Resource IDs) but can optionally fetch real values from AWS (Imports, Secrets) if a profile is provided.
16+
* **Extended Syntax:** Supports custom 4th-argument "DefaultValue" syntax for `Fn::FindInMap`.
1517

16-
## Installation
18+
## **Installation**
1719

1820
This project is managed with [uv](https://github.com/astral-sh/uv).
1921

@@ -26,55 +28,72 @@ cd samrenderer
2628
uv sync
2729
```
2830

29-
## Usage
31+
## **Usage**
3032

3133
Run the renderer against a template file. You can optionally specify a `samconfig.toml` environment or an AWS profile.
3234

33-
### Basic Rendering
35+
### **Basic Rendering**
3436

35-
Resolves parameters using defaults defined in the template.uv run sam-render template.yaml
37+
Resolves parameters using defaults defined in the template.
3638

37-
### Using SAM Config (Recommended)
39+
```bash
40+
uv run sam-render template.yaml
41+
```
42+
43+
### **Using SAM Config (Recommended)**
3844

3945
Applies parameters from `[<env>.deploy.parameters]` in `samconfig.toml`.
4046

4147
```bash
42-
uv run sam-render examples/template.yaml --config examples/samconfig.toml --env dev
48+
uv run sam-render template.yaml --config samconfig.toml --env dev
49+
```
50+
51+
### **Comparing Environments**
52+
53+
Generate a colored diff between two environments defined in `samconfig.toml`. This is useful for detecting drift or verifying configuration changes between stages.
54+
55+
```bash
56+
uv run sam-render template.yaml --config samconfig.toml --env dev --env2 stag
4357
```
4458

45-
### Fetching Real Exports
59+
### **AWS Integration (Imports & Secrets)**
4660

47-
By default, `Fn::ImportValue` returns a mock string. Provide an AWS profile to fetch real values from CloudFormation exports.
61+
By default, `Fn::ImportValue` and `{{resolve:secretsmanager:...}}` return mock strings. Provide an AWS profile to fetch real values from your AWS account.
4862

4963
```bash
5064
uv run sam-render template.yaml --config samconfig.toml --env dev --profile my-aws-profile
5165
```
5266

53-
## Supported Functions
54-
55-
| Category | Function | Status | Notes |
56-
|----------|-------------------|--------|---------------------------------------------------------------------|
57-
| Core | `Ref` || Resolves Parameters/Pseudo-params; mocks Resources. |
58-
| | `Fn::GetAtt` | ⚠️ | Returns mock string `mock-resource-attr`. |
59-
| | `Fn::ImportValue` || Fetches from AWS if `--profile` is set, otherwise mocks. |
60-
| Logic | `Fn::If` || Full support. |
61-
| | `Fn::Equals` || Full support. |
62-
| | `Fn::Not` || Full support. |
63-
| | `Fn::And / Or` || Full support. |
64-
| | `Condition` || Resolves Condition keys in dictionaries. |
65-
| Maps | `Fn::FindInMap` || Supports standard 3-arg and custom 4-arg (DefaultValue) syntax. |
66-
| String | `Fn::Sub` || Supports String and Key-Value map interpolation. |
67-
| | `Fn::Join` || Full support. |
68-
| | `Fn::Split` || Full support. |
69-
| | `Fn::Select` || Full support. |
70-
| | `Fn::Base64` | ⚠️ | ️Returns readable string `[Base64: ...]` instead of encoding. |
71-
| | `Fn::GetAZs` | ⚠️ | Returns mock list based on Region (e.g., `us-east-1a`, `1b`, `1c`). |
72-
73-
## Development & Testing
67+
## **Supported Functions**
68+
69+
| Category | Function | Status | Notes |
70+
|:---------|:----------------|:-------|:-------------------------------------------------------------------|
71+
| Core | Ref || Resolves Parameters/Pseudo-params; mocks Resources. |
72+
| | Fn::GetAtt | ⚠️ | Returns mock string mock-resource-attr. |
73+
| | Fn::ImportValue || Fetches from AWS if `--profile` is set, otherwise mocks. |
74+
| Logic | Fn::If || Full support. |
75+
| | Fn::Equals || Full support. |
76+
| | Fn::Not || Full support. |
77+
| | Fn::And / Or || Full support. |
78+
| | Condition || Resolves Condition keys in dictionaries. |
79+
| Maps | Fn::FindInMap || Supports standard 3-arg and custom 4-arg (DefaultValue) syntax. |
80+
| String | Fn::Sub || Supports String and Key-Value map interpolation. |
81+
| | Fn::Join || Full support. |
82+
| | Fn::Split || Full support. |
83+
| | Fn::Select || Full support. |
84+
| | Fn::Base64 | ⚠️ | Returns readable string `[Base64: ...]` instead of encoding. |
85+
| | Fn::GetAZs | ⚠️ | Returns mock list based on Region (e.g., us-east-1a, 1b, 1c). |
86+
| Dynamic | {{resolve:...}} || Supports Secrets Manager lookups (JSON & String) with `--profile`. |
87+
88+
## **Development & Testing**
89+
90+
Makefile is used to provide consistency between local and remote builds.
7491

92+
```bash
93+
make help
94+
```
7595
Tests are written using pytest.
7696

7797
```bash
78-
# Run all tests
79-
uv run pytest
98+
make test
8099
```

src/samrenderer/main.py

Lines changed: 151 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import re
44
import sys
55
import argparse
6+
import difflib
67

78
try:
89
import tomllib as toml # Python 3.11+
9-
except ImportError:
10+
except ImportError: # pragma: no cover
1011
import tomli as toml # pip install tomli
1112

1213

@@ -160,17 +161,87 @@ def resolve(self, node):
160161
# Filter out AWS::NoValue (None) from lists
161162
return [r for x in node if (r := self.resolve(x)) is not None]
162163

164+
elif isinstance(node, str):
165+
# Check for CloudFormation dynamic references
166+
return self._resolve_dynamic_reference(node)
167+
163168
return node
164169

165170
# --- Intrinsic Handlers ---
166171

167172
def _handle_ref(self, ref_key):
168173
if ref_key in self.context:
169-
return self.context[ref_key]
174+
result = self.context[ref_key]
175+
# Recursively resolve in case the parameter contains a dynamic reference
176+
if isinstance(result, str):
177+
return self._resolve_dynamic_reference(result)
178+
return result
170179
if ref_key in self.resources:
171180
return f"mock-{ref_key.lower()}-id"
172181
return f"{{Ref: {ref_key}}}"
173182

183+
def _resolve_dynamic_reference(self, text):
184+
"""Resolve CloudFormation dynamic references like {{resolve:secretsmanager:...}}"""
185+
if not isinstance(text, str):
186+
return text
187+
188+
# Pattern for {{resolve:service:...}}
189+
pattern = r"\{\{resolve:([^:]+):([^}]+)\}\}"
190+
match = re.search(pattern, text)
191+
192+
if not match:
193+
return text
194+
195+
service = match.group(1)
196+
reference = match.group(2)
197+
198+
if service == "secretsmanager":
199+
return self._resolve_secretsmanager(reference)
200+
201+
# Unsupported service - return as-is
202+
return text
203+
204+
def _resolve_secretsmanager(self, reference):
205+
"""Resolve a Secrets Manager reference."""
206+
# Parse the reference: secret-id:json-key:version-stage:version-id
207+
parts = reference.split(":")
208+
secret_id = parts[0]
209+
json_key = parts[1] if len(parts) > 1 else None
210+
211+
# Try to get the secret value if we have a boto session
212+
if self.boto_session:
213+
try:
214+
sm_client = self.boto_session.client("secretsmanager")
215+
response = sm_client.get_secret_value(SecretId=secret_id)
216+
217+
# Handle binary secrets
218+
if "SecretBinary" in response:
219+
return str(response["SecretBinary"])
220+
221+
# Handle string secrets
222+
secret_string = response.get("SecretString", "")
223+
224+
# If a JSON key is specified, parse and extract
225+
if json_key:
226+
try:
227+
import json
228+
229+
secret_data = json.loads(secret_string)
230+
if json_key not in secret_data:
231+
return f"{{Error: Key {json_key} not found in secret {secret_id}}}"
232+
return secret_data[json_key]
233+
except json.JSONDecodeError:
234+
return f"{{Error: Secret is not valid JSON: {secret_id}}}"
235+
236+
return secret_string
237+
238+
except Exception:
239+
# Fall through to mock value
240+
pass
241+
242+
# Return mock value if we can't resolve
243+
return f"mock-secret-{secret_id}"
244+
174245
def _handle_map(self, args):
175246
m_name = self.resolve(args[0])
176247
top = self.resolve(args[1])
@@ -287,33 +358,97 @@ def _handle_if(self, args):
287358
return self.resolve(result_node)
288359

289360

361+
def process(config, env, template, profile):
362+
sam_params = load_sam_config(config, env)
363+
region = sam_params.get("AWS::Region", "us-east-1")
364+
365+
renderer = TemplateRenderer(template, profile=profile, region=region)
366+
renderer.context.update(sam_params)
367+
368+
resolved_resources = renderer.resolve(renderer.resources)
369+
370+
output = {
371+
"Resources": resolved_resources,
372+
"Conditions": renderer.resolve(renderer.conditions),
373+
}
374+
return output
375+
376+
377+
def compare(a, b):
378+
# Convert dictionaries to YAML strings for text comparison
379+
# sort_keys=True is crucial to prevent false diffs from random dict ordering
380+
a_lines = yaml.dump(a[1], sort_keys=True).splitlines()
381+
b_lines = yaml.dump(b[1], sort_keys=True).splitlines()
382+
383+
diff = difflib.unified_diff(
384+
a_lines,
385+
b_lines,
386+
fromfile=f"Environment {a[0]}",
387+
tofile=f"Environment {b[0]}",
388+
lineterm="",
389+
)
390+
391+
# ANSI Color Codes
392+
RED = "\033[31m"
393+
GREEN = "\033[32m"
394+
CYAN = "\033[36m"
395+
RESET = "\033[0m"
396+
397+
colored_output = []
398+
for line in diff:
399+
if line.startswith("---") or line.startswith("+++"):
400+
colored_output.append(f"{CYAN}{line}{RESET}")
401+
elif line.startswith("-"):
402+
colored_output.append(f"{RED}{line}{RESET}")
403+
elif line.startswith("+"):
404+
colored_output.append(f"{GREEN}{line}{RESET}")
405+
elif line.startswith("@@"):
406+
colored_output.append(f"{CYAN}{line}{RESET}")
407+
else:
408+
colored_output.append(line)
409+
410+
return "\n".join(colored_output)
411+
412+
290413
def main():
291-
parser = argparse.ArgumentParser(description="Render CloudFormation/SAM templates.")
414+
parser = argparse.ArgumentParser(
415+
description="Render CloudFormation/SAM templates.",
416+
formatter_class=argparse.RawDescriptionHelpFormatter,
417+
epilog="""Examples:
418+
# Basic render of 'dev' environment
419+
sam-render template.yaml --config samconfig.toml --env dev
420+
421+
# Render with AWS profile for real value lookups
422+
sam-render template.yaml --env dev --profile my-profile
423+
424+
# Compare 'dev' and 'stag' environments (Colored Diff)
425+
sam-render template.yaml --env dev --env2 stag
426+
""",
427+
)
292428
parser.add_argument("template", help="Path to template.yaml")
293429
parser.add_argument(
294430
"--config", help="Path to samconfig.toml", default="samconfig.toml"
295431
)
296432
parser.add_argument(
297433
"--env", help="Environment name in samconfig (e.g., dev)", default="default"
298434
)
435+
parser.add_argument(
436+
"--env2",
437+
help="Second Environment name in samconfig (e.g., stag), used to diff the first environment against.",
438+
default=None,
439+
)
299440
parser.add_argument("--profile", help="AWS CLI Profile", default=None)
300441

301442
args = parser.parse_args()
302443

303-
sam_params = load_sam_config(args.config, args.env)
304-
region = sam_params.get("AWS::Region", "us-east-1")
305-
306-
renderer = TemplateRenderer(args.template, profile=args.profile, region=region)
307-
renderer.context.update(sam_params)
308-
309-
resolved_resources = renderer.resolve(renderer.resources)
310-
311-
output = {
312-
"Resources": resolved_resources,
313-
"Conditions": renderer.resolve(renderer.conditions),
314-
}
444+
output = process(args.config, args.env, args.template, args.profile)
315445

316-
print(yaml.dump(output))
446+
if args.env2 is not None:
447+
output2 = process(args.config, args.env2, args.template, args.profile)
448+
diff = compare([args.env, output], [args.env2, output2])
449+
print(diff)
450+
else:
451+
print(yaml.dump(output))
317452

318453

319454
if __name__ == "__main__":

0 commit comments

Comments
 (0)