infrastructure
The same stack, written twice

The home automation stack has been running on SAM since January. Over a few weekends in the autumn I wrote the whole thing again as a Pulumi program — same tables, same functions, same rules, same alarms — not to migrate, but because comparing two tools on a toy is worthless and this was the first stack I had that was small enough to rewrite and real enough to be honest.
Twenty-one resources. Here is where the two of them actually differ.
SAM is a macro, and that is the whole story
AWS::Serverless::Function is not a resource type. It is an instruction to a
CloudFormation transform that expands into a function, a role, a log group and
whatever the event sources imply. When it lines up with what you want, the
leverage is absurd:
Events:
SmartHome:
Type: AlexaSkill
Properties:
SkillId: !Ref AlexaSkillId
Four lines. They expand into a Lambda::Permission with
alexa-connectedhome.amazon.com as the principal and the skill id as the event
source token. I did not have to know that. The same is true of the IoT rule
that feeds sensor readings in, and of the policy templates:
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref DeviceRegistryTable
Three lines against a hand-written policy document with six actions and two ARNs. Across five functions, that is the single largest thing SAM saved me, and it is not close.
The bill comes due when you step off the path. Anything SAM has no template for is raw CloudFormation in the same file, so the template ends up bilingual — a few terse serverless resources sitting next to a hundred lines of long-hand alarms. And the leverage is invisible: the permission that lets Alexa call my function exists, but it exists nowhere I can read.
Pulumi has no magic, which cuts both ways
The Pulumi version of that Alexa trigger is the thing SAM was hiding:
aws.lambda_.Permission(
"alexa-invoke",
action="lambda:InvokeFunction",
function=skill_function.name,
principal=ALEXA_PRINCIPAL,
event_source_token=skill_id,
opts=pulumi.ResourceOptions(parent=self),
)
Longer, and I now know what it does. The policy document, conversely, is the thing SAM was doing well and Pulumi makes me write out in full — fifty lines of JSON in a Python function, which is fifty lines I did not have before.
Where Pulumi pulls ahead is everything that is a loop. Four scheduled jobs in SAM are four near-identical thirty-line blocks; I generated the last three by copying the first and editing strings, which is precisely the activity a programming language exists to remove. In Pulumi:
SCHEDULES = {
"sunset": "cron(0/15 * * * ? *)",
"sunrise": "cron(0/15 * * * ? *)",
"nightly": "cron(20 1 * * ? *)",
"energy-report": "cron(0 8 ? * MON *)",
}
for key, expression in SCHEDULES.items():
function = scheduled_functions.get(key)
if function is None:
continue
self.schedules[key] = self._schedule(key, expression, function, child)
The rule, the target and the permission are one private method called four times. Adding a fifth job is a line in a dict.
The other place it wins is reading things. The Step Functions definitions live in JSON files with the task ARN as a placeholder, and in Pulumi the substitution is just code:
definition=task_function.arn.apply(
lambda arn: template.replace(PLACEHOLDER, arn)
)
apply on an output value is the one genuinely new concept Pulumi asks you to
learn, and it is the concept that makes the rest of it feel like Python instead
of like YAML wearing a costume.
Dependency order is a real constraint, not a detail
Writing it twice exposed something the SAM version had been hiding from me. My
routines need the task function; my skill function needs the routine ARNs so it
can start them. CloudFormation resolves that from the !Ref graph regardless
of where you put the blocks. A Pulumi program is a script, so the order in the
file is the order — and the first version deadlocked on itself until I moved
things around and left a comment saying why:
# Same resources as template.yaml. The order below matters: the routines need
# the task function, and the skill function needs the routine ARNs to start
# them.
Being forced to write that down was worth more than the rewrite. It is a real constraint in the architecture and I had not noticed it in eight months of YAML.
What I actually concluded
SAM stays the deploy path here. It is one file, one CLI, sam build does the
packaging, and for a single stack in a single account the transform is doing
more for me than the loops would.
For anything with more than one environment I would reach for Pulumi, or for Terraform, which I use more of at work — the pull is the same in both cases and it is not really about the tool. It is that a stack described in a programming language can have functions in it, and once you have three environments, the difference between a function and a copied block is the difference between one change and three.
The Pulumi program stays in the repo under infra/pulumi. Every resource name
carries the stage, so both stacks can run side by side, which is how I checked
they described the same thing.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


