diff --git a/examples/databricks/serverless/stackql_manifest.yml b/examples/databricks/serverless/stackql_manifest.yml index 9d220ea..108c646 100644 --- a/examples/databricks/serverless/stackql_manifest.yml +++ b/examples/databricks/serverless/stackql_manifest.yml @@ -61,6 +61,16 @@ resources: - file(aws/iam/policy_statements/iam_service_linked_role.json) Version: '2012-10-17' PolicyName: "{{ stack_name }}-{{ stack_env }}-policy" + - name: managed_policy_arns + value: [] + - name: max_session_duration + value: 3600 + - name: permissions_boundary + value: "" + - name: tags + value: [] + merge: + - global_tags exports: - aws_iam_role_name: aws_iam_cross_account_role_name - aws_iam_role_arn: aws_iam_cross_account_role_arn diff --git a/src/core/templating.rs b/src/core/templating.rs index 6a72390..d7a33c1 100644 --- a/src/core/templating.rs +++ b/src/core/templating.rs @@ -288,6 +288,37 @@ pub fn render_query( "Error rendering query for [{}] [{}]: {}", res_name, anchor, e ); + + // Extract template variable references for diagnostics + let re = Regex::new(r"\{\{\s*(\w+)").unwrap(); + let referenced_vars: Vec<&str> = re + .captures_iter(&processed_query) + .filter_map(|c| c.get(1).map(|m| m.as_str())) + .collect(); + let missing: Vec<&&str> = referenced_vars + .iter() + .filter(|v| !ctx.contains_key(**v)) + .collect(); + + if !missing.is_empty() { + error!( + "Missing variables in context for [{}] [{}]: {:?}", + res_name, anchor, missing + ); + error!( + "Hint: ensure these properties are defined in the manifest for resource [{}], \ + or that the .iql template only references variables provided by the manifest.", + res_name + ); + } + + debug!( + "[{}] [{}] available context keys: {:?}", + res_name, + anchor, + ctx.keys().collect::>() + ); + process::exit(1); } } @@ -383,6 +414,35 @@ pub fn render_inline_template( "Error rendering inline template for [{}]: {}", resource_name, e ); + + let re = Regex::new(r"\{\{\s*(\w+)").unwrap(); + let referenced_vars: Vec<&str> = re + .captures_iter(&processed) + .filter_map(|c| c.get(1).map(|m| m.as_str())) + .collect(); + let missing: Vec<&&str> = referenced_vars + .iter() + .filter(|v| !temp_context.contains_key(**v)) + .collect(); + + if !missing.is_empty() { + error!( + "Missing variables in context for [{}]: {:?}", + resource_name, missing + ); + error!( + "Hint: ensure these properties are defined in the manifest for resource [{}], \ + or that the inline SQL only references variables provided by the manifest.", + resource_name + ); + } + + debug!( + "[{}] available context keys: {:?}", + resource_name, + temp_context.keys().collect::>() + ); + process::exit(1); } } diff --git a/src/template/engine.rs b/src/template/engine.rs index 628773d..a29281a 100644 --- a/src/template/engine.rs +++ b/src/template/engine.rs @@ -8,6 +8,7 @@ //! `generate_patch_document`, `sql_list`, `sql_escape`. use std::collections::HashMap; +use std::error::Error as StdError; use base64::Engine as Base64Engine; use serde_json::Value as JsonValue; @@ -122,7 +123,7 @@ impl TemplateEngine { register_custom_filters(&mut tera); tera.add_raw_template(template_name, template) - .map_err(|e| TemplateError::SyntaxError(e.to_string()))?; + .map_err(|e| TemplateError::SyntaxError(full_error_chain(&e)))?; let mut tera_context = TeraContext::new(); for (key, value) in context { @@ -133,11 +134,30 @@ impl TemplateEngine { let uuid_val = uuid::Uuid::new_v4().to_string(); tera_context.insert("uuid", &uuid_val); - tera.render(template_name, &tera_context) - .map_err(|e| TemplateError::RenderError(e.to_string())) + tera.render(template_name, &tera_context).map_err(|e| { + let full_msg = full_error_chain(&e); + if full_msg.contains("not found in context") { + TemplateError::VariableNotFound(full_msg) + } else { + TemplateError::RenderError(full_msg) + } + }) } } +/// Walk the full error source chain and concatenate all messages. +/// Tera's top-level `Display` often only shows "Failed to render 'name'" while +/// the root cause (e.g., missing variable) is buried in `source()`. +fn full_error_chain(err: &dyn StdError) -> String { + let mut parts = vec![err.to_string()]; + let mut current = err.source(); + while let Some(cause) = current { + parts.push(cause.to_string()); + current = cause.source(); + } + parts.join(": ") +} + /// Register all custom Jinja2 filters matching the Python implementation. fn register_custom_filters(tera: &mut Tera) { tera.register_filter("from_json", filter_from_json); @@ -371,4 +391,37 @@ mod tests { let result = engine.render("JSON: {{ json }}", &context).unwrap(); assert_eq!(result, r#"JSON: {"key": "value"}"#); } + + #[test] + fn test_render_with_filters_missing_var_shows_name() { + let engine = TemplateEngine::new(); + let context = HashMap::new(); + + let result = engine.render_with_filters("test_tpl", "Hello {{ missing_var }}!", &context); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("missing_var"), + "Error should mention the missing variable name, got: {}", + err_msg + ); + } + + #[test] + fn test_render_with_filters_missing_var_is_variable_not_found() { + let engine = TemplateEngine::new(); + let context = HashMap::new(); + + let result = engine.render_with_filters("test_tpl", "{{ no_such_var }}", &context); + match result { + Err(TemplateError::VariableNotFound(msg)) => { + assert!( + msg.contains("no_such_var"), + "VariableNotFound error should contain variable name, got: {}", + msg + ); + } + other => panic!("Expected VariableNotFound error, got: {:?}", other), + } + } }