|
3 | 3 | import re |
4 | 4 | import sys |
5 | 5 | import argparse |
| 6 | +import difflib |
6 | 7 |
|
7 | 8 | try: |
8 | 9 | import tomllib as toml # Python 3.11+ |
9 | | -except ImportError: |
| 10 | +except ImportError: # pragma: no cover |
10 | 11 | import tomli as toml # pip install tomli |
11 | 12 |
|
12 | 13 |
|
@@ -160,17 +161,87 @@ def resolve(self, node): |
160 | 161 | # Filter out AWS::NoValue (None) from lists |
161 | 162 | return [r for x in node if (r := self.resolve(x)) is not None] |
162 | 163 |
|
| 164 | + elif isinstance(node, str): |
| 165 | + # Check for CloudFormation dynamic references |
| 166 | + return self._resolve_dynamic_reference(node) |
| 167 | + |
163 | 168 | return node |
164 | 169 |
|
165 | 170 | # --- Intrinsic Handlers --- |
166 | 171 |
|
167 | 172 | def _handle_ref(self, ref_key): |
168 | 173 | 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 |
170 | 179 | if ref_key in self.resources: |
171 | 180 | return f"mock-{ref_key.lower()}-id" |
172 | 181 | return f"{{Ref: {ref_key}}}" |
173 | 182 |
|
| 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 | + |
174 | 245 | def _handle_map(self, args): |
175 | 246 | m_name = self.resolve(args[0]) |
176 | 247 | top = self.resolve(args[1]) |
@@ -287,33 +358,97 @@ def _handle_if(self, args): |
287 | 358 | return self.resolve(result_node) |
288 | 359 |
|
289 | 360 |
|
| 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 | + |
290 | 413 | 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 | + ) |
292 | 428 | parser.add_argument("template", help="Path to template.yaml") |
293 | 429 | parser.add_argument( |
294 | 430 | "--config", help="Path to samconfig.toml", default="samconfig.toml" |
295 | 431 | ) |
296 | 432 | parser.add_argument( |
297 | 433 | "--env", help="Environment name in samconfig (e.g., dev)", default="default" |
298 | 434 | ) |
| 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 | + ) |
299 | 440 | parser.add_argument("--profile", help="AWS CLI Profile", default=None) |
300 | 441 |
|
301 | 442 | args = parser.parse_args() |
302 | 443 |
|
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) |
315 | 445 |
|
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)) |
317 | 452 |
|
318 | 453 |
|
319 | 454 | if __name__ == "__main__": |
|
0 commit comments