Skip to content

Commit 374bca8

Browse files
authored
Add getatt support for registry schemas (#3061)
* Add getatt support for registry schemas
1 parent 3398334 commit 374bca8

File tree

4 files changed

+296
-1
lines changed

4 files changed

+296
-1
lines changed

src/cfnlint/template/template.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import logging
99
from copy import deepcopy
10-
from typing import List, Union
10+
from typing import Any, Dict, List, Union
1111

1212
import regex as re
1313

@@ -20,6 +20,94 @@
2020
LOGGER = logging.getLogger(__name__)
2121

2222

23+
def resolve_pointer(obj, pointer) -> Dict:
24+
"""Find the elements at the end of a Cfn pointer
25+
26+
Args:
27+
obj (dict): the root schema used for searching for the pointer
28+
pointer (str): the pointer using / to separate levels
29+
Returns:
30+
Dict: returns the object from the pointer
31+
"""
32+
json_pointer = _SchemaPointer(obj, pointer)
33+
return json_pointer.resolve()
34+
35+
36+
class _SchemaPointer:
37+
def __init__(self, obj: dict, pointer: str) -> None:
38+
self.obj = obj
39+
self.parts = pointer.split("/")[1:]
40+
41+
def resolve(self) -> Dict:
42+
"""Find the elements at the end of a Cfn pointer
43+
44+
Args:
45+
Returns:
46+
Dict: returns the object from the pointer
47+
"""
48+
obj = self.obj
49+
for part in self.parts:
50+
try:
51+
obj = self.walk(obj, part)
52+
except KeyError as e:
53+
raise e
54+
55+
if "*" in self.parts:
56+
return {"type": "array", "items": obj}
57+
58+
return obj
59+
60+
# pylint: disable=too-many-return-statements
61+
def walk(self, obj: Dict, part: str) -> Any:
62+
"""Walks one step in doc and returns the referenced part
63+
64+
Args:
65+
obj (dict): the object to evaluate for the part
66+
part (str): the string representation of the part
67+
Returns:
68+
Dict: returns the object at the part
69+
"""
70+
assert hasattr(obj, "__getitem__"), f"invalid document type {type(obj)}"
71+
72+
try:
73+
# using a test for typeName as that is a root schema property
74+
if part == "properties" and obj.get("typeName"):
75+
return obj[part]
76+
if (
77+
obj.get("properties")
78+
and part != "definitions"
79+
and not obj.get("typeName")
80+
):
81+
return obj["properties"][part]
82+
# arrays have a * in the path
83+
if part == "*" and obj.get("type") == "array":
84+
return obj.get("items")
85+
return obj[part]
86+
87+
except KeyError as e:
88+
# CFN JSON pointers can go down $ref paths so lets do that if we can
89+
if obj.get("$ref"):
90+
try:
91+
return resolve_pointer(self.obj, f"{obj.get('$ref')}/{part}")
92+
except KeyError as ke:
93+
raise ke
94+
if obj.get("items", {}).get("$ref"):
95+
ref = obj.get("items", {}).get("$ref")
96+
try:
97+
return resolve_pointer(self.obj, f"{ref}/{part}")
98+
except KeyError as ke:
99+
raise ke
100+
if obj.get("oneOf"):
101+
for oneOf in obj.get("oneOf"): # type: ignore
102+
try:
103+
return self.walk(oneOf, part)
104+
except KeyError:
105+
pass
106+
107+
raise KeyError(f"No oneOf matches for {part}") from e
108+
raise e
109+
110+
23111
class Template: # pylint: disable=R0904,too-many-lines,too-many-instance-attributes
24112
"""Class for a CloudFormation template"""
25113

@@ -246,6 +334,7 @@ def get_valid_refs(self):
246334
results[pseudoparam] = element
247335
return results
248336

337+
# pylint: disable=too-many-locals
249338
def get_valid_getatts(self):
250339
resourcetypes = cfnlint.helpers.RESOURCE_SPECS["us-east-1"].get("ResourceTypes")
251340
propertytypes = cfnlint.helpers.RESOURCE_SPECS["us-east-1"].get("PropertyTypes")
@@ -314,6 +403,44 @@ def build_output_string(resource_type, property_name):
314403
element = {}
315404
element.update(attvalue)
316405
results[name][attname] = element
406+
for schema in cfnlint.helpers.REGISTRY_SCHEMAS:
407+
if value["Type"] == schema["typeName"]:
408+
results[name] = {}
409+
for ro_property in schema["readOnlyProperties"]:
410+
try:
411+
item = resolve_pointer(schema, ro_property)
412+
except KeyError:
413+
continue
414+
item_type = item["type"]
415+
_type = None
416+
primitive_type = None
417+
if item_type == "string":
418+
primitive_type = "String"
419+
elif item_type == "number":
420+
primitive_type = "Double"
421+
elif item_type == "integer":
422+
primitive_type = "Integer"
423+
elif item_type == "boolean":
424+
primitive_type = "Boolean"
425+
elif item_type == "array":
426+
_type = "List"
427+
primitive_type = "String"
428+
429+
ro_property = ro_property.replace(
430+
"/properties/", ""
431+
)
432+
results[name][".".join(ro_property.split("/"))] = {}
433+
if _type:
434+
results[name][".".join(ro_property.split("/"))][
435+
"Type"
436+
] = _type
437+
results[name][".".join(ro_property.split("/"))][
438+
"PrimitiveItemType"
439+
] = primitive_type
440+
elif primitive_type:
441+
results[name][".".join(ro_property.split("/"))][
442+
"PrimitiveType"
443+
] = primitive_type
317444

318445
return results
319446

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
{
2+
"typeName": "Initech::TPS::Report",
3+
"description": "An example resource schema demonstrating some basic constructs and validation rules.",
4+
"sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git",
5+
"definitions": {
6+
"InitechDateFormat": {
7+
"$comment": "Use the `definitions` block to provide shared resource property schemas",
8+
"type": "string",
9+
"format": "date-time"
10+
},
11+
"Memo": {
12+
"type": "object",
13+
"properties": {
14+
"Heading": {
15+
"type": "string"
16+
},
17+
"Body": {
18+
"type": "string"
19+
}
20+
},
21+
"additionalProperties": false
22+
},
23+
"Tag": {
24+
"description": "A key-value pair to associate with a resource.",
25+
"type": "object",
26+
"properties": {
27+
"Key": {
28+
"type": "string",
29+
"description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.",
30+
"minLength": 1,
31+
"maxLength": 128
32+
},
33+
"Value": {
34+
"type": "string",
35+
"description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.",
36+
"minLength": 0,
37+
"maxLength": 256
38+
}
39+
},
40+
"required": [
41+
"Key",
42+
"Value"
43+
],
44+
"additionalProperties": false
45+
}
46+
},
47+
"properties": {
48+
"TPSCode": {
49+
"description": "A TPS Code is automatically generated on creation and assigned as the unique identifier.",
50+
"type": "string",
51+
"pattern": "^[A-Z]{3,5}[0-9]{8}-[0-9]{4}$"
52+
},
53+
"Title": {
54+
"description": "The title of the TPS report is a mandatory element.",
55+
"type": "string",
56+
"minLength": 20,
57+
"maxLength": 250
58+
},
59+
"CoverSheetIncluded": {
60+
"description": "Required for all TPS Reports submitted after 2/19/1999",
61+
"type": "boolean"
62+
},
63+
"DueDate": {
64+
"$ref": "#/definitions/InitechDateFormat"
65+
},
66+
"ApprovalDate": {
67+
"$ref": "#/definitions/InitechDateFormat"
68+
},
69+
"Memo": {
70+
"$ref": "#/definitions/Memo"
71+
},
72+
"SecondCopyOfMemo": {
73+
"description": "In case you didn't get the first one.",
74+
"$ref": "#/definitions/Memo"
75+
},
76+
"TestCode": {
77+
"type": "string",
78+
"enum": [
79+
"NOT_STARTED",
80+
"CANCELLED"
81+
]
82+
},
83+
"Authors": {
84+
"type": "array",
85+
"items": {
86+
"type": "string"
87+
}
88+
},
89+
"Tags": {
90+
"description": "An array of key-value pairs to apply to this resource.",
91+
"type": "array",
92+
"uniqueItems": true,
93+
"insertionOrder": false,
94+
"items": {
95+
"$ref": "#/definitions/Tag"
96+
}
97+
}
98+
},
99+
"additionalProperties": false,
100+
"required": [
101+
"TestCode",
102+
"Title"
103+
],
104+
"readOnlyProperties": [
105+
"/properties/TPSCode",
106+
"/properties/Authors"
107+
],
108+
"primaryIdentifier": [
109+
"/properties/TPSCode"
110+
],
111+
"handlers": {
112+
"create": {
113+
"permissions": [
114+
"initech:CreateReport"
115+
]
116+
},
117+
"read": {
118+
"permissions": [
119+
"initech:DescribeReport"
120+
]
121+
},
122+
"update": {
123+
"permissions": [
124+
"initech:UpdateReport"
125+
]
126+
},
127+
"delete": {
128+
"permissions": [
129+
"initech:DeleteReport"
130+
]
131+
},
132+
"list": {
133+
"permissions": [
134+
"initech:ListReports"
135+
]
136+
}
137+
}
138+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Resources:
2+
MyReport:
3+
Type: Initech::TPS::Report
4+
Properties:
5+
TestCode: NOT_STARTED
6+
Title: My report has to be longer
7+
Outputs:
8+
TpsCode:
9+
Value: !GetAtt MyReport.TPSCode

test/unit/module/test_template.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import json
77
import os
88
from test.testlib.testcase import BaseTestCase
9+
from unittest.mock import patch
910

1011
from cfnlint.decode import cfn_yaml, convert_dict
12+
from cfnlint.helpers import REGISTRY_SCHEMAS
1113
from cfnlint.template import Template # pylint: disable=E0401
1214

1315

@@ -1233,3 +1235,22 @@ def test_get_directives(self):
12331235
"I1001": ["myBucket1"],
12341236
}
12351237
self.assertDictEqual(directives, expected_result)
1238+
1239+
def test_schemas(self):
1240+
"""Validate getAtt when using a registry schema"""
1241+
schema = self.load_template("test/fixtures/registry/custom/resource.json")
1242+
1243+
filename = "test/fixtures/templates/good/schema_resource.yaml"
1244+
template = self.load_template(filename)
1245+
self.template = Template(filename, template)
1246+
1247+
with patch("cfnlint.helpers.REGISTRY_SCHEMAS", [schema]):
1248+
self.assertDictEqual(
1249+
{
1250+
"MyReport": {
1251+
"TPSCode": {"PrimitiveType": "String"},
1252+
"Authors": {"PrimitiveItemType": "String", "Type": "List"},
1253+
}
1254+
},
1255+
self.template.get_valid_getatts(),
1256+
)

0 commit comments

Comments
 (0)