From 8f94265ff152c14c8dde403c1bce98d1490bc07f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 00:58:33 +0000 Subject: [PATCH] Add resource_name as a special template variable for resource context Introduces {{ resource_name }} as a JIT-evaluated template variable (alongside stack_name and stack_env) that resolves to the current resource's name during processing. This enables tagging resources with their own name in both resource-level props and globals like global_tags. Global values containing {{ resource_name }} are preserved as literal template expressions during initial rendering (since no resource is known yet), then re-rendered per-resource in get_full_context() when the resource name becomes available. https://claude.ai/code/session_01JVr4F97rZNWMEsADMpAotd --- src/core/config.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 2 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index e2ed7a4..6305939 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -284,6 +284,10 @@ pub fn render_properties( /// Build the full context for a resource by merging global context with resource properties. /// Matches Python's `get_full_context`. +/// +/// Injects `resource_name` as a special variable (like `stack_name` and `stack_env`) +/// containing the current resource's name. Any global values that contain deferred +/// template expressions (e.g., `{{ resource_name }}`) are re-rendered at this point. pub fn get_full_context( engine: &TemplateEngine, global_context: &HashMap, @@ -292,9 +296,19 @@ pub fn get_full_context( ) -> HashMap { debug!("Getting full context for {}...", resource.name); - let prop_context = render_properties(engine, &resource.props, global_context, stack_env); + // Inject resource_name into the context so it's available in props and re-rendered globals + let mut context_with_resource_name = global_context.clone(); + context_with_resource_name.insert("resource_name".to_string(), resource.name.clone()); - let mut full_context = global_context.clone(); + // Re-render any global values that contain deferred template expressions. + // This allows globals (e.g., global_tags) to use {{ resource_name }} which couldn't + // be resolved at global rendering time since the resource wasn't known yet. + let resolved_context = + re_render_context_with_deferred_vars(engine, &context_with_resource_name); + + let prop_context = render_properties(engine, &resource.props, &resolved_context, stack_env); + + let mut full_context = resolved_context; for (k, v) in prop_context { full_context.insert(k, v); } @@ -303,6 +317,39 @@ pub fn get_full_context( full_context } +/// Re-render context values that contain deferred template expressions (`{{ ... }}`). +/// This is used to resolve variables like `resource_name` that weren't available +/// when globals were initially rendered. +fn re_render_context_with_deferred_vars( + engine: &TemplateEngine, + context: &HashMap, +) -> HashMap { + let mut result = context.clone(); + + for (key, value) in context { + if value.contains("{{") { + match engine.render(value, context) { + Ok(rendered) => { + let rendered = rendered.replace("True", "true").replace("False", "false"); + debug!( + "Re-rendered deferred global [{}]: {} -> {}", + key, value, rendered + ); + result.insert(key.clone(), rendered); + } + Err(e) => { + debug!( + "Warning: could not re-render deferred global '{}': {}", + key, e + ); + } + } + } + } + + result +} + /// Prepare context for SQL query rendering. /// JSON string values are re-serialized to ensure proper format (compact, lowercase bools). /// Matches Python's `render_queries` context preparation. @@ -351,3 +398,152 @@ pub fn is_json(s: &str) -> bool { Err(_) => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::resource::manifest::{Property, Resource}; + + /// Helper to create a minimal Resource for testing. + fn make_resource(name: &str, props: Vec) -> Resource { + Resource { + name: name.to_string(), + r#type: "resource".to_string(), + file: None, + sql: None, + run: None, + props, + exports: vec![], + protected: vec![], + description: String::new(), + r#if: None, + skip_validation: None, + auth: None, + } + } + + /// Helper to create a Property with a simple string value. + fn make_prop(name: &str, value: &str) -> Property { + Property { + name: name.to_string(), + value: Some(serde_yaml::Value::String(value.to_string())), + values: None, + description: String::new(), + merge: None, + } + } + + #[test] + fn test_resource_name_available_in_full_context() { + let engine = TemplateEngine::new(); + let mut global_context = HashMap::new(); + global_context.insert("stack_name".to_string(), "my-stack".to_string()); + global_context.insert("stack_env".to_string(), "dev".to_string()); + + let resource = make_resource("cross_account_role", vec![]); + + let ctx = get_full_context(&engine, &global_context, &resource, "dev"); + + assert_eq!(ctx.get("resource_name").unwrap(), "cross_account_role"); + // Existing variables still present + assert_eq!(ctx.get("stack_name").unwrap(), "my-stack"); + assert_eq!(ctx.get("stack_env").unwrap(), "dev"); + } + + #[test] + fn test_resource_name_usable_in_props() { + let engine = TemplateEngine::new(); + let mut global_context = HashMap::new(); + global_context.insert("stack_name".to_string(), "my-stack".to_string()); + global_context.insert("stack_env".to_string(), "dev".to_string()); + + let resource = make_resource( + "cross_account_role", + vec![make_prop("tag_value", "{{ resource_name }}")], + ); + + let ctx = get_full_context(&engine, &global_context, &resource, "dev"); + + assert_eq!(ctx.get("tag_value").unwrap(), "cross_account_role"); + } + + #[test] + fn test_resource_name_resolves_in_deferred_globals() { + let engine = TemplateEngine::new(); + let mut global_context = HashMap::new(); + global_context.insert("stack_name".to_string(), "my-stack".to_string()); + global_context.insert("stack_env".to_string(), "dev".to_string()); + // Simulate a global that was rendered at startup but contained {{ resource_name }} + // which couldn't be resolved then, so it's preserved as a literal. + global_context.insert( + "global_tags".to_string(), + r#"[{"Key":"stackql:resource-name","Value":"{{ resource_name }}"}]"#.to_string(), + ); + + let resource = make_resource("cross_account_role", vec![]); + + let ctx = get_full_context(&engine, &global_context, &resource, "dev"); + + let global_tags = ctx.get("global_tags").unwrap(); + assert!( + global_tags.contains("cross_account_role"), + "global_tags should contain the resolved resource name, got: {}", + global_tags + ); + assert!( + !global_tags.contains("{{ resource_name }}"), + "global_tags should not contain unresolved template expression" + ); + } + + #[test] + fn test_resource_name_varies_per_resource() { + let engine = TemplateEngine::new(); + let mut global_context = HashMap::new(); + global_context.insert("stack_name".to_string(), "my-stack".to_string()); + global_context.insert("stack_env".to_string(), "dev".to_string()); + global_context.insert( + "global_tags".to_string(), + r#"[{"Key":"res","Value":"{{ resource_name }}"}]"#.to_string(), + ); + + let res1 = make_resource("vpc_network", vec![]); + let res2 = make_resource("storage_bucket", vec![]); + + let ctx1 = get_full_context(&engine, &global_context, &res1, "dev"); + let ctx2 = get_full_context(&engine, &global_context, &res2, "dev"); + + assert_eq!(ctx1.get("resource_name").unwrap(), "vpc_network"); + assert_eq!(ctx2.get("resource_name").unwrap(), "storage_bucket"); + assert!(ctx1.get("global_tags").unwrap().contains("vpc_network")); + assert!(ctx2.get("global_tags").unwrap().contains("storage_bucket")); + } + + #[test] + fn test_re_render_context_no_templates_is_noop() { + let engine = TemplateEngine::new(); + let mut context = HashMap::new(); + context.insert("stack_name".to_string(), "my-stack".to_string()); + context.insert("plain_value".to_string(), "no templates here".to_string()); + + let result = re_render_context_with_deferred_vars(&engine, &context); + + assert_eq!(result.get("stack_name").unwrap(), "my-stack"); + assert_eq!(result.get("plain_value").unwrap(), "no templates here"); + } + + #[test] + fn test_re_render_context_resolves_deferred_vars() { + let engine = TemplateEngine::new(); + let mut context = HashMap::new(); + context.insert("resource_name".to_string(), "my_resource".to_string()); + context.insert( + "tag".to_string(), + "resource:{{ resource_name }}".to_string(), + ); + + let result = re_render_context_with_deferred_vars(&engine, &context); + + assert_eq!(result.get("tag").unwrap(), "resource:my_resource"); + } +}