Exit Serverless to Jsonnet¶
Convert Serverless Framework or SAM-generated CloudFormation templates into
Jsonnet source files using cfn.libsonnet, sls.libsonnet, and sam.libsonnet.
When to use¶
The user provides a CloudFormation JSON template generated by sls package or
pulled from a deployed stack. For SAM, use the processed deployed template
(aws cloudformation get-template --template-stage Processed), because
sam build does not expand the transform to its final raw resources.
Inputs¶
- A CloudFormation JSON template (file path or inline)
- Which library to target:
sls.libsonnet(explicit wiring) orsam.libsonnet(SAM-style events) - Service name and stage (extract from the template if not provided)
Process¶
Step 1: Inventory the template¶
Read the template and classify every resource by pattern. Use this table:
| CFN resource type | Count it as | Notes |
|---|---|---|
AWS::S3::Bucket named ServerlessDeploymentBucket |
Deployment bucket | Use sls.deploymentBucketG |
AWS::S3::BucketPolicy on that bucket |
Deployment bucket | Included in sls.deploymentBucketG |
AWS::IAM::Role with AssumeRolePolicyDocument allowing lambda.amazonaws.com |
Lambda execution role | Use sls.iamRoleG() |
AWS::Logs::LogGroup with name /aws/lambda/... |
Lambda triplet (⅓) | Part of sls.lambdaFnG() |
AWS::Lambda::Function |
Lambda triplet (⅔) | Part of sls.lambdaFnG() |
AWS::Lambda::Version referencing a function |
Lambda triplet (3/3) | Part of sls.lambdaFnG() |
AWS::Logs::SubscriptionFilter on a log group |
Log forwarding | Use logDestination param on sls.lambdaFnG() |
AWS::Events::Rule with ScheduleExpression |
Schedule event | Use sls.scheduleEventG() |
AWS::Lambda::Permission for events.amazonaws.com |
Schedule event (companion) | Included in sls.scheduleEventG() |
AWS::SQS::Queue |
SQS queue | Use sls.sqsQueue() |
AWS::Lambda::EventSourceMapping with SQS source |
SQS event | Use sls.sqsEventSource() |
AWS::Lambda::EventSourceMapping with DynamoDB source |
DynamoDB stream | Not yet in library — use raw Jsonnet object |
AWS::ApiGateway::RestApi |
REST API (v1) | Use sls.restApiG() |
AWS::ApiGateway::Resource |
REST API path segment | Use sls.apiResource() |
AWS::ApiGateway::Method with Type: AWS_PROXY |
REST API route | Use sls.apiMethod() |
AWS::ApiGateway::Method with Type: MOCK and HttpMethod: OPTIONS |
CORS mock | Use sls.corsOptions() |
AWS::ApiGateway::Deployment |
REST API deployment | Use sls.apiDeployment() |
AWS::Lambda::Permission for apigateway.amazonaws.com |
API GW permission | Use sls.apiLambdaPermission() |
AWS::ApiGateway::ApiKey |
API key | Part of API key bundle (manual block or sam.Api with UsagePlan) |
AWS::ApiGateway::UsagePlan |
Usage plan | Part of API key bundle |
AWS::ApiGateway::UsagePlanKey |
Usage plan key | Part of API key bundle |
AWS::ApiGatewayV2::Api |
HTTP API (v2) | Use sls.httpApiG() |
AWS::ApiGatewayV2::Stage |
HTTP API stage | Included in sls.httpApiG() |
AWS::ApiGatewayV2::Authorizer |
JWT authorizer | Use sls.httpApiJwtAuthorizer() |
AWS::ApiGatewayV2::Integration |
HTTP API integration | Part of sls.httpApiFnG() |
AWS::ApiGatewayV2::Route |
HTTP API route | Part of sls.httpApiFnG() |
AWS::Lambda::Permission for apigateway.amazonaws.com (v2) |
HTTP API permission | Included in sls.httpApiFnG() |
AWS::EC2::SecurityGroup |
VPC security group | Pass-through as raw Jsonnet object |
AWS::DynamoDB::Table |
DynamoDB table | Pass-through as raw Jsonnet object |
AWS::SNS::Topic |
SNS topic | Pass-through as raw Jsonnet object |
AWS::SNS::Subscription |
SNS subscription | Pass-through as raw Jsonnet object |
AWS::SQS::QueuePolicy |
Queue policy | Pass-through as raw Jsonnet object |
Anything with Custom:: type |
SLS custom resource | Copy verbatim as raw Jsonnet block |
Step 1b: Recognize interpolation hacks¶
If you have access to the serverless.yml (not just the generated JSON),
look for the interpolation patterns below. Each one represents a value that
was resolved at a different time in the SLS pipeline. Understanding when each
resolves tells you how to replace it in Jsonnet.
${self:...} / ${opt:...} — resolved at package time. SLS bakes
these into the generated JSON as string literals. ${self:provider.stage}
becomes "dev", ${opt:region} becomes "eu-west-1". In Jsonnet, replace
with std.extVar('stage') or a local variable. If the serverless.yml uses
custom.defaultStage / custom.defaultRegion placeholder values (like
default-stage), these placeholders will appear as literals in the generated
JSON — see the sed pattern below.
#{AWS::AccountId} / #{AWS::Region} — the serverless-cf-vars plugin.
This plugin rewrites #{...} to ${...} in the output JSON so that
CloudFormation's Fn::Sub resolves them at deploy time. Without the plugin,
SLS would try to resolve ${AWS::AccountId} as its own variable and fail.
In the generated JSON these appear inside Fn::Sub expressions. In Jsonnet,
use cfn.sub(...) or cfn.arn(...).
${file(path):field} — file-based lookups, resolved at package time.
Common variants:
- ${file(build_info.yml):Branch} — CI build metadata injected into tags.
In Jsonnet, replace with std.extVar('branch') passed at render time.
- ${file(references.yml):${self:provider.stage}.subnetIds} — stage-keyed
config in a separate YAML file. In Jsonnet, replace with std.extVar or
an imported Jsonnet config object.
default-* placeholder + sed — the most fragile pattern. When one
sls package output is deployed to multiple stages/regions, the
serverless.yml uses made-up defaults (default-stage, default-kmskey,
default-cmosEventsTopic, etc.) that appear as literals in the generated
JSON. The deploy script then runs sed -i "s/default-stage/${REAL_VALUE}/g"
on the JSON before sls deploy --package. This is global string replacement
on machine-generated JSON — if the placeholder appears in an unexpected
context, or a real value contains the placeholder string, things break
silently. Larger services can have 8+ sed replacements. In Jsonnet, all of
these become std.extVar(...) — resolved at render time with no string
replacement. This is often the single biggest reliability win of the
migration.
{{resolve:ssm:/path}} — CloudFormation dynamic references. These pass
through SLS untouched and are resolved by CloudFormation at deploy time.
Common for Splunk destinations, VPC IDs, subnet IDs, KMS keys. In Jsonnet,
keep these as literal strings — they work in raw CloudFormation JSON
unchanged.
When converting, check the deploy.sh / deployspec.yml alongside the
serverless.yml. The sed replacements in the deploy script reveal which
values are parameterized at deploy time — these are your std.extVar
candidates. Values that only appear in ${self:...} expressions are
package-time constants that become locals or ext-vars in Jsonnet.
Step 2: Extract parameters¶
From the template, identify:
- Service name: usually in the IAM role name pattern
{service}-{stage}-{region}-lambdaRole - Stage: from function names
{service}-{stage}-{functionKey}, or tag values - S3 key: from
Code.S3Keyon Lambda functions - CodeSha256: from Lambda::Version resources
- Tags: from any tagged resource — collect the common tag set
- Environment variables: from
Lambda::Function.Properties.Environment.Variables - Extra IAM statements: everything in the IAM role's PolicyDocument except the standard CloudWatch log permissions (CreateLogStream, CreateLogGroup, TagResource, PutLogEvents scoped to the service prefix)
- Managed policies: from
ManagedPolicyArnson the IAM role (e.g.AWSLambdaVPCAccessExecutionRole) - VPC config: from
VpcConfigon Lambda functions - Log destinations: from
SubscriptionFilter.Properties.DestinationArn
Step 3: Choose the library level¶
Three libraries are available, each at a different abstraction level:
Use aws.libsonnet (clean-room) when:
- The user is not migrating an existing stack in-place (no logical ID preservation needed)
- The template has custom IAM roles (not the standard SLS-generated shared role)
- The user wants a chainable builder API: aws.lambda() returns an object with
.routes(), .schedule(), .sqsSource(), .logSubscription() methods
- HTTP API v2 with JWT auth maps cleanly to aws.httpApiG() + aws.jwtAuthorizer()
+ the lambda builder's .routes() method
Use sls.libsonnet (SLS-compatible) when:
- Migrating an existing SLS stack in-place — logical IDs match SLS conventions
(IamRoleLambdaExecution, ServerlessDeploymentBucket, {Prefix}LambdaFunction)
- The template uses the standard SLS pattern (one shared IAM role, deployment bucket)
- Resources have unusual DependsOn chains or Conditions that need explicit wiring
Use sam.libsonnet (SAM-shaped input) when:
- The template follows the standard SLS/SAM pattern (one role, functions with events)
- The user prefers conciseness over explicit control
- The user is familiar with SAM concepts (Events inside Functions)
Step 4: Write the Jsonnet file¶
With sls.libsonnet (preserves SLS logical IDs):
local cfn = import 'lib/cfn.libsonnet';
local sls = import 'lib/sls.libsonnet';
local service = '{extracted service name}';
local stage = std.extVar('stage');
local tags = cfn.tags({ ... });
local extraStatements = [ ... ];
{
AWSTemplateFormatVersion: '2010-09-09',
Resources:
sls.deploymentBucketG
+ sls.iamRoleG(service + '-' + stage, extraStatements)
+ sls.lambdaFnG(...)
+ ...remaining resources...,
Outputs: { ... },
}
With aws.libsonnet (clean logical IDs, chainable builders):
local cfn = import 'lib/cfn.libsonnet';
local aws = import 'lib/aws.libsonnet';
local service = '{extracted service name}';
local stage = std.extVar('stage');
local tags = cfn.tags({ ... });
local app = aws.lambda('App', {
FunctionName: service + '-' + stage + '-app',
Handler: 'wsgi_handler.handler',
Code: { S3Bucket: '...', S3Key: '...' },
Role: cfn.getArn('AppRole'),
Tags: tags,
});
{
AWSTemplateFormatVersion: '2010-09-09',
Resources:
app.resources
+ app.routes('Api', {
Default: { routeKey: '$default', authorizerId: cfn.ref('ApiAuth') },
})
+ app.schedule('Cleanup', 'cron(0 3 * * ? *)')
+ aws.httpApiG('Api', service + '-' + stage)
+ {
ApiAuth: aws.jwtAuthorizer('Api', 'jwt', issuer, audience),
AppRole: aws.lambdaRole(service + '-' + stage, extraStatements),
},
Outputs: { ... },
}
Key rules:
- Parameterize stage with std.extVar('stage') so one file generates all stages
- Put tags in a local and pass to every lambdaFn call
- Put the log forwarding destination in a local and pass as logDestination
- Keep service-specific resources (DynamoDB tables, SecurityGroups, SNS topics) as raw Jsonnet objects — don't force them into library helpers
- Preserve the original logical IDs if the user needs to deploy to an existing stack without replacement
Step 5: Verify¶
Instruct the user to run:
jsonnet --preserve-field-order --ext-str stage=dev my-service.jsonnet > generated.json
diff <(python3 -m json.tool original.json) <(python3 -m json.tool generated.json)
Expected differences: - Lambda::Version logical IDs (SLS embeds a hash, Jsonnet uses a stable name) - Minor formatting
Note: Use --preserve-field-order (available in
go-jsonnet-fork) to keep keys
in source order. Without it, Jsonnet sorts keys alphabetically, producing noisy
diffs against the original template.
Unexpected differences to investigate: - Missing resources (forgot to include a pattern) - Wrong ARN references (logical ID mismatch) - Missing tags or environment variables
Pattern recognition guide¶
The Lambda triplet¶
The most common pattern. Always appears as exactly three resources with a shared name prefix:
{Prefix}LogGroup → AWS::Logs::LogGroup
{Prefix}LambdaFunction → AWS::Lambda::Function (DependsOn: LogGroup)
{Prefix}LambdaVersion{Hash} → AWS::Lambda::Version (Ref: Function)
The prefix is the PascalCased function key from serverless.yml. SLS replaced
- with Dash and _ with Underscore:
- api_handler → ApiUnderscorehandler
- my-service → MyDashservice
Replace with: sls.lambdaFnG('{Prefix}', ...)
Optional fourth resource if log forwarding was configured:
Replace with:logDestination parameter on sls.lambdaFnG()
The schedule pair¶
Always two resources:
{Prefix}EventsRuleSchedule1 → AWS::Events::Rule (ScheduleExpression)
{Prefix}LambdaPermissionEventsRuleSchedule1 → AWS::Lambda::Permission (events.amazonaws.com)
Replace with: sls.scheduleEventG('{Prefix}', schedule, enabled, ruleName)
The SQS event source¶
Single resource with a name like {Prefix}EventSourceMappingSQS{QueueLogical}:
{Prefix}EventSourceMapping... → AWS::Lambda::EventSourceMapping
EventSourceArn: {Fn::GetAtt: [QueueLogical, Arn]}
FunctionName: {Fn::GetAtt: [{Prefix}LambdaFunction, Arn]}
Replace with: { logicalName: sls.sqsEventSource(functionLogical, queueLogical, batchSize) }
Remember: SLS auto-added sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes to the IAM role. These must be in extraStatements.
The REST API resource tree¶
A single ApiGateway::RestApi at the root, then a tree of ApiGateway::Resource entries linked by ParentId:
ApiGatewayRestApi
└─ ApiGatewayResource{PathSegment} (parent: RootResourceId)
└─ ApiGatewayResource{PathSegment}{Seg} (parent: above)
Path parameters become {name} → Var suffix: {id} → IdVar, {proxy+} → ProxyVar.
Replace with: sls.restApiG() + sls.apiResource() per segment, or sam.Api() which auto-generates the tree.
The API method + CORS pair¶
Per route, two resources:
ApiGatewayMethod{Path}{Method} → AWS_PROXY integration to Lambda
ApiGatewayMethod{Path}Options → MOCK integration (CORS preflight)
The CORS OPTIONS method's Access-Control-Allow-Methods header lists all real HTTP methods on that path resource (not all methods globally). So /items with GET and POST would have 'OPTIONS,GET,POST'.
Replace with: sls.apiMethod() + sls.corsOptions(path, [methods on this path])
The API key bundle¶
Three resources that always appear together:
ApiGatewayApiKey1 → AWS::ApiGateway::ApiKey
ApiGatewayUsagePlan → AWS::ApiGateway::UsagePlan
ApiGatewayUsagePlanKey1 → AWS::ApiGateway::UsagePlanKey
All three DependOn the Deployment resource. Triggered by private: true on routes in serverless.yml.
The HTTP API cluster¶
Simpler than REST API — no resource tree, no CORS mocks:
HttpApi → AWS::ApiGatewayV2::Api (ProtocolType: HTTP)
HttpApiStage → AWS::ApiGatewayV2::Stage ($default, AutoDeploy)
HttpApiAuthorizer{Name} → AWS::ApiGatewayV2::Authorizer (JWT)
HttpApiIntegration{FunctionPrefix} → AWS::ApiGatewayV2::Integration (one per Lambda)
HttpApiRoute{Method}{Path} → AWS::ApiGatewayV2::Route (one per event)
{Function}LambdaPermissionHttpApi → AWS::Lambda::Permission
Replace with: sls.httpApiG() + sls.httpApiJwtAuthorizer() + sls.httpApiFnG(), or sam.HttpApi().
The deployment bucket¶
Present in almost every template. Two resources:
ServerlessDeploymentBucket → AWS::S3::Bucket (AES256 encryption)
ServerlessDeploymentBucketPolicy → AWS::S3::BucketPolicy (deny insecure transport)
Replace with: sls.deploymentBucketG (a constant, not a function call).
The IAM execution role¶
One per service, shared by all functions:
IamRoleLambdaExecution → AWS::IAM::Role
AssumeRolePolicyDocument: allows lambda.amazonaws.com
Policies: [{
PolicyDocument: {
Statement: [
// STANDARD (always present, do not include in extraStatements):
logs:CreateLogStream + logs:CreateLogGroup + logs:TagResource → scoped to service prefix
logs:PutLogEvents → scoped to service prefix
// EXTRA (include in extraStatements):
everything else — ec2, dynamodb, s3, ssm, sqs, etc.
]
}
}]
RoleName: {service}-{stage}-{region}-lambdaRole
ManagedPolicyArns: [...optional, e.g. VPC access policy...]
Replace with: sls.iamRoleG(namePrefix, extraStatements, managedPolicies)
The base log permissions are generated automatically. Only pass the extra statements.