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.
- cfn_typed_sumo-0.2.1/.gitignore +10 -0
- cfn_typed_sumo-0.2.1/PKG-INFO +31 -0
- cfn_typed_sumo-0.2.1/README.md +20 -0
- cfn_typed_sumo-0.2.1/cfn-typegen.toml +25 -0
- cfn_typed_sumo-0.2.1/pyproject.toml +27 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/__init__.py +25 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/patterns.py +197 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/py.typed +0 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/__init__.py +2 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/cloudwatch.py +835 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/dynamodb.py +403 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/ec2.py +103 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/ecr.py +70 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/elasticloadbalancingv2.py +255 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/events.py +451 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/iam.py +447 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/kms.py +104 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/lambda_.py +934 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/logs.py +680 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/s3.py +871 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/sns.py +143 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/sqs.py +101 -0
- cfn_typed_sumo-0.2.1/src/cfn_typed_sumo/types/ssm.py +542 -0
- cfn_typed_sumo-0.2.1/tests/conftest.py +7 -0
- cfn_typed_sumo-0.2.1/tests/test_patterns.py +125 -0
|
@@ -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
|