1use std::{collections::BTreeSet, path::Path, sync::Arc};
25
26use serde::{Deserialize, Serialize};
27use typed_builder::TypedBuilder;
28
29use crate::{
30 Result,
31 diagnostic::Diagnostic,
32 discovery::{DiscoveredDir, Discoverer, DiscoveryOptions, FsDiscoverer},
33 eval::{
34 EnvVarMode, EvalContext, EvalLimits, EvaluatedComponent, Evaluator, FuncRegistry,
35 HclEvaluator,
36 },
37 graph::{DefaultGraphBuilder, GraphBuilder, GraphContext, ModuleRegistry},
38 ir::{ComponentId, Map, Value, Workspace},
39 loader::{HclEditLoader, LoadContext, Loader, LoaderLimits, RawComponent, SourceMap},
40 projection::project_component,
41 provider::{DefaultProviderResolver, ProfileMap, ProviderContext, ProviderResolver},
42 terragrunt::{FsTerragruntResolver, TerragruntResolver, TgContext},
43};
44
45#[derive(Clone, Debug, Serialize, Deserialize, TypedBuilder)]
50#[non_exhaustive]
51#[serde(rename_all = "camelCase")]
52#[builder(field_defaults(setter(into)))]
53pub struct PipelineOptions {
54 #[serde(with = "crate::ir::path_serde::arc_path")]
56 pub root: Arc<Path>,
57
58 #[builder(default)]
60 pub environment: Option<Arc<str>>,
61
62 #[builder(default)]
64 pub env_var_mode: EnvVarMode,
65
66 #[serde(default)]
69 #[builder(default)]
70 pub allowed_env: BTreeSet<Arc<str>>,
71
72 #[serde(default)]
75 #[builder(default)]
76 pub repo_vars: Map,
77
78 #[builder(default = 16)]
80 pub max_walk_depth: u32,
81
82 #[builder(default = 200_000)]
84 pub max_total_files: u64,
85
86 #[builder(default = 4 * 1024 * 1024)]
88 pub max_file_bytes: u64,
89
90 #[builder(default = 32)]
92 pub max_include_depth: u32,
93
94 #[builder(default = false)]
96 pub follow_symlinks: bool,
97}
98
99impl PipelineOptions {
100 #[must_use]
103 pub fn new(root: impl Into<Arc<Path>>) -> Self {
104 Self::builder().root(root.into()).build()
105 }
106}
107
108pub trait Pipeline: Send + Sync {
114 fn run(&self, options: &PipelineOptions) -> Result<Workspace>;
122}
123
124#[derive(Clone, Debug)]
131#[non_exhaustive]
132pub struct DefaultPipeline {
133 pub profile_map: Option<Arc<ProfileMap>>,
142 pub default_region: Option<crate::ir::Region>,
145 pub strict_providers: bool,
148}
149
150impl Default for DefaultPipeline {
151 fn default() -> Self {
152 Self::new()
153 }
154}
155
156impl DefaultPipeline {
157 #[must_use]
159 pub const fn new() -> Self {
160 Self {
161 profile_map: None,
162 default_region: None,
163 strict_providers: false,
164 }
165 }
166
167 #[must_use]
169 pub fn with_profile_map(mut self, map: Arc<ProfileMap>) -> Self {
170 self.profile_map = Some(map);
171 self
172 }
173
174 #[must_use]
176 pub fn with_default_region(mut self, region: crate::ir::Region) -> Self {
177 self.default_region = Some(region);
178 self
179 }
180
181 #[must_use]
183 pub const fn strict(mut self) -> Self {
184 self.strict_providers = true;
185 self
186 }
187}
188
189impl Pipeline for DefaultPipeline {
190 #[tracing::instrument(
191 level = "info",
192 name = "pipeline.run",
193 skip(self, opts),
194 fields(root = %opts.root.display())
195 )]
196 #[allow(clippy::too_many_lines)]
203 fn run(&self, opts: &PipelineOptions) -> Result<Workspace> {
204 let canonical_root: Arc<Path> = canonicalize_root(opts.root.as_ref())?;
205
206 let discovery_opts = DiscoveryOptions::builder()
208 .max_depth(opts.max_walk_depth)
209 .max_total_files(opts.max_total_files)
210 .max_file_size_bytes(opts.max_file_bytes)
211 .follow_symlinks(opts.follow_symlinks)
212 .build();
213 let discovered = FsDiscoverer.discover(canonical_root.as_ref(), &discovery_opts)?;
214
215 let sources = SourceMap::new();
217 let loader_file_cap: u32 = u32::try_from(opts.max_file_bytes).unwrap_or(u32::MAX);
220 let limits = LoaderLimits::builder()
221 .max_file_bytes(loader_file_cap)
222 .build();
223 let load_ctx = LoadContext::new(&discovered.root, &sources, &limits);
224
225 let mut diagnostics: Vec<Diagnostic> = Vec::new();
226 diagnostics.extend(discovered.diagnostics.iter().cloned());
227
228 let mut next_component_id: usize = 0;
229 let mut next_index = || {
230 let id = ComponentId::from_index(next_component_id);
231 next_component_id += 1;
232 id
233 };
234
235 let mut module_raws: Vec<(DiscoveredDir, RawComponent, ComponentId)> = Vec::new();
238 for dir in &discovered.modules {
239 let raw = HclEditLoader.load(dir, &load_ctx)?;
240 diagnostics.extend(raw.diagnostics.iter().cloned());
241 module_raws.push((dir.clone(), raw, next_index()));
242 }
243 let mut component_raws: Vec<(DiscoveredDir, RawComponent, ComponentId)> = Vec::new();
244 for dir in &discovered.components {
245 let raw = HclEditLoader.load(dir, &load_ctx)?;
246 diagnostics.extend(raw.diagnostics.iter().cloned());
247 component_raws.push((dir.clone(), raw, next_index()));
248 }
249
250 let module_components: Vec<(DiscoveredDir, crate::ir::Component)> = module_raws
252 .iter()
253 .map(|(dir, raw, id)| {
254 let mut diag = Vec::new();
255 let component = project_component(raw, *id, &mut diag);
256 diagnostics.extend(diag);
257 (dir.clone(), component)
258 })
259 .collect();
260 let mut component_components: Vec<(DiscoveredDir, crate::ir::Component)> = component_raws
261 .iter()
262 .map(|(dir, raw, id)| {
263 let mut diag = Vec::new();
264 let component = project_component(raw, *id, &mut diag);
265 diagnostics.extend(diag);
266 (dir.clone(), component)
267 })
268 .collect();
269
270 let tg_resolver = FsTerragruntResolver::new();
272 let mut tg_ctx = TgContext::new(Arc::clone(&canonical_root));
273 tg_ctx.environment.clone_from(&opts.environment);
274 tg_ctx.env_var_mode.clone_from(&opts.env_var_mode);
275 tg_ctx.allowed_env = Arc::new(opts.allowed_env.clone());
276 tg_ctx.max_include_depth = opts.max_include_depth;
277
278 for (dir, component) in &mut component_components {
279 let abs_component_dir = canonical_root.join(dir.path.as_ref());
280 if !is_terragrunt_component(&abs_component_dir) {
281 continue;
282 }
283 match tg_resolver.resolve(&abs_component_dir, &tg_ctx) {
284 Ok(cfg) => {
285 diagnostics.extend(cfg.diagnostics.iter().cloned());
286 component.terragrunt = Some(cfg);
287 }
288 Err(err) => {
289 diagnostics.push(Diagnostic::new(
290 crate::Severity::Warn,
291 "TG2001",
292 format!(
293 "terragrunt resolve failed for {}: {err}",
294 abs_component_dir.display()
295 ),
296 ));
297 }
298 }
299 }
300
301 let evaluator = HclEvaluator::new();
303 let funcs: Arc<FuncRegistry> = Arc::new(FuncRegistry::default_with_stdlib());
304 let limits = EvalLimits::default();
305
306 let mut registry = ModuleRegistry::new();
307 let mut module_evals: Vec<EvaluatedComponent> = Vec::with_capacity(module_components.len());
308 for (dir, component) in &module_components {
309 let eval_ctx = EvalContext::new(
310 Arc::clone(&canonical_root),
311 opts.environment.clone(),
312 opts.env_var_mode.clone(),
313 opts.repo_vars.clone(),
314 Map::new(), Arc::clone(&funcs),
316 limits,
317 );
318 let evald = evaluator.evaluate(component, &eval_ctx)?;
319 diagnostics.extend(evald.diagnostics.iter().cloned());
320 let canonical: Arc<Path> = Arc::from(canonical_root.join(dir.path.as_ref()));
321 registry.insert_local(canonical, evald.clone());
322 module_evals.push(evald);
323 }
324
325 let mut component_evals: Vec<EvaluatedComponent> =
326 Vec::with_capacity(component_components.len());
327 for (_, component) in &component_components {
328 let cascade_locals: Map = component
330 .terragrunt
331 .as_ref()
332 .map(|c| c.effective_locals.clone())
333 .unwrap_or_default();
334
335 let mut repo_vars: Map = component
339 .terragrunt
340 .as_ref()
341 .map(|c| c.inputs.clone())
342 .unwrap_or_default();
343 for (k, v) in &opts.repo_vars {
344 override_or_push(&mut repo_vars, k.as_ref(), v.clone());
345 }
346
347 let eval_ctx = EvalContext::new(
348 Arc::clone(&canonical_root),
349 opts.environment.clone(),
350 opts.env_var_mode.clone(),
351 repo_vars,
352 cascade_locals,
353 Arc::clone(&funcs),
354 limits,
355 );
356 let evald = evaluator.evaluate(component, &eval_ctx)?;
357 diagnostics.extend(evald.diagnostics.iter().cloned());
358 component_evals.push(evald);
359 }
360
361 let graph_ctx = GraphContext::new(Arc::clone(&canonical_root));
363 let mut combined: Vec<EvaluatedComponent> =
364 Vec::with_capacity(module_evals.len() + component_evals.len());
365 combined.extend(module_evals);
366 combined.extend(component_evals);
367 let mut ws = DefaultGraphBuilder::new().build(combined, ®istry, &graph_ctx)?;
368
369 ws.diagnostics.extend(diagnostics);
372
373 let profile_map = self
375 .profile_map
376 .clone()
377 .unwrap_or_else(crate::provider::empty_profile_map);
378 let mut provider_ctx = ProviderContext::new(profile_map);
379 provider_ctx.default_region.clone_from(&self.default_region);
380 provider_ctx.strict = self.strict_providers;
381 DefaultProviderResolver::new().resolve(&mut ws, &provider_ctx)?;
382
383 let _ = (&discovered.envs_dir, &discovered.root_hcl);
388
389 Ok(ws)
390 }
391}
392
393fn canonicalize_root(root: &Path) -> Result<Arc<Path>> {
394 let canonical = root.canonicalize().map_err(|source| crate::Error::Io {
395 path: root.to_path_buf(),
396 source,
397 })?;
398 Ok(Arc::from(canonical))
399}
400
401fn is_terragrunt_component(abs_dir: &Path) -> bool {
403 abs_dir.join("terragrunt.hcl").is_file()
404}
405
406fn override_or_push(map: &mut Map, key: &str, value: Value) {
407 if let Some(slot) = map.iter_mut().find(|(k, _)| k.as_ref() == key) {
408 slot.1 = value;
409 } else {
410 map.push((Arc::from(key), value));
411 }
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used)]
416mod tests {
417 use std::path::PathBuf;
418
419 use super::*;
420
421 #[test]
422 fn test_should_build_pipeline_options_with_defaults() {
423 let opts = PipelineOptions::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
424 assert_eq!(opts.max_walk_depth, 16);
425 assert_eq!(opts.max_total_files, 200_000);
426 assert!(matches!(opts.env_var_mode, EnvVarMode::Strict { .. }));
427 assert!(!opts.follow_symlinks);
428 }
429
430 #[test]
431 fn test_should_override_specific_options() {
432 let opts = PipelineOptions::builder()
433 .root(Arc::<Path>::from(PathBuf::from("/tmp/repo")))
434 .environment(Some(Arc::<str>::from("staging")))
435 .max_walk_depth(8_u32)
436 .follow_symlinks(true)
437 .build();
438 assert_eq!(opts.max_walk_depth, 8);
439 assert!(opts.follow_symlinks);
440 assert_eq!(opts.environment.as_deref(), Some("staging"));
441 }
442
443 #[test]
444 fn test_should_serde_round_trip_options() {
445 let opts = PipelineOptions::builder()
449 .root(Arc::<Path>::from(PathBuf::from("/tmp/repo")))
450 .max_walk_depth(8_u32)
451 .build();
452 let json = serde_json::to_string(&opts).unwrap();
453 let back: PipelineOptions = serde_json::from_str(&json).unwrap();
454 assert_eq!(back.root, opts.root);
455 assert_eq!(back.max_walk_depth, opts.max_walk_depth);
456 assert!(back.repo_vars.is_empty());
457 }
458
459 #[test]
460 fn test_default_pipeline_smoke_run_on_single_component_fixture() {
461 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
462 let workspace_root = manifest.ancestors().nth(2).unwrap();
463 let fixture = workspace_root.join("fixtures").join("single-component");
464 if !fixture.exists() {
465 return; }
467 let opts = PipelineOptions::new(Arc::<Path>::from(fixture));
468 let ws = DefaultPipeline::new().run(&opts).unwrap();
469 assert!(
470 !ws.components.is_empty(),
471 "expected at least one component in single-component fixture"
472 );
473 }
474
475 #[test]
476 fn test_pipeline_is_send_sync_object_safe() {
477 fn assert_send_sync<T: Send + Sync>() {}
478 assert_send_sync::<DefaultPipeline>();
479 assert_send_sync::<Box<dyn Pipeline>>();
480 }
481}