1use std::{
17 collections::{BTreeSet, HashSet},
18 sync::Arc,
19};
20
21use crate::{
22 Result,
23 diagnostic::{Diagnostic, Severity},
24 ir::{
25 AccountId, AssumeRole, Component, Expression, ProviderBlock, ProviderRef, Region,
26 StateBackend, Value, Workspace,
27 },
28 provider::{
29 error::ProviderError,
30 profile_map::{ProfileEntry, ProfileMap},
31 },
32};
33
34#[derive(Clone, Debug)]
38#[non_exhaustive]
39pub struct ProviderContext {
40 pub profile_map: Arc<ProfileMap>,
42 pub default_region: Option<Region>,
45 pub strict: bool,
50}
51
52impl ProviderContext {
53 #[must_use]
56 pub fn new(profile_map: Arc<ProfileMap>) -> Self {
57 Self {
58 profile_map,
59 default_region: None,
60 strict: false,
61 }
62 }
63}
64
65pub trait ProviderResolver: Send + Sync + std::fmt::Debug {
69 fn resolve(&self, ws: &mut Workspace, ctx: &ProviderContext) -> Result<()>;
79}
80
81#[derive(Clone, Copy, Debug, Default)]
83pub struct DefaultProviderResolver;
84
85impl DefaultProviderResolver {
86 #[must_use]
88 pub const fn new() -> Self {
89 Self
90 }
91}
92
93impl ProviderResolver for DefaultProviderResolver {
94 fn resolve(&self, ws: &mut Workspace, ctx: &ProviderContext) -> Result<()> {
95 let mut unresolved: BTreeSet<Arc<str>> = BTreeSet::new();
99 let mut new_diags: Vec<Diagnostic> = Vec::new();
100
101 for component in &mut ws.components {
102 resolve_component(component, ctx, &mut unresolved, &mut new_diags);
103 }
104
105 for profile in &unresolved {
107 new_diags.push(
108 Diagnostic::new(
109 Severity::Warn,
110 "TF1601",
111 format!("missing profile-map entry for `{profile}`"),
112 )
113 .with_suggestion(Arc::<str>::from(
114 "add the profile to your profile-map.yaml or to ~/.aws/config",
115 )),
116 );
117 }
118
119 if ctx.strict && !unresolved.is_empty() {
122 let first = unresolved
123 .iter()
124 .next()
125 .cloned()
126 .unwrap_or_else(|| Arc::from("<unknown>"));
127 ws.diagnostics.extend(new_diags);
130 return Err(ProviderError::StrictUnresolved {
131 count: unresolved.len(),
132 first,
133 }
134 .into());
135 }
136 ws.diagnostics.extend(new_diags);
137 Ok(())
138 }
139}
140
141fn resolve_component(
143 component: &mut Component,
144 ctx: &ProviderContext,
145 unresolved: &mut BTreeSet<Arc<str>>,
146 diagnostics: &mut Vec<Diagnostic>,
147) {
148 let providers: Vec<ProviderBlock> = component.providers.clone();
151 let mut alias_not_found_emitted: HashSet<Arc<str>> = HashSet::new();
152
153 let cascade_region_v: Option<Region> = component
154 .terragrunt
155 .as_ref()
156 .and_then(cascade_region)
157 .or_else(|| ctx.default_region.clone());
158
159 let cascade_account_v: Option<AccountId> =
160 component.terragrunt.as_ref().and_then(cascade_account);
161
162 for resource in &mut component.resources {
163 let provider = pick_provider(&providers, resource.provider_ref.as_ref());
164
165 if let Some(local) = resource
166 .provider_ref
167 .as_ref()
168 .filter(|_| provider.is_none())
169 .map(|r| Arc::clone(&r.local_name))
170 {
171 let alias = resource
172 .provider_ref
173 .as_ref()
174 .and_then(|r| r.alias.clone())
175 .unwrap_or_else(|| Arc::from("default"));
176 if alias_not_found_emitted.insert(Arc::clone(&alias)) {
177 diagnostics.push(Diagnostic::new(
178 Severity::Warn,
179 "TF1602",
180 format!(
181 "provider alias `{local}.{alias}` referenced by `{}` not declared in \
182 component `{}`",
183 resource.address.as_str(),
184 component.path.display()
185 ),
186 ));
187 }
188 }
189
190 let region = first_resolved_region(
191 provider,
192 cascade_region_v.as_ref(),
193 ctx.default_region.as_ref(),
194 );
195 let (account_id, account_name) = first_resolved_account(
196 provider,
197 ctx,
198 unresolved,
199 diagnostics,
200 cascade_account_v.as_ref(),
201 );
202
203 resource.region = region;
204 resource.account_id = account_id;
205 resource.account_name = account_name;
206 }
207
208 if let Some(backend) = component.state_backend.as_mut() {
210 fill_state_backend(backend, ctx, unresolved, diagnostics);
211 }
212 if let Some(tg) = component.terragrunt.as_mut()
213 && let Some(backend) = tg.state_backend.as_mut()
214 {
215 fill_state_backend(backend, ctx, unresolved, diagnostics);
216 }
217}
218
219fn pick_provider<'a>(
221 providers: &'a [ProviderBlock],
222 pref: Option<&ProviderRef>,
223) -> Option<&'a ProviderBlock> {
224 match pref {
225 Some(r) => providers.iter().find(|p| {
226 p.local_name == r.local_name && p.alias.as_ref().map(Arc::as_ref) == r.alias.as_deref()
227 }),
228 None => providers
229 .iter()
230 .find(|p| p.alias.is_none() && &*p.local_name == "aws")
231 .or_else(|| providers.iter().find(|p| p.alias.is_none())),
232 }
233}
234
235fn first_resolved_region(
236 provider: Option<&ProviderBlock>,
237 cascade: Option<&Region>,
238 default: Option<&Region>,
239) -> Option<Region> {
240 if let Some(p) = provider
241 && let Some(r) = p.region_expr.as_ref().and_then(literal_region)
242 {
243 return Some(r);
244 }
245 cascade.cloned().or_else(|| default.cloned())
246}
247
248fn first_resolved_account(
250 provider: Option<&ProviderBlock>,
251 ctx: &ProviderContext,
252 unresolved: &mut BTreeSet<Arc<str>>,
253 _diagnostics: &mut [Diagnostic],
254 cascade: Option<&AccountId>,
255) -> (Option<AccountId>, Option<Arc<str>>) {
256 if let Some(p) = provider
258 && let Some(arn_str) = p.assume_role.as_ref().and_then(assume_role_arn)
259 && let Some(id) = extract_account_id(arn_str.as_ref())
260 {
261 let name = lookup_name_by_account(&ctx.profile_map, &id);
267 return (Some(id), name);
268 }
269
270 if let Some(p) = provider
272 && let Some(profile) = p.profile_expr.as_ref().and_then(literal_str)
273 {
274 if let Some(entry) = ctx.profile_map.lookup(&profile) {
275 return (
276 Some(entry.account_id.clone()),
277 Some(Arc::clone(&entry.account_name)),
278 );
279 }
280 unresolved.insert(profile);
281 }
282
283 if let Some(id) = cascade.cloned() {
285 let name = lookup_name_by_account(&ctx.profile_map, &id);
286 return (Some(id), name);
287 }
288
289 (None, None)
290}
291
292fn lookup_name_by_account(map: &ProfileMap, id: &AccountId) -> Option<Arc<str>> {
297 let mut hits: Vec<(&Arc<str>, &Arc<str>)> = map
298 .iter()
299 .filter(|(_, entry)| &entry.account_id == id)
300 .map(|(profile, entry)| (profile, &entry.account_name))
301 .collect();
302 hits.sort_by(|a, b| a.0.cmp(b.0));
303 hits.first().map(|(_, name)| Arc::clone(name))
304}
305
306fn fill_state_backend(
307 backend: &mut StateBackend,
308 ctx: &ProviderContext,
309 unresolved: &mut BTreeSet<Arc<str>>,
310 diagnostics: &mut [Diagnostic],
311) {
312 let attrs = &backend.attributes;
313
314 if backend.state_account_id.is_none()
316 && let Some(profile) = attr_str(attrs, "profile")
317 {
318 if let Some(entry) = ctx.profile_map.lookup(&profile) {
319 backend.state_account_id = Some(entry.account_id.clone());
320 } else {
321 unresolved.insert(profile);
322 }
323 }
324
325 if backend.state_account_id.is_none()
327 && let Some(arn) = attr_str(attrs, "role_arn")
328 && let Some(id) = extract_account_id(&arn)
329 {
330 backend.state_account_id = Some(id);
331 }
332
333 if backend.state_region.is_none()
335 && let Some(r) = attr_str(attrs, "region")
336 && let Ok(parsed) = Region::new(&r)
337 {
338 backend.state_region = Some(parsed);
339 }
340
341 let _ = diagnostics; }
343
344#[must_use]
354pub fn extract_account_id(role_arn: &str) -> Option<AccountId> {
355 let rest = role_arn.strip_prefix("arn:aws:iam::")?;
356 let (account_id, _) = rest.split_once(":role/")?;
357 AccountId::new(account_id).ok()
358}
359
360fn literal_str(expr: &Expression) -> Option<Arc<str>> {
361 match expr {
362 Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
363 _ => None,
364 }
365}
366
367fn literal_region(expr: &Expression) -> Option<Region> {
368 let s = literal_str(expr)?;
369 Region::new(s.as_ref()).ok()
370}
371
372fn assume_role_arn(ar: &AssumeRole) -> Option<Arc<str>> {
373 literal_str(&ar.role_arn_expr)
374}
375
376fn attr_str(attrs: &crate::ir::AttributeMap, key: &str) -> Option<Arc<str>> {
377 attrs
378 .iter()
379 .find(|(k, _)| k.as_ref() == key)
380 .and_then(|(_, v)| literal_str(v))
381}
382
383fn cascade_region(tg: &crate::ir::TerragruntConfig) -> Option<Region> {
384 tg.effective_locals
385 .iter()
386 .find(|(k, _)| k.as_ref() == "aws_region")
387 .and_then(|(_, v)| match v {
388 Value::Str(s) => Region::new(s.as_ref()).ok(),
389 _ => None,
390 })
391}
392
393fn cascade_account(tg: &crate::ir::TerragruntConfig) -> Option<AccountId> {
394 tg.effective_locals
395 .iter()
396 .find(|(k, _)| k.as_ref() == "aws_account_id")
397 .and_then(|(_, v)| match v {
398 Value::Str(s) => AccountId::new(s.as_ref()).ok(),
399 _ => None,
400 })
401}
402
403#[allow(dead_code)]
404fn _profile_entry_dbg(e: &ProfileEntry) -> &ProfileEntry {
405 e
406}
407
408#[cfg(test)]
409#[allow(
410 clippy::unwrap_used,
411 clippy::expect_used,
412 clippy::panic,
413 clippy::indexing_slicing
414)]
415mod tests {
416 use std::{
417 collections::HashMap,
418 path::{Path, PathBuf},
419 };
420
421 use super::*;
422 use crate::{
423 ir::{
424 Address, AssumeRole, AttributeMap, Component, ComponentId, ComponentKind, Expression,
425 ProviderBlock, ProviderRef, Resource, ResourceKind, Span, TerragruntConfig, Workspace,
426 },
427 provider::profile_map::{ProfileMap, empty},
428 };
429
430 #[test]
433 fn test_should_extract_account_id_from_role_arn() {
434 let id = extract_account_id("arn:aws:iam::123456789012:role/admin").unwrap();
435 assert_eq!(id.as_str(), "123456789012");
436 }
437
438 #[test]
439 fn test_should_reject_malformed_arn() {
440 assert!(extract_account_id("not-an-arn").is_none());
441 assert!(extract_account_id("arn:aws:iam::abc:role/x").is_none());
442 assert!(extract_account_id("arn:aws:s3:::bucket").is_none());
443 }
444
445 fn synth_profile_map_with(profile: &str, account: &str, region: &str) -> Arc<ProfileMap> {
448 let f = tempfile::NamedTempFile::new().unwrap();
449 std::fs::write(
450 f.path(),
451 format!(
452 "profiles:\n {profile}:\n account_id: \"{account}\"\n account_name: \
453 \"{profile}\"\n region: \"{region}\"\n"
454 ),
455 )
456 .unwrap();
457 crate::provider::profile_map::load_yaml_profile_map(f.path()).unwrap()
458 }
459
460 fn span() -> Span {
461 Span::synthetic()
462 }
463
464 fn resource_with_provider(addr: &str, alias: Option<&str>) -> Resource {
465 Resource::builder()
466 .address(Address::new(addr).unwrap())
467 .kind(ResourceKind::Managed)
468 .type_(Arc::<str>::from("aws_iam_role"))
469 .name(Arc::<str>::from("r"))
470 .provider_ref(alias.map(|a| {
471 ProviderRef::builder()
472 .local_name(Arc::<str>::from("aws"))
473 .alias(Some(Arc::<str>::from(a)))
474 .span(span())
475 .build()
476 }))
477 .span(span())
478 .build()
479 }
480
481 fn component_with(
482 path: &str,
483 providers: Vec<ProviderBlock>,
484 resources: Vec<Resource>,
485 ) -> Component {
486 Component::builder()
487 .id(ComponentId::from_index(0))
488 .path(Arc::<Path>::from(PathBuf::from(path)))
489 .kind(ComponentKind::Component)
490 .providers(providers)
491 .resources(resources)
492 .build()
493 }
494
495 fn workspace_with(component: Component) -> Workspace {
496 Workspace::builder()
497 .root(Arc::<Path>::from(PathBuf::from("/tmp/repo")))
498 .components(vec![component])
499 .build()
500 }
501
502 fn provider_with(
503 alias: Option<&str>,
504 profile: Option<&str>,
505 region: Option<&str>,
506 ) -> ProviderBlock {
507 ProviderBlock::builder()
508 .local_name(Arc::<str>::from("aws"))
509 .alias(alias.map(Arc::<str>::from))
510 .profile_expr(profile.map(|p| Expression::Literal(Value::Str(Arc::from(p)))))
511 .region_expr(region.map(|r| Expression::Literal(Value::Str(Arc::from(r)))))
512 .span(span())
513 .build()
514 }
515
516 #[test]
517 fn test_should_resolve_account_from_provider_profile() {
518 let map = synth_profile_map_with("primary", "100000000001", "us-west-2");
519 let resource = resource_with_provider("aws_iam_role.r", Some("main"));
520 let provider = provider_with(Some("main"), Some("primary"), Some("us-west-2"));
521 let component = component_with("svc", vec![provider], vec![resource]);
522 let mut ws = workspace_with(component);
523
524 DefaultProviderResolver
525 .resolve(&mut ws, &ProviderContext::new(map))
526 .unwrap();
527 let r = &ws.components[0].resources[0];
528 assert_eq!(
529 r.account_id.as_ref().map(AccountId::as_str),
530 Some("100000000001")
531 );
532 assert_eq!(r.region.as_ref().map(Region::as_str), Some("us-west-2"));
533 assert_eq!(r.account_name.as_deref(), Some("primary"));
534 }
535
536 #[test]
537 fn test_should_prefer_assume_role_over_profile() {
538 let map = synth_profile_map_with("primary", "100000000001", "us-west-2");
539 let mut provider = provider_with(Some("main"), Some("primary"), Some("us-west-2"));
540 provider.assume_role = Some(
541 AssumeRole::builder()
542 .role_arn_expr(Expression::Literal(Value::Str(Arc::from(
543 "arn:aws:iam::999999999999:role/x",
544 ))))
545 .span(span())
546 .build(),
547 );
548 let resource = resource_with_provider("aws_iam_role.r", Some("main"));
549 let component = component_with("svc", vec![provider], vec![resource]);
550 let mut ws = workspace_with(component);
551
552 DefaultProviderResolver
553 .resolve(&mut ws, &ProviderContext::new(map))
554 .unwrap();
555 let r = &ws.components[0].resources[0];
556 assert_eq!(
557 r.account_id.as_ref().map(AccountId::as_str),
558 Some("999999999999")
559 );
560 }
561
562 #[test]
563 fn test_should_fall_through_to_terragrunt_cascade_account() {
564 let map = empty();
565 let resource = resource_with_provider("aws_iam_role.r", None);
566 let mut component = component_with("svc", vec![], vec![resource]);
567 component.terragrunt = Some(
568 TerragruntConfig::builder()
569 .component_dir(Arc::<Path>::from(PathBuf::from("/repo/svc")))
570 .effective_locals(vec![
571 (
572 Arc::from("aws_account_id"),
573 Value::Str(Arc::from("200000000002")),
574 ),
575 (Arc::from("aws_region"), Value::Str(Arc::from("eu-west-1"))),
576 ])
577 .build(),
578 );
579 let mut ws = workspace_with(component);
580 DefaultProviderResolver
581 .resolve(&mut ws, &ProviderContext::new(map))
582 .unwrap();
583 let r = &ws.components[0].resources[0];
584 assert_eq!(
585 r.account_id.as_ref().map(AccountId::as_str),
586 Some("200000000002")
587 );
588 assert_eq!(r.region.as_ref().map(Region::as_str), Some("eu-west-1"));
589 }
590
591 #[test]
592 fn test_should_emit_missing_profile_diagnostic_once() {
593 let map = empty();
594 let mut providers = HashMap::<String, ProviderBlock>::new();
595 providers.insert("a".into(), provider_with(Some("a"), Some("missing"), None));
596 providers.insert("b".into(), provider_with(Some("b"), Some("missing"), None));
597 let provider_a = providers.remove("a").unwrap();
598 let provider_b = providers.remove("b").unwrap();
599 let r1 = resource_with_provider("aws_iam_role.r1", Some("a"));
600 let r2 = resource_with_provider("aws_iam_role.r2", Some("b"));
601 let component = component_with("svc", vec![provider_a, provider_b], vec![r1, r2]);
602 let mut ws = workspace_with(component);
603
604 DefaultProviderResolver
605 .resolve(&mut ws, &ProviderContext::new(map))
606 .unwrap();
607 let missing: Vec<_> = ws
608 .diagnostics
609 .iter()
610 .filter(|d| d.code.as_ref() == "TF1601")
611 .collect();
612 assert_eq!(missing.len(), 1, "expected dedup; got {missing:?}");
613 }
614
615 #[test]
616 fn test_should_emit_alias_not_found_diagnostic_per_alias() {
617 let map = empty();
618 let resource = resource_with_provider("aws_iam_role.r", Some("ghost"));
619 let component = component_with("svc", vec![], vec![resource]);
620 let mut ws = workspace_with(component);
621 DefaultProviderResolver
622 .resolve(&mut ws, &ProviderContext::new(map))
623 .unwrap();
624 assert!(
625 ws.diagnostics.iter().any(|d| d.code.as_ref() == "TF1602"),
626 "{:?}",
627 ws.diagnostics
628 );
629 }
630
631 #[test]
632 fn test_strict_mode_returns_error_on_unresolved_profile() {
633 let map = empty();
634 let provider = provider_with(Some("main"), Some("ghost"), None);
635 let resource = resource_with_provider("aws_iam_role.r", Some("main"));
636 let component = component_with("svc", vec![provider], vec![resource]);
637 let mut ws = workspace_with(component);
638 let mut ctx = ProviderContext::new(map);
639 ctx.strict = true;
640 let err = DefaultProviderResolver.resolve(&mut ws, &ctx).unwrap_err();
641 assert!(
642 format!("{err}").contains("ghost"),
643 "expected strict error to mention profile: {err}"
644 );
645 }
646
647 #[test]
648 fn test_should_fill_state_backend_account_from_profile() {
649 let map = synth_profile_map_with("backend-profile", "300000000003", "ap-southeast-1");
650 let attrs: AttributeMap = vec![
651 (
652 Arc::from("profile"),
653 Expression::Literal(Value::Str(Arc::from("backend-profile"))),
654 ),
655 (
656 Arc::from("region"),
657 Expression::Literal(Value::Str(Arc::from("ap-southeast-1"))),
658 ),
659 ];
660 let mut backend = StateBackend::builder()
661 .kind(Arc::<str>::from("s3"))
662 .attributes(attrs)
663 .span(span())
664 .build();
665 let mut unresolved: BTreeSet<Arc<str>> = BTreeSet::new();
666 let mut diags: Vec<Diagnostic> = Vec::new();
667 fill_state_backend(
668 &mut backend,
669 &ProviderContext::new(map),
670 &mut unresolved,
671 &mut diags,
672 );
673 assert_eq!(
674 backend.state_account_id.as_ref().map(AccountId::as_str),
675 Some("300000000003")
676 );
677 assert_eq!(
678 backend.state_region.as_ref().map(Region::as_str),
679 Some("ap-southeast-1")
680 );
681 }
682
683 #[test]
684 fn test_should_fill_state_backend_account_from_role_arn() {
685 let map = empty();
686 let attrs: AttributeMap = vec![(
687 Arc::from("role_arn"),
688 Expression::Literal(Value::Str(Arc::from(
689 "arn:aws:iam::444444444444:role/state-writer",
690 ))),
691 )];
692 let mut backend = StateBackend::builder()
693 .kind(Arc::<str>::from("s3"))
694 .attributes(attrs)
695 .span(span())
696 .build();
697 let mut unresolved: BTreeSet<Arc<str>> = BTreeSet::new();
698 let mut diags: Vec<Diagnostic> = Vec::new();
699 fill_state_backend(
700 &mut backend,
701 &ProviderContext::new(map),
702 &mut unresolved,
703 &mut diags,
704 );
705 assert_eq!(
706 backend.state_account_id.as_ref().map(AccountId::as_str),
707 Some("444444444444")
708 );
709 }
710}