A typical project¶
cfn-typed does not need a project layout. At its smallest it is a function that returns a dict, and you write
that dict out yourself:
The generator, the CLI, the golden-file tests and the sumo patterns are all optional. Use as much or as little as is useful, and ignore this page if one script is enough.
This page is for the case where the infrastructure has grown into a few stacks and a team. It shows one layout that
works, and it is checked on every change: it is
examples/reference/cfn, a real project
that CI syncs, generates the types for, checks, diffs, tests and type-checks the way you would, and every code block
below is pulled from its files. Take the parts you want.
The idea is to keep everything about the infrastructure in one directory, cfn/, so the rest of the
repository does not need to know that Python is involved in it.
my-service/
src/ # the application
cfn/
cfn-typegen.toml # which resource types you use
cfn_types/ # generated by cfn-typegen (gitignored here)
patterns/ # your helpers: ejected from sumo, or written by you
queues.py
stacks/ # one module per kind of stack
orders.py # one stack
buckets.py # many stacks from one function
main.py # names the stacks and calls cfn_typed.cli.main
tests/
test_stacks.py # golden-file checks
rendered/ # the templates, committed
orders.yaml
buckets/
bucket-logs.yaml
bucket-uploads.yaml
bucket-exports.yaml
pyproject.toml # dependencies and the pytest setting
pyproject.toml
The files¶
cfn-typegen.toml lists the types the stacks and patterns use. cfn-typegen --config cfn-typegen.toml writes
cfn_types/ next to it, wherever you run it from.
# The resource types the stacks and patterns use. `cfn-typegen --config cfn-typegen.toml` writes cfn_types/ next to it.
out = "cfn_types"
docs = false
types = ["AWS::SQS::Queue", "AWS::S3::Bucket"]
patterns/ holds the helpers your stacks share, and most of them will be your own: the functions that say how
your company names things, tags them and wires a service up. Some may start as a copy of a sumo pattern that you
ejected: in the reference project,
patterns/queues.py is queue_with_dlq copied from cfn_typed_sumo.patterns with one change, its types come from
the project's own cfn_types. The project does not depend on cfn-typed-sumo at all. Either way a pattern is just
a function in your repository.
stacks/ has a module per kind of stack. There is one template per stack and no prod or dev variant of
it: the stage is a CloudFormation parameter (Stage here), and the same rendered file
is deployed to every stage with different parameter values
(aws cloudformation deploy --parameter-overrides Stage=prod). What would change the template's shape goes in a
config object, as OrdersConfig does:
from dataclasses import dataclass
from pathlib import Path
import cfn_typed as cfn
from cfn_typed.cli import StackSpec
from patterns.queues import queue_with_dlq
HERE = Path(__file__).parent
@dataclass(frozen=True)
class OrdersConfig:
visibility_timeout: int = 180
def build(config: OrdersConfig = OrdersConfig()) -> cfn.Template:
params = cfn.Params()
stage = params.string("Stage", default="dev", allowed_values=["dev", "test", "prod"])
jobs = queue_with_dlq("Jobs", visibility_timeout=config.visibility_timeout, tags=cfn.tags(Stage=stage))
return {
"AWSTemplateFormatVersion": "2010-09-09",
"Parameters": params.declarations(),
"Resources": jobs.resources,
"Outputs": {"JobsUrl": {"Value": jobs.queue_url}, "JobsDlqUrl": {"Value": jobs.dlq_url}},
}
def stacks() -> dict[str, StackSpec]:
return {"orders": StackSpec(build, out=HERE.parent / "rendered")}
main.py is the command line, as in the CLI page:
import sys
import cfn_types
from cfn_typed import cli
from stacks import buckets, orders
def main() -> int:
return cli.main({**orders.stacks(), **buckets.stacks()}, schemas=cfn_types)
if __name__ == "__main__":
sys.exit(main())
Do not call it stacks.py
Next to a stacks/ directory it would shadow the package, and the import fails.
rendered/ holds the output. It is committed, so every template change shows up in review next to the
Python change that caused it, as in the testing page. Each stack's StackSpec says which directory
it renders into, so emit and diff need no arguments.
If the rendered templates already live elsewhere (a deploy/ or infra/ directory that a pipeline reads), leave
them there and point out at them from outside the cfn/ tree, or pass the directory on the command line
(python main.py diff ../deploy/templates, emit -o ../deploy/templates). That keeps the diff in review to the
files that are actually deployed, and avoids a second copy of every template. The files are named <stack>.yaml.
Working in it¶
Run everything from inside cfn/, so patterns, stacks and cfn_types are plain top-level imports with no
package or build configuration:
cd cfn
uv sync
uv run cfn-typegen --config cfn-typegen.toml # once, and when the type list changes
uv run python main.py check # every check, against the registry schemas
uv run python main.py emit # write the templates, each into its own directory
uv run python main.py diff # exit 1 if they differ from the committed ones
uv run pytest # the golden-file tests
uv run ty check
The tests render every stack and compare it with its committed file:
import cfn_types
from cfn_typed import cli
from cfn_typed.testing import assert_matches_file
from main import main # noqa: F401 (the stacks, as the command line sees them)
from stacks import buckets, orders
def test_orders() -> None:
assert_matches_file(orders.build(), orders.HERE.parent / "rendered/orders.yaml")
def test_every_stack_matches_its_committed_file() -> None:
stacks = {**orders.stacks(), **buckets.stacks()}
assert cli.main(stacks, schemas=cfn_types, argv=["diff"]) == 0
assert cli.main(stacks, schemas=cfn_types, argv=["check"]) == 0
cfn/pyproject.toml makes cfn/ a small project of its own: it declares the dependencies (so uv sync works
in that directory, and in the workflow below) and lets pytest find the modules. It is not a package to build.
dependencies = ["cfn-typed"]
[dependency-groups]
dev = ["pytest", "ty"]
[tool.pytest.ini_options]
pythonpath = ["."]
(In this repository the file also points cfn-typed at the checkout, and has a [tool.ty] section so ty does not
read the repository's own configuration. Your own project needs neither.)
Generated types: commit or generate¶
With a handful of types, commit cfn_types/: there is no extra step and the code is there to read. As the
list grows it is tidier to put cfn_types/ in .gitignore and run cfn-typegen first in development and in
CI, which is what the reference project does. Distributing patterns covers the
build-time variant in more detail.
A workflow that runs only for cfn/¶
Give the infrastructure its own workflow, triggered only when something in cfn/ changes, so application-only
pull requests do not wait for it:
# .github/workflows/cfn.yml
name: CloudFormation
on:
push:
branches: [main]
paths:
- "cfn/**"
- ".github/workflows/cfn.yml"
pull_request:
paths:
- "cfn/**"
- ".github/workflows/cfn.yml"
jobs:
templates:
runs-on: ubuntu-latest
defaults:
run:
working-directory: cfn
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- run: uv sync
# only if cfn_types/ is gitignored rather than committed:
- run: uv run cfn-typegen --config cfn-typegen.toml
- run: uv run python main.py check
- run: uv run python main.py diff
- run: uv run pytest
- run: uv run ty check
The steps are the ones this repository's own CI runs against the reference project (in its test.yml); the
paths filter and the file itself are not exercised there, because GitHub reads workflows only from the root of a
repository.
checkanddifffail the build.checkexits 1 on a problem in a template,diffexits 1 when the rendered files are not what the code produces, so a change to a stack must come with its regeneratedrendered/files in the same pull request.- Add the paths of anything outside
cfn/that the templates are rendered into, such asdeploy/templates/**, so a hand edit of a rendered file is caught too. - If the workflow is a required check, a path filter means it does not run, and so never reports, on pull
requests that touch nothing in
cfn/; GitHub then leaves the required check pending. Either do not make it required, or run it always and skip the work with adorny/paths-filterstep. - Deploying is a separate job, or a separate workflow, that runs on
mainafter this one passes and sendsrendered/toaws cloudformation deploy. The templates it deploys are the committed ones, which is the point of committing them.
Many stacks from one function¶
When the stacks are the same function with different inputs, such as one stack per bucket, give the function a
config object and make one StackSpec per config. The input is a typed
BucketConfig, not a growing list of keyword arguments, and adding a bucket is one line of data:
from dataclasses import dataclass
from functools import partial
from pathlib import Path
import cfn_typed as cfn
from cfn_types import s3
from cfn_typed.cli import StackSpec
HERE = Path(__file__).parent
@dataclass(frozen=True)
class BucketConfig:
name: str # also the stack name: bucket-<name>
data_class: str # a tag value: audit, customer-data, ...
archive_after_days: int | None = None
def build(config: BucketConfig) -> cfn.Template:
props: s3.BucketProps = {
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": True, "BlockPublicPolicy": True, "IgnorePublicAcls": True, "RestrictPublicBuckets": True,
},
"BucketEncryption": {"ServerSideEncryptionConfiguration": [
{"ServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}},
]},
"Tags": cfn.tags(DataClass=config.data_class),
}
if config.archive_after_days is not None:
props["LifecycleConfiguration"] = {"Rules": [{
"Id": "archive", "Status": "Enabled",
"Transitions": [{"StorageClass": "GLACIER", "TransitionInDays": config.archive_after_days}],
}]}
return {
"AWSTemplateFormatVersion": "2010-09-09",
"Resources": {"Bucket": s3.bucket(deletion_policy="Retain", update_replace_policy="Retain", **props)},
"Outputs": {"BucketName": {"Value": cfn.ref("Bucket")}},
}
CONFIGS = [
BucketConfig("logs", "audit", archive_after_days=90),
BucketConfig("uploads", "customer-data"),
BucketConfig("exports", "customer-data"),
]
def stacks() -> dict[str, StackSpec]:
return {
f"bucket-{c.name}": StackSpec(partial(build, c), out=HERE.parent / "rendered/buckets")
for c in CONFIGS
}
emit writes rendered/buckets/bucket-logs.yaml, bucket-uploads.yaml and bucket-exports.yaml, and diff and
check cover all three. Each bucket is its own stack with its own file in review, and a bucket that later needs
something the others do not (replication, say) gets a new field in BucketConfig. The properties that only some buckets have are added with if to a typed props dict, as in
Properties that are set only sometimes. If the stacks will
always be identical and differ only by a name or a tag, a single template with parameters, deployed once per bucket, is
simpler.
Growing¶
- Another stack: a module in
stacks/with astacks()function, and one line inmain.py. - Another pattern: a file in
patterns/. A pattern you want to keep as sumo has it can keep coming fromcfn_typed_sumountil you want to change it. - Several services in one repository: one
cfn/per service, each with its owncfn-typegen.toml, so a service generates only the types it uses.