cfn-typed-sumo 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ site/
5
+ .cache/
6
+ examples/cfn_types/
7
+ docs/reference/
8
+ uv.lock
9
+ dist/
10
+ packages/sumo/src/cfn_typed_sumo/types/
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: cfn-typed-sumo
3
+ Version: 0.2.1
4
+ Summary: cfn-typed with the generated types its reusable patterns use, plus the patterns
5
+ Project-URL: Homepage, https://github.com/vivainio/cfn-typed
6
+ Project-URL: Repository, https://github.com/vivainio/cfn-typed
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: cfn-typed==0.2.1
10
+ Description-Content-Type: text/markdown
11
+
12
+ # cfn-typed-sumo
13
+
14
+ [cfn-typed](https://github.com/vivainio/cfn-typed) with the resource types its patterns use
15
+ already generated (`cfn_typed_sumo.types`) and a set of reusable patterns
16
+ (`cfn_typed_sumo.patterns`): queue with a dead-letter queue, Lambda role, company tags and rules.
17
+
18
+ ```sh
19
+ uv add cfn-typed-sumo
20
+ ```
21
+
22
+ Lots of pre-generated types mean you can try things out quickly: install it and start writing templates.
23
+
24
+ This is the **fat pattern library**: a lot of batteries, and a **volatile API**. Patterns and names change
25
+ between releases, so pin the version. The core, `cfn-typed`, is the package whose API is meant to stay reliable.
26
+ See [the book](https://vivainio.github.io/cfn-typed/sumo/).
27
+
28
+ ```python
29
+ from cfn_typed_sumo.types import sqs
30
+ from cfn_typed_sumo import queue_with_dlq
31
+ ```
@@ -0,0 +1,20 @@
1
+ # cfn-typed-sumo
2
+
3
+ [cfn-typed](https://github.com/vivainio/cfn-typed) with the resource types its patterns use
4
+ already generated (`cfn_typed_sumo.types`) and a set of reusable patterns
5
+ (`cfn_typed_sumo.patterns`): queue with a dead-letter queue, Lambda role, company tags and rules.
6
+
7
+ ```sh
8
+ uv add cfn-typed-sumo
9
+ ```
10
+
11
+ Lots of pre-generated types mean you can try things out quickly: install it and start writing templates.
12
+
13
+ This is the **fat pattern library**: a lot of batteries, and a **volatile API**. Patterns and names change
14
+ between releases, so pin the version. The core, `cfn-typed`, is the package whose API is meant to stay reliable.
15
+ See [the book](https://vivainio.github.io/cfn-typed/sumo/).
16
+
17
+ ```python
18
+ from cfn_typed_sumo.types import sqs
19
+ from cfn_typed_sumo import queue_with_dlq
20
+ ```
@@ -0,0 +1,25 @@
1
+ # Public as cfn_typed_sumo.types. Generated by CI and before building; never committed.
2
+ # The types the patterns use, plus the very core services. Add a type when a pattern starts using it.
3
+ out = "src/cfn_typed_sumo/types"
4
+ docs = false
5
+ types = [
6
+ # core services
7
+ "AWS::IAM::*",
8
+ "AWS::Lambda::*",
9
+ "AWS::SQS::*",
10
+ "AWS::SNS::*",
11
+ "AWS::S3::*",
12
+ "AWS::KMS::*",
13
+ "AWS::DynamoDB::*",
14
+ "AWS::Logs::*",
15
+ "AWS::Events::*",
16
+ "AWS::SSM::*",
17
+ "AWS::CloudWatch::*",
18
+ # types the patterns use outside those
19
+ "AWS::ECR::Repository",
20
+ "AWS::EC2::SecurityGroup",
21
+ "AWS::EC2::SecurityGroupIngress",
22
+ "AWS::ElasticLoadBalancingV2::LoadBalancer",
23
+ "AWS::ElasticLoadBalancingV2::TargetGroup",
24
+ "AWS::ElasticLoadBalancingV2::Listener",
25
+ ]
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "cfn-typed-sumo"
3
+ version = "0.2.1"
4
+ description = "cfn-typed with the generated types its reusable patterns use, plus the patterns"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.14"
8
+ dependencies = ["cfn-typed==0.2.1"] # the release workflow pins it to the same version
9
+
10
+ [project.urls]
11
+ Homepage = "https://github.com/vivainio/cfn-typed"
12
+ Repository = "https://github.com/vivainio/cfn-typed"
13
+
14
+ [tool.uv.sources]
15
+ cfn-typed = { workspace = true }
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ # The generated types are gitignored; hatch leaves out gitignored files, so name them here
22
+ # (under [tool.hatch.build], not just the wheel target: uv builds the wheel from the sdist).
23
+ [tool.hatch.build]
24
+ artifacts = ["src/cfn_typed_sumo/types"]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/cfn_typed_sumo"]
@@ -0,0 +1,25 @@
1
+ """cfn-typed plus the generated resource types its patterns use (`cfn_typed_sumo.types`) and the patterns."""
2
+
3
+ from cfn_typed_sumo.patterns import (
4
+ COMPANY_RULES,
5
+ CompanyTags,
6
+ QueueWithDlq,
7
+ SomeCompanyTags,
8
+ lambda_role,
9
+ queue_with_dlq,
10
+ sqs_event_source,
11
+ tagged,
12
+ untagged,
13
+ )
14
+
15
+ __all__ = [
16
+ "COMPANY_RULES",
17
+ "CompanyTags",
18
+ "QueueWithDlq",
19
+ "SomeCompanyTags",
20
+ "lambda_role",
21
+ "queue_with_dlq",
22
+ "sqs_event_source",
23
+ "tagged",
24
+ "untagged",
25
+ ]
@@ -0,0 +1,197 @@
1
+ """Pattern helpers shown in the book (docs/). Sections feed the pages via pymdownx.snippets."""
2
+
3
+ # --8<-- [start:imports]
4
+ import re
5
+ from collections.abc import Collection, Mapping, Sequence
6
+ from dataclasses import asdict, dataclass, fields
7
+ from typing import Any, Literal, TypedDict
8
+
9
+ from cfn_typed_sumo.types import iam, lambda_, sqs
10
+
11
+ from cfn_typed import AnyResource, AnyStatement, Expr, Resources, Tag, allow, arn, assume_role_policy, get_att, policy_document, ref
12
+ from cfn_typed.policy import service
13
+ from cfn_typed.checks import Rule
14
+ from cfn_typed.query import prop, resources_of
15
+ from cfn_typed.rules import each, forbid, require
16
+
17
+ # --8<-- [end:imports]
18
+
19
+
20
+ # --8<-- [start:lambda_role]
21
+ def lambda_role(name_prefix: str, extra: Sequence[AnyStatement]) -> iam.Role:
22
+ """Execution role for a Lambda: log permissions plus the `extra` statements."""
23
+ log_arn = arn.log_group(f"/aws/lambda/{name_prefix}*", streams=True)
24
+ return iam.role(
25
+ AssumeRolePolicyDocument=assume_role_policy(service("lambda.amazonaws.com")),
26
+ Policies=[iam.RolePolicyProperty(
27
+ PolicyName=f"{name_prefix}-lambda",
28
+ PolicyDocument=policy_document([
29
+ allow(["logs:CreateLogStream", "logs:CreateLogGroup", "logs:PutLogEvents"], [log_arn]),
30
+ *extra,
31
+ ]),
32
+ )],
33
+ )
34
+ # --8<-- [end:lambda_role]
35
+
36
+
37
+ # --8<-- [start:sqs_event_source]
38
+ def sqs_event_source(function_logical: str, queue_arn: Expr[str], batch_size: int = 10) -> lambda_.EventSourceMapping:
39
+ """Trigger the Lambda `function_logical` from an SQS queue."""
40
+ return lambda_.event_source_mapping(
41
+ BatchSize=batch_size,
42
+ EventSourceArn=queue_arn,
43
+ FunctionName=get_att(function_logical, "Arn"),
44
+ )
45
+ # --8<-- [end:sqs_event_source]
46
+
47
+
48
+ # --8<-- [start:queue_with_dlq]
49
+ @dataclass(frozen=True)
50
+ class QueueWithDlq:
51
+ """The handle `queue_with_dlq` returns: the logical IDs, how to refer to them, and the resources."""
52
+
53
+ queue: str
54
+ dlq: str
55
+ resources: Resources
56
+
57
+ @property
58
+ def queue_arn(self) -> Expr[str]:
59
+ return get_att(self.queue, "Arn")
60
+
61
+ @property
62
+ def queue_url(self) -> Expr[str]:
63
+ return ref(self.queue)
64
+
65
+ @property
66
+ def dlq_url(self) -> Expr[str]:
67
+ return ref(self.dlq)
68
+
69
+
70
+ def queue_with_dlq(
71
+ name: str,
72
+ *,
73
+ visibility_timeout: int = 30,
74
+ max_receive_count: int = 3,
75
+ tags: list[Expr[Tag]] | None = None,
76
+ ) -> QueueWithDlq:
77
+ """A queue and its dead-letter queue, with logical IDs `{name}Queue` and `{name}DLQ`."""
78
+ queue_id, dlq_id = f"{name}Queue", f"{name}DLQ"
79
+ dlq: sqs.QueueProps = {}
80
+ main: sqs.QueueProps = {
81
+ "VisibilityTimeout": visibility_timeout,
82
+ "RedrivePolicy": {"deadLetterTargetArn": get_att(dlq_id, "Arn"), "maxReceiveCount": max_receive_count},
83
+ }
84
+ if tags:
85
+ dlq["Tags"] = main["Tags"] = tags
86
+ return QueueWithDlq(queue_id, dlq_id, {dlq_id: sqs.queue(**dlq), queue_id: sqs.queue(**main)})
87
+
88
+
89
+ # --8<-- [end:queue_with_dlq]
90
+
91
+
92
+ # --8<-- [start:company_tags]
93
+ @dataclass(frozen=True, kw_only=True)
94
+ class CompanyTags:
95
+ """The tags every taggable resource must end up with. No defaults, so a missing one is an error."""
96
+
97
+ owner: str
98
+ cost_center: str
99
+ environment: Literal["dev", "test", "prod"]
100
+ role: str
101
+
102
+ def __post_init__(self) -> None:
103
+ if not re.fullmatch(r"CC-\d{4}", self.cost_center):
104
+ raise ValueError(f"cost_center must look like CC-1234, got {self.cost_center!r}")
105
+
106
+
107
+ REQUIRED = frozenset(f.name for f in fields(CompanyTags))
108
+
109
+
110
+ class SomeCompanyTags(TypedDict, total=False):
111
+ """Any subset of the company tags: what a service sets for all its resources."""
112
+
113
+ owner: str
114
+ cost_center: str
115
+ environment: Literal["dev", "test", "prod"]
116
+ role: str
117
+
118
+
119
+ # The company decides which types it expects tags on; extend it as you adopt services.
120
+ TAGGABLE = frozenset({"AWS::SQS::Queue", "AWS::S3::Bucket", "AWS::DynamoDB::Table", "AWS::KMS::Key"})
121
+
122
+
123
+ def tagged(resources: Resources, service: SomeCompanyTags, *, taggable: Collection[str] = TAGGABLE) -> Resources:
124
+ """Resolve the company tags for every taggable resource: its own tags over the `service` tags.
125
+
126
+ Each resource must end up with all of `CompanyTags`, or this raises naming the resource and what is missing.
127
+ Tags that are not company tags are left alone.
128
+ """
129
+ out: Resources = {}
130
+ for logical_id, resource in resources.items():
131
+ if resource["Type"] in taggable:
132
+ props = dict(resource.get("Properties", {}))
133
+ own = props.get("Tags", [])
134
+ merged = {**service, **{t["Key"]: t["Value"] for t in own if t["Key"] in REQUIRED}}
135
+ if missing := REQUIRED - merged.keys():
136
+ raise ValueError(f"{logical_id} is missing company tags: {', '.join(sorted(missing))}")
137
+ others = [t for t in own if t["Key"] not in REQUIRED]
138
+ resolved = [{"Key": k, "Value": v} for k, v in asdict(CompanyTags(**merged)).items()]
139
+ resource = AnyResource(**{**resource, "Properties": {**props, "Tags": [*others, *resolved]}})
140
+ out[logical_id] = resource
141
+ return out
142
+
143
+
144
+ def untagged(resources: Resources, *, taggable: Collection[str] = TAGGABLE) -> list[str]:
145
+ """Logical IDs of taggable resources missing a company tag: a backstop for templates that skip `tagged`."""
146
+ return [
147
+ logical_id
148
+ for logical_id, r in resources.items()
149
+ if r["Type"] in taggable and REQUIRED - {t["Key"] for t in r.get("Properties", {}).get("Tags", [])}
150
+ ]
151
+ # --8<-- [end:company_tags]
152
+
153
+
154
+ # --8<-- [start:company_rules]
155
+ def as_list(value: Any) -> list[Any]:
156
+ return value if isinstance(value, list) else [] if value is None else [value]
157
+
158
+
159
+ @each("AWS::IAM::Role")
160
+ def no_admin_policies(logical_id: str, role: Mapping[str, Any]) -> list[str]:
161
+ """No inline policy allows every action on every resource. Several problems per resource are fine."""
162
+ return [
163
+ f"Policies.{i} allows * on *"
164
+ for i, policy in enumerate(prop(role, "Policies") or [])
165
+ for st in as_list(policy["PolicyDocument"]["Statement"])
166
+ if st["Effect"] == "Allow" and "*" in as_list(st.get("Action")) and "*" in as_list(st.get("Resource"))
167
+ ]
168
+
169
+
170
+ @each("AWS::SQS::Queue")
171
+ def queue_names_say_what_they_are(logical_id: str, queue: Mapping[str, Any]) -> str | None:
172
+ """Per-resource logic that `require` cannot express: the logical ID ends in Queue or DLQ."""
173
+ return None if logical_id.endswith(("Queue", "DLQ")) else "name ends in neither Queue nor DLQ"
174
+
175
+
176
+ def queues_have_a_dead_letter_queue(template: Mapping[str, Any]) -> list[str]:
177
+ """Logic across resources: every queue that is not itself a dead-letter queue redrives to one."""
178
+ queues = resources_of(template, "AWS::SQS::Queue")
179
+ targets = {
180
+ t["Fn::GetAtt"][0]
181
+ for q in queues.values()
182
+ if isinstance(t := prop(q, "RedrivePolicy.deadLetterTargetArn"), dict) and "Fn::GetAtt" in t
183
+ }
184
+ return [f"{k}: no RedrivePolicy" for k, q in queues.items() if k not in targets and prop(q, "RedrivePolicy") is None]
185
+
186
+
187
+ COMPANY_RULES: list[Rule] = [
188
+ require("AWS::S3::Bucket", "BucketEncryption"),
189
+ *(require("AWS::S3::Bucket", f"PublicAccessBlockConfiguration.{s}", equals=True)
190
+ for s in ("BlockPublicAcls", "BlockPublicPolicy", "IgnorePublicAcls", "RestrictPublicBuckets")),
191
+ forbid("AWS::S3::Bucket", "AccessControl", one_of=["PublicRead", "PublicReadWrite"]),
192
+ require("AWS::Logs::LogGroup", "RetentionInDays", at_least=30),
193
+ no_admin_policies,
194
+ queue_names_say_what_they_are,
195
+ queues_have_a_dead_letter_queue,
196
+ ]
197
+ # --8<-- [end:company_rules]
File without changes
@@ -0,0 +1,2 @@
1
+ # Generated by cfn-typegen.
2
+ """Generated CloudFormation resource modules, one per service."""