Ejecting from the Serverless Framework¶
Two kinds of ejecting
This page is about leaving the Serverless Framework (or SAM) for a service that is already deployed. Moving
patterns you used from cfn-typed-sumo into your own package is a different thing, described in
cfn-typed-sumo. The two meet at the end of this page, in
dropping the sumo dependency.
Start here
Install both packages: uv add cfn-typed cfn-typed-sumo. cfn-typed-sumo has the Serverless helpers (cfn_typed_sumo.sls, and
cfn-typed import --sls, which writes calls to them) and the generated types for about 440 resource types, so you can
pass --types cfn_typed_sumo.types to import and skip cfn-typegen for ordinary stacks. The checks (check --live,
zip) are in cfn-typed itself. When the ejected services are running smoothly, you can
drop the sumo dependency by copying the helpers into your own package.
The Serverless Framework in a few lines page shows what a functions: entry becomes as patterns. This page
is for a service that already runs: you want to stop using the framework for it, and you do not want CloudFormation to
replace anything when you do.
The framework is a template generator, and sls package writes what it generates. So ejecting is not a rewrite
from the YAML. It is taking that template, turning it into Python that renders to exactly the same template, and then
changing it from there. The example is made up: a ledger service with one scheduled Python function, in
examples/serverless_eject. Its
original.json is written the way sls package writes one, with the framework's names, but it did not come from a real run
and nothing in it is from a real account.
Where it goes¶
A Serverless service is one repository, and it keeps its infrastructure in a cfn/ directory next to serverless.yml, in the
layout of a typical project. The ejection adds to it as you go:
my-service/ # the repository that had serverless.yml
serverless.yml # stays until the ejected stack has deployed with an empty change set; then delete it
src/ # the application, and the build step that makes the zip (it stays outside cfn/)
cfn/
cfn-typegen.toml # the types the service uses: `cfn-typegen --template original.json` lists them
cfn_types/ # generated, so it is gitignored (or committed, for a handful of types)
patterns/
sls.py # only after you drop the sumo dependency; until then it is `from cfn_typed_sumo import sls`
stacks/
ledger.py # the imported module, tidied: build(settings) -> cfn.Template
main.py # cli.main({"ledger": StackSpec(build, out=..., stack_name=...)}, settings=Settings)
originals/ # scratch, gitignored: what each stage had when you ejected (dev.json, prod.json), fetched with get-template
tests/
test_ejection.py # build(stage) == the original, for each stage
rendered/
ledger-dev.yaml # one per stage, committed: the files that are reviewed and deployed, and the permanent pin
ledger-prod.yaml
pyproject.toml
Three decisions in it:
- One original per stage. A service deployed to
devandprodhas two templates that differ in their values, andSTAGEis an argument ofbuild. The test pins each stage, so a stage you have not ejected yet is not blocked by one you have. Theparametrizelist in the test is the stages that are ejected. - The originals are scratch,
rendered/is the pin.originals/is for the ejection: fetch each stage withaws cloudformation get-template --stack-name NAME-STAGE --template-stage Original, keep the directory in.gitignore, and let the test skip when a file is absent, so it runs everywhere and bites where the files are (a CI job with read access fetches them first). Once the test passes,rendered/ledger-dev.yamlis the original in a different format, and from then on the golden-file test (testing) is what pins it:assert_matches_filein the test (below) fails on any change, andCFN_TYPED_UPDATE=1writes the file. Both hold the same values (bucket names, ARNs, sometimes an account ID inside an ARN), so what you may commit is the same for both: in a private repository, the rendered files; in a public one, replace the identifying values first, as the made-uporiginal.jsonhere does. serverless.ymlgoes last. Keep it, and keep deploying with it, until the new stack has deployed once with an empty change set. Then delete it, thepackage.jsonof its plugins, and the deploy script'sslslines, in a commit of their own.
The test is short:
# cfn/tests/test_ejection.py
import json
from pathlib import Path
import pytest
from cfn_typed.testing import assert_matches_file
from main import Settings
from stacks.ledger import build
CFN = Path(__file__).parent.parent
STAGES = ["dev", "prod"] # the stages that are ejected
@pytest.mark.parametrize("stage", STAGES)
def test_the_stack_renders_what_is_deployed(stage: str) -> None:
original = CFN / "originals" / f"{stage}.json"
if not original.exists():
pytest.skip(f"no {original}: fetch it with get-template (see the ejecting guide)")
assert build(Settings(stage=stage)) == json.loads(original.read_text())
@pytest.mark.parametrize("stage", STAGES)
def test_the_rendered_file_is_committed(stage: str) -> None:
assert_matches_file(build(Settings(stage=stage)), CFN / "rendered" / f"ledger-{stage}.yaml") # CFN_TYPED_UPDATE=1 writes it
and main.py is the command line with stage as a flag, so python main.py emit ledger --stage prod
renders one stage (-o rendered/ledger-prod.yaml writes it) and python main.py check ledger --stage prod --live --stack-name ledger-prod checks it against the running stack.
Once the stack is ejected, --deployed is the same check as the test, against the live template, and does not need the originals at all.
Take the template¶
For a service built with SAM, sam build does not expand the transform, so take the processed template of the deployed stack
instead: aws cloudformation get-template --stack-name NAME --template-stage Processed.
If the deploy script edits the package before it deploys (sed -i "s/default-stage/$STAGE/g" on the JSON, see
below), the file that matters is the one that was deployed, not the one sls package wrote:
take it from the stack, aws cloudformation get-template --stack-name NAME --template-stage Original, so that the values are the real ones
and the test compares with what is running.
Only that file matters. It has the framework's logical IDs, WorkerLambdaFunction, IamRoleLambdaExecution,
EventsRuleSchedule1, and those IDs are what CloudFormation matches the live stack against.
Generate the types and import¶
$ cfn-typegen --template original.json --out cfn_types
wrote 8 resources in 5 modules to cfn_types
$ cfn-typed import original.json -o imported.py --types cfn_types
wrote imported.py
cfn-typegen --template reads the resource types the template uses and generates a typed factory for each, from the
CloudFormation registry schemas, so you get lambda_.function, iam.role and the rest for exactly this service. import
writes build() -> cfn.Template with those factories, and a plain dict where there is no type for something.
For a template from sls package, cfn-typed import --sls original.json -o stack.py goes further: where the framework's resources for a function,
an event, the role, the bucket or an API are exactly what cfn_typed_sumo.sls writes (see below), the module
calls it, and everything else stays as the plain resources. Each call is tried against the template while importing and used only when it
renders the same resources, so the module still renders to the original, and the header lists what stayed plain.
The module renders back to the original. That is the first thing to pin, in the test suite and before you change a line:
This is also the check for the whole ejection. If it holds, the logical IDs, the properties and the outputs are unchanged, and a deploy of this code has nothing to change.
Reading the framework's output¶
The template looks like a pile, but the framework writes the same few shapes every time, and knowing them tells you what to keep and what to move to a pattern.
| You see | It is | A pattern for it |
|---|---|---|
{Name}LogGroup, {Name}LambdaFunction, {Name}LambdaVersion{hash} |
one functions: entry, three resources |
function (log group and role included) |
{Name}EventsRuleSchedule1 and {Name}LambdaPermissionEventsRuleSchedule1 |
a schedule event, the rule and its permission |
schedule |
{Name}EventSourceMappingSQS{Queue} |
an sqs event |
SQS-triggered Lambda |
ApiGatewayRestApi, ApiGatewayResource…, ApiGatewayMethod…, an …Options method per path |
http events on REST API: a resource tree, and a mock OPTIONS method for CORS |
keep as typed resources until you need to change them |
ApiGatewayApiKey1, ApiGatewayUsagePlan, ApiGatewayUsagePlanKey1 |
private: true on a route |
keep |
HttpApi, HttpApiStage, HttpApiRoute…, HttpApiIntegration… |
httpApi events |
HTTP API with a JWT authorizer |
ServerlessDeploymentBucket and its policy |
the packaging bucket | see the next section |
IamRoleLambdaExecution |
one role for every function in the service | see the next section |
a type starting Custom:: |
a plugin's custom resource | copy it as it is |
Names carry the original key: the framework replaces - with Dash and _ with Underscore, so a function called
api_handler is ApiUnderscorehandlerLambdaFunction. Keep that, since it is the logical ID.
Two things in the role are the framework's doing and not yours. It writes the log statements (logs:CreateLogStream,
CreateLogGroup, TagResource, PutLogEvents, scoped to the service prefix) itself, and it adds the statements
an event needs: an sqs event adds sqs:ReceiveMessage, DeleteMessage and GetQueueAttributes. Your
iamRoleStatements are the rest. When you replace the role, the second kind is what gets forgotten, and the first
time you notice is a function that stops reading its queue.
Where the framework's variables went¶
If you have the serverless.yml and the deploy script, read them next to the template, because some values in it were put there
by steps you cannot see in the JSON.
The framework resolves every ${…} on the machine that runs sls, before it builds the template. So whatever the
variable was, original.json holds the value it had that day, and ejecting from the template is safe. The table matters
for the next step: deciding what each one becomes in Python, so the values do not stay frozen.
serverless.yml |
What it reads | In Python |
|---|---|---|
${self:custom.x}, ${self:provider.region} |
another key in the same file | a variable or function argument. This is the trivial one |
${opt:stage}, ${opt:memory, 1024} |
a CLI flag, with a default | a field of a settings dataclass, which cfn_typed.cli turns into --stage and --memory 2048 |
${sls:stage} |
${opt:stage, self:provider.stage, "dev"} |
a stage field in the settings, with default="dev" |
${sls:instanceId} |
a random id for each CLI run | uuid.uuid4().hex. It is used in ApiGatewayDeployment${sls:instanceId}, so copy the one from the template instead, as in deployment_id |
${aws:region}, ${aws:partition} |
${opt:region, self:provider.region, "us-east-1"}, and a lookup of the ARN prefix |
a region argument, or cfn.ref("AWS::Region") and cfn.ref("AWS::Partition") when the value should be the deployed one |
${aws:accountId} |
an STS call with your credentials | cfn.ref("AWS::AccountId") or ${AWS::AccountId} in cfn.sub(...), which is better than a literal because it works in any account |
${env:NAME}, ${env:NAME, default} |
the process environment, and .env and .env.${stage} files |
os.environ["NAME"] (and os.environ.get("NAME", default)). Or a settings field with metadata={"env": "NAME"}: the flag, then the variable, then the default, and required when it has none. The .env files need python-dotenv or an explicit load |
${file(./x.yml):key}, ${file(./x.json)} |
a YAML or JSON file, a key or all of it | yaml.safe_load or json.loads, then index it. Often a whole resources: block moves into typed resources instead |
${file(./x.js):fn}, ${file(./x.ts)} |
a JavaScript or TypeScript module, an export or a function that gets options, resolveVariable and resolveConfigurationProperty |
an ordinary Python function. This is the case where Python is simply better: the code was always the point |
${param:domain}, stages.prod.params |
a value that is set per stage | a dict keyed by stage: PARAMS[stage]["domain"], with stage a settings field |
${strToBool(${ssm:X})} |
"true" or "false" (also "0", "1") to a boolean |
value.lower() in ("true", "1"). Anything else was an error in the framework, so raise too |
${git:branch}, sha1, commit, message, describe, isDirty, user, email, repository, tags |
the checkout | subprocess.run(["git", "rev-parse", "--short", "HEAD"], ...). Better as a settings field that CI passes in (--sha $GITHUB_SHA), so rendering does not depend on a checkout |
${ssm:/path}, ${aws:ssm:/path} |
read at package time, from SSM Parameter Store or Secrets Manager | pick one. params.ssm(...) (an SSM-typed parameter) or cfn.dynamic_ssm("/path") ({{resolve:ssm:/path}}) read it at deploy time, which is usually what you wanted. boto3 at render time freezes the value in the template, the same as the framework did |
${cf:stack.Output} |
an output of another stack, read at package time | cfn.import_value("Name") if that stack exports it. Otherwise a boto3 call at render time, or a parameter |
${s3:bucket/key} |
the text of an S3 object | boto3, at render time. Rare, and it freezes the value like ssm |
${terraform:outputs:name}, ${vault:path.key}, ${doppler:project/KEY} |
a Terraform state output, or a secret | the same as ssm: a parameter or a dynamic reference for secrets (never in the template text), a render-time call for the rest |
${service:other.Output} |
another service's output, in Serverless Compose only | an import value, or a parameter |
${a, b} |
the first one that exists | x if x is not None else b, or dict.get |
${file(./config.${sls:stage}.json):CREDS} |
a variable inside another | f-strings and function calls, so nesting is not a feature to translate |
The framework only substitutes in values, never in keys: no variable can make a logical ID. That is one thing Python does better. A resource name built in a loop is just a string.
Three of these, ssm, cf and s3, need a decision and not a translation. The framework made a network call when
you ran sls package, and what is in the template is that answer. If you keep the literal, the template is the same as it
was, and a change in the parameter store does not reach the stack until you render again. If you switch to a deploy-time
reference, the template changes, which is what you want, and which a change set shows. Do it as a separate step from the ejection.
A few of these are described in more detail:
- A
sedfor the region. Some deploy scripts also runsed -i "s/eu-west-1/$REGION/g"on the package: a literal region that was typed somewhere in the service is replaced in the generated text, wherever it appears. Find where it was meant (an ARN, a bucket name) and make it theregionargument; the same goes for an identity such asdefault-identitythat the script fills in from a pipeline variable. ${self:…}and${opt:…}are resolved whensls packageruns, so the template holds the literal (dev,eu-west-1). These are theSERVICEandSTAGEvalues ineject.py, andSTAGEis a function argument if one file should render every stage.#{AWS::AccountId}is theserverless-cf-varsplugin: it rewrites#{…}to${…}so thatFn::Subresolves it at deploy time. It is alreadycfn.sub(...)in the imported module.${file(path):field}is also package time: build metadata for tags, or per-stage settings such as subnet IDs. In Python they are a function argument, an environment variable, or a module you import.{{resolve:ssm:/path}}passes through untouched and CloudFormation resolves it at deploy time. Keep the string.default-*placeholders andsed. When onesls packageoutput is deployed to several stages, theserverless.ymlholds made-up values (default-stage,default-kmskey) and the deploy script runssed -i "s/default-stage/$STAGE/g"on the JSON. That is a global string replacement on generated output: it breaks quietly if the placeholder shows up somewhere else, and a large service can have eight of them. Each becomes an argument tobuild, and nothing is substituted in the text. For many services this is the biggest gain of the whole move. Thesedlines in the deploy script are the list of values to turn into arguments.
Tidy by hand, keeping the IDs¶
An imported module is faithful and repetitive: ledger and dev are written into a dozen strings. The first pass
makes the repeated parts values, and nothing else:
SERVICE, STAGE = "ledger", "dev"
PREFIX = f"{SERVICE}-{STAGE}"
LOG_GROUPS = f"arn:${{AWS::Partition}}:logs:${{AWS::Region}}:${{AWS::AccountId}}:log-group:/aws/lambda/{PREFIX}*:*"
return {
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "The AWS CloudFormation template for this Serverless application",
"Resources": {
"ServerlessDeploymentBucket": s3.bucket(
BucketEncryption={"ServerSideEncryptionConfiguration": [{"ServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]},
),
"ServerlessDeploymentBucketPolicy": s3.bucket_policy(
Bucket=cfn.ref("ServerlessDeploymentBucket"),
PolicyDocument={
"Statement": [{
"Action": "s3:*",
"Effect": "Deny",
"Principal": "*",
"Resource": [bucket_arn("/*"), bucket_arn()],
"Condition": {"Bool": {"aws:SecureTransport": False}},
}],
},
),
"WorkerLogGroup": logs.log_group(LogGroupName=f"/aws/lambda/{PREFIX}-worker", RetentionInDays=30),
"IamRoleLambdaExecution": iam.role(
AssumeRolePolicyDocument={
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"Service": ["lambda.amazonaws.com"]}, "Action": ["sts:AssumeRole"]}],
},
Policies=[{
"PolicyName": cfn.join("-", [SERVICE, STAGE, "lambda"]),
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:CreateLogGroup", "logs:TagResource"], "Resource": [cfn.sub(LOG_GROUPS)]},
{"Effect": "Allow", "Action": ["logs:PutLogEvents"], "Resource": [cfn.sub(LOG_GROUPS + ":*")]},
],
},
}],
Path="/",
RoleName=cfn.join("-", [SERVICE, STAGE, cfn.ref("AWS::Region"), "lambdaRole"]),
),
"WorkerLambdaFunction": lambda_.function(
depends_on=["WorkerLogGroup"],
Code={
"S3Bucket": cfn.ref("ServerlessDeploymentBucket"),
"S3Key": "serverless/ledger/dev/1730000000000-2026-10-31T10:00:00.000Z/ledger.zip",
},
Handler="handler.cleanup",
Runtime="python3.12",
FunctionName=f"{PREFIX}-worker",
MemorySize=512,
Timeout=600,
Role=cfn.get_att("IamRoleLambdaExecution", "Arn"),
),
"WorkerLambdaVersionAbc123def456": lambda_.version(
deletion_policy="Retain",
FunctionName=cfn.ref("WorkerLambdaFunction"),
CodeSha256="abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
),
"WorkerEventsRuleSchedule1": events.rule(
ScheduleExpression="cron(0 3 * * ? *)",
State="ENABLED",
Targets=[{"Arn": cfn.get_att("WorkerLambdaFunction", "Arn"), "Id": "workerSchedule"}],
),
"WorkerLambdaPermissionEventsRuleSchedule1": lambda_.permission(
FunctionName=cfn.get_att("WorkerLambdaFunction", "Arn"),
Action="lambda:InvokeFunction",
Principal="events.amazonaws.com",
SourceArn=cfn.get_att("WorkerEventsRuleSchedule1", "Arn"),
),
},
"Outputs": {
"ServerlessDeploymentBucketName": {
"Value": cfn.ref("ServerlessDeploymentBucket"),
"Export": {"Name": f"sls-{PREFIX}-ServerlessDeploymentBucketName"},
},
"WorkerLambdaFunctionQualifiedArn": {
"Description": "Current Lambda function version",
"Value": cfn.ref("WorkerLambdaVersionAbc123def456"),
"Export": {"Name": f"sls-{PREFIX}-WorkerLambdaFunctionQualifiedArn"},
},
},
}
Everything that CloudFormation sees is the same: each resource keeps the ID the framework gave it, and the test
that compares build() with original.json still passes. That test is what lets you tidy without worrying. Change
something that shows in the template and it fails with the difference.
Decide what the framework did for you¶
The framework does four things a template does not show, and ejecting means doing them or choosing not to.
| The framework | After ejecting |
|---|---|
Packages the code and uploads it to ServerlessDeploymentBucket with a new key on every deploy |
You upload the zip (aws s3 cp, your CI) and pass the key. The S3Key in the template is the one thing that changes per release |
Adds a AWS::Lambda::Version with a hash in its logical ID on each deploy |
That ID changes on every deploy, so it cannot be a fixed name. Drop it (DeletionPolicy: Retain kept the old versions), or use live_alias from CDK L2 constructs |
| Creates the deployment bucket and its policy | Keep it while Code points into it. Move the code to a bucket you own, then remove it in a later change |
Builds IAM statements from iamRoleStatements |
They are plain statements in the role. Typed grants replace the handwritten ones when you want them |
Each of these is a change to the template, and each shows up in a diff.
What real services set¶
The example services here use the framework's defaults. Most real ones set something that changes the template, and these are the
ones found in a sample of real serverless.yml files:
serverless.yml |
What is different | In sls.function |
|---|---|---|
no functions:, only resources: |
the template is those resources, copied as they are: no role, no deployment bucket resource. Most of the simple stacks are a bucket and its policy | nothing to call. import (or import --sls, which says it found nothing and writes the same) is the whole job |
provider.deploymentBucket.name |
no ServerlessDeploymentBucket; Code.S3Bucket is that name, not a Ref, and the output holds the name |
bucket="name", and sls.outputs(..., bucket="name") |
provider.versionFunctions: false |
no {Key}LambdaVersion, so no hash to copy |
versioned=False |
provider.iam.role: MyRole |
no IamRoleLambdaExecution; the role is yours, in resources:, and the function's Role points at it |
role=get_att("MyRole", "Arn") |
functions.x.name: some-name |
the function is not {service}-{stage}-{key} |
name="some-name" |
http event path: /{proxy+}, method: ANY, cors: true |
one REST method ANY on the {proxy+} resource, and its OPTIONS |
Route("/{proxy+}", "ANY", "app") |
package.artifact: x.zip, individually: true |
the zip is yours, built outside the framework: its key in the bucket is the only thing that moves | s3_key |
import --sls handles all of them: it tries each call and writes it only when it renders what the template has, so a service
that combines two of them is still right.
Python WSGI apps¶
A Flask or Django service that uses the serverless-wsgi plugin has wsgi_handler.handler as its Lambda handler, not
serverless_wsgi.handler. The plugin adds three files to the package when it packages, and none of them is in your code:
| File | Where it comes from |
|---|---|
wsgi_handler.py |
only the plugin's npm package (npm pack serverless-wsgi and take it from the tarball), not PyPI. Vendor it into your repo with the plugin's licence |
serverless_wsgi.py |
the PyPI package serverless-wsgi: install it with your dependencies, or vendor the file |
.serverless-wsgi |
JSON the plugin writes from custom.wsgi.app: cfn_typed.packaging.wsgi_config("api.app") writes it |
None of it is tied to the rest of Serverless, so the function runs unchanged once the three files are in the zip and Handler
stays wsgi_handler.handler. The danger is that an empty change set cannot see them: they live in the zip, and a package
that lacks one deploys cleanly and fails on the first request. Put a check in CI, after the build step that makes the zip:
from cfn_typed.packaging import call_handler
from cfn_typed.testing import assert_wsgi_package_ok
def test_the_package_is_sound() -> None:
assert_wsgi_package_ok("build/api.zip", app="api.app") # all three files, and the app is a module in the zip
reply = call_handler("build/api.zip", "wsgi_handler.handler", HEALTH_EVENT) # the handler, once, in a fresh Python
assert reply["statusCode"] == 200
HEALTH_EVENT is an API Gateway proxy event: copy a real one (the API Gateway console's test tab, or a log line) and shorten it. The
command line does the same: cfn-typed zip build/api.zip --wsgi --app api.app --invoke event.json. .serverless-wsgi is the plugin's file, so
compare wsgi_config with the one in a package from a real sls package before relying on it.
Where the zip is¶
The template names the code by an S3 key in the deployment bucket, and a plugin's custom resource (the existing: true S3 bucket
one, for example) names its own zip there too. Keep those objects: if the key is gone, the next update of that function fails, and the
change set does not tell you. check --code --bucket-name ServerlessDeploymentBucket=NAME (or assert_code_exists) looks up every
S3 key the template reads and lists the missing ones. Run it before the first deploy from the new code (advanced checks).
Then use the patterns¶
Most patterns pick their own logical IDs: function makes Worker, WorkerRole and WorkerLogs, not
WorkerLambdaFunction, IamRoleLambdaExecution and WorkerLogGroup, and a new ID is a replacement. For a service that
is already deployed, use cfn_typed_sumo.sls: the framework's resources, written under the framework's IDs.
from cfn_typed_sumo import sls
import cfn_typed as cfn
SERVICE, STAGE = "ledger", "dev"
def build() -> cfn.Template:
worker = sls.function(
"worker",
service=SERVICE,
stage=STAGE,
s3_key="serverless/ledger/dev/1730000000000-2026-10-31T10:00:00.000Z/ledger.zip",
handler="handler.cleanup",
runtime="python3.12",
memory=512,
timeout=600,
log_retention_days=30,
code_sha256="abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
version_id="Abc123def456",
)
return {
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "The AWS CloudFormation template for this Serverless application",
"Resources": cfn.combine(
sls.deployment_bucket(),
sls.iam_role(SERVICE, STAGE),
worker,
sls.schedule_event("worker", "cron(0 3 * * ? *)"),
),
"Outputs": sls.outputs(SERVICE, STAGE, worker),
}
build() renders to exactly original.json, and the test suite fails on any difference, so deploying it changes
nothing.
| Function | Writes | IDs |
|---|---|---|
sls.function(key, ...) |
a functions: entry, with log_destination= for log forwarding |
{Key}LogGroup, {Key}LambdaFunction, {Key}LambdaVersion{id}, {Key}LogSubscriptionFilter |
sls.iam_role(service, stage, statements) |
the shared role. log_actions= is for a framework that wrote the log statement without logs:TagResource (versions 1 and 2) |
IamRoleLambdaExecution |
sls.deployment_bucket() |
the packaging bucket and its policy | ServerlessDeploymentBucket, ServerlessDeploymentBucketPolicy |
sls.schedule_event(key, expression) |
a schedule event |
{Key}EventsRuleSchedule1, {Key}LambdaPermissionEventsRuleSchedule1 |
sls.sqs_event(key, queue) and sls.sqs_statement(queue) |
an sqs event, and the role statement the framework adds for it |
{Key}EventSourceMappingSQS{Queue} |
sls.sns_event(key, topic) |
an sns event on a topic in the template: the subscription and its permission |
{Key}SnsSubscription{Name}, {Key}LambdaPermission{Name}SNS |
sls.stream_event(key, stream, kind=...) and sls.stream_statement(kind, arn) |
a stream event (dynamodb or kinesis), and its role statement |
{Key}EventSourceMapping{Dynamodb\|Kinesis}{Name} |
sls.function(..., vpc=sls.Vpc(...)) and sls.vpc_statement() |
a function in a VPC, and the network interface statement the framework adds to the role | VpcConfig on {Key}LambdaFunction |
sls.rest_api(service, stage, [Route(...)]) |
http events: the API, a resource per path segment, a method per route, a CORS OPTIONS per path, the deployment, the permissions, and the API key bundle for private=True routes. binary_media_types=[...] is apiGateway.binaryMediaTypes and goes on the API; authorizers={...} and Route(authorizer=...) write custom authorizers |
ApiGatewayRestApi, ApiGatewayResourceTodosIdVar, ApiGatewayMethodTodosIdVarGet, ApiGatewayMethodTodosOptions, ApiGatewayDeployment{id}, {Key}LambdaPermissionApiGateway, ApiGatewayApiKey1 |
sls.http_api(name, [HttpRoute(...)], authorizers=...) |
httpApi events with JWT authorizers |
HttpApi, HttpApiStage, HttpApiAuthorizer{Name}, HttpApiIntegration{Key}, HttpApiRouteGetUsersIdVar, {Key}LambdaPermissionHttpApi |
sls.outputs(...) and sls.endpoint_outputs(...) |
the outputs the framework adds | ServerlessDeploymentBucketName, {Key}LambdaFunctionQualifiedArn, ServiceEndpoint, HttpApiUrl |
sls.logical("api_handler") gives ApiUnderscorehandler, the framework's rule for turning a key into a name, and
sls.path_id("/todos/{id}") gives TodosIdVar, its rule for a path.
A service with the usual events looks like this, from
with_sls_api.py: a REST API,
an HTTP API behind a JWT authorizer, and an SQS consumer, that renders to exactly the made-up original_api.json:
from cfn_typed_sumo import sls
from cfn_typed_sumo.types import sqs
import cfn_typed as cfn
SERVICE, STAGE = "shop", "dev"
S3_KEY = "serverless/shop/dev/1730000000000-2026-10-31T10:00:00.000Z/shop.zip"
def fn(key: str, version_id: str, **more):
return sls.function(
key, service=SERVICE, stage=STAGE, s3_key=S3_KEY, handler=f"{key}.handler", runtime="python3.12",
memory=256, timeout=30, code_sha256="f" * 64, version_id=version_id, **more,
)
def build() -> cfn.Template:
todos, users = fn("todos", "TodosHash1"), fn("users", "UsersHash1")
consumer = fn("consumer", "ConsumerHash1", log_destination="arn:aws:logs:eu-west-1:111111111111:destination:central")
auth0 = sls.JwtAuthorizer("https://example.auth0.com/", ["https://api.example.com"])
return {
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "The AWS CloudFormation template for this Serverless application",
"Resources": cfn.combine(
sls.deployment_bucket(),
sls.iam_role(SERVICE, STAGE, [
cfn.allow(["dynamodb:GetItem", "dynamodb:PutItem"],
[cfn.sub("arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/shop-dev-todos")]),
sls.sqs_statement("OrdersQueue"),
]),
{"OrdersQueue": sqs.queue(QueueName="shop-dev-orders", VisibilityTimeout=60)},
todos, users, consumer,
sls.rest_api(SERVICE, STAGE, [
sls.Route("/todos", "GET", "todos", private=True),
sls.Route("/todos", "POST", "todos"),
sls.Route("/todos/{id}", "GET", "todos"),
], deployment_id="1730000000001"),
sls.http_api("dev-shop", [
sls.HttpRoute("GET", "/users/{id}", "users", authorizer="auth0"),
sls.HttpRoute("POST", "/users", "users", authorizer="auth0"),
], authorizers={"auth0": auth0}),
sls.sqs_event("consumer", "OrdersQueue", batch_size=5),
),
"Outputs": sls.outputs(SERVICE, STAGE, todos, users, consumer) | sls.endpoint_outputs(SERVICE, STAGE, rest=True, http=True),
}
Some arguments need a value from the template you are ejecting from, because the framework computes it:
version_id and code_sha256 for each function, and deployment_id for the REST API's deployment (it is a timestamp). Copy them.
The IDs have to stay the same, and the next deploy that changes the code decides what the new ones are.
What is not here: s3 and cloudwatchLog events, and custom domains (see the next section). They stay as typed resources from the import, and
the test that compares with the original keeps them honest. The authorizer, sns, stream and VPC pieces were written from the framework's
documentation and its usual output, not from a template of yours, so import --sls is the check: when it does not claim a group, the
group differs from what sls writes, and the plain resources are what to keep.
The two templates here are made up, in the shapes the framework writes, so check the IDs against one of
your own: sls.function(...) for a function, then emit and compare.
Moving a resource to a pattern with its own IDs is still a real change, which is fine for stateless things and not for anything that holds data.
Resources the framework made with API calls¶
A plugin can create a resource that is not in the template at all. serverless-domain-manager makes the API's custom domain and its
base path mapping with API calls, and the package has no trace of them. Ejecting such a service means putting them in the template, and
CloudFormation cannot create a domain that exists: it has to be imported (CloudFormation's resource import). cfn_typed.adopt.resources_to_import
builds the list, checking each identifier against the registry schemas, and cfn_typed.changes.plan(..., resources_to_import=...)
makes the IMPORT change set. It is rare (one service in a sample of fifty used the plugin), so there is no command for it.
When the runtime is already dead¶
The check says which runtimes are past their dates (cfn-typed check), and for a runtime AWS no longer lets you update it says so:
nodejs8.10, python2.7 and dotnetcore2.1 can be neither created nor updated. An ejection with an unchanged template never calls
UpdateFunction, so the first deploy is safe, and the function keeps running. The block bites on the first change to the function
(new code, memory, an environment variable): that deploy fails. So eject first, with an empty change set, and move the runtime as its own
change, which is a Python or Node upgrade with its own testing. A runtime that is only deprecated is a warning, with a date.
The version of the framework¶
A template from Serverless 1 or 2 differs from a version 3 one in details, so do not assume the shapes above. Known: the role's log
statement has no logs:TagResource (iam_role(log_actions=[...])). The rest you find by importing: import --sls claims what matches
and leaves the rest as plain resources. A service with its own stackName deploys under that name: give the stack
StackSpec(build, stack_name="...") so check --plan and check --deployed look at the right one.
Try it on one real stack first¶
The two example templates here are made up, and the tests prove the tools against those only. Before trusting any of it, run the whole procedure on one real stack, which costs nothing because nothing is executed:
$ aws cloudformation get-template --stack-name NAME --template-stage Processed > original.json
$ cfn-typed import --sls original.json -o stack.py # then the tests: build() == original.json
$ python stacks.py check ledger --deployed --plan --stack-name NAME --template-stage Processed # rendered vs running; a change set, never executed, that should say no changes
--deployed says where the rendered template and the live one differ, and --plan stops on a stateful resource that would be replaced. When both are clean, the
ejection is exact for that stack. Start with one of the S3-only stacks, and keep a copy of the real original.json, with the
account IDs and names replaced, as the test fixture for the services like it.
Dropping the sumo dependency¶
cfn-typed-sumo is a good place to start, and a poor thing to keep: its helpers can change between releases, and the template of a
running service should change when you decide. Once the ejected services deploy cleanly, with an empty change set and the tests
that compare build() with the original passing, cut the dependency in three steps. Each leaves the rendered templates unchanged,
which the tests prove.
- Copy
sls.pyfrompackages/sumo/src/cfn_typed_sumo/into your ownpatterns/directory (cfn/patterns/sls.py, in the project layout). It depends only oncfn-typedand on the generated types of a few services, so there is nothing else to untangle. Copytests/test_sls.pywith it. - Generate the types it imports, yourself.
sls.pyhas no types of its own: there is no "sls" module to generate. It imports the ordinary registry types of eight services (thefrom cfn_typed_sumo.types import apigateway, apigatewayv2, events, iam, lambda_, logs, s3, snsat the top), whichcfn-typegenmakes for any project. Put them in yourcfn-typegen.toml, with the templates of the services you ejected, so that their other resources (a queue, a table) get factories in the same package:
# cfn/cfn-typegen.toml: `cfn-typegen --config cfn/cfn-typegen.toml` writes cfn/cfn_types/
out = "cfn_types"
docs = false
types = [
# what patterns/sls.py imports
"AWS::ApiGateway::*",
"AWS::ApiGatewayV2::*",
"AWS::Events::Rule",
"AWS::IAM::Role",
"AWS::Lambda::*",
"AWS::Logs::*",
"AWS::S3::Bucket*",
"AWS::SNS::Subscription",
]
# what each ejected service's own template uses
templates = ["../services/ledger/original.json", "../services/shop/original.json"]
Then change the imports. In patterns/sls.py, from cfn_typed_sumo.types import (...) becomes from cfn_types import (...). In each stack
module, from cfn_typed_sumo import sls becomes from patterns import sls, and from cfn_typed_sumo.types import sqs becomes from cfn_types import sqs.
Commit the toml, and either commit the generated files or generate them in CI
(the choice is described here).
3. Run what proves it. The tests that compare build() with original.json for each service, emit before and after, and
ty check. Then remove cfn-typed-sumo from the dependencies, so that no import slips back.
import --sls is a one-time tool and does not need to be copied: use it while you are ejecting
(uvx --with cfn-typed-sumo cfn-typed import --sls original.json -o stack.py runs it without adding the package to the project), and the
module it writes needs only your copy of sls.py. A service you eject later, after dropping the dependency, can run it the same way.
You can also stop at any point: a service that works with sumo's helpers can keep them until you want to change one.
Start with sumo, then eject what you use is the general version of this.
Advanced checks¶
An empty change set only proves that CloudFormation sees no change. These checks ask what it cannot see: the live template, what a
deploy would destroy, and the code behind the template. They are opt-in because they call AWS; plain check stays offline.
Use them in this order on each service, and stop at the first one that reports something.
| Check | Asks | Finds |
|---|---|---|
check --deployed |
aws cloudformation get-template |
where the template your code renders differs from the one the stack has, by path in the template. This is the live version of the test that compares build() with original.json |
check --plan |
aws cloudformation create-change-set, then describe-change-set, then delete-change-set |
a stateful resource (table, bucket, queue, key, distribution, log group, database) that a deploy would replace or remove. It prints the change set, and exits 1 on such a change. The change set is never executed |
check --code |
aws s3api head-object, once per distinct object |
a function or layer whose S3 zip is not there: the deployment bucket's cleaned-up keys, a plugin's custom resource zip |
zip |
nothing: it reads a file | a package zip that lacks files (the wsgi trio, a handler that does not exist), and with --invoke a handler that fails when called once |
check --retention |
nothing: it reads the template | a stateful resource with no Retain or Snapshot policy, which a later removal or replacement would destroy |
--live is --deployed --plan --code together. The stack is named like the stack here, unless it has
StackSpec(build, stack_name="...") or you pass --stack-name (a service that set its own stackName).
$ python stacks.py check ledger --live --stack-name ledger-dev --bucket-name ServerlessDeploymentBucket=ledger-dev-serverlessdeploymentbucket-abc
How they use AWS. Each one runs the aws command line as a subprocess: there is no boto3 and cfn-typed never reads credentials. It
uses whatever aws resolves (environment variables, a profile, SSO), and --aws-profile and --aws-region are passed on. A command that
fails (no such stack, no permission) exits 2, which says the check could not run, not that the template is wrong. The permissions needed
are cloudformation:GetTemplate for --deployed; CreateChangeSet, DescribeChangeSet and DeleteChangeSet for --plan; and
s3:GetObject on the zips for --code.
What to know before running them.
--planis the only one that is not purely a read: it creates a change set and deletes it again. The stack is untouched. If the process dies in between, a harmless change set is left behind (--keep-change-setleaves it on purpose, for you to read in CloudFormation). It sends the template inline, so it must be under 51,200 bytes, and a stack that is in the middle of an update cannot have one.--codetreats any failure ofhead-objectas a missing object, including a permission error, so a role that cannot read the deployment bucket sees every zip as missing. A bucket that is aRefneeds its real name from--bucket-name LOGICAL=NAME; it is reported and not guessed.--deployedcompares the whole template, includingDescriptionandMetadata. Take the original from the same stage you rendered against (--template-stage Processedfor a SAM stack, after the transforms have run), and expect differences for anything the deploy scriptseds into the package.zip --invokeruns the code in the zip, in a fresh Python on your machine: use it on a package you built.- None of these has been run against a real stack by the people who wrote them: the tests use a stand-in for
aws. The first run on a real stack is the real test, so make it an S3-only one, as below.
The same checks are Python functions for your test suite (find_deployed_differences, find_change_problems, find_missing_code, find_zip_problems,
find_wsgi_problems and find_retention_problems, and the assert_* forms in cfn_typed.testing). The command line is
check --live and zip.
Deploy to the same stack¶
Use the stack name the framework used (ledger-dev, the service and the stage), and do not run sls remove: it
deletes the stack.
$ aws cloudformation deploy --stack-name ledger-dev --template-file ledger.yaml \
--capabilities CAPABILITY_NAMED_IAM --no-execute-changeset
$ aws cloudformation describe-change-set --stack-name ledger-dev --change-set-name <name from the output>
python stacks.py check --plan ledger (the command line of your stack script) does the same without leaving a change set behind, prints the changes and exits 1 when a
table, bucket, queue, key, distribution or database would be replaced or removed.
Read the change set before executing it. An empty one means the ejection is exact. A resource marked Replacement is
a logical ID or an immutable property that changed, and you decide whether that is fine for it.
Note
The example was checked by rendering and comparing, in the tests, and not against a real stack. The framework's
template here is made up in the framework's style. A real one has more in it (API Gateway, event source mappings,
custom resources), and the types for them come from the same cfn-typegen --template.