1#![allow(clippy::too_many_lines)]
22
23use std::{
24 path::Path,
25 sync::{Arc, OnceLock},
26};
27
28use dashmap::DashMap;
29
30use crate::{
31 Diagnostic, LimitKind, Result, Severity,
32 diagnostic::Diagnostic as Diag,
33 eval::{CallCx, EvalLimits, FuncRegistry, FuncRegistryBuilder, HclFunc},
34 ir::{
35 AttributeMap, DependencyBlock, Expression, GenerateBlock, IncludePath, Map, StateBackend,
36 TerragruntConfig, Value,
37 },
38 loader::{HclEditLoader, LoaderLimits},
39 terragrunt::{
40 context::TgContext,
41 funcs::{
42 FindInParentFoldersFn, FindInParentFoldersFromFn, GetParentTerragruntDirFn,
43 GetRepoRootFn, GetTerragruntDirFn, PathRelativeFromIncludeFn, PathRelativeToIncludeFn,
44 TgState, TryFn,
45 },
46 merge::{MergeStrategy, merge_locals},
47 parsed::{self, ParsedTerragrunt},
48 },
49 util::paths::{self, SymlinkPolicy},
50};
51
52pub trait TerragruntResolver: Send + Sync + std::fmt::Debug {
55 fn resolve(&self, component_dir: &Path, ctx: &TgContext) -> Result<TerragruntConfig>;
65}
66
67#[derive(Debug, Default)]
70#[non_exhaustive]
71pub struct FsTerragruntResolver;
72
73impl FsTerragruntResolver {
74 #[must_use]
76 pub const fn new() -> Self {
77 Self
78 }
79}
80
81#[derive(Clone, Debug)]
85pub(super) struct ResolvedTerragrunt {
86 pub locals: Map,
87 pub inputs: Map,
88}
89
90impl TerragruntResolver for FsTerragruntResolver {
91 fn resolve(&self, component_dir: &Path, ctx: &TgContext) -> Result<TerragruntConfig> {
92 let component_dir_arc: Arc<Path> = Arc::from(component_dir);
93
94 if paths::canonicalize_inside(component_dir, &ctx.workspace_root, SymlinkPolicy::Follow)
96 .is_err()
97 {
98 return Ok(TerragruntConfig::builder()
99 .component_dir(Arc::clone(&component_dir_arc))
100 .diagnostics(vec![Diag::new(
101 Severity::Warn,
102 "TG2001",
103 format!(
104 "component dir `{}` is outside workspace root `{}`",
105 component_dir.display(),
106 ctx.workspace_root.display()
107 ),
108 )])
109 .build());
110 }
111
112 let tg_state = Arc::new(TgState::new(
116 Arc::clone(&ctx.workspace_root),
117 Arc::clone(&component_dir_arc),
118 ));
119 let memo: Arc<DashMap<Arc<Path>, Arc<ResolvedTerragrunt>>> = Arc::new(DashMap::new());
120 let stack: Arc<std::sync::Mutex<Vec<Arc<Path>>>> =
121 Arc::new(std::sync::Mutex::new(Vec::new()));
122 let inflight: Arc<DashMap<Arc<Path>, ()>> = Arc::new(DashMap::new());
123
124 let mut diagnostics: Vec<Diagnostic> = Vec::new();
125 let loader = HclEditLoader::new();
126 let loader_limits = LoaderLimits::default();
127 let eval_limits = EvalLimits::default();
128
129 let registry_slot: Arc<OnceLock<Arc<FuncRegistry>>> = Arc::new(OnceLock::new());
136 let funcs = Arc::new(build_func_registry(
137 Arc::clone(&tg_state),
138 Arc::clone(&memo),
139 Arc::clone(&stack),
140 Arc::clone(&inflight),
141 Arc::clone(®istry_slot),
142 ctx,
143 loader,
144 loader_limits,
145 eval_limits,
146 ));
147 let _ = registry_slot.set(Arc::clone(&funcs));
148
149 let tg_hcl = component_dir_arc.join("terragrunt.hcl");
150 let Some(parsed_root) = read_and_project(
151 &tg_hcl,
152 &ctx.workspace_root,
153 loader,
154 &loader_limits,
155 &mut diagnostics,
156 ) else {
157 return Ok(TerragruntConfig::builder()
161 .component_dir(Arc::clone(&component_dir_arc))
162 .diagnostics(diagnostics)
163 .build());
164 };
165
166 let mut visited: std::collections::HashSet<std::path::PathBuf> =
168 std::collections::HashSet::new();
169 let chain = resolve_include_chain(
170 &parsed_root,
171 &tg_hcl,
172 ctx,
173 &funcs,
174 &tg_state,
175 loader,
176 &loader_limits,
177 &eval_limits,
178 &mut visited,
179 0,
180 &mut diagnostics,
181 );
182
183 let merged = apply_cascade(&chain, &mut diagnostics);
186
187 let effective_locals = evaluate_locals(
190 &merged.locals,
191 &funcs,
192 &tg_state,
193 &ctx.workspace_root,
194 &ctx.env_var_mode,
195 &eval_limits,
196 );
197
198 let inputs = if let Some(inputs_attrs) = &merged.inputs {
200 evaluate_inputs(
201 inputs_attrs,
202 &effective_locals,
203 &funcs,
204 &tg_state,
205 &ctx.workspace_root,
206 &ctx.env_var_mode,
207 &eval_limits,
208 )
209 } else {
210 Map::new()
211 };
212
213 let reduced_generates = reduce_generates(
217 &merged.generates,
218 &effective_locals,
219 &funcs,
220 &ctx.workspace_root,
221 &ctx.env_var_mode,
222 &eval_limits,
223 );
224 let reduced_dependencies = reduce_dependencies(
225 &merged.dependencies,
226 &effective_locals,
227 &funcs,
228 &ctx.workspace_root,
229 &ctx.env_var_mode,
230 &eval_limits,
231 );
232
233 let generates = build_generates(&reduced_generates);
234 let dependencies = build_dependencies(&reduced_dependencies, &component_dir_arc);
235 let state_backend = extract_state_backend(&merged, loader, &loader_limits);
236
237 let includes: Vec<IncludePath> = chain
239 .iter()
240 .filter_map(|entry| {
241 entry.include_origin.as_ref().map(|origin| {
242 IncludePath::builder()
243 .path(Arc::clone(origin))
244 .label(entry.include_label.clone())
245 .span(entry.span.clone())
246 .build()
247 })
248 })
249 .collect();
250
251 Ok(TerragruntConfig::builder()
255 .component_dir(component_dir_arc)
256 .effective_locals(effective_locals)
257 .inputs(inputs)
258 .includes(includes)
259 .generates(generates)
260 .dependencies(dependencies)
261 .state_backend(state_backend)
262 .diagnostics(diagnostics)
263 .build())
264 }
265}
266
267#[derive(Debug)]
270struct ChainEntry {
271 parsed: ParsedTerragrunt,
272 merge_strategy: MergeStrategy,
273 include_origin: Option<Arc<Path>>,
276 include_label: Option<Arc<str>>,
277 span: crate::ir::Span,
278}
279
280#[allow(clippy::too_many_arguments)]
290fn resolve_include_chain(
291 child: &ParsedTerragrunt,
292 child_path: &Path,
293 ctx: &TgContext,
294 funcs: &Arc<FuncRegistry>,
295 tg_state: &Arc<TgState>,
296 loader: HclEditLoader,
297 loader_limits: &LoaderLimits,
298 eval_limits: &EvalLimits,
299 visited: &mut std::collections::HashSet<std::path::PathBuf>,
300 depth: u32,
301 diagnostics: &mut Vec<Diagnostic>,
302) -> Vec<ChainEntry> {
303 let mut chain: Vec<ChainEntry> = Vec::new();
304 if depth >= ctx.max_include_depth {
305 diagnostics.push(Diag::limit(
306 LimitKind::IncludeDepth,
307 "TG2002",
308 format!(
309 "terragrunt include depth cap ({}) exceeded",
310 ctx.max_include_depth
311 ),
312 ));
313 chain.push(ChainEntry {
314 parsed: child.clone(),
315 merge_strategy: MergeStrategy::DeepMapOnly,
316 include_origin: None,
317 include_label: None,
318 span: crate::ir::Span::synthetic(),
319 });
320 return chain;
321 }
322 if let Ok(canonical) =
323 paths::canonicalize_inside(child_path, &ctx.workspace_root, SymlinkPolicy::Follow)
324 {
325 visited.insert(canonical);
326 }
327
328 for include in &child.includes {
329 let Some(path_expr) = &include.path_expr else {
330 diagnostics.push(
331 Diag::new(
332 Severity::Warn,
333 "TG2003",
334 format!("include `{}` missing `path = ...` attribute", include.label),
335 )
336 .with_span(include.span.clone()),
337 );
338 continue;
339 };
340 let Some(path_value) = reduce_to_string(
341 path_expr,
342 &Map::new(),
343 funcs,
344 tg_state,
345 &ctx.workspace_root,
346 &ctx.env_var_mode,
347 eval_limits,
348 ) else {
349 diagnostics.push(
350 Diag::new(
351 Severity::Warn,
352 "TG2004",
353 format!(
354 "include `{}` path expression did not reduce to a string",
355 include.label
356 ),
357 )
358 .with_span(include.span.clone()),
359 );
360 continue;
361 };
362 let candidate = Path::new(path_value.as_ref()).to_path_buf();
363 let canonical = match paths::canonicalize_inside(
364 &candidate,
365 &ctx.workspace_root,
366 SymlinkPolicy::Follow,
367 ) {
368 Ok(p) => p,
369 Err(err) => {
370 diagnostics.push(
371 Diag::new(
372 Severity::Warn,
373 "TG2005",
374 format!(
375 "include `{}` path `{}` did not resolve under workspace root: {err}",
376 include.label,
377 candidate.display()
378 ),
379 )
380 .with_span(include.span.clone()),
381 );
382 continue;
383 }
384 };
385 if !visited.insert(canonical.clone()) {
386 diagnostics.push(
387 Diag::new(
388 Severity::Warn,
389 "TG2006",
390 format!(
391 "terragrunt include cycle detected at `{}`",
392 canonical.display()
393 ),
394 )
395 .with_span(include.span.clone()),
396 );
397 continue;
398 }
399 let Some(parent_parsed) = read_and_project(
400 &canonical,
401 &ctx.workspace_root,
402 loader,
403 loader_limits,
404 diagnostics,
405 ) else {
406 continue;
407 };
408
409 if let Ok(mut g) = tg_state.active_include.lock() {
411 *g = Some(Arc::from(canonical.as_path()));
412 }
413
414 let mut sub = resolve_include_chain(
416 &parent_parsed,
417 &canonical,
418 ctx,
419 funcs,
420 tg_state,
421 loader,
422 loader_limits,
423 eval_limits,
424 visited,
425 depth + 1,
426 diagnostics,
427 );
428 chain.append(&mut sub);
429
430 let strategy = parse_merge_strategy(include.merge_strategy_expr.as_ref());
431 chain.push(ChainEntry {
432 parsed: parent_parsed,
433 merge_strategy: strategy,
434 include_origin: Some(Arc::from(canonical.as_path())),
435 include_label: Some(Arc::clone(&include.label)),
436 span: include.span.clone(),
437 });
438 }
439
440 chain.push(ChainEntry {
442 parsed: child.clone(),
443 merge_strategy: MergeStrategy::DeepMapOnly,
444 include_origin: None,
445 include_label: None,
446 span: child
447 .locals
448 .first()
449 .map_or_else(crate::ir::Span::synthetic, |l| l.span.clone()),
450 });
451
452 chain
453}
454
455fn apply_cascade(chain: &[ChainEntry], _diagnostics: &mut Vec<Diagnostic>) -> ParsedTerragrunt {
458 if chain.is_empty() {
459 return ParsedTerragrunt::default();
460 }
461 let mut acc = ParsedTerragrunt::default();
463 for entry in chain {
464 let parent_locals = literal_map_for(&acc.locals);
466 let child_locals = literal_map_for(&entry.parsed.locals);
467 let merged_locals = merge_locals(&parent_locals, &child_locals, entry.merge_strategy);
468 let prior_non_literals: Vec<crate::ir::Local> = acc
474 .locals
475 .iter()
476 .filter(|l| !matches!(l.value, Expression::Literal(_)))
477 .cloned()
478 .collect();
479 acc.locals =
480 map_to_locals_with_inherited(&merged_locals, &entry.parsed, &prior_non_literals);
481
482 if entry.parsed.inputs.is_some() {
486 acc.inputs.clone_from(&entry.parsed.inputs);
487 }
488
489 for g in &entry.parsed.generates {
491 if let Some(slot) = acc.generates.iter_mut().find(|x| x.label == g.label) {
492 *slot = g.clone();
493 } else {
494 acc.generates.push(g.clone());
495 }
496 }
497 for d in &entry.parsed.dependencies {
499 if let Some(slot) = acc.dependencies.iter_mut().find(|x| x.name == d.name) {
500 *slot = d.clone();
501 } else {
502 acc.dependencies.push(d.clone());
503 }
504 }
505 acc.terraform.extend(entry.parsed.terraform.iter().cloned());
508 if entry.parsed.remote_state.is_some() {
509 acc.remote_state.clone_from(&entry.parsed.remote_state);
510 }
511 acc.diagnostics
512 .extend(entry.parsed.diagnostics.iter().cloned());
513 }
514 acc
515}
516
517fn literal_map_for(locals: &[crate::ir::Local]) -> Map {
522 locals
523 .iter()
524 .filter_map(|l| match &l.value {
525 Expression::Literal(v) => Some((Arc::clone(&l.name), v.clone())),
526 _ => None,
527 })
528 .collect()
529}
530
531fn map_to_locals_with_inherited(
543 merged_map: &Map,
544 parsed: &ParsedTerragrunt,
545 inherited: &[crate::ir::Local],
546) -> Vec<crate::ir::Local> {
547 let mut out: Vec<crate::ir::Local> = Vec::new();
548 for l in inherited {
550 out.push(l.clone());
551 }
552 for l in parsed
554 .locals
555 .iter()
556 .filter(|l| !matches!(l.value, Expression::Literal(_)))
557 {
558 if let Some(slot) = out.iter_mut().find(|x| x.name == l.name) {
559 *slot = l.clone();
560 } else {
561 out.push(l.clone());
562 }
563 }
564 for (k, v) in merged_map {
567 let local = crate::ir::Local::builder()
568 .name(Arc::clone(k))
569 .value(Expression::Literal(v.clone()))
570 .span(crate::ir::Span::synthetic())
571 .build();
572 if let Some(slot) = out.iter_mut().find(|x| x.name == *k) {
573 *slot = local;
574 } else {
575 out.push(local);
576 }
577 }
578 out
579}
580
581fn evaluate_locals(
585 locals: &[crate::ir::Local],
586 funcs: &Arc<FuncRegistry>,
587 _tg_state: &Arc<TgState>,
588 workspace_root: &Path,
589 env_var_mode: &crate::eval::EnvVarMode,
590 eval_limits: &EvalLimits,
591) -> Map {
592 use crate::eval::reduce::{Scope, reduce_expression};
593 let mut resolved: Map = Map::new();
594 let mut pending: Vec<crate::ir::Local> = locals.to_vec();
595
596 for _ in 0..16 {
599 let mut made_progress = false;
600 let mut next_pending: Vec<crate::ir::Local> = Vec::new();
601 for local in pending.drain(..) {
602 let scope = Scope::new(
603 Map::new(),
604 resolved.clone(),
605 workspace_root,
606 env_var_mode,
607 eval_limits,
608 funcs,
609 None,
610 );
611 let reduced = reduce_expression(&local.value, &scope);
612 if let Expression::Literal(v) = &reduced {
613 resolved.push((Arc::clone(&local.name), v.clone()));
614 made_progress = true;
615 } else {
616 next_pending.push(
617 crate::ir::Local::builder()
618 .name(Arc::clone(&local.name))
619 .value(reduced)
620 .span(local.span.clone())
621 .build(),
622 );
623 }
624 }
625 pending = next_pending;
626 if !made_progress || pending.is_empty() {
627 break;
628 }
629 }
630 resolved
631}
632
633fn evaluate_inputs(
635 inputs_attrs: &AttributeMap,
636 effective_locals: &Map,
637 funcs: &Arc<FuncRegistry>,
638 _tg_state: &Arc<TgState>,
639 workspace_root: &Path,
640 env_var_mode: &crate::eval::EnvVarMode,
641 eval_limits: &EvalLimits,
642) -> Map {
643 use crate::eval::reduce::{Scope, reduce_expression};
644 let scope = Scope::new(
645 Map::new(),
646 effective_locals.clone(),
647 workspace_root,
648 env_var_mode,
649 eval_limits,
650 funcs,
651 None,
652 );
653 let mut out: Map = Vec::with_capacity(inputs_attrs.len());
654 for (name, expr) in inputs_attrs {
655 let reduced = reduce_expression(expr, &scope);
656 if let Expression::Literal(v) = reduced {
657 out.push((Arc::clone(name), v));
658 }
659 }
660 out
661}
662
663fn reduce_generates(
668 raw: &[parsed::GenerateBlockRaw],
669 effective_locals: &Map,
670 funcs: &Arc<FuncRegistry>,
671 workspace_root: &Path,
672 env_var_mode: &crate::eval::EnvVarMode,
673 eval_limits: &EvalLimits,
674) -> Vec<parsed::GenerateBlockRaw> {
675 use crate::eval::reduce::{Scope, reduce_expression};
676 let scope = Scope::new(
677 Map::new(),
678 effective_locals.clone(),
679 workspace_root,
680 env_var_mode,
681 eval_limits,
682 funcs,
683 None,
684 );
685 raw.iter()
686 .map(|g| parsed::GenerateBlockRaw {
687 label: Arc::clone(&g.label),
688 attrs: g
689 .attrs
690 .iter()
691 .map(|(k, v)| (Arc::clone(k), reduce_expression(v, &scope)))
692 .collect(),
693 span: g.span.clone(),
694 })
695 .collect()
696}
697
698fn reduce_dependencies(
701 raw: &[parsed::DependencyBlockRaw],
702 effective_locals: &Map,
703 funcs: &Arc<FuncRegistry>,
704 workspace_root: &Path,
705 env_var_mode: &crate::eval::EnvVarMode,
706 eval_limits: &EvalLimits,
707) -> Vec<parsed::DependencyBlockRaw> {
708 use crate::eval::reduce::{Scope, reduce_expression};
709 let scope = Scope::new(
710 Map::new(),
711 effective_locals.clone(),
712 workspace_root,
713 env_var_mode,
714 eval_limits,
715 funcs,
716 None,
717 );
718 raw.iter()
719 .map(|d| parsed::DependencyBlockRaw {
720 name: Arc::clone(&d.name),
721 attrs: d
722 .attrs
723 .iter()
724 .map(|(k, v)| (Arc::clone(k), reduce_expression(v, &scope)))
725 .collect(),
726 span: d.span.clone(),
727 })
728 .collect()
729}
730
731fn build_generates(raw: &[parsed::GenerateBlockRaw]) -> Vec<GenerateBlock> {
739 raw.iter()
740 .filter_map(|r| {
741 let mut path: Option<Arc<Path>> = None;
742 let mut if_exists: Option<Arc<str>> = None;
743 let mut contents: Option<Arc<str>> = None;
744 for (k, v) in &r.attrs {
745 match k.as_ref() {
746 "path" => {
747 path = render_expr_to_string(v).map(|s| Arc::from(Path::new(s.as_ref())));
748 }
749 "if_exists" => {
750 if_exists = render_expr_to_string(v);
751 }
752 "contents" => {
753 contents = render_expr_to_string(v);
754 }
755 _ => {}
756 }
757 }
758 match (path, if_exists, contents) {
759 (Some(p), Some(ie), Some(c)) => Some(
760 GenerateBlock::builder()
761 .label(Arc::clone(&r.label))
762 .path(p)
763 .if_exists(ie)
764 .contents(c)
765 .span(r.span.clone())
766 .build(),
767 ),
768 _ => None,
769 }
770 })
771 .collect()
772}
773
774fn render_contents_for_reparse(expr: &Expression) -> Option<Arc<str>> {
787 match expr {
788 Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
789 Expression::TemplateConcat(parts) => {
790 let mut out = String::new();
791 for part in parts {
792 match part {
793 Expression::Literal(Value::Str(s)) => out.push_str(s),
794 Expression::Literal(Value::Int(n)) => out.push_str(&n.to_string()),
795 Expression::Literal(Value::Bool(b)) => out.push_str(&b.to_string()),
796 _ => {}
799 }
800 }
801 Some(Arc::from(out))
802 }
803 _ => None,
804 }
805}
806
807fn render_expr_to_string(expr: &Expression) -> Option<Arc<str>> {
811 match expr {
812 Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
813 Expression::Literal(Value::Int(n)) => Some(Arc::from(n.to_string())),
814 Expression::Literal(Value::Number(n)) => Some(Arc::from(n.to_string())),
815 Expression::Literal(Value::Bool(b)) => Some(Arc::from(b.to_string())),
816 Expression::TemplateConcat(parts) => {
817 let mut out = String::new();
818 for part in parts {
819 match part {
820 Expression::Literal(Value::Str(s)) => out.push_str(s),
821 Expression::Literal(Value::Int(n)) => out.push_str(&n.to_string()),
822 Expression::Literal(Value::Bool(b)) => out.push_str(&b.to_string()),
823 Expression::Unresolved(sym) => {
824 out.push_str("${");
825 out.push_str(&sym.source);
826 out.push('}');
827 }
828 _ => return None,
829 }
830 }
831 Some(Arc::from(out))
832 }
833 _ => None,
834 }
835}
836
837fn build_dependencies(
839 raw: &[parsed::DependencyBlockRaw],
840 component_dir: &Arc<Path>,
841) -> Vec<DependencyBlock> {
842 raw.iter()
843 .filter_map(|r| {
844 let mut config_path: Option<Arc<Path>> = None;
845 let mut mock_outputs: AttributeMap = Vec::new();
846 for (k, v) in &r.attrs {
847 match (k.as_ref(), v) {
848 ("config_path", Expression::Literal(Value::Str(s))) => {
849 let joined = component_dir.join(s.as_ref());
850 config_path = Some(Arc::from(joined.as_path()));
851 }
852 ("mock_outputs", Expression::Object(entries)) => {
853 mock_outputs = entries
854 .iter()
855 .filter_map(|(k, v)| match k {
856 Expression::Literal(Value::Str(s)) => {
857 Some((Arc::clone(s), v.clone()))
858 }
859 _ => None,
860 })
861 .collect();
862 }
863 _ => {}
864 }
865 }
866 config_path.map(|cp| {
867 DependencyBlock::builder()
868 .name(Arc::clone(&r.name))
869 .config_path(cp)
870 .mock_outputs(mock_outputs)
871 .span(r.span.clone())
872 .build()
873 })
874 })
875 .collect()
876}
877
878fn extract_state_backend(
882 merged: &ParsedTerragrunt,
883 loader: HclEditLoader,
884 loader_limits: &LoaderLimits,
885) -> Option<StateBackend> {
886 for tf_body in &merged.terraform {
888 if let Some(backend) = backend_from_terraform_body(tf_body) {
889 return Some(backend);
890 }
891 }
892 for g in &merged.generates {
894 if g.label.as_ref() != "backend" {
895 continue;
896 }
897 let contents_str = g.attrs.iter().find_map(|(k, v)| {
898 if k.as_ref() == "contents" {
899 return render_contents_for_reparse(v);
900 }
901 None
902 });
903 let Some(contents) = contents_str else {
904 continue;
905 };
906 let synthetic_path: Arc<Path> = Arc::from(Path::new("generated_backend.tf"));
907 let parsed = loader.parse_bytes(contents.as_bytes(), &synthetic_path, loader_limits);
908 for block in &parsed.blocks {
909 if !matches!(block.kind, crate::ir::BlockKind::Terraform) {
910 continue;
911 }
912 if let Some(backend) = backend_from_terraform_body(&block.body) {
913 return Some(backend);
914 }
915 }
916 }
917 None
918}
919
920fn backend_from_terraform_body(body: &AttributeMap) -> Option<StateBackend> {
921 for (k, v) in body {
928 if k.as_ref() == "backend"
929 && let Expression::Object(entries) = v
930 {
931 let mut attrs: AttributeMap = Vec::new();
932 let mut kind: Arc<str> = Arc::from("s3");
933 for (kk, vv) in entries {
934 let Expression::Literal(Value::Str(name)) = kk else {
935 continue;
936 };
937 if name.as_ref() == "__labels__"
938 && let Expression::Literal(Value::List(labels)) = vv
939 && let Some(Value::Str(label)) = labels.first()
940 {
941 kind = Arc::clone(label);
942 continue;
943 }
944 attrs.push((Arc::clone(name), vv.clone()));
945 }
946 return Some(
947 StateBackend::builder()
948 .kind(kind)
949 .attributes(attrs)
950 .span(crate::ir::Span::synthetic())
951 .build(),
952 );
953 }
954 }
955 None
956}
957
958fn parse_merge_strategy(expr: Option<&Expression>) -> MergeStrategy {
959 let Some(Expression::Literal(Value::Str(s))) = expr else {
960 return MergeStrategy::default();
961 };
962 MergeStrategy::parse(s.as_ref()).unwrap_or_default()
963}
964
965fn read_and_project(
969 path: &Path,
970 workspace_root: &Path,
971 loader: HclEditLoader,
972 loader_limits: &LoaderLimits,
973 diagnostics: &mut Vec<Diagnostic>,
974) -> Option<ParsedTerragrunt> {
975 let canonical = paths::canonicalize_inside(path, workspace_root, SymlinkPolicy::Follow).ok()?;
976 let Ok(bytes) = std::fs::read(&canonical) else {
977 return None;
978 };
979 let canonical_arc: Arc<Path> = Arc::from(canonical.as_path());
980 let parsed_bytes = loader.parse_bytes(&bytes, &canonical_arc, loader_limits);
981 let parsed = parsed::project(&parsed_bytes, &canonical_arc);
982 diagnostics.extend(parsed.diagnostics.iter().cloned());
983 Some(parsed)
984}
985
986#[allow(clippy::too_many_arguments)]
989fn reduce_to_string(
990 expr: &Expression,
991 extra_locals: &Map,
992 funcs: &Arc<FuncRegistry>,
993 _tg_state: &Arc<TgState>,
994 workspace_root: &Path,
995 env_var_mode: &crate::eval::EnvVarMode,
996 eval_limits: &EvalLimits,
997) -> Option<Arc<str>> {
998 use crate::eval::reduce::{Scope, reduce_expression};
999 let scope = Scope::new(
1000 Map::new(),
1001 extra_locals.clone(),
1002 workspace_root,
1003 env_var_mode,
1004 eval_limits,
1005 funcs,
1006 None,
1007 );
1008 match reduce_expression(expr, &scope) {
1009 Expression::Literal(Value::Str(s)) => Some(s),
1010 _ => None,
1011 }
1012}
1013
1014#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
1018fn build_func_registry(
1019 tg_state: Arc<TgState>,
1020 memo: Arc<DashMap<Arc<Path>, Arc<ResolvedTerragrunt>>>,
1021 stack: Arc<std::sync::Mutex<Vec<Arc<Path>>>>,
1022 inflight: Arc<DashMap<Arc<Path>, ()>>,
1023 registry_slot: Arc<OnceLock<Arc<FuncRegistry>>>,
1024 ctx: &TgContext,
1025 loader: HclEditLoader,
1026 loader_limits: LoaderLimits,
1027 eval_limits: EvalLimits,
1028) -> FuncRegistry {
1029 let mut b: FuncRegistryBuilder = FuncRegistry::default_with_stdlib().to_builder();
1030 b.register(
1031 "get_terragrunt_dir",
1032 Arc::new(GetTerragruntDirFn {
1033 state: Arc::clone(&tg_state),
1034 }) as Arc<dyn HclFunc>,
1035 );
1036 b.register(
1037 "get_repo_root",
1038 Arc::new(GetRepoRootFn {
1039 state: Arc::clone(&tg_state),
1040 }) as Arc<dyn HclFunc>,
1041 );
1042 b.register(
1043 "get_parent_terragrunt_dir",
1044 Arc::new(GetParentTerragruntDirFn {
1045 state: Arc::clone(&tg_state),
1046 }) as Arc<dyn HclFunc>,
1047 );
1048 b.register(
1049 "find_in_parent_folders",
1050 Arc::new(FindInParentFoldersFn {
1051 state: Arc::clone(&tg_state),
1052 }) as Arc<dyn HclFunc>,
1053 );
1054 b.register(
1055 "find_in_parent_folders_from",
1056 Arc::new(FindInParentFoldersFromFn {
1057 state: Arc::clone(&tg_state),
1058 }) as Arc<dyn HclFunc>,
1059 );
1060 b.register(
1061 "path_relative_to_include",
1062 Arc::new(PathRelativeToIncludeFn {
1063 state: Arc::clone(&tg_state),
1064 }) as Arc<dyn HclFunc>,
1065 );
1066 b.register(
1067 "path_relative_from_include",
1068 Arc::new(PathRelativeFromIncludeFn {
1069 state: Arc::clone(&tg_state),
1070 }) as Arc<dyn HclFunc>,
1071 );
1072 b.register("try", Arc::new(TryFn) as Arc<dyn HclFunc>);
1073 b.register(
1074 "read_terragrunt_config",
1075 Arc::new(ReadTerragruntConfigFn {
1076 state: Arc::clone(&tg_state),
1077 workspace_root: Arc::clone(&ctx.workspace_root),
1078 memo,
1079 stack,
1080 inflight,
1081 registry_slot,
1082 loader,
1083 loader_limits,
1084 eval_limits,
1085 env_var_mode: ctx.env_var_mode.clone(),
1086 }) as Arc<dyn HclFunc>,
1087 );
1088 b.build()
1089}
1090
1091#[derive(Debug)]
1097struct ReadTerragruntConfigFn {
1098 state: Arc<TgState>,
1099 workspace_root: Arc<Path>,
1100 memo: Arc<DashMap<Arc<Path>, Arc<ResolvedTerragrunt>>>,
1101 stack: Arc<std::sync::Mutex<Vec<Arc<Path>>>>,
1102 inflight: Arc<DashMap<Arc<Path>, ()>>,
1103 registry_slot: Arc<OnceLock<Arc<FuncRegistry>>>,
1108 loader: HclEditLoader,
1109 loader_limits: LoaderLimits,
1110 eval_limits: EvalLimits,
1111 env_var_mode: crate::eval::EnvVarMode,
1112}
1113
1114impl HclFunc for ReadTerragruntConfigFn {
1115 fn call(
1116 &self,
1117 args: &[Value],
1118 _cx: &CallCx<'_>,
1119 ) -> std::result::Result<Value, crate::eval::FuncError> {
1120 let target_path: &str = match args.first() {
1121 Some(Value::Str(s)) => s.as_ref(),
1122 None => {
1123 return Err(crate::eval::FuncError::Arity {
1124 name: Arc::from("read_terragrunt_config"),
1125 expected: 1,
1126 got: 0,
1127 });
1128 }
1129 Some(_) => {
1130 return Err(crate::eval::FuncError::Type {
1131 name: Arc::from("read_terragrunt_config"),
1132 index: 0,
1133 expected: "string",
1134 got: "non-string",
1135 });
1136 }
1137 };
1138
1139 let candidate = Path::new(target_path).to_path_buf();
1140 let canonical = match paths::canonicalize_inside(
1141 &candidate,
1142 &self.workspace_root,
1143 SymlinkPolicy::Follow,
1144 ) {
1145 Ok(p) => Arc::<Path>::from(p),
1146 Err(_) => {
1147 return Ok(args.get(1).cloned().unwrap_or(Value::Map(Vec::new())));
1149 }
1150 };
1151
1152 if let Some(entry) = self.memo.get(&canonical) {
1153 return Ok(read_result_to_value(&entry));
1154 }
1155
1156 let cycle = match self.stack.lock() {
1158 Ok(g) => g.iter().any(|p| Arc::ptr_eq(p, &canonical)),
1159 Err(p) => p.into_inner().iter().any(|p| Arc::ptr_eq(p, &canonical)),
1160 };
1161 if cycle {
1162 return Err(crate::eval::FuncError::Other {
1163 name: Arc::from("read_terragrunt_config"),
1164 message: Arc::from(format!(
1165 "terragrunt read cycle at `{}`",
1166 canonical.display()
1167 )),
1168 });
1169 }
1170 if self.inflight.insert(Arc::clone(&canonical), ()).is_some() {
1172 return Ok(args.get(1).cloned().unwrap_or(Value::Map(Vec::new())));
1173 }
1174
1175 if let Ok(mut g) = self.stack.lock() {
1176 g.push(Arc::clone(&canonical));
1177 }
1178
1179 let Ok(bytes) = std::fs::read(canonical.as_ref()) else {
1180 self.inflight.remove(&canonical);
1181 if let Ok(mut g) = self.stack.lock() {
1182 g.pop();
1183 }
1184 return Ok(args.get(1).cloned().unwrap_or(Value::Map(Vec::new())));
1185 };
1186 let parsed_bytes = self
1187 .loader
1188 .parse_bytes(&bytes, &canonical, &self.loader_limits);
1189 let parsed = parsed::project(&parsed_bytes, &canonical);
1190
1191 let nested_funcs: Arc<FuncRegistry> = self
1198 .registry_slot
1199 .get()
1200 .map_or_else(|| Arc::new(FuncRegistry::default_with_stdlib()), Arc::clone);
1201
1202 let literal_map: Map = literal_map_for(&parsed.locals);
1203 let resolved = evaluate_locals(
1204 &parsed.locals,
1205 &nested_funcs,
1206 &self.state,
1207 &self.workspace_root,
1208 &self.env_var_mode,
1209 &self.eval_limits,
1210 );
1211 let merged_locals = merge_locals(&literal_map, &resolved, MergeStrategy::DeepMapOnly);
1212 let inputs = if let Some(input_attrs) = &parsed.inputs {
1213 evaluate_inputs(
1214 input_attrs,
1215 &merged_locals,
1216 &nested_funcs,
1217 &self.state,
1218 &self.workspace_root,
1219 &self.env_var_mode,
1220 &self.eval_limits,
1221 )
1222 } else {
1223 Map::new()
1224 };
1225
1226 let resolved_entry = Arc::new(ResolvedTerragrunt {
1227 locals: merged_locals,
1228 inputs,
1229 });
1230 self.memo
1231 .insert(Arc::clone(&canonical), Arc::clone(&resolved_entry));
1232
1233 if let Ok(mut g) = self.stack.lock() {
1234 g.pop();
1235 }
1236 self.inflight.remove(&canonical);
1237
1238 Ok(read_result_to_value(&resolved_entry))
1239 }
1240}
1241
1242fn read_result_to_value(entry: &ResolvedTerragrunt) -> Value {
1246 let out: Map = vec![
1247 (Arc::from("locals"), Value::Map(entry.locals.clone())),
1248 (Arc::from("inputs"), Value::Map(entry.inputs.clone())),
1249 ];
1250 Value::Map(out)
1251}
1252
1253#[cfg(test)]
1254#[allow(
1255 clippy::unwrap_used,
1256 clippy::expect_used,
1257 clippy::indexing_slicing,
1258 clippy::panic
1259)]
1260mod tests {
1261 use super::*;
1262
1263 fn write_file(root: &Path, rel: &str, body: &str) -> std::path::PathBuf {
1264 let p = root.join(rel);
1265 if let Some(parent) = p.parent() {
1266 std::fs::create_dir_all(parent).unwrap();
1267 }
1268 std::fs::write(&p, body).unwrap();
1269 p
1270 }
1271
1272 #[test]
1273 fn test_resolves_component_with_root_include() {
1274 let tmp = tempfile::tempdir().unwrap();
1275 let root = std::fs::canonicalize(tmp.path()).unwrap();
1276 write_file(&root, "root.hcl", "locals { aws_region = \"us-east-2\" }\n");
1277 write_file(
1278 &root,
1279 "services/api/terragrunt.hcl",
1280 "include \"root\" {\n path = find_in_parent_folders(\"root.hcl\")\n}\n",
1281 );
1282
1283 let ctx = TgContext::new(Arc::from(root.as_path()));
1284 let cfg = FsTerragruntResolver::new()
1285 .resolve(&root.join("services/api"), &ctx)
1286 .unwrap();
1287
1288 assert!(
1290 cfg.effective_locals
1291 .iter()
1292 .any(|(k, v)| &**k == "aws_region"
1293 && matches!(v, Value::Str(s) if s.as_ref() == "us-east-2")),
1294 "diags={:?} locals={:?}",
1295 cfg.diagnostics,
1296 cfg.effective_locals
1297 );
1298 assert_eq!(cfg.includes.len(), 1);
1299 }
1300
1301 #[test]
1302 fn test_detects_include_cycle() {
1303 let tmp = tempfile::tempdir().unwrap();
1304 let root = std::fs::canonicalize(tmp.path()).unwrap();
1305 write_file(
1307 &root,
1308 "a.hcl",
1309 "include \"b\" { path = find_in_parent_folders(\"b.hcl\") }\n",
1310 );
1311 write_file(
1312 &root,
1313 "b.hcl",
1314 "include \"a\" { path = find_in_parent_folders(\"a.hcl\") }\n",
1315 );
1316 write_file(
1317 &root,
1318 "x/terragrunt.hcl",
1319 "include \"a\" { path = find_in_parent_folders(\"a.hcl\") }\n",
1320 );
1321
1322 let ctx = TgContext::new(Arc::from(root.as_path()));
1323 let cfg = FsTerragruntResolver::new()
1324 .resolve(&root.join("x"), &ctx)
1325 .unwrap();
1326 assert!(
1327 cfg.diagnostics.iter().any(|d| &*d.code == "TG2006"),
1328 "expected TG2006 cycle diag; got {:?}",
1329 cfg.diagnostics
1330 );
1331 }
1332
1333 #[test]
1334 fn test_path_escape_in_find_in_parent_folders_falls_back() {
1335 let tmp = tempfile::tempdir().unwrap();
1336 let root = std::fs::canonicalize(tmp.path()).unwrap();
1337 write_file(
1339 &root,
1340 "x/terragrunt.hcl",
1341 "locals { x = find_in_parent_folders(\"missing.hcl\", \"fallback.hcl\") }\n",
1342 );
1343 let ctx = TgContext::new(Arc::from(root.as_path()));
1344 let cfg = FsTerragruntResolver::new()
1345 .resolve(&root.join("x"), &ctx)
1346 .unwrap();
1347 assert!(
1348 cfg.effective_locals
1349 .iter()
1350 .any(|(k, v)| &**k == "x"
1351 && matches!(v, Value::Str(s) if s.as_ref() == "fallback.hcl")),
1352 "{:?}",
1353 cfg.effective_locals
1354 );
1355 }
1356
1357 #[test]
1358 fn test_captures_generate_block() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 let root = std::fs::canonicalize(tmp.path()).unwrap();
1361 write_file(
1362 &root,
1363 "x/terragrunt.hcl",
1364 "generate \"backend\" {\n path = \"backend.tf\"\n if_exists = \
1365 \"overwrite_terragrunt\"\n contents = \"terraform {}\"\n}\n",
1366 );
1367 let ctx = TgContext::new(Arc::from(root.as_path()));
1368 let cfg = FsTerragruntResolver::new()
1369 .resolve(&root.join("x"), &ctx)
1370 .unwrap();
1371 assert_eq!(cfg.generates.len(), 1);
1372 assert_eq!(&*cfg.generates[0].label, "backend");
1373 }
1374
1375 #[test]
1384 fn test_should_extract_backend_from_templated_generate_contents() {
1385 let tmp = tempfile::tempdir().unwrap();
1386 let root = std::fs::canonicalize(tmp.path()).unwrap();
1387 write_file(
1388 &root,
1389 "root.hcl",
1390 "locals {\n profile_block = \"\"\n}\ngenerate \"backend\" {\n path = \
1391 \"backend.tf\"\n if_exists = \"overwrite_terragrunt\"\n contents = \
1392 <<EOF\nterraform {\n backend \"s3\" {\n bucket = \"my-tfstate\"\n region = \
1393 \"us-west-2\"\n key = \
1394 \"acct/${path_relative_to_include()}.tfstate\"\n${local.profile_block}\n \
1395 }\n}\nEOF\n}\n",
1396 );
1397 write_file(
1398 &root,
1399 "svc/terragrunt.hcl",
1400 "include \"root\" { path = find_in_parent_folders(\"root.hcl\") }\n",
1401 );
1402
1403 let ctx = TgContext::new(Arc::from(root.as_path()));
1404 let cfg = FsTerragruntResolver::new()
1405 .resolve(&root.join("svc"), &ctx)
1406 .unwrap();
1407 let backend = cfg
1408 .state_backend
1409 .as_ref()
1410 .expect("backend should be extracted from templated generate contents");
1411 assert_eq!(&*backend.kind, "s3");
1412 let attr_str = |name: &str| -> Option<Arc<str>> {
1417 backend
1418 .attributes
1419 .iter()
1420 .find(|(k, _)| &**k == name)
1421 .and_then(|(_, v)| match v {
1422 Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
1423 _ => None,
1424 })
1425 };
1426 assert_eq!(attr_str("bucket").as_deref(), Some("my-tfstate"));
1427 assert_eq!(attr_str("region").as_deref(), Some("us-west-2"));
1428 }
1429
1430 #[test]
1431 fn test_captures_dependency_block() {
1432 let tmp = tempfile::tempdir().unwrap();
1433 let root = std::fs::canonicalize(tmp.path()).unwrap();
1434 write_file(
1435 &root,
1436 "x/terragrunt.hcl",
1437 "dependency \"vpc\" { config_path = \"../net\" }\n",
1438 );
1439 let ctx = TgContext::new(Arc::from(root.as_path()));
1440 let cfg = FsTerragruntResolver::new()
1441 .resolve(&root.join("x"), &ctx)
1442 .unwrap();
1443 assert_eq!(cfg.dependencies.len(), 1);
1444 assert_eq!(&*cfg.dependencies[0].name, "vpc");
1445 }
1446}