aws2-1 Exercise 3: Check an Account Layout and Measure Tag Coverage ==================================================================== Run with Python 3.14.0 and cfn-lint 1.57.2 on Windows 11. VERIFICATION LEVEL: 1 (run here, no AWS account, no AWS CLI). What the scripts check is MY reading of AWS documentation pages read on 2026-10-12 (through a page-fetch tool that summarises pages); it is not AWS's own behaviour, and AWS may change its limits and rules. The cost file is INVENTED (fixed seed): the dollar amounts are not AWS prices, only the method is real. Rules L4, L6 and L7 in the layout checker are my own conventions, not AWS rules. --- layout.json --- { "organization": "acme", "management_account": "acme-management", "ous": { "Security": {"scps": ["deny-leave-and-close", "deny-disable-logging"]}, "Infrastructure": {"scps": ["deny-leave-and-close"]}, "Workloads": { "scps": ["deny-leave-and-close", "region-lock-eu"], "children": { "Prod": {"scps": ["deny-delete-backups"]}, "NonProd": {"scps": []} } }, "Sandbox": {"scps": ["deny-leave-and-close", "region-lock-eu", "deny-expensive-services"]} }, "accounts": [ {"name": "acme-management", "ou": "(root)", "email": "aws+management@example.com", "workload": false, "tags": {"owner": "platform", "env": "mgmt", "cost-center": "cc-100"}}, {"name": "acme-log-archive", "ou": "Security", "email": "aws+log-archive@example.com", "workload": false, "tags": {"owner": "security", "env": "shared", "cost-center": "cc-100"}}, {"name": "acme-security-tooling", "ou": "Security", "email": "aws+security-tooling@example.com", "workload": false, "tags": {"owner": "security", "env": "shared", "cost-center": "cc-100"}}, {"name": "acme-shared-services", "ou": "Infrastructure", "email": "aws+shared-services@example.com", "workload": true, "tags": {"owner": "platform", "env": "shared", "cost-center": "cc-100"}}, {"name": "acme-shop-prod", "ou": "Workloads/Prod", "email": "aws+shop-prod@example.com", "workload": true, "tags": {"owner": "shop", "env": "prod", "cost-center": "cc-210"}}, {"name": "acme-shop-staging", "ou": "Workloads/NonProd", "email": "aws+shop-staging@example.com", "workload": true, "tags": {"owner": "shop", "env": "staging", "cost-center": "cc-210"}}, {"name": "acme-shop-dev", "ou": "Workloads/NonProd", "email": "aws+shop-dev@example.com", "workload": true, "tags": {"owner": "shop", "env": "dev", "cost-center": "cc-210"}}, {"name": "acme-sandbox", "ou": "Sandbox", "email": "aws+sandbox@example.com", "workload": true, "tags": {"owner": "shop", "env": "sandbox", "cost-center": "cc-900"}} ] } --- layout_check.py --- """layout_check.py - check a proposed AWS account layout (layout.json) and show what four broken layouts trigger. Rules, and where each comes from: L1 OU nesting is at most 5 levels under the root (Organizations quotas page, "OU maximum nesting in a root") L2 account count is within the default quota of 10 (quotas page; adjustable, so a warning when over) L3 the management account hosts no workload (Organizations best practices for the management account) L4 every account has the required tags (my team rule for cost allocation; the docs only say tags must be activated) L5 every account's OU exists in the layout (basic consistency) L6 account names and root e-mail addresses are distinct (my own sanity check; I did not find a sentence about e-mail uniqueness on the pages I read) L7 every OU has at least one account or child OU (my tidiness rule) L8 every SCP name used by an OU is one of the names in SCPS below (catches typos in the plan) """ import copy, json, pathlib, sys REQUIRED_TAGS = ("owner", "env", "cost-center") SCPS = {"deny-leave-and-close", "deny-disable-logging", "region-lock-eu", "deny-delete-backups", "deny-expensive-services"} QUOTA = 10 def walk(ous, prefix=""): for name, o in ous.items(): path = prefix + name yield path, o yield from walk(o.get("children", {}), path + "/") def check(lay): errs, warns = [], [] ou_paths = {p: o for p, o in walk(lay["ous"])} depth = max((p.count("/") + 1 for p in ou_paths), default=0) if depth > 5: errs.append(f"L1 OU nesting is {depth} levels, limit 5") n = len(lay["accounts"]) if n > QUOTA: warns.append(f"L2 {n} accounts is over the default quota of {QUOTA}: a Service Quotas increase must be requested first") names, emails = set(), set() for a in lay["accounts"]: if a["name"] == lay["management_account"] and a.get("workload"): errs.append(f"L3 management account {a['name']} hosts a workload") miss = [t for t in REQUIRED_TAGS if not a.get("tags", {}).get(t)] if miss: errs.append(f"L4 {a['name']} is missing tags {miss}") if a["ou"] != "(root)" and a["ou"] not in ou_paths: errs.append(f"L5 {a['name']} is in unknown OU {a['ou']!r}") if a["name"] in names: errs.append(f"L6 duplicate account name {a['name']}") if a["email"].lower() in emails: errs.append(f"L6 duplicate e-mail {a['email']}") names.add(a["name"]); emails.add(a["email"].lower()) used = {a["ou"] for a in lay["accounts"]} for p, o in ou_paths.items(): has_acct = any(u == p or u.startswith(p + "/") for u in used) if not has_acct: warns.append(f"L7 OU {p} has no account under it") for s in o.get("scps", []): if s not in SCPS: errs.append(f"L8 OU {p} uses unknown SCP {s!r}") return errs, warns, n, depth, len(ou_paths) def tree(lay): out = [f"Root (management account: {lay['management_account']})"] by_ou = {} for a in lay["accounts"]: by_ou.setdefault(a["ou"], []).append(a["name"]) def rec(ous, prefix, indent): items = list(ous.items()) for i, (name, o) in enumerate(items): last = i == len(items) - 1 path = prefix + name out.append(f"{indent}{'└── ' if last else '├── '}{name}/ SCPs: {', '.join(o.get('scps', [])) or '-'}") child_indent = indent + (' ' if last else '│ ') for acct in by_ou.get(path, []): out.append(f"{child_indent}• {acct}") rec(o.get("children", {}), path + "/", child_indent) rec(lay["ous"], "", "") return "\n".join(out) def show(label, lay): errs, warns, n, depth, nous = check(lay) print(f"=== {label}: {n} accounts, {nous} OUs, deepest OU path {depth} -> {'FAIL' if errs else 'PASS'}") for m in errs: print(" error:", m) for m in warns: print(" warn: ", m) if __name__ == "__main__": lay = json.loads(pathlib.Path(r"C:\lwaws\c1\layout.json").read_text(encoding="utf-8")) print(tree(lay)); print() show("the layout", lay) b = copy.deepcopy(lay); b["accounts"].append({"name": "x", "ou": "Workloads/Prod", "email": "aws+x@example.com", "workload": True, "tags": {"owner": "a", "env": "prod"}}) show("broken 1: new account without a cost-center tag", b) b = copy.deepcopy(lay); b["accounts"][0]["workload"] = True show("broken 2: workload placed in the management account", b) b = copy.deepcopy(lay); b["accounts"][5]["email"] = b["accounts"][4]["email"] show("broken 3: two accounts share one e-mail", b) b = copy.deepcopy(lay) for i in range(3): b["accounts"].append({"name": f"extra-{i}", "ou": "Sandbox", "email": f"aws+extra{i}@example.com", "workload": True, "tags": {"owner": "x", "env": "sandbox", "cost-center": "cc-900"}}) show("broken 4: 11 accounts", b) b = copy.deepcopy(lay); node = b["ous"] for lvl in range(6): node["L%d" % lvl] = {"scps": [], "children": {}}; node = node["L%d" % lvl]["children"] show("broken 5: OUs nested six deep, and an empty OU chain", b) --- py -X utf8 layout_check.py --- Root (management account: acme-management) ├── Security/ SCPs: deny-leave-and-close, deny-disable-logging │ • acme-log-archive │ • acme-security-tooling ├── Infrastructure/ SCPs: deny-leave-and-close │ • acme-shared-services ├── Workloads/ SCPs: deny-leave-and-close, region-lock-eu │ ├── Prod/ SCPs: deny-delete-backups │ │ • acme-shop-prod │ └── NonProd/ SCPs: - │ • acme-shop-staging │ • acme-shop-dev └── Sandbox/ SCPs: deny-leave-and-close, region-lock-eu, deny-expensive-services • acme-sandbox === the layout: 8 accounts, 6 OUs, deepest OU path 2 -> PASS === broken 1: new account without a cost-center tag: 9 accounts, 6 OUs, deepest OU path 2 -> FAIL error: L4 x is missing tags ['cost-center'] === broken 2: workload placed in the management account: 8 accounts, 6 OUs, deepest OU path 2 -> FAIL error: L3 management account acme-management hosts a workload === broken 3: two accounts share one e-mail: 8 accounts, 6 OUs, deepest OU path 2 -> FAIL error: L6 duplicate e-mail aws+shop-prod@example.com === broken 4: 11 accounts: 11 accounts, 6 OUs, deepest OU path 2 -> PASS warn: L2 11 accounts is over the default quota of 10: a Service Quotas increase must be requested first === broken 5: OUs nested six deep, and an empty OU chain: 8 accounts, 12 OUs, deepest OU path 6 -> FAIL error: L1 OU nesting is 6 levels, limit 5 warn: L7 OU L0 has no account under it warn: L7 OU L0/L1 has no account under it warn: L7 OU L0/L1/L2 has no account under it warn: L7 OU L0/L1/L2/L3 has no account under it warn: L7 OU L0/L1/L2/L3/L4 has no account under it warn: L7 OU L0/L1/L2/L3/L4/L5 has no account under it --- cost_report.py --- """cost_report.py - group a cost file by tag and measure how much spend has no tag. THE DATA IS INVENTED. gen_costs() makes a small, repeatable (fixed seed) file shaped like a cost allocation report: one row per account/service with tag columns and a cost. The amounts are NOT AWS prices. What is real here is the technique: group by tag, keep an explicit '(untagged)' bucket, and make the totals reconcile. The docs say the real report "includes both tagged and untagged resources" and that its total reconciles with the Bills page, so the same reconciliation check is applied here. """ import csv, io, json, random, pathlib, collections LAY = json.loads(pathlib.Path(r"C:\lwaws\c1\layout.json").read_text(encoding="utf-8")) SERVICES = ["Amazon EC2", "Amazon S3", "Amazon RDS", "AWS Lambda", "Amazon CloudWatch", "AWS Data Transfer"] BASE = {"acme-management": 12, "acme-log-archive": 40, "acme-security-tooling": 55, "acme-shared-services": 140, "acme-shop-prod": 900, "acme-shop-staging": 210, "acme-shop-dev": 130, "acme-sandbox": 35} def gen_costs(): rnd = random.Random(2026) rows = [] for a in LAY["accounts"]: for svc in SERVICES: cost = round(BASE[a["name"]] * rnd.uniform(0.02, 0.35), 2) tags = dict(a["tags"]) # about one row in five loses its team tag: resources created by hand, or before the tagging rule if rnd.random() < 0.2 and a["name"] not in ("acme-management",): tags["owner"] = "" rows.append({"account": a["name"], "service": svc, "user:owner": tags["owner"], "user:env": tags["env"], "cost_usd": f"{cost:.2f}"}) return rows def money(x): return f"{x:,.2f}" def report(rows): total_cents = sum(round(float(r["cost_usd"]) * 100) for r in rows) by_owner = collections.Counter(); by_acct = collections.Counter(); by_env = collections.Counter() for r in rows: c = round(float(r["cost_usd"]) * 100) by_owner[r["user:owner"] or "(untagged)"] += c by_acct[r["account"]] += c by_env[r["user:env"] or "(untagged)"] += c assert sum(by_owner.values()) == total_cents == sum(by_acct.values()) == sum(by_env.values()), "buckets do not add up" print(f"rows {len(rows)}, total {money(total_cents / 100)} (synthetic dollars)") for title, c in (("by owner tag", by_owner), ("by account", by_acct), ("by env tag", by_env)): print(f"-- {title}") for k, v in c.most_common(): print(f" {k:<24}{money(v / 100):>10} {100 * v / total_cents:5.1f}%") un = by_owner["(untagged)"] print(f"untagged share of spend: {100 * un / total_cents:.1f}%") print("-- the untagged rows, biggest first (where the next tagging effort should go)") bad = sorted((r for r in rows if not r["user:owner"]), key=lambda r: -float(r["cost_usd"])) for r in bad[:5]: print(f" {r['account']:<24}{r['service']:<20}{money(float(r['cost_usd'])):>9}") print("totals reconcile: owner buckets = account buckets = env buckets = file total") if __name__ == "__main__": rows = gen_costs() buf = io.StringIO(); w = csv.DictWriter(buf, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows) pathlib.Path(r"C:\lwaws\c1\costs.csv").write_text(buf.getvalue(), encoding="utf-8", newline="") print("first three lines of costs.csv:"); print("\n".join(buf.getvalue().splitlines()[:4])); print() report(list(csv.DictReader(io.StringIO(buf.getvalue())))) print("\n-- control: drop one row from the data before grouping, and the reconciliation must fail") rows2 = list(csv.DictReader(io.StringIO(buf.getvalue()))) expected_total = round(sum(round(float(r["cost_usd"]) * 100) for r in rows2)) got = round(sum(round(float(r["cost_usd"]) * 100) for r in rows2[1:])) print(f" file total {expected_total} cents, after dropping a row {got} cents -> {'DETECTED' if got != expected_total else 'NOT detected'}") --- py -X utf8 cost_report.py --- first three lines of costs.csv: account,service,user:owner,user:env,cost_usd acme-management,Amazon EC2,platform,mgmt,0.71 acme-management,Amazon S3,platform,mgmt,2.27 acme-management,Amazon RDS,platform,mgmt,0.65 rows 48, total 1,643.56 (synthetic dollars) -- by owner tag shop 1,018.79 62.0% (untagged) 376.22 22.9% platform 146.17 8.9% security 102.38 6.2% -- by account acme-shop-prod 840.79 51.2% acme-shop-staging 320.66 19.5% acme-shared-services 162.93 9.9% acme-shop-dev 152.71 9.3% acme-security-tooling 75.71 4.6% acme-sandbox 40.95 2.5% acme-log-archive 37.09 2.3% acme-management 12.72 0.8% -- by env tag prod 840.79 51.2% staging 320.66 19.5% shared 275.73 16.8% dev 152.71 9.3% sandbox 40.95 2.5% mgmt 12.72 0.8% untagged share of spend: 22.9% -- the untagged rows, biggest first (where the next tagging effort should go) acme-shop-prod AWS Lambda 171.84 acme-shop-prod Amazon CloudWatch 84.68 acme-shop-staging Amazon EC2 65.66 acme-shared-services Amazon EC2 29.48 acme-shop-dev AWS Data Transfer 14.14 totals reconcile: owner buckets = account buckets = env buckets = file total -- control: drop one row from the data before grouping, and the reconciliation must fail file total 164356 cents, after dropping a row 164285 cents -> DETECTED WHY THIS WORKS AS AN ANSWER --------------------------- The layout is data, so a script can check it before anyone creates an account. Each rule says where it comes from: L1 (five OU levels) and L2 (default 10 accounts) come from the Organizations quotas page, L3 (no workload in the management account) from the best-practices page, and L4, L6 and L7 are labelled as my own. The five broken layouts each trigger the rule they were built for, and the 11-account layout is a warning rather than a failure because the quota can be raised. The cost report keeps an explicit '(untagged)' bucket instead of dropping rows, then asserts that the owner, account and environment groupings all add up to the file total, which is the reconciliation the Billing documentation describes for the real report. 22.9% of the invented spend has no owner tag. The control (drop one row) is detected. Not run: real Organizations, a real cost allocation report or Cost Explorer, tag activation and its 24-hour delay, tag policies, and anything with actual AWS prices.