Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,37 +1,40 @@
.PHONY: install test lint format build clean all help render-example check-env

# Default target
all: check-env format lint test build
all: check-env format lint test build ## Run all checks and build

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

check-env:
check-env: ## Check if uv is installed
@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; }

install: check-env
install: check-env ## Install dependencies
uv sync

test: check-env
test: check-env ## Run tests
uv run pytest

lint: check-env
lint: check-env ## Run linter with auto-fix
uv run ruff check --fix .

format: check-env
format: check-env ## Format code
uv run ruff format .

build: check-env install
build: check-env install ## Build distribution packages
uv build

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

render-example: check-env
render-example: check-env ## Render example template for dev environment
uv run sam-render examples/template.yml --config examples/samconfig.toml --env dev

render-example-compare: check-env ## Compare dev and stag environments
uv run sam-render examples/template.yml --config examples/samconfig.toml --env dev --env2 stag
99 changes: 59 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# SAM Template Renderer
# **SAM Template Renderer**

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

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.
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.

## Features
## **Features**

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

## Installation
## **Installation**

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

Expand All @@ -26,55 +28,72 @@ cd samrenderer
uv sync
```

## Usage
## **Usage**

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

### Basic Rendering
### **Basic Rendering**

Resolves parameters using defaults defined in the template.uv run sam-render template.yaml
Resolves parameters using defaults defined in the template.

### Using SAM Config (Recommended)
```bash
uv run sam-render template.yaml
```

### **Using SAM Config (Recommended)**

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

```bash
uv run sam-render examples/template.yaml --config examples/samconfig.toml --env dev
uv run sam-render template.yaml --config samconfig.toml --env dev
```

### **Comparing Environments**

Generate a colored diff between two environments defined in `samconfig.toml`. This is useful for detecting drift or verifying configuration changes between stages.

```bash
uv run sam-render template.yaml --config samconfig.toml --env dev --env2 stag
```

### Fetching Real Exports
### **AWS Integration (Imports & Secrets)**

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

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

## Supported Functions

| Category | Function | Status | Notes |
|----------|-------------------|--------|---------------------------------------------------------------------|
| Core | `Ref` | ✅ | Resolves Parameters/Pseudo-params; mocks Resources. |
| | `Fn::GetAtt` | ⚠️ | Returns mock string `mock-resource-attr`. |
| | `Fn::ImportValue` | ✅ | Fetches from AWS if `--profile` is set, otherwise mocks. |
| Logic | `Fn::If` | ✅ | Full support. |
| | `Fn::Equals` | ✅ | Full support. |
| | `Fn::Not` | ✅ | Full support. |
| | `Fn::And / Or` | ✅ | Full support. |
| | `Condition` | ✅ | Resolves Condition keys in dictionaries. |
| Maps | `Fn::FindInMap` | ✅ | Supports standard 3-arg and custom 4-arg (DefaultValue) syntax. |
| String | `Fn::Sub` | ✅ | Supports String and Key-Value map interpolation. |
| | `Fn::Join` | ✅ | Full support. |
| | `Fn::Split` | ✅ | Full support. |
| | `Fn::Select` | ✅ | Full support. |
| | `Fn::Base64` | ⚠️ | ️Returns readable string `[Base64: ...]` instead of encoding. |
| | `Fn::GetAZs` | ⚠️ | Returns mock list based on Region (e.g., `us-east-1a`, `1b`, `1c`). |

## Development & Testing
## **Supported Functions**

| Category | Function | Status | Notes |
|:---------|:----------------|:-------|:-------------------------------------------------------------------|
| Core | Ref | ✅ | Resolves Parameters/Pseudo-params; mocks Resources. |
| | Fn::GetAtt | ⚠️ | Returns mock string mock-resource-attr. |
| | Fn::ImportValue | ✅ | Fetches from AWS if `--profile` is set, otherwise mocks. |
| Logic | Fn::If | ✅ | Full support. |
| | Fn::Equals | ✅ | Full support. |
| | Fn::Not | ✅ | Full support. |
| | Fn::And / Or | ✅ | Full support. |
| | Condition | ✅ | Resolves Condition keys in dictionaries. |
| Maps | Fn::FindInMap | ✅ | Supports standard 3-arg and custom 4-arg (DefaultValue) syntax. |
| String | Fn::Sub | ✅ | Supports String and Key-Value map interpolation. |
| | Fn::Join | ✅ | Full support. |
| | Fn::Split | ✅ | Full support. |
| | Fn::Select | ✅ | Full support. |
| | Fn::Base64 | ⚠️ | Returns readable string `[Base64: ...]` instead of encoding. |
| | Fn::GetAZs | ⚠️ | Returns mock list based on Region (e.g., us-east-1a, 1b, 1c). |
| Dynamic | {{resolve:...}} | ✅ | Supports Secrets Manager lookups (JSON & String) with `--profile`. |

## **Development & Testing**

Makefile is used to provide consistency between local and remote builds.

```bash
make help
```
Tests are written using pytest.

```bash
# Run all tests
uv run pytest
make test
```
167 changes: 151 additions & 16 deletions src/samrenderer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import re
import sys
import argparse
import difflib

try:
import tomllib as toml # Python 3.11+
except ImportError:
except ImportError: # pragma: no cover
import tomli as toml # pip install tomli


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

elif isinstance(node, str):
# Check for CloudFormation dynamic references
return self._resolve_dynamic_reference(node)

return node

# --- Intrinsic Handlers ---

def _handle_ref(self, ref_key):
if ref_key in self.context:
return self.context[ref_key]
result = self.context[ref_key]
# Recursively resolve in case the parameter contains a dynamic reference
if isinstance(result, str):
return self._resolve_dynamic_reference(result)
return result
if ref_key in self.resources:
return f"mock-{ref_key.lower()}-id"
return f"{{Ref: {ref_key}}}"

def _resolve_dynamic_reference(self, text):
"""Resolve CloudFormation dynamic references like {{resolve:secretsmanager:...}}"""
if not isinstance(text, str):
return text

# Pattern for {{resolve:service:...}}
pattern = r"\{\{resolve:([^:]+):([^}]+)\}\}"
match = re.search(pattern, text)

if not match:
return text

service = match.group(1)
reference = match.group(2)

if service == "secretsmanager":
return self._resolve_secretsmanager(reference)

# Unsupported service - return as-is
return text

def _resolve_secretsmanager(self, reference):
"""Resolve a Secrets Manager reference."""
# Parse the reference: secret-id:json-key:version-stage:version-id
parts = reference.split(":")
secret_id = parts[0]
json_key = parts[1] if len(parts) > 1 else None

# Try to get the secret value if we have a boto session
if self.boto_session:
try:
sm_client = self.boto_session.client("secretsmanager")
response = sm_client.get_secret_value(SecretId=secret_id)

# Handle binary secrets
if "SecretBinary" in response:
return str(response["SecretBinary"])

# Handle string secrets
secret_string = response.get("SecretString", "")

# If a JSON key is specified, parse and extract
if json_key:
try:
import json

secret_data = json.loads(secret_string)
if json_key not in secret_data:
return f"{{Error: Key {json_key} not found in secret {secret_id}}}"
return secret_data[json_key]
except json.JSONDecodeError:
return f"{{Error: Secret is not valid JSON: {secret_id}}}"

return secret_string

except Exception:
# Fall through to mock value
pass

# Return mock value if we can't resolve
return f"mock-secret-{secret_id}"

def _handle_map(self, args):
m_name = self.resolve(args[0])
top = self.resolve(args[1])
Expand Down Expand Up @@ -287,33 +358,97 @@ def _handle_if(self, args):
return self.resolve(result_node)


def process(config, env, template, profile):
sam_params = load_sam_config(config, env)
region = sam_params.get("AWS::Region", "us-east-1")

renderer = TemplateRenderer(template, profile=profile, region=region)
renderer.context.update(sam_params)

resolved_resources = renderer.resolve(renderer.resources)

output = {
"Resources": resolved_resources,
"Conditions": renderer.resolve(renderer.conditions),
}
return output


def compare(a, b):
# Convert dictionaries to YAML strings for text comparison
# sort_keys=True is crucial to prevent false diffs from random dict ordering
a_lines = yaml.dump(a[1], sort_keys=True).splitlines()
b_lines = yaml.dump(b[1], sort_keys=True).splitlines()

diff = difflib.unified_diff(
a_lines,
b_lines,
fromfile=f"Environment {a[0]}",
tofile=f"Environment {b[0]}",
lineterm="",
)

# ANSI Color Codes
RED = "\033[31m"
GREEN = "\033[32m"
CYAN = "\033[36m"
RESET = "\033[0m"

colored_output = []
for line in diff:
if line.startswith("---") or line.startswith("+++"):
colored_output.append(f"{CYAN}{line}{RESET}")
elif line.startswith("-"):
colored_output.append(f"{RED}{line}{RESET}")
elif line.startswith("+"):
colored_output.append(f"{GREEN}{line}{RESET}")
elif line.startswith("@@"):
colored_output.append(f"{CYAN}{line}{RESET}")
else:
colored_output.append(line)

return "\n".join(colored_output)


def main():
parser = argparse.ArgumentParser(description="Render CloudFormation/SAM templates.")
parser = argparse.ArgumentParser(
description="Render CloudFormation/SAM templates.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
# Basic render of 'dev' environment
sam-render template.yaml --config samconfig.toml --env dev

# Render with AWS profile for real value lookups
sam-render template.yaml --env dev --profile my-profile

# Compare 'dev' and 'stag' environments (Colored Diff)
sam-render template.yaml --env dev --env2 stag
""",
)
parser.add_argument("template", help="Path to template.yaml")
parser.add_argument(
"--config", help="Path to samconfig.toml", default="samconfig.toml"
)
parser.add_argument(
"--env", help="Environment name in samconfig (e.g., dev)", default="default"
)
parser.add_argument(
"--env2",
help="Second Environment name in samconfig (e.g., stag), used to diff the first environment against.",
default=None,
)
parser.add_argument("--profile", help="AWS CLI Profile", default=None)

args = parser.parse_args()

sam_params = load_sam_config(args.config, args.env)
region = sam_params.get("AWS::Region", "us-east-1")

renderer = TemplateRenderer(args.template, profile=args.profile, region=region)
renderer.context.update(sam_params)

resolved_resources = renderer.resolve(renderer.resources)

output = {
"Resources": resolved_resources,
"Conditions": renderer.resolve(renderer.conditions),
}
output = process(args.config, args.env, args.template, args.profile)

print(yaml.dump(output))
if args.env2 is not None:
output2 = process(args.config, args.env2, args.template, args.profile)
diff = compare([args.env, output], [args.env2, output2])
print(diff)
else:
print(yaml.dump(output))


if __name__ == "__main__":
Expand Down
Loading