aws2-4 Exercise 2: A Template Resolver Tested Against the Documentation, Two Environments, and a YAML/JSON Round Trip ===================================================================================================================== Run with Python 3.14.0 on Windows 11 in a scratch venv: cfn-lint 1.57.2, cfn-flip 1.3.0, PyYAML 6.0.3. Setup: py -m venv .venv ; .venv/Scripts/python.exe -m pip install cfn-lint cfn-flip pyyaml. VERIFICATION LEVEL: 1 (run here; no AWS account, no AWS CLI, no stack created). cfn-resolve, the change set approximation, the failure simulator, the capability and quota checkers are MODELS of CloudFormation documentation pages read on 2026-10-12 and 2026-10-13 (through a page-fetch tool that summarises pages): they are not AWS, and AWS may change its rules and limits. The three cases for Fn::Join, Fn::Split with Fn::Select and the ${!Literal} escape in Fn::Sub come from my own knowledge of those functions, not from pages I read. --- cfn_resolve.py --- """cfn_resolve.py - a small MODEL of how CloudFormation turns a template plus parameter values into resources. NOT AWS. What is modelled, each from a sentence or example on the CloudFormation pages read 2026-10-12 (Parameters, Condition functions, Template sections): * parameter types: Number is validated as a number but "becomes a string" under Ref; List and CommaDelimitedList become lists of strings; CommaDelimitedList members are space-trimmed * constraints: AllowedValues, AllowedPattern (must match the ENTIRE value), MinLength/MaxLength, MinValue/MaxValue * a parameter with no Default and no value fails with: Parameters: [Name] must have values * Ref, Fn::Sub (both forms, ${!Literal}), Fn::Join, Fn::Select, Fn::Split, Fn::FindInMap, Fn::If, Fn::Base64, Fn::GetAtt (left as a placeholder), Fn::ImportValue (from a dict) * conditions: Fn::Equals, Fn::And and Fn::Or (2 to 10 conditions), Fn::Not, Condition; Fn::Equals and the other condition functions accept only Ref, Fn::FindInMap and other condition functions * AWS::NoValue removes the property; a resource or output with a false Condition is not created NOT modelled (raises NotModelled): Fn::GetAZs, Fn::Cidr, Fn::Transform, Fn::ForEach, Fn::Length, dynamic references, Rules, Transform, nested stacks. Values that only AWS knows (a resource's physical ID, its attributes) are left as placeholders such as and . """ import base64, re from cfn_flip import load as cfn_load class CfnError(Exception): pass class NotModelled(Exception): pass class Unresolved(str): """a value only AWS knows""" NOVALUE = object() PSEUDO_DEFAULTS = {"AWS::Region": "eu-west-1", "AWS::AccountId": "111122223333", "AWS::StackName": "demo-stack", "AWS::Partition": "aws", "AWS::URLSuffix": "amazonaws.com", "AWS::StackId": "arn:aws:cloudformation:eu-west-1:111122223333:stack/demo-stack/00000000-0000-0000-0000-000000000000"} def parse_template(text): data, _ = cfn_load(text) return data class Template: def __init__(self, data, params=None, pseudo=None, exports=None): self.t, self.exports = data, exports or {} self.pseudo = dict(PSEUDO_DEFAULTS, **(pseudo or {})) self.params = self._params(params or {}) self.conditions = {} for name in self.t.get("Conditions", {}): self._cond(name) # ---------------------------------------------------------------- parameters def _params(self, given): out, missing = {}, [] for name, p in self.t.get("Parameters", {}).items(): if name in given: raw = given[name] elif "Default" in p: raw = p["Default"] else: missing.append(name) continue out[name] = self._coerce(name, p, raw) if missing: raise CfnError("Parameters: [%s] must have values" % ", ".join(missing)) return out def _coerce(self, name, p, raw): typ = p.get("Type", "String") def fail(msg=None): raise CfnError(f"Malformed input-Parameter {name} " + (msg or f"must match pattern {p['AllowedPattern']}" if 'AllowedPattern' in p else msg or "is not valid")) if typ in ("Number", "List"): items = [str(x).strip() for x in str(raw).split(",")] if typ == "List" else [str(raw)] for x in items: try: float(x) except ValueError: raise CfnError(f"Parameter '{name}' must be a number: {x!r}") vals = items for x in items: if "MinValue" in p and float(x) < float(p["MinValue"]) or "MaxValue" in p and float(x) > float(p["MaxValue"]): raise CfnError(f"Parameter {name}: {x} is outside MinValue/MaxValue") if "AllowedValues" in p and not all(x in [str(v) for v in p["AllowedValues"]] for x in items): raise CfnError(f"Parameter {name}: value not in AllowedValues") return vals if typ == "List" else vals[0] if typ == "CommaDelimitedList": items = [x.strip() for x in str(raw).split(",")] for x in items: self._string_constraints(name, p, x) return items s = str(raw) self._string_constraints(name, p, s) return s def _string_constraints(self, name, p, s): if "AllowedValues" in p and s not in [str(v) for v in p["AllowedValues"]]: raise CfnError(f"Parameter {name}: {s!r} is not one of AllowedValues {p['AllowedValues']}") if "AllowedPattern" in p and not re.fullmatch(p["AllowedPattern"], s): # the pattern must match the ENTIRE value raise CfnError(f"Malformed input-Parameter {name} must match pattern {p['AllowedPattern']}" if "ConstraintDescription" not in p else f"Malformed input-Parameter {name} {p['ConstraintDescription']}") if "MinLength" in p and len(s) < int(p["MinLength"]) or "MaxLength" in p and len(s) > int(p["MaxLength"]): raise CfnError(f"Parameter {name}: length {len(s)} is outside MinLength/MaxLength") # ---------------------------------------------------------------- conditions def _cond(self, name): if name not in self.conditions: self.conditions[name] = self._cexpr(self.t["Conditions"][name]) return self.conditions[name] def _cexpr(self, e): if not (isinstance(e, dict) and len(e) == 1): raise CfnError("a condition must be a single function") (k, v), = e.items() if k == "Condition": return self._cond(v) if k == "Fn::Equals": return self._cval(v[0]) == self._cval(v[1]) if k in ("Fn::And", "Fn::Or"): if not 2 <= len(v) <= 10: raise CfnError(f"{k} takes 2 to 10 conditions, got {len(v)}") r = [self._cexpr(x) for x in v] return all(r) if k == "Fn::And" else any(r) if k == "Fn::Not": return not self._cexpr(v[0]) raise CfnError(f"{k} is not a condition function") def _cval(self, v): """values allowed inside Fn::Equals: literals, Ref, Fn::FindInMap (documented)""" if isinstance(v, dict): (k, a), = v.items() if k not in ("Ref", "Fn::FindInMap"): raise CfnError(f"{k} is not allowed inside a condition function (only Ref, Fn::FindInMap and other condition functions)") return self.resolve(v) # ---------------------------------------------------------------- resolution def _ref(self, name): if name in self.pseudo: return self.pseudo[name] if name == "AWS::NoValue": return NOVALUE if name in self.params: return self.params[name] if name in self.t.get("Resources", {}): return Unresolved(f"") raise CfnError(f"Ref to {name!r}: no such parameter, pseudo parameter or resource") def _sub(self, s, extra): def rep(m): name = m.group(1) if name.startswith("!"): return "${" + name[1:] + "}" if name in extra: return str(extra[name]) if "." in name and name.split(".")[0] in self.t.get("Resources", {}): return f"" v = self._ref(name) if isinstance(v, list): raise CfnError(f"Sub variable {name} is a list") return str(v) return re.sub(r"\$\{([^}]+)\}", rep, s) def resolve(self, n): if isinstance(n, dict): if len(n) == 1: (k, v), = n.items() if k == "Ref": return self._ref(v) if k == "Condition": return self._cond(v) if k == "Fn::Sub": if isinstance(v, str): return self._sub(v, {}) return self._sub(v[0], {a: self.resolve(b) for a, b in v[1].items()}) if k == "Fn::Join": delim, items = v[0], self.resolve(v[1]) return delim.join(str(x) for x in items) if k == "Fn::Select": idx, items = int(self.resolve(v[0])), self.resolve(v[1]) if isinstance(items, Unresolved): raise NotModelled("Select from a value only AWS knows") return items[idx] if k == "Fn::Split": return self.resolve(v[1]).split(v[0]) if k == "Fn::FindInMap": m, k1, k2 = (self.resolve(x) for x in v) try: return self.t["Mappings"][m][k1][k2] except KeyError: raise CfnError(f"mapping {m} has no entry [{k1}][{k2}]") if k == "Fn::If": return self.resolve(v[1] if self._cond(v[0]) else v[2]) if k in ("Fn::Equals", "Fn::And", "Fn::Or", "Fn::Not"): return self._cexpr(n) if k == "Fn::Base64": return base64.b64encode(str(self.resolve(v)).encode()).decode() if k == "Fn::GetAtt": res, attr = (v if isinstance(v, list) else v.split(".", 1)) return Unresolved(f"") if k == "Fn::ImportValue": name = self.resolve(v) if name not in self.exports: raise CfnError(f"no export named {name!r}") return self.exports[name] if k in ("Fn::GetAZs", "Fn::Cidr", "Fn::Transform", "Fn::ForEach", "Fn::Length", "Fn::ToJsonString"): raise NotModelled(k) out = {} for a, b in n.items(): r = self.resolve(b) if r is not NOVALUE: out[a] = r return out if isinstance(n, list): return [x for x in (self.resolve(i) for i in n) if x is not NOVALUE] return n def render(self): res = {} for name, r in self.t.get("Resources", {}).items(): if "Condition" in r and not self._cond(r["Condition"]): continue res[name] = {"Type": r["Type"], "Properties": self.resolve(r.get("Properties", {}))} outs = {} for name, o in self.t.get("Outputs", {}).items(): if "Condition" in o and not self._cond(o["Condition"]): continue outs[name] = self.resolve(o["Value"]) return {"Resources": res, "Outputs": outs} --- t_resolve.py --- """t_resolve.py - run the documented examples through cfn_resolve.py. 'docs say' values are sentences or examples from the CloudFormation pages (named in the label), written down before the model ran. A MISMATCH would mean the model disagrees with the documentation.""" import pathlib, sys, yaml from cfn_resolve import Template, CfnError, NotModelled, parse_template, Unresolved B = pathlib.Path(r"C:/lwaws/c4") rows = [] def check(label, expected, fn): try: got = fn() except (CfnError, NotModelled) as e: got = f"{type(e).__name__}: {e}" rows.append((label, expected, got)) def _try(fn): try: fn() return False except CfnError: return True def tpl(text, params=None, **kw): return Template(parse_template(text), params, **kw) # ---- parameter types (Parameters page) P = "Parameters:\n N: {Type: Number}\n L: {Type: 'List'}\n C: {Type: CommaDelimitedList}\nResources: {}\n" check("Number 8888, then Ref: 'becomes a string'", "8888", lambda: tpl(P, {"N": "8888", "L": "1", "C": "a"}).resolve({"Ref": "N"})) check("List '80,20': a Ref 'would result in [\"80\",\"20\"]'", ["80", "20"], lambda: tpl(P, {"N": "1", "L": "80,20", "C": "a"}).resolve({"Ref": "L"})) check("CommaDelimitedList 'test,dev,prod'", ["test", "dev", "prod"], lambda: tpl(P, {"N": "1", "L": "1", "C": "test,dev,prod"}).resolve({"Ref": "C"})) check("CommaDelimitedList members are space-trimmed", ["a", "b", "c"], lambda: tpl(P, {"N": "1", "L": "1", "C": " a , b ,c"}).resolve({"Ref": "C"})) check("Number parameter given 'abc'", "CfnError: Parameter 'N' must be a number: 'abc'", lambda: tpl(P, {"N": "abc", "L": "1", "C": "a"})) # ---- constraints PAT = "Parameters:\n MyParameter: {Type: String, AllowedPattern: '[A-Za-z0-9]+'}\nResources: {}\n" check("AllowedPattern [A-Za-z0-9]+ and the value abc123", "abc123", lambda: tpl(PAT, {"MyParameter": "abc123"}).resolve({"Ref": "MyParameter"})) check("the page's message for a failed AllowedPattern", "CfnError: Malformed input-Parameter MyParameter must match pattern [A-Za-z0-9]+", lambda: tpl(PAT, {"MyParameter": "abc-123"})) PAT2 = "Parameters:\n P: {Type: String, AllowedPattern: 'abc123'}\nResources: {}\n" check("the pattern must match the ENTIRE value: 'xxabc123yy' against 'abc123'", "CfnError: Malformed input-Parameter P must match pattern abc123", lambda: tpl(PAT2, {"P": "xxabc123yy"})) check("a parameter with no Default and no value", "CfnError: Parameters: [KeyName] must have values", lambda: tpl("Parameters:\n KeyName: {Type: String}\nResources: {}\n")) # ---- the Parameters page's subnet example (Select and Sub) subnets = (B / "doc" / "subnets_example.yaml").read_text(encoding="utf-8") def az(i, region="us-west-2"): r = tpl(subnets, pseudo={"AWS::Region": region}).render() return r["Resources"][f"DbSubnet{i}"]["Properties"]["AvailabilityZone"] check("DbSubnet1 AvailabilityZone: Select 0 of the AZ list", "us-west-2a", lambda: az(1)) check("DbSubnet1 CidrBlock: Select 0 of the CIDR list", "172.16.0.0/26", lambda: tpl(subnets).render()["Resources"]["DbSubnet1"]["Properties"]["CidrBlock"]) check("DbSubnet3 CidrBlock: Select 2", "172.16.0.128/26", lambda: tpl(subnets).render()["Resources"]["DbSubnet3"]["Properties"]["CidrBlock"]) check("FINDING (not counted): DbSubnet2 AvailabilityZone, ${AWS::Region}${AZ} with the page's own default AZ 'us-west-2b'; the evident intent is 'us-west-2b'", "us-west-2b", lambda: az(2)) # ---- the Condition functions page's sample (EnvType) cs = (B / "doc" / "conditions_sample.yaml").read_text(encoding="utf-8") def inst(env): return tpl(cs, {"EnvType": env}).render() for env, itype, has_vol in (("prod", "c5.xlarge", True), ("dev", "t3.medium", False), ("test", "t3.small", False)): check(f"EnvType {env}: InstanceType (nested Fn::If)", itype, lambda env=env: inst(env)["Resources"]["EC2Instance"]["Properties"]["InstanceType"]) check(f"EnvType {env}: MountPoint and NewVolume are created only for prod", has_vol, lambda env=env: ("NewVolume" in inst(env)["Resources"]) and ("MountPoint" in inst(env)["Resources"])) check("EnvType 'qa' is not among AllowedValues", "CfnError", lambda: "CfnError" if _try(lambda: tpl(cs, {"EnvType": "qa"})) else "accepted") # ---- AWS::NoValue (Condition functions page, the RDS snippet: 'CloudFormation removes the DBSnapshotIdentifier property'); the Conditions block is my reconstruction rds = ("Parameters:\n DBSnapshotName: {Type: String, Default: ''}\nConditions:\n UseDBSnapshot: !Not [!Equals [!Ref DBSnapshotName, '']]\n" "Resources:\n MyDatabase:\n Type: AWS::RDS::DBInstance\n Properties:\n Engine: MySQL\n" " DBSnapshotIdentifier: !If [UseDBSnapshot, !Ref DBSnapshotName, !Ref 'AWS::NoValue']\n") check("NoValue: no snapshot name -> the property is removed", False, lambda: "DBSnapshotIdentifier" in tpl(rds).render()["Resources"]["MyDatabase"]["Properties"]) check("NoValue: snapshot name 'snap-1' -> the property is present", "snap-1", lambda: tpl(rds, {"DBSnapshotName": "snap-1"}).render()["Resources"]["MyDatabase"]["Properties"]["DBSnapshotIdentifier"]) # ---- condition function rules (Condition functions page) def cond(expr, **p): return tpl("Parameters:\n E: {Type: String, Default: prod}\nConditions:\n X: " + expr + "\nResources: {}\n", p).conditions["X"] check("Fn::Not of Fn::Equals (the page's EnvCondition shape)", False, lambda: cond("!Not [!Equals [!Ref E, prod]]")) check("Fn::And with ONE condition (the minimum is 2)", "CfnError: Fn::And takes 2 to 10 conditions, got 1", lambda: cond("!And [!Equals [a, a]]")) check("Fn::Or with 11 conditions (the maximum is 10)", "CfnError: Fn::Or takes 2 to 10 conditions, got 11", lambda: cond("!Or [" + ",".join(["!Equals [a, b]"] * 11) + "]")) check("Fn::Sub inside Fn::Equals (only Ref, Fn::FindInMap and condition functions are allowed)", "CfnError", lambda: ("CfnError" if _try(lambda: cond('!Equals [!Sub "${E}", prod]')) else "accepted")) check("a condition that names another condition", True, lambda: tpl("Parameters:\n E: {Type: String, Default: prod}\nConditions:\n A: !Equals [!Ref E, prod]\n X: !Or [!Condition A, !Equals [a, b]]\nResources: {}\n").conditions["X"]) # ---- the YAML trap the Parameters page warns about ('Yes' and 'No' in AllowedValues need single quotes) check("YAML: AllowedValues [Yes, No] without quotes is read by PyYAML as booleans", [True, False], lambda: yaml.safe_load("[Yes, No]")) check("YAML: with single quotes the strings survive", ["Yes", "No"], lambda: yaml.safe_load("['Yes', 'No']")) check("cfn-flip's loader on AllowedValues [Yes, No] (what a CloudFormation tool sees)", [True, False], lambda: parse_template("Parameters:\n A:\n Type: String\n AllowedValues: [Yes, No]\nResources: {}\n")["Parameters"]["A"]["AllowedValues"]) # ---- documented functions with simple semantics check("Fn::Join ['-', [a, b, c]]", "a-b-c", lambda: tpl("Resources: {}\n").resolve({"Fn::Join": ["-", ["a", "b", "c"]]})) check("Fn::Split [',', 'x,y,z'] then Select 1", "y", lambda: tpl("Resources: {}\n").resolve({"Fn::Select": [1, {"Fn::Split": [",", "x,y,z"]}]})) check("Sub with an escaped literal ${!Literal}", "a ${Literal} b", lambda: tpl("Resources: {}\n").resolve({"Fn::Sub": "a ${!Literal} b"})) check("Fn::GetAZs is not modelled (it needs the account's Regions)", "NotModelled: Fn::GetAZs", lambda: tpl("Resources: {}\n").resolve({"Fn::GetAZs": ""})) if __name__ == "__main__": flip = "--flip" in sys.argv bad = 0 w = max(len(r[0]) for r in rows) print(f"{'result':<10}case") for i, (label, exp, got) in enumerate(rows): if flip and i == 0: exp = "0000" ok = got == exp finding = label.startswith("FINDING") bad += (not ok) and not finding print(f"{'match' if ok else ('FINDING' if finding else 'MISMATCH'):<10}{label}" + ("" if ok else f" [expected={exp!r} model={got!r}]")) counted = [r for r in rows if not r[0].startswith("FINDING")] print(f"\n{len(counted)} counted cases, mismatches: {bad}; {len(rows) - len(counted)} finding(s) shown but not counted") --- py -X utf8 t_resolve.py --- result case match Number 8888, then Ref: 'becomes a string' match List '80,20': a Ref 'would result in ["80","20"]' match CommaDelimitedList 'test,dev,prod' match CommaDelimitedList members are space-trimmed match Number parameter given 'abc' match AllowedPattern [A-Za-z0-9]+ and the value abc123 match the page's message for a failed AllowedPattern match the pattern must match the ENTIRE value: 'xxabc123yy' against 'abc123' match a parameter with no Default and no value match DbSubnet1 AvailabilityZone: Select 0 of the AZ list match DbSubnet1 CidrBlock: Select 0 of the CIDR list match DbSubnet3 CidrBlock: Select 2 FINDING FINDING (not counted): DbSubnet2 AvailabilityZone, ${AWS::Region}${AZ} with the page's own default AZ 'us-west-2b'; the evident intent is 'us-west-2b' [expected='us-west-2b' model='us-west-2us-west-2b'] match EnvType prod: InstanceType (nested Fn::If) match EnvType prod: MountPoint and NewVolume are created only for prod match EnvType dev: InstanceType (nested Fn::If) match EnvType dev: MountPoint and NewVolume are created only for prod match EnvType test: InstanceType (nested Fn::If) match EnvType test: MountPoint and NewVolume are created only for prod match EnvType 'qa' is not among AllowedValues match NoValue: no snapshot name -> the property is removed match NoValue: snapshot name 'snap-1' -> the property is present match Fn::Not of Fn::Equals (the page's EnvCondition shape) match Fn::And with ONE condition (the minimum is 2) match Fn::Or with 11 conditions (the maximum is 10) match Fn::Sub inside Fn::Equals (only Ref, Fn::FindInMap and condition functions are allowed) match a condition that names another condition match YAML: AllowedValues [Yes, No] without quotes is read by PyYAML as booleans match YAML: with single quotes the strings survive match cfn-flip's loader on AllowedValues [Yes, No] (what a CloudFormation tool sees) match Fn::Join ['-', [a, b, c]] match Fn::Split [',', 'x,y,z'] then Select 1 match Sub with an escaped literal ${!Literal} match Fn::GetAZs is not modelled (it needs the account's Regions) 33 counted cases, mismatches: 0; 1 finding(s) shown but not counted --- py -X utf8 t_resolve.py --flip (control: one expectation flipped) --- result case MISMATCH Number 8888, then Ref: 'becomes a string' [expected='0000' model='8888'] match List '80,20': a Ref 'would result in ["80","20"]' match CommaDelimitedList 'test,dev,prod' match CommaDelimitedList members are space-trimmed match Number parameter given 'abc' match AllowedPattern [A-Za-z0-9]+ and the value abc123 match the page's message for a failed AllowedPattern match the pattern must match the ENTIRE value: 'xxabc123yy' against 'abc123' match a parameter with no Default and no value match DbSubnet1 AvailabilityZone: Select 0 of the AZ list match DbSubnet1 CidrBlock: Select 0 of the CIDR list match DbSubnet3 CidrBlock: Select 2 FINDING FINDING (not counted): DbSubnet2 AvailabilityZone, ${AWS::Region}${AZ} with the page's own default AZ 'us-west-2b'; the evident intent is 'us-west-2b' [expected='us-west-2b' model='us-west-2us-west-2b'] match EnvType prod: InstanceType (nested Fn::If) match EnvType prod: MountPoint and NewVolume are created only for prod match EnvType dev: InstanceType (nested Fn::If) match EnvType dev: MountPoint and NewVolume are created only for prod match EnvType test: InstanceType (nested Fn::If) match EnvType test: MountPoint and NewVolume are created only for prod match EnvType 'qa' is not among AllowedValues match NoValue: no snapshot name -> the property is removed match NoValue: snapshot name 'snap-1' -> the property is present match Fn::Not of Fn::Equals (the page's EnvCondition shape) match Fn::And with ONE condition (the minimum is 2) match Fn::Or with 11 conditions (the maximum is 10) match Fn::Sub inside Fn::Equals (only Ref, Fn::FindInMap and condition functions are allowed) match a condition that names another condition match YAML: AllowedValues [Yes, No] without quotes is read by PyYAML as booleans match YAML: with single quotes the strings survive match cfn-flip's loader on AllowedValues [Yes, No] (what a CloudFormation tool sees) match Fn::Join ['-', [a, b, c]] match Fn::Split [',', 'x,y,z'] then Select 1 match Sub with an escaped literal ${!Literal} match Fn::GetAZs is not modelled (it needs the account's Regions) 33 counted cases, mismatches: 1; 1 finding(s) shown but not counted --- render_app.py --- """render_app.py - what app_stack.yaml becomes for two parameter sets, according to cfn_resolve.py (a model: values only AWS knows stay as / ).""" import json, pathlib from cfn_resolve import Template, parse_template text = pathlib.Path(r"C:/lwaws/c4/app_stack.yaml").read_text(encoding="utf-8") base = {"KeyAdminRoleArn": "arn:aws:iam::111122223333:role/KeyAdmin"} for env in ("dev", "prod"): out = Template(parse_template(text), dict(base, Environment=env), pseudo={"AWS::StackName": "reports"}).render() print(f"== Environment={env}: {len(out['Resources'])} resources: {', '.join(sorted(out['Resources']))}") print(" WorkQueue :", json.dumps(out["Resources"]["WorkQueue"]["Properties"])) print(" DataKey desc.:", out["Resources"]["DataKey"]["Properties"]["Description"]) print(" DataKeyAlias :", out["Resources"]["DataKeyAlias"]["Properties"]) print(" Outputs :", json.dumps(out["Outputs"])) for label, params in (("RetentionDays 0 (MinValue is 1)", {"RetentionDays": "0"}), ("Environment qa", {"Environment": "qa"}), ("KeyAdminRoleArn that is not a role ARN", {"KeyAdminRoleArn": "arn:aws:iam::111122223333:user/bob"}), ("no KeyAdminRoleArn (no Default)", None)): try: p = dict(base, **params) if params is not None else {} Template(parse_template(text), p) print(f" {label}: accepted") except Exception as e: print(f" {label}: {type(e).__name__}: {e}") --- py -X utf8 render_app.py --- == Environment=dev: 7 resources: DataKey, DataKeyAlias, ReportBucket, ReportBucketPolicy, WorkDeadLetterQueue, WorkQueue, WorkerRole WorkQueue : {"VisibilityTimeout": 30, "KmsMasterKeyId": "", "RedrivePolicy": {"deadLetterTargetArn": "", "maxReceiveCount": 3}} DataKey desc.: Data key for reports (dev) DataKeyAlias : {'AliasName': 'alias/reports-data', 'TargetKeyId': ''} Outputs : {"BucketName": "", "QueueUrl": "", "KeyArn": ""} == Environment=prod: 8 resources: BacklogAlarm, DataKey, DataKeyAlias, ReportBucket, ReportBucketPolicy, WorkDeadLetterQueue, WorkQueue, WorkerRole WorkQueue : {"VisibilityTimeout": 120, "KmsMasterKeyId": "", "RedrivePolicy": {"deadLetterTargetArn": "", "maxReceiveCount": 5}} DataKey desc.: Data key for reports (prod) DataKeyAlias : {'AliasName': 'alias/reports-data', 'TargetKeyId': ''} Outputs : {"BucketName": "", "QueueUrl": "", "KeyArn": "", "AlarmName": ""} RetentionDays 0 (MinValue is 1): CfnError: Parameter RetentionDays: 0 is outside MinValue/MaxValue Environment qa: CfnError: Parameter Environment: 'qa' is not one of AllowedValues ['dev', 'staging', 'prod'] KeyAdminRoleArn that is not a role ARN: CfnError: Malformed input-Parameter KeyAdminRoleArn Must be the ARN of an IAM role no KeyAdminRoleArn (no Default): CfnError: Parameters: [KeyAdminRoleArn] must have values --- flip_roundtrip.py --- """flip_roundtrip.py - YAML <-> JSON with cfn-flip (version printed): does the template survive, and does cfn-lint agree with itself on both forms?""" import importlib.metadata as md, json, pathlib, subprocess from cfn_flip import to_json, to_yaml, load B = pathlib.Path(r"C:/lwaws/c4") LINT = str(B / ".venv" / "Scripts" / "cfn-lint.exe") text = (B / "app_stack.yaml").read_text(encoding="utf-8") print("cfn-flip", md.version("cfn-flip")) as_json = to_json(text) back = to_yaml(as_json) d0, d1, d2 = load(text)[0], json.loads(as_json), load(back)[0] print(f"parsed data equal: yaml == json: {json.loads(json.dumps(d0)) == d1}; yaml -> json -> yaml == original: {json.loads(json.dumps(d0)) == json.loads(json.dumps(d2))}") print(f"size: YAML {len(text.encode()):,} bytes, JSON {len(as_json.encode()):,} bytes ({100 * len(as_json.encode()) / len(text.encode()):.0f}% of the YAML)") print("short forms in the YAML:", sum(text.count(x) for x in ("!Ref", "!GetAtt", "!Sub", "!FindInMap", "!Equals", "!If")), "| the same functions in the JSON:", sum(as_json.count(x) for x in ('"Ref"', '"Fn::GetAtt"', '"Fn::Sub"', '"Fn::FindInMap"', '"Fn::Equals"', '"Fn::If"'))) (B / "app_stack.json").write_text(as_json, encoding="utf-8") (B / "app_stack_roundtrip.yaml").write_text(back, encoding="utf-8") for name in ("app_stack.yaml", "app_stack.json", "app_stack_roundtrip.yaml"): r = subprocess.run([LINT, name], cwd=B, capture_output=True, text=True) print(f"cfn-lint {name:<28} exit {r.returncode} {(r.stdout.strip()[:100] or '(no findings)')}") bad = text.replace("EnableKeyRotation: true", "EnableKeyRotations: true") (B / "bad.yaml").write_text(bad, encoding="utf-8") (B / "bad.json").write_text(to_json(bad), encoding="utf-8") for name in ("bad.yaml", "bad.json"): r = subprocess.run([LINT, name], cwd=B, capture_output=True, text=True) first = r.stdout.strip().splitlines() print(f"cfn-lint {name:<28} exit {r.returncode} {first[0][:90] if first else ''} | location {first[1] if len(first) > 1 else ''}") --- py -X utf8 flip_roundtrip.py --- cfn-flip 1.3.0 parsed data equal: yaml == json: True; yaml -> json -> yaml == original: True size: YAML 6,187 bytes, JSON 14,023 bytes (227% of the YAML) short forms in the YAML: 29 | the same functions in the JSON: 29 cfn-lint app_stack.yaml exit 0 (no findings) cfn-lint app_stack.json exit 0 (no findings) cfn-lint app_stack_roundtrip.yaml exit 0 (no findings) cfn-lint bad.yaml exit 2 E3002 Additional properties are not allowed ('EnableKeyRotations' was unexpected. Did you | location bad.yaml:48:7 cfn-lint bad.json exit 2 E3002 Additional properties are not allowed ('EnableKeyRotations' was unexpected. Did you | location bad.json:87:17 WHY THIS WORKS AS AN ANSWER --------------------------- Each expected value comes from a sentence or example on the Parameters or Condition functions pages (parameter types and the 'becomes a string' rule, constraints and the whole-value AllowedPattern rule, the exact failure messages, the EnvType and NoValue examples, the 2 to 10 limit on And/Or, the allowed functions inside Equals), written before the model ran. 33 counted cases match; the control reports one mismatch. The subnet example is reported as a FINDING and not counted: with the page's own default AZ 'us-west-2b', ${AWS::Region}${AZ} resolves to 'us-west-2us-west-2b', so the example only works if the list held bare letters. cfn-lint did not notice. The YAML Yes/No trap is shown with PyYAML and with cfn-flip's loader. The resolver refuses Fn::GetAZs, Fn::Cidr, Fn::Transform, Fn::ForEach, Fn::Length and Fn::ToJsonString, and leaves values only AWS knows (a resource's ID or attribute) as placeholders. The round trip shows the parsed data identical in YAML and JSON, the JSON about 2.3 times larger, and cfn-lint giving the same finding at different lines. Not run: dynamic references, Rules, Transform, nested stacks, macros, any real stack.