Skip to content

Commit eba6a45

Browse files
authored
Bring back better findinmap resolution (#3579)
* Bring back better findinmap resolution
1 parent 240e525 commit eba6a45

File tree

6 files changed

+193
-65
lines changed

6 files changed

+193
-65
lines changed

src/cfnlint/context/_mappings.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def create_from_dict(cls, instance: Any) -> Mappings:
3232
for k, v in instance.items():
3333
if k == "Fn::Transform":
3434
is_transform = True
35-
else:
35+
elif isinstance(k, str):
3636
result[k] = Map.create_from_dict(v)
3737
return cls(result, is_transform)
3838
except (ValueError, AttributeError) as e:
@@ -65,10 +65,8 @@ def create_from_dict(cls, instance: Any) -> _MappingSecondaryKey:
6565
for k, v in instance.items():
6666
if k == "Fn::Transform":
6767
is_transform = True
68-
elif isinstance(v, (str, list, int, float)):
68+
elif isinstance(k, str) and isinstance(v, (str, list, int, float)):
6969
keys[k] = v
70-
else:
71-
continue
7270
return cls(keys, is_transform)
7371

7472

@@ -95,6 +93,6 @@ def create_from_dict(cls, instance: Any) -> Map:
9593
for k, v in instance.items():
9694
if k == "Fn::Transform":
9795
is_transform = True
98-
else:
96+
elif isinstance(k, str):
9997
keys[k] = _MappingSecondaryKey.create_from_dict(v)
10098
return cls(keys, is_transform)

src/cfnlint/jsonschema/_resolvers_cfn.py

+112-44
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from cfnlint.helpers import AVAILABILITY_ZONES, REGEX_SUB_PARAMETERS
1515
from cfnlint.jsonschema import ValidationError, Validator
1616
from cfnlint.jsonschema._typing import ResolutionResult
17+
from cfnlint.jsonschema._utils import equal
1718

1819

1920
def unresolvable(validator: Validator, instance: Any) -> ResolutionResult:
@@ -39,11 +40,12 @@ def find_in_map(validator: Validator, instance: Any) -> ResolutionResult:
3940
if len(instance) not in [3, 4]:
4041
return
4142

42-
default_value_found = None
43+
default_value_found = False
4344
if len(instance) == 4:
4445
options = instance[3]
4546
if validator.is_type(options, "object"):
4647
if "DefaultValue" in options:
48+
default_value_found = True
4749
for value, v, _ in validator.resolve_value(options["DefaultValue"]):
4850
yield value, v.evolve(
4951
context=v.context.evolve(
@@ -52,7 +54,6 @@ def find_in_map(validator: Validator, instance: Any) -> ResolutionResult:
5254
)
5355
),
5456
), None
55-
default_value_found = True
5657

5758
if not default_value_found and not validator.context.mappings.maps:
5859
if validator.context.mappings.is_transform:
@@ -65,54 +66,121 @@ def find_in_map(validator: Validator, instance: Any) -> ResolutionResult:
6566
path=deque([0]),
6667
)
6768

68-
if (
69-
validator.is_type(instance[0], "string")
70-
and (
71-
validator.is_type(instance[1], "string")
72-
or validator.is_type(instance[1], "integer")
73-
)
74-
and validator.is_type(instance[2], "string")
75-
):
76-
map = validator.context.mappings.maps.get(instance[0])
77-
if map is None:
78-
if not default_value_found:
79-
yield None, validator, ValidationError(
80-
(
81-
f"{instance[0]!r} is not one of "
82-
f"{list(validator.context.mappings.maps.keys())!r}"
83-
),
84-
path=deque([0]),
85-
)
86-
return
69+
mappings = list(validator.context.mappings.maps.keys())
70+
results = []
71+
found_valid_combination = False
72+
for map_name, map_v, _ in validator.resolve_value(instance[0]):
73+
if not validator.is_type(map_name, "string"):
74+
continue
8775

88-
top_key = map.keys.get(instance[1])
89-
if top_key is None:
90-
if map.is_transform:
91-
return
76+
if all(not (equal(map_name, each)) for each in mappings):
9277
if not default_value_found:
93-
yield None, validator, ValidationError(
78+
results.append(
9479
(
95-
f"{instance[1]!r} is not one of "
96-
f"{list(map.keys.keys())!r} for "
97-
f"mapping {instance[0]!r}"
98-
),
99-
path=deque([1]),
80+
None,
81+
map_v,
82+
ValidationError(
83+
f"{map_name!r} is not one of {mappings!r}",
84+
path=deque([0]),
85+
),
86+
)
10087
)
101-
return
88+
continue
10289

103-
value = top_key.keys.get(instance[2])
104-
if value is None:
105-
if top_key.is_transform:
106-
return
107-
if not default_value_found:
108-
yield value, validator, ValidationError(
109-
(
110-
f"{instance[2]!r} is not one of "
111-
f"{list(top_key.keys.keys())!r} for mapping "
112-
f"{instance[0]!r} and key {instance[1]!r}"
113-
),
114-
path=deque([2]),
90+
if validator.context.mappings.maps[map_name].is_transform:
91+
continue
92+
93+
for top_level_key, top_v, _ in validator.resolve_value(instance[1]):
94+
if validator.is_type(top_level_key, "integer"):
95+
top_level_key = str(top_level_key)
96+
if not validator.is_type(top_level_key, "string"):
97+
continue
98+
99+
top_level_keys = list(validator.context.mappings.maps[map_name].keys.keys())
100+
if all(not (equal(top_level_key, each)) for each in top_level_keys):
101+
if not default_value_found:
102+
results.append(
103+
(
104+
None,
105+
top_v,
106+
ValidationError(
107+
(
108+
f"{top_level_key!r} is not one of "
109+
f"{top_level_keys!r} for mapping "
110+
f"{map_name!r}"
111+
),
112+
path=deque([1]),
113+
),
114+
)
115+
)
116+
continue
117+
118+
if (
119+
not top_level_key
120+
or validator.context.mappings.maps[map_name]
121+
.keys[top_level_key]
122+
.is_transform
123+
):
124+
continue
125+
126+
for second_level_key, second_v, err in validator.resolve_value(instance[2]):
127+
if validator.is_type(second_level_key, "integer"):
128+
second_level_key = str(second_level_key)
129+
if not validator.is_type(second_level_key, "string"):
130+
continue
131+
second_level_keys = list(
132+
validator.context.mappings.maps[map_name]
133+
.keys[top_level_key]
134+
.keys.keys()
115135
)
136+
if all(
137+
not (equal(second_level_key, each)) for each in second_level_keys
138+
):
139+
if not default_value_found:
140+
results.append(
141+
(
142+
None,
143+
second_v,
144+
ValidationError(
145+
(
146+
f"{second_level_key!r} is not "
147+
f"one of {second_level_keys!r} "
148+
f"for mapping {map_name!r} and "
149+
f"key {top_level_key!r}"
150+
),
151+
path=deque([2]),
152+
),
153+
)
154+
)
155+
continue
156+
157+
found_valid_combination = True
158+
159+
for value in validator.context.mappings.maps[map_name].find_in_map(
160+
top_level_key,
161+
second_level_key,
162+
):
163+
yield (
164+
value,
165+
validator.evolve(
166+
context=validator.context.evolve(
167+
path=validator.context.path.evolve(
168+
value_path=deque(
169+
[
170+
"Mappings",
171+
map_name,
172+
top_level_key,
173+
second_level_key,
174+
]
175+
)
176+
)
177+
)
178+
),
179+
None,
180+
)
181+
182+
if not found_valid_combination:
183+
yield from iter(results)
116184

117185

118186
def get_azs(validator: Validator, instance: Any) -> ResolutionResult:

src/cfnlint/rules/functions/_BaseFn.py

+14-8
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ def validator(self, validator: Validator) -> Validator:
5656
),
5757
)
5858

59+
def _clean_resolve_errors(
60+
self, err: ValidationError, value: Any, instance: Any
61+
) -> ValidationError:
62+
err.message = err.message.replace(f"{value!r}", f"{instance!r}")
63+
err.message = f"{err.message} when {self.fn.name!r} is resolved"
64+
if self.child_rules[self.resolved_rule]:
65+
err.rule = self.child_rules[self.resolved_rule]
66+
for i, err_ctx in enumerate(err.context):
67+
err.context[i] = self._clean_resolve_errors(err_ctx, value, instance)
68+
return err
69+
5970
def resolve(
6071
self,
6172
validator: Validator,
@@ -92,14 +103,9 @@ def resolve(
92103
return
93104

94105
for err in errs:
95-
err.message = err.message.replace(f"{value!r}", f"{instance!r}")
96-
err.message = f"{err.message} when {self.fn.name!r} is resolved"
97-
all_errs.append(err)
98-
99-
for err in all_errs:
100-
if self.child_rules[self.resolved_rule]:
101-
err.rule = self.child_rules[self.resolved_rule]
102-
yield err
106+
all_errs.append(self._clean_resolve_errors(err, value, instance))
107+
108+
yield from iter(all_errs)
103109

104110
def _resolve_ref(self, validator, schema) -> Any:
105111

test/unit/module/context/test_mappings.py

+13
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ def test_transforms():
8282
},
8383
Mappings({}, True),
8484
),
85+
(
86+
"Invalid mappings with wrong types",
87+
{
88+
"A": {True: {"C": "foo"}},
89+
"1": {"2": {False: "foo"}},
90+
},
91+
Mappings(
92+
{
93+
"A": Map({}, False),
94+
"1": Map({"2": _MappingSecondaryKey({}, False)}),
95+
}
96+
),
97+
),
8598
],
8699
)
87100
def test_mapping_creation(name, mappings, expected):

test/unit/module/jsonschema/test_resolvers_cfn.py

+21-8
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ def _resolve(name, instance, expected_results, **kwargs):
1818

1919
resolutions = list(validator.resolve_value(instance))
2020

21+
assert len(resolutions) == len(
22+
expected_results
23+
), f"{name!r} got {len(resolutions)!r}"
24+
2125
for i, (instance, v, errors) in enumerate(resolutions):
22-
assert instance == expected_results[i][0]
23-
assert v.context.path.value_path == expected_results[i][1]
24-
assert errors == expected_results[i][2]
26+
assert instance == expected_results[i][0], f"{name!r} got {instance!r}"
27+
assert (
28+
v.context.path.value_path == expected_results[i][1]
29+
), f"{name!r} got {v.context.path.value_path!r}"
30+
assert errors == expected_results[i][2], f"{name!r} got {errors!r}"
2531

2632

2733
@pytest.mark.parametrize(
@@ -153,7 +159,7 @@ def test_resolvers_ref(name, instance, response):
153159
),
154160
(
155161
"Invalid FindInMap with an invalid type for third element",
156-
{"Fn::FindInMap": ["foo", "bar", ["value"]]},
162+
{"Fn::FindInMap": ["foo", "first", ["value"]]},
157163
[],
158164
),
159165
(
@@ -218,16 +224,16 @@ def test_invalid_functions(name, instance, response):
218224
],
219225
),
220226
(
221-
"Valid FindInMap with a default value",
227+
"Valid FindInMap with bad keys and a default value",
222228
{"Fn::FindInMap": ["foo", "bar", "value", {"DefaultValue": "default"}]},
223229
[("default", deque([4, "DefaultValue"]), None)],
224230
),
225231
(
226-
"Valid FindInMap with a default value",
232+
"Valid FindInMap with valid keys and a default value",
227233
{"Fn::FindInMap": ["foo", "first", "second", {"DefaultValue": "default"}]},
228234
[
229235
("default", deque([4, "DefaultValue"]), None),
230-
("bar", deque([2]), None),
236+
("bar", deque(["Mappings", "foo", "first", "second"]), None),
231237
],
232238
),
233239
(
@@ -240,7 +246,8 @@ def test_invalid_functions(name, instance, response):
240246
ValidationError(
241247
(
242248
"'bar' is not one of ['foo', "
243-
"'transformFirstKey', 'transformSecondKey']"
249+
"'transformFirstKey', 'transformSecondKey', "
250+
"'integers']"
244251
),
245252
path=deque(["Fn::FindInMap", 0]),
246253
),
@@ -300,6 +307,11 @@ def test_invalid_functions(name, instance, response):
300307
)
301308
],
302309
),
310+
(
311+
"Valid FindInMap with integer types",
312+
{"Fn::FindInMap": ["integers", 1, 2]},
313+
[("Value", deque(["Mappings", "integers", "1", "2"]), None)],
314+
),
303315
(
304316
"Valid FindInMap with a bad second key and default",
305317
{"Fn::FindInMap": ["foo", "first", "third", {"DefaultValue": "default"}]},
@@ -334,6 +346,7 @@ def test_valid_functions(name, instance, response):
334346
"foo": {"first": {"second": "bar"}},
335347
"transformFirstKey": {"Fn::Transform": {"second": "bar"}},
336348
"transformSecondKey": {"first": {"Fn::Transform": "bar"}},
349+
"integers": {"1": {"2": "Value"}},
337350
}
338351
)
339352
)

test/unit/rules/functions/test_basefn.py

+30
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,36 @@ def rule():
5656
)
5757
],
5858
),
59+
(
60+
"Errors with context error",
61+
{"Fn::Sub": "Bar"},
62+
{"anyOf": [{"enum": ["Foo"]}]},
63+
[
64+
ValidationError(
65+
message=(
66+
"{'Fn::Sub': 'Bar'} is not valid "
67+
"under any of the given schemas "
68+
"when '' is resolved"
69+
),
70+
path=deque(["Fn::Sub"]),
71+
validator="",
72+
schema_path=deque(["anyOf"]),
73+
rule=_ChildRule(),
74+
context=[
75+
ValidationError(
76+
message=(
77+
"{'Fn::Sub': 'Bar'} is not one of "
78+
"['Foo'] when '' is resolved"
79+
),
80+
path=deque([]),
81+
validator="enum",
82+
schema_path=deque([0, "enum"]),
83+
rule=_ChildRule(),
84+
)
85+
],
86+
)
87+
],
88+
),
5989
],
6090
)
6191
def test_resolve(name, instance, schema, expected, validator, rule):

0 commit comments

Comments
 (0)