A command line for your stacks¶
cfn_typed.cli is an optional command line that you call from your own main(). It is not a
cfn-typed command: your script says which stacks exist, and the library gives them four
subcommands, emit, check, diff and ls (check can also ask AWS about a stack that is already deployed; zip needs no stack, and the standalone cfn-typed command adds import).
Nothing else in the package imports it, and it needs nothing beyond the standard library and ruamel.yaml.
# stacks.py
import sys
import cfn_types # generated by cfn-typegen
from cfn_typed import cli
from billing import build as billing
from orders import build as orders
def main() -> int:
return cli.main(
{"orders": orders, "billing": billing},
schemas=cfn_types, # optional: turns on the checks against the registry schemas
# rules=[my_rule], # optional: your own checks, see "Company rules"
)
if __name__ == "__main__":
sys.exit(main())
The first argument is one builder (a function that returns the template) or a mapping from stack
name to builder. cli.main returns the exit status instead of exiting, so your main() decides what
to do with it. Every subcommand takes stack names and uses all of them when you give none.
Without any Python: cfn-typed¶
The same subcommands work on template files, so the command line is useful for a template that
cfn-typed did not write. Install the package (uv tool install cfn-typed, or
uvx --from cfn-typed cfn-typed ..., or python -m cfn_typed.cli) and give it files, JSON
or YAML with the short-form intrinsics (!Ref, !GetAtt A.Arn, !Sub, ...):
$ cfn-typed check template.yaml other.json
template: ok
other: ok
$ cfn-typed ls template.yaml -t lambda
$ cfn-typed emit template.yaml -f json # convert YAML to JSON, or the other way round
$ cfn-typed diff rendered/ template.yaml # compare with rendered/template.yaml
A stack is named after its file without the suffix, and two files with the same name are an
error. YAML needs ruamel.yaml, which is installed with the package; JSON needs nothing. Without generated types the
schema checks are off, and --schemas cfn_types (for check) turns them on from a package that
cfn-typegen generated, imported from the current directory. To add your own rules, write the
main() above instead.
emit: print or write templates¶
$ python stacks.py emit billing
AWSTemplateFormatVersion: '2010-09-09'
Resources:
Jobs:
Type: AWS::SQS::Queue
...
-f json|yaml picks the format (YAML by default, or the suffix of -o). With several stacks you
have to name one, or write them all: -o rendered/ creates rendered/orders.yaml,
rendered/billing.yaml, and -o template.json writes one stack to a file.
check: run every check¶
This is find_all_problems for each stack: references, unused
parameters, quotas, values, IAM names and Lambda runtimes, and with schemas= the constraints,
Fn::GetAtt names and property combinations too. A problem is printed as [check] message, and
the exit status is 1 if any stack has one, so it works as a CI step. --inline applies the
51,200-byte inline limit, --no-unused and --no-defaults leave those checks out.
diff: compare with the committed files¶
$ python stacks.py diff rendered/
orders: matches rendered/orders.yaml
--- rendered/billing.yaml
+++ rendered
@@ -10,4 +10,3 @@
Outputs:
JobsUrl:
Value: !Ref Jobs
-x
It compares each stack with DIR/<name>.yaml (-f json for JSON), prints a unified diff and exits 1
on a difference or a missing file. --update rewrites the files instead. This is the
golden-file workflow without pytest, for a repository that wants a CI step and not a
test file.
StackSpec: where each stack renders¶
By default you give emit -o and diff one directory per run. A StackSpec is a builder plus the directory
its template renders into. It also suits the case where one function makes many stacks, here one per bucket, each
with its own BucketConfig:
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
}
The file is part of the reference project, which CI checks. Run from cfn/, with each stack's
out set as above:
$ python main.py emit # every stack, each into its own out; stacks without one are an error
$ python main.py diff # compare each with <out>/<name>.yaml
$ python main.py diff bucket-logs # only that stack
$ python main.py emit bucket-logs -o - # print one stack instead of writing it
$ python main.py emit -o /tmp/all # an explicit directory overrides every out
outis used as given, so a relative path is relative to where the script runs. Build it fromPath(__file__)as above to make it independent of that.StackSpecis accepted wherever a builder is, including a single builder, and plain builders keep working (and keep printing when you name one stack).- With
diff, if the first word is a stack name there is no directory argument. To give a directory that has the same name as a stack, write it as./bucket-logs. lsshows each stack'soutafter its name.
Settings: a dataclass whose fields are flags¶
A builder often needs a value that is not in the code: the stage, the region, a size. Give main a dataclass and its
fields become flags on emit, check, diff and ls. Each builder that takes an argument is called with an instance:
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class Settings:
stage: str = field(metadata={"help": "which environment", "env": "STAGE"}) # no default: required
memory: int = 1024
debug: bool = False # --debug / --no-debug
region: Literal["eu-west-1", "us-east-1"] = "eu-west-1"
def orders(s: Settings) -> cfn.Template: ...
cli.main({"orders": orders, "billing": billing}, settings=Settings)
$ python stacks.py emit orders --stage prod --memory 2048
$ STAGE=prod python stacks.py check
$ python stacks.py emit --help # the flags, with their help and defaults
- A field with no default is required: the flag, or the environment variable named in
metadata={"env": ...}.X | Noneis not optional by itself, writeX | None = None. - The types are
str,int,float,bool(a--flag/--no-flagpair),Path, anEnum(by member name), aLiteral, alistof those (repeat the flag, or comma-separated in the environment variable), andX | None. Another type is an error whenmainstarts. - A value that does not convert, an unknown choice, or a
ValueErrorfrom your__post_init__is a usage error (exit status 2) with the message, so the dataclass is where to reject a combination such asstage="prod"withdebug. - A builder that takes no argument is called as before, so plain builders and builders with settings can share one mapping.
- A field cannot be named like an option of the command (
format,out,type,long,update,inline, ...). That is an error whenmainstarts, and the fix is a different name. - With
diff, pass the same flags you rendered the committed files with, or it reports the difference. If the committed files should not depend on a flag, give the field a default that matches them.
Settings for one stack¶
StackSpec(build, settings=Other) uses Other for that stack, in place of the one given to main. A flag is only
required by the stacks you select, so a stack that needs --stage does not stop emit billing from running:
cli.main(
{
"orders": orders, # Settings
"reports": StackSpec(reports, settings=ReportSettings), # its own flags
},
settings=Settings,
)
All the flags are on the command, whichever stack uses them, and a name that two classes both declare has to have the
same type in both. A stack with its own settings does not get the shared ones.
import: start from an existing template¶
Only the standalone cfn-typed command has this one. It writes a template file as a Python module with build() -> cfn.Template, using the generated factories where there
is one and plain dicts where there is not (the module lists what it kept as a dict, and why). Generate the types
first with cfn-typegen --template template.json. By default the module renders back to exactly the original.
--sls is for a template that sls package wrote: wherever the Serverless Framework's resources for a function, an event, an API or the
role are exactly what a call of cfn_typed_sumo.sls renders, the module calls it, and everything else stays as the plain resources. Each call is
tried against the template while importing and written only when it renders the same resources, so the result still renders to the original.
--asl writes each state machine's DefinitionString as a Definition object that
cfn_typed.asl types and checks; the template then differs from the original in that one
property. For a service built with the Serverless Framework, see Ejecting from the Serverless Framework.
check --live: ask AWS about a stack that is already deployed¶
check is offline and fast by default. Three opt-in flags add the checks that need AWS, and --live turns on all three. None of them
changes anything: they read, and the one change set is created, read and deleted. They use your credentials (--aws-profile, --aws-region),
and the stack name is the name here, unless the stack has StackSpec(build, stack_name="shop-site-dev") (a service that set its own
stackName) or you pass --stack-name.
$ python stacks.py check --live site --bucket-name ServerlessDeploymentBucket=shop-dev-deployment-abc
site: 3 problem(s)
[deployed] Resources.Fn.Properties.Runtime: rendered "python3.12", deployed "python3.11"
[plan] Dist (AWS::CloudFront::Distribution) would be replaced (Properties.Origins (always))
[plan] Key (AWS::KMS::Key) may be replaced
[code] Resources.Fn.Properties.Code: s3://shop-dev-deployment-abc/api-2.zip does not exist
changes to shop-site-dev:
Fn AWS::Lambda::Function Modify Properties.MemorySize
Dist AWS::CloudFront::Distribution Modify (replacement) Properties.Origins (always)
Key AWS::KMS::Key Modify (maybe replacement)
| Flag | Finds |
|---|---|
--deployed |
where the rendered template differs from the running stack's, by path in the template (get-template; --template-stage Processed is after the transforms have run, the one to take for a SAM stack). It is the live version of the test that compares build() with original.json, and the first thing to run on one real stack before ejecting any others |
--plan |
a stateful resource that a change set would replace or remove: a table, bucket, queue, key, distribution, log group or database (the list is cfn_typed.changes.STATEFUL; --strict stops on any replacement or removal). The change set is never executed, and --keep-change-set leaves it in CloudFormation to read there. An empty one prints no changes to ..., which is what an ejection should give. The template goes inline, so it must be under 51,200 bytes |
--code |
an S3 object that a function or layer reads and that is not there (aws s3api head-object). A bucket that is a Ref needs its real name from --bucket-name LOGICAL=NAME |
An aws command that fails (no such stack, no permission) exits 2: the check could not run, which says nothing about the template.
check --retention is the offline companion of --plan: it requires DeletionPolicy and UpdateReplacePolicy Retain on those same resources.
zip: what is inside a package¶
An empty change set cannot see inside a zip.
zip reads a package, with no stack: --require NAME files, a --handler module.function that the zip defines, and --wsgi
for what the Serverless WSGI plugin packages (see ejecting). --invoke EVENT.json calls the handler once
in a fresh Python and prints the result; that runs the code in the zip, so use it on a zip you built.
The same checks are in Python: cfn_typed.packaging, and assert_zip_ok, assert_wsgi_package_ok and assert_code_exists in cfn_typed.testing.
ls: list the resources¶
Each line has the logical id, the type (its alias when it has one, ManagedPolicy for an AWS::IAM:: type without one, and the full name otherwise; -l shows the full AWS::SQS::Queue) and the resource's own name when it is a literal (QueueName,
FunctionName, and Name for the types that call it that). -t keeps only some types: an alias
(queue, lambda, role, lb, sg, table, ...), a glob such as 'AWS::SQS::*', or the last
part of a type name, so -t function finds every ...::Function.
The name and alias tables are short lists in cfn_typed/cli.py (_NAME_PROPERTY and _ALIASES).
A type with a blank name is a missing entry, and a pull request that adds it is welcome.
Exit status¶
| Status | Meaning |
|---|---|
| 0 | everything is fine |
| 1 | check found problems, or diff found a difference or a missing file, or check --live (or one of its flags) found a problem, or zip found one |
| 2 | bad usage, such as emit with several stacks and no -o, or an aws command that failed |