Skip to content

Commit e3b60ec

Browse files
authored
Support GetAtts for nested stacks and outputs (#4011)
* Support GetAtts for nested stacks and outputs
1 parent fe59771 commit e3b60ec

File tree

9 files changed

+212
-82
lines changed

9 files changed

+212
-82
lines changed

src/cfnlint/context/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["Context", "create_context_for_template"]
22

3-
from cfnlint.context.context import Context, Path, create_context_for_template
3+
from cfnlint.context.context import Context, Path, Resource, create_context_for_template

src/cfnlint/context/context.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
from __future__ import annotations
77

8+
import os
89
from abc import ABC, abstractmethod
910
from collections import deque
1011
from dataclasses import InitVar, dataclass, field, fields
1112
from functools import lru_cache
12-
from typing import Any, Deque, Iterator, Sequence, Set, Tuple
13+
from typing import TYPE_CHECKING, Any, Deque, Iterator, Sequence, Set, Tuple
1314

1415
from cfnlint.context._mappings import Mappings
1516
from cfnlint.context.conditions._conditions import Conditions
@@ -22,6 +23,9 @@
2223
)
2324
from cfnlint.schema import PROVIDER_SCHEMA_MANAGER, AttributeDict
2425

26+
if TYPE_CHECKING:
27+
from cfnlint.template import Template
28+
2529
_PSEUDOPARAMS_NON_REGION = ["AWS::AccountId", "AWS::NoValue", "AWS::StackName"]
2630

2731

@@ -384,6 +388,37 @@ def is_ssm_parameter(self) -> bool:
384388
return self.type.startswith("AWS::SSM::Parameter::")
385389

386390

391+
def _nested_stack_get_atts(filename, template_url):
392+
if (
393+
template_url.startswith("http://")
394+
or template_url.startswith("https://")
395+
or template_url.startswith("s3://")
396+
):
397+
return None
398+
399+
base_dir = os.path.dirname(os.path.abspath(filename))
400+
template_path = os.path.normpath(os.path.join(base_dir, template_url))
401+
402+
try:
403+
from cfnlint.decode import decode
404+
405+
(tmp, matches) = decode(template_path)
406+
except Exception: # noqa: E722
407+
return None
408+
if matches:
409+
return None
410+
411+
outputs = AttributeDict()
412+
413+
tmp_outputs = tmp.get("Outputs")
414+
if not isinstance(tmp_outputs, dict):
415+
return outputs
416+
417+
for name, _ in tmp_outputs.items():
418+
outputs[f"Outputs.{name}"] = "/properties/CfnLintStringType"
419+
return outputs
420+
421+
387422
@dataclass
388423
class Resource(_Ref):
389424
"""
@@ -393,8 +428,10 @@ class Resource(_Ref):
393428
type: str = field(init=False)
394429
condition: str | None = field(init=False, default=None)
395430
resource: InitVar[Any]
431+
filename: InitVar[str | None] = field(default=None)
432+
_nested_stack_get_atts: AttributeDict | None = field(init=False, default=None)
396433

397-
def __post_init__(self, resource) -> None:
434+
def __post_init__(self, resource: Any, filename: str | None) -> None:
398435
if not isinstance(resource, dict):
399436
raise ValueError("Resource must be a object")
400437
t = resource.get("Type")
@@ -409,7 +446,18 @@ def __post_init__(self, resource) -> None:
409446
raise ValueError("Condition must be a string")
410447
self.condition = c
411448

449+
if self.type == "AWS::CloudFormation::Stack":
450+
properties = resource.get("Properties")
451+
if isinstance(properties, dict):
452+
template_url = properties.get("TemplateURL")
453+
if isinstance(template_url, str):
454+
self._nested_stack_get_atts = _nested_stack_get_atts(
455+
filename, template_url
456+
)
457+
412458
def get_atts(self, region: str = REGION_PRIMARY) -> AttributeDict:
459+
if self._nested_stack_get_atts is not None:
460+
return self._nested_stack_get_atts
413461
return PROVIDER_SCHEMA_MANAGER.get_type_getatts(self.type, region)
414462

415463
def ref(self, region: str = REGION_PRIMARY) -> dict[str, Any]:
@@ -433,13 +481,13 @@ def _init_parameters(parameters: Any) -> dict[str, Parameter]:
433481
return obj
434482

435483

436-
def _init_resources(resources: Any) -> dict[str, Resource]:
484+
def _init_resources(resources: Any, filename: str | None = None) -> dict[str, Resource]:
437485
obj = {}
438486
if not isinstance(resources, dict):
439487
raise ValueError("Resource must be a object")
440488
for k, v in resources.items():
441489
try:
442-
obj[k] = Resource(v)
490+
obj[k] = Resource(v, filename)
443491
except ValueError:
444492
pass
445493
return obj
@@ -451,7 +499,7 @@ def _init_transforms(transforms: Any) -> Transforms:
451499
return Transforms([])
452500

453501

454-
def create_context_for_template(cfn):
502+
def create_context_for_template(cfn: Template) -> "Context":
455503
parameters = {}
456504
try:
457505
parameters = _init_parameters(cfn.template.get("Parameters", {}))
@@ -460,7 +508,7 @@ def create_context_for_template(cfn):
460508

461509
resources = {}
462510
try:
463-
resources = _init_resources(cfn.template.get("Resources", {}))
511+
resources = _init_resources(cfn.template.get("Resources", {}), cfn.filename)
464512
except (ValueError, AttributeError):
465513
pass
466514

src/cfnlint/schema/_getatts.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,9 @@
213213
)
214214

215215

216-
class AttributeDict(UserDict):
217-
def __init__(self, __dict: None = None) -> None:
216+
class AttributeDict(UserDict[str, str]):
217+
def __init__(self, __dict: dict[str, str] | None = None) -> None:
218218
super().__init__(__dict)
219-
self.data: dict[str, str] = {}
220219

221220
def __getitem__(self, key: str) -> str:
222221
possible_items = {}

src/cfnlint/template/getatts.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections import UserDict
44
from typing import Any
55

6+
from cfnlint.context import Resource
67
from cfnlint.schema import PROVIDER_SCHEMA_MANAGER, AttributeDict, ResourceNotFoundError
78

89

@@ -51,21 +52,19 @@ def __init__(self, regions: list[str]) -> None:
5152
for region in self._regions:
5253
self._getatts[region] = _ResourceDict()
5354

54-
def add(self, resource_name: str, resource_type: str) -> None:
55+
def add(self, resource_name: str, resource: Resource) -> None:
5556
for region in self._regions:
5657
if resource_name not in self._getatts[region]:
57-
if resource_type.endswith("::MODULE"):
58+
if resource.type.endswith("::MODULE"):
5859
self._getatts[region][f"{resource_name}.*"] = (
5960
PROVIDER_SCHEMA_MANAGER.get_type_getatts(
60-
resource_type=resource_type, region=region
61+
resource_type=resource.type, region=region
6162
)
6263
)
6364

6465
try:
65-
self._getatts[region][resource_name] = (
66-
PROVIDER_SCHEMA_MANAGER.get_type_getatts(
67-
resource_type=resource_type, region=region
68-
)
66+
self._getatts[region][resource_name] = resource.get_atts(
67+
region=region
6968
)
7069
except ResourceNotFoundError:
7170
self._getatts[region][resource_name] = AttributeDict()

src/cfnlint/template/template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def get_valid_getatts(self) -> GetAtts:
327327
results = GetAtts(self.regions)
328328

329329
for name, value in self.context.resources.items():
330-
results.add(name, value.type)
330+
results.add(name, value)
331331

332332
return results
333333

test/unit/module/context/test_resource.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
SPDX-License-Identifier: MIT-0
44
"""
55

6+
from unittest.mock import patch
7+
68
import pytest
79

810
from cfnlint.context.context import Resource, _init_resources
11+
from cfnlint.match import Match
12+
from cfnlint.rules.errors.parse import ParseError
13+
from cfnlint.schema import AttributeDict
914

1015

1116
@pytest.mark.parametrize(
@@ -41,3 +46,65 @@ def test_errors(name, instance):
4146
def test_resources():
4247
with pytest.raises(ValueError):
4348
_init_resources([])
49+
50+
51+
@pytest.mark.parametrize(
52+
"name,instance,decode_results,expected_getatts",
53+
[
54+
(
55+
"Nested stack with no Properties",
56+
{"Type": "AWS::CloudFormation::Stack"},
57+
None,
58+
AttributeDict({"Outputs\\..*": "/properties/CfnLintStringType"}),
59+
),
60+
(
61+
"Nested stack with template URL",
62+
{
63+
"Type": "AWS::CloudFormation::Stack",
64+
"Properties": {"TemplateURL": "https://bucket/path.yaml"},
65+
},
66+
None,
67+
AttributeDict({"Outputs\\..*": "/properties/CfnLintStringType"}),
68+
),
69+
(
70+
"Nested stack with a local file",
71+
{
72+
"Type": "AWS::CloudFormation::Stack",
73+
"Properties": {"TemplateURL": "./bar.yaml"},
74+
},
75+
({"Outputs": {"MyValue": {"Type": "String"}}}, None),
76+
AttributeDict({"Outputs.MyValue": "/properties/CfnLintStringType"}),
77+
),
78+
(
79+
"Nested stack with a local file and no outputs",
80+
{
81+
"Type": "AWS::CloudFormation::Stack",
82+
"Properties": {"TemplateURL": "./bar.yaml"},
83+
},
84+
({}, None),
85+
AttributeDict({}),
86+
),
87+
(
88+
"Nested stack with a local file but match error",
89+
{
90+
"Type": "AWS::CloudFormation::Stack",
91+
"Properties": {"TemplateURL": "./bar.yaml"},
92+
},
93+
(None, Match("test", rule=ParseError())),
94+
AttributeDict({"Outputs\\..*": "/properties/CfnLintStringType"}),
95+
),
96+
],
97+
)
98+
def test_nested_stacks(name, instance, decode_results, expected_getatts):
99+
region = "us-east-1"
100+
filename = "foo/bar.yaml"
101+
102+
with patch("cfnlint.decode.decode") as mock_decode:
103+
if decode_results is not None:
104+
mock_decode.return_value = decode_results
105+
106+
resource = Resource(instance, filename)
107+
108+
assert expected_getatts == resource.get_atts(
109+
region
110+
), f"{name!r} test got {resource.get_atts(region)}"

test/unit/module/template/test_getatts.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from test.testlib.testcase import BaseTestCase
77

8+
from cfnlint.context import Resource
89
from cfnlint.template.getatts import GetAtts
910

1011

@@ -13,14 +14,20 @@ class TestGetAtts(BaseTestCase):
1314

1415
def test_getatt(self):
1516
getatts = GetAtts(["us-east-1", "us-west-2"])
16-
getatts.add("Module", "Organization::Resource::Type::MODULE")
17+
getatts.add(
18+
"Module", Resource({"Type": "Organization::Resource::Type::MODULE"})
19+
)
1720
results = getatts.match("us-east-1", "ModuleResource.Module")
1821
self.assertEqual(results, "/properties/CfnLintAllTypes")
1922

2023
def test_many_modules(self):
2124
getatts = GetAtts(["us-east-1", "us-west-2"])
22-
getatts.add("Module", "Organization::Resource::Type::MODULE")
23-
getatts.add("Module1", "Organization::Resource::Type::MODULE")
25+
getatts.add(
26+
"Module", Resource({"Type": "Organization::Resource::Type::MODULE"})
27+
)
28+
getatts.add(
29+
"Module1", Resource({"Type": "Organization::Resource::Type::MODULE"})
30+
)
2431
results = getatts.match("us-east-1", "ModuleResource.Module")
2532
self.assertEqual(results, "/properties/CfnLintAllTypes")
2633

@@ -29,8 +36,10 @@ def test_many_modules(self):
2936

3037
def test_getatt_resource_and_modules(self):
3138
getatts = GetAtts(["us-east-1", "us-west-2"])
32-
getatts.add("Resource", "Organization::Resource::Type::MODULE")
33-
getatts.add("Resource1", "Organization::Resource::Type")
39+
getatts.add(
40+
"Resource", Resource({"Type": "Organization::Resource::Type::MODULE"})
41+
)
42+
getatts.add("Resource1", Resource({"Type": "Organization::Resource::Type"}))
3443
results = getatts.match("us-east-1", "ResourceOne.Module")
3544
self.assertEqual(results, "/properties/CfnLintAllTypes")
3645

@@ -48,7 +57,7 @@ def test_getatt_type_errors(self):
4857

4958
def test_getatt_resource_with_list(self):
5059
getatts = GetAtts(["us-east-1"])
51-
getatts.add("Resource", "AWS::NetworkFirewall::Firewall")
60+
getatts.add("Resource", Resource({"Type": "AWS::NetworkFirewall::Firewall"}))
5261
results = getatts.match("us-east-1", "Resource.EndpointIds")
5362
self.assertEqual(results, "/properties/EndpointIds")
5463
self.assertDictEqual(

0 commit comments

Comments
 (0)