From 568ac1c04bfef357f91ab21a4328c0123989c637 Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Thu, 20 Nov 2025 14:12:24 +0000 Subject: [PATCH 1/3] Added Diff to compare two envs --- Makefile | 3 ++ src/samrenderer/main.py | 95 ++++++++++++++++++++++++++++++++++------- tests/test_main.py | 73 +++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 157 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 2d8c3dd..87ec460 100644 --- a/Makefile +++ b/Makefile @@ -35,3 +35,6 @@ clean: render-example: check-env uv run sam-render examples/template.yml --config examples/samconfig.toml --env dev + +render-example-compare: check-env + uv run sam-render examples/template.yml --config examples/samconfig.toml --env dev --env2 stag diff --git a/src/samrenderer/main.py b/src/samrenderer/main.py index bd0b7a1..8271daf 100644 --- a/src/samrenderer/main.py +++ b/src/samrenderer/main.py @@ -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 @@ -287,8 +288,73 @@ 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" @@ -296,24 +362,23 @@ def main(): 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__": diff --git a/tests/test_main.py b/tests/test_main.py index c1a4b71..3b6d2ca 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,6 +7,7 @@ load_sam_config, CFNLoader, main, + compare, # Added import ) @@ -66,6 +67,78 @@ def test_main_cli_execution(capsys, simple_template): assert "mock-mybucket-id" in captured.out +def test_main_cli_diff(capsys, simple_template, tmp_path): + """Test the main entrypoint with --env2 to trigger diff mode.""" + # 1. Create a temporary samconfig.toml with two environments + # FIX: Remove indentation to ensure valid TOML + config_content = """version = 0.1 +[dev.deploy.parameters] +parameter_overrides = "Env=\\\"dev\\\"" + +[prod.deploy.parameters] +parameter_overrides = "Env=\\\"prod\\\"" +""" + config_file = tmp_path / "samconfig.toml" + config_file.write_text(config_content, encoding="utf-8") + + # 2. Run CLI with --env dev --env2 prod + args = [ + "sam-render", + simple_template, + "--config", + str(config_file), + "--env", + "dev", + "--env2", + "prod", + ] + + with patch("sys.argv", args): + main() + captured = capsys.readouterr() + + # 3. Assert Diff Headers exist + assert "--- Environment dev" in captured.out + assert "+++ Environment prod" in captured.out + + # 4. Assert specific changes (IsProd changes from false to true) + # Note: We look for substrings because exact spacing might vary + assert "IsProd: false" in captured.out # Removed line (Red) + assert "IsProd: true" in captured.out # Added line (Green) + + +def test_compare_function(): + """Test the compare logic and ANSI coloring.""" + # Setup two dictionaries that differ + env1_data = {"Resources": {"Bucket": {"Properties": {"Name": "DevBucket"}}}} + env2_data = {"Resources": {"Bucket": {"Properties": {"Name": "ProdBucket"}}}} + + # Call compare with [Name, Data] tuples + output = compare(["dev", env1_data], ["prod", env2_data]) + + # Check Headers + assert "--- Environment dev" in output + assert "+++ Environment prod" in output + + # Check ANSI Color Codes + RED = "\033[31m" + GREEN = "\033[32m" + RESET = "\033[0m" + + # Verify deletion (DevBucket) is Red + assert f"{RED}- Name: DevBucket{RESET}" in output + # Verify addition (ProdBucket) is Green + assert f"{GREEN}+ Name: ProdBucket{RESET}" in output + + +def test_compare_no_diff(): + """Test compare with identical inputs returns empty string.""" + data = {"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + # Compare identical data + output = compare(["dev", data], ["dev", data]) + assert output == "" + + def test_sam_config_missing_file(): """Test graceful failure when config file doesn't exist.""" # Should return empty dict and print warning to stderr diff --git a/uv.lock b/uv.lock index 84ab00a..18dd569 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "samrenderer" -version = "0.0.1" +version = "0.0.0.dev0" source = { editable = "." } dependencies = [ { name = "boto3" }, From 42f0707f326e89e153dab8c857ba27d600d95fa2 Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Thu, 20 Nov 2025 14:14:41 +0000 Subject: [PATCH 2/3] Added More Tests --- tests/test_main.py | 106 ++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 55 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 3b6d2ca..84c7459 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,7 +7,7 @@ load_sam_config, CFNLoader, main, - compare, # Added import + compare, ) @@ -59,18 +59,14 @@ def renderer(simple_template): def test_main_cli_execution(capsys, simple_template): """Test the main entrypoint via CLI arguments.""" - # Simulate running: sam-render template.yaml with patch("sys.argv", ["sam-render", simple_template]): main() captured = capsys.readouterr() - # Check if output contains resolved resource ID assert "mock-mybucket-id" in captured.out def test_main_cli_diff(capsys, simple_template, tmp_path): """Test the main entrypoint with --env2 to trigger diff mode.""" - # 1. Create a temporary samconfig.toml with two environments - # FIX: Remove indentation to ensure valid TOML config_content = """version = 0.1 [dev.deploy.parameters] parameter_overrides = "Env=\\\"dev\\\"" @@ -81,7 +77,6 @@ def test_main_cli_diff(capsys, simple_template, tmp_path): config_file = tmp_path / "samconfig.toml" config_file.write_text(config_content, encoding="utf-8") - # 2. Run CLI with --env dev --env2 prod args = [ "sam-render", simple_template, @@ -96,64 +91,81 @@ def test_main_cli_diff(capsys, simple_template, tmp_path): with patch("sys.argv", args): main() captured = capsys.readouterr() - - # 3. Assert Diff Headers exist assert "--- Environment dev" in captured.out assert "+++ Environment prod" in captured.out - - # 4. Assert specific changes (IsProd changes from false to true) - # Note: We look for substrings because exact spacing might vary - assert "IsProd: false" in captured.out # Removed line (Red) - assert "IsProd: true" in captured.out # Added line (Green) + assert "IsProd: false" in captured.out + assert "IsProd: true" in captured.out def test_compare_function(): """Test the compare logic and ANSI coloring.""" - # Setup two dictionaries that differ - env1_data = {"Resources": {"Bucket": {"Properties": {"Name": "DevBucket"}}}} - env2_data = {"Resources": {"Bucket": {"Properties": {"Name": "ProdBucket"}}}} + # Setup data with some shared lines (context) and some diffs + env1_data = { + "Resources": { + "Shared": {"Type": "AWS::S3::Bucket"}, + "Bucket": {"Properties": {"Name": "DevBucket"}}, + } + } + env2_data = { + "Resources": { + "Shared": {"Type": "AWS::S3::Bucket"}, + "Bucket": {"Properties": {"Name": "ProdBucket"}}, + } + } - # Call compare with [Name, Data] tuples output = compare(["dev", env1_data], ["prod", env2_data]) - # Check Headers - assert "--- Environment dev" in output - assert "+++ Environment prod" in output - - # Check ANSI Color Codes RED = "\033[31m" GREEN = "\033[32m" RESET = "\033[0m" - # Verify deletion (DevBucket) is Red + # Verify deletion and addition are colored assert f"{RED}- Name: DevBucket{RESET}" in output - # Verify addition (ProdBucket) is Green assert f"{GREEN}+ Name: ProdBucket{RESET}" in output + # Verify context lines (shared) are present and NOT colored + # Note: unified_diff adds 1 space prefix, plus YAML adds 2 spaces = 3 spaces total + assert " Shared:" in output + assert f"{RED} Shared:{RESET}" not in output + def test_compare_no_diff(): - """Test compare with identical inputs returns empty string.""" data = {"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} - # Compare identical data output = compare(["dev", data], ["dev", data]) assert output == "" def test_sam_config_missing_file(): - """Test graceful failure when config file doesn't exist.""" - # Should return empty dict and print warning to stderr assert load_sam_config("nonexistent_file.toml") == {} def test_sam_config_malformed(tmp_path): - """Test graceful failure with invalid TOML.""" f = tmp_path / "bad.toml" f.write_text("This is not TOML", encoding="utf-8") assert load_sam_config(str(f)) == {} +def test_sam_config_parsing_edge_cases(): + """Test empty or None overrides.""" + assert parse_sam_overrides("") == {} + assert parse_sam_overrides(None) == {} + + +def test_sam_config_with_region(tmp_path): + """Test that region is extracted from SAM config.""" + config_content = """version = 0.1 +[default.deploy.parameters] +region = "eu-central-1" +parameter_overrides = "Key=Val" +""" + f = tmp_path / "samconfig.toml" + f.write_text(config_content, encoding="utf-8") + + config = load_sam_config(str(f)) + assert config["AWS::Region"] == "eu-central-1" + + def test_yaml_loader_complex_tags(): - """Test custom YAML loader handles lists and dicts in tags.""" yaml_str = """ GetAttList: !GetAtt [Res, Attr] TagOnList: !MyTag [1, 2] @@ -183,14 +195,11 @@ def test_find_in_map_standard(renderer): def test_find_in_map_default_value(renderer): node = {"Fn::FindInMap": ["ConfigMap", "dev", "InvalidKey", {"DefaultValue": 10}]} assert renderer.resolve(node) == 10 - - # Test direct value syntax node_direct = {"Fn::FindInMap": ["ConfigMap", "dev", "InvalidKey", 99]} assert renderer.resolve(node_direct) == 99 def test_find_in_map_error(renderer): - """Test missing key without default value returns error string.""" node = {"Fn::FindInMap": ["ConfigMap", "dev", "Missing"]} result = renderer.resolve(node) assert "Error: Could not resolve Map" in result @@ -202,26 +211,17 @@ def test_sub_resolution(renderer): def test_sub_priority(renderer): - """Ensure variable precedence: Local > Context > Resources > Unknown.""" renderer.resources["MyRes"] = {} - - # 1. Local overrides everything assert renderer.resolve({"Fn::Sub": ["${Var}", {"Var": "local"}]}) == "local" - # 2. Context (Parameters/Pseudo) assert renderer.resolve({"Fn::Sub": "${AWS::Region}"}) == "us-east-1" - # 3. Resource Mock ID assert renderer.resolve({"Fn::Sub": "${MyRes}"}) == "mock-myres-id" - # 4. Unknown stays as is assert renderer.resolve({"Fn::Sub": "${Whoops}"}) == "${Whoops}" def test_import_value_mock_aws(simple_template): - """Test Fn::ImportValue with a mocked Boto3 client.""" with patch("boto3.Session") as mock_session: mock_client = MagicMock() mock_session.return_value.client.return_value = mock_client - - # Mock AWS response mock_client.list_exports.return_value = { "Exports": [ {"Name": "MyExport", "Value": "RealValue"}, @@ -229,55 +229,51 @@ def test_import_value_mock_aws(simple_template): ] } - # Initialize with profile to trigger boto3 logic r = TemplateRenderer(simple_template, profile="test-profile") - # Case 1: Export found assert r.resolve({"Fn::ImportValue": "MyExport"}) == "RealValue" - # Case 2: Export not found assert r.resolve({"Fn::ImportValue": "Missing"}) == "mock-import-Missing" - # Case 3: AWS Error (Client fails), fallback to mock + mock_client.list_exports.side_effect = Exception("AWS Down") assert r.resolve({"Fn::ImportValue": "MyExport"}) == "mock-import-MyExport" def test_split_select_success(renderer): - """Test successful Split and Select operations.""" - # Split "a,b,c" -> ["a","b","c"], Select index 1 -> "b" node = {"Fn::Select": ["1", {"Fn::Split": [",", "a,b,c"]}]} assert renderer.resolve(node) == "b" def test_select_edge_cases(renderer): - # Index out of bounds node = {"Fn::Select": ["5", ["a", "b"]]} assert "Error: Select index 5" in renderer.resolve(node) def test_length_edge_cases(renderer): - # Not a list node = {"Fn::Length": "NotAList"} assert renderer.resolve(node) == 0 - # List node_list = {"Fn::Length": ["a", "b"]} assert renderer.resolve(node_list) == 2 def test_getazs(renderer): - # Explicit region node = {"Fn::GetAZs": "eu-west-1"} assert renderer.resolve(node) == ["eu-west-1a", "eu-west-1b", "eu-west-1c"] - - # Implicit region (empty string) -> uses context region node_implicit = {"Fn::GetAZs": ""} assert renderer.resolve(node_implicit) == ["us-east-1a", "us-east-1b", "us-east-1c"] def test_condition_missing(renderer): - """Test referring to a Condition that doesn't exist in the template.""" assert renderer.resolve({"Condition": "NonExistent"}) is False +def test_fn_condition_explicit(renderer): + """Test explicit Fn::Condition dict usage.""" + # Assuming 'IsProd' exists in the renderer (from simple_template fixture) + # IsProd depends on Env=dev (Default), so it is False. + assert renderer.resolve({"Fn::Condition": "IsProd"}) is False + assert renderer.resolve({"Fn::Condition": "IsNotProd"}) is True + + # --- Logic Tests (Existing) --- From 6ece0fc821234173500388f9e1c39b00b827e5e8 Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Thu, 20 Nov 2025 15:00:48 +0000 Subject: [PATCH 3/3] Fixed failing tests and Updated Makefile and Readme --- Makefile | 22 ++++----- README.md | 99 ++++++++++++++++++++++++----------------- src/samrenderer/main.py | 72 +++++++++++++++++++++++++++++- tests/test_main.py | 78 +++++++++++++++++++++++++++++--- 4 files changed, 214 insertions(+), 57 deletions(-) diff --git a/Makefile b/Makefile index 87ec460..49c3ea5 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +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 +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 diff --git a/README.md b/README.md index 96628b8..69b584a 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 `[.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 ``` diff --git a/src/samrenderer/main.py b/src/samrenderer/main.py index 8271daf..d673ab7 100644 --- a/src/samrenderer/main.py +++ b/src/samrenderer/main.py @@ -161,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]) diff --git a/tests/test_main.py b/tests/test_main.py index 84c7459..29a9f6d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import pytest import yaml +import boto3 from unittest.mock import patch, MagicMock from samrenderer.main import ( TemplateRenderer, @@ -123,10 +124,11 @@ def test_compare_function(): assert f"{RED}- Name: DevBucket{RESET}" in output assert f"{GREEN}+ Name: ProdBucket{RESET}" in output - # Verify context lines (shared) are present and NOT colored - # Note: unified_diff adds 1 space prefix, plus YAML adds 2 spaces = 3 spaces total + # Verify context lines (shared) are present and NOT colored. + # The logic is: ' ' (diff prefix) + ' ' (yaml indent) = 3 spaces. + # We simply check that the string exists in the output without color codes prefixing it. assert " Shared:" in output - assert f"{RED} Shared:{RESET}" not in output + assert f"{RED} Shared:" not in output def test_compare_no_diff(): @@ -219,9 +221,15 @@ def test_sub_priority(renderer): def test_import_value_mock_aws(simple_template): - with patch("boto3.Session") as mock_session: + with patch.object(boto3, "Session") as mock_session_cls: + # Explicitly create the session mock instance + mock_sess_inst = MagicMock() + mock_session_cls.return_value = mock_sess_inst + mock_client = MagicMock() - mock_session.return_value.client.return_value = mock_client + # Attach return_value to the client method of the instance + mock_sess_inst.client.return_value = mock_client + mock_client.list_exports.return_value = { "Exports": [ {"Name": "MyExport", "Value": "RealValue"}, @@ -238,6 +246,66 @@ def test_import_value_mock_aws(simple_template): assert r.resolve({"Fn::ImportValue": "MyExport"}) == "mock-import-MyExport" +def test_secrets_manager_edge_cases(simple_template): + """Test binary secrets, invalid JSON, and missing keys.""" + with patch.object(boto3, "Session") as mock_session_cls: + mock_sess_inst = MagicMock() + mock_session_cls.return_value = mock_sess_inst + + mock_sm = MagicMock() + + # Correct side_effect signature and logic + def client_side_effect(service_name, **kwargs): + if service_name == "secretsmanager": + return mock_sm + return MagicMock() + + # Important: Set side_effect on the .client method of the session instance + mock_sess_inst.client.side_effect = client_side_effect + + r = TemplateRenderer(simple_template, profile="test-profile") + + # 1. Binary Secret (SecretString is None) + mock_sm.get_secret_value.return_value = {"SecretBinary": b"binary_data"} + # Ensure we convert bytes to string representation for assertion + assert r.resolve("{{resolve:secretsmanager:BinarySecret}}") == "b'binary_data'" + + # 2. Invalid JSON + mock_sm.get_secret_value.return_value = {"SecretString": "not_json"} + res = r.resolve("{{resolve:secretsmanager:BadJson:Key}}") + assert "Error: Secret is not valid JSON" in res + + # 3. Missing Key in JSON + mock_sm.get_secret_value.return_value = {"SecretString": '{"Foo": "Bar"}'} + res = r.resolve("{{resolve:secretsmanager:MissingKey:Baz}}") + assert "Error: Key Baz not found" in res + + +def test_ref_to_dynamic_reference(simple_template): + """Test that !Ref to a parameter containing {{resolve...}} recursively resolves it.""" + with patch.object(boto3, "Session") as mock_session_cls: + mock_sess_inst = MagicMock() + mock_session_cls.return_value = mock_sess_inst + + mock_sm = MagicMock() + + def client_side_effect(service_name, **kwargs): + if service_name == "secretsmanager": + return mock_sm + return MagicMock() + + mock_sess_inst.client.side_effect = client_side_effect + mock_sm.get_secret_value.return_value = {"SecretString": "SecretValue"} + + # Setup renderer + r = TemplateRenderer(simple_template, profile="test-profile") + # Inject parameter with dynamic ref + r.context["MyParam"] = "{{resolve:secretsmanager:MySecret}}" + + # Resolve !Ref MyParam + assert r.resolve({"Ref": "MyParam"}) == "SecretValue" + + def test_split_select_success(renderer): node = {"Fn::Select": ["1", {"Fn::Split": [",", "a,b,c"]}]} assert renderer.resolve(node) == "b"