Writing components
A component wraps a group of AWS resources behind one Python class. Users construct it, link
it, read .resources. Three files are worth reading next to this page:
stelvio/aws/cron.py: the shape to copy. Validation, handler parsing, a wrapped Function.stelvio/aws/topic.py: linking, properties, child components.stelvio/aws/vpc.py: many resources, per-resource customization and tags.
In Pulumi terms
Component extends Pulumi's ComponentResource. The type string passed to
super().__init__, "stelvio:aws:Cron", becomes its URN type, and everything created with
self._resource_opts() is parented under it in state:
stelvio:aws:Cron (nightly-report)
├── aws:cloudwatch/eventRule:EventRule
├── aws:cloudwatch/eventTarget:EventTarget
├── aws:lambda/permission:Permission
└── stelvio:aws:Function (nightly-report-fn)
├── aws:iam/role:Role
├── aws:iam/rolePolicyAttachment:RolePolicyAttachment
└── aws:lambda/function:Function
This tree is what the CLI groups deploy output by. _resource_opts() also adds an alias
from the stack root, so apps deployed before a resource moved into a component migrate
without replacement.
The shape
Two halves. __init__ validates and stores, _create_resources() creates and returns.
The component registers itself with Pulumi on construction; the AWS resources don't exist
until Stelvio reads .resources at deploy time.
class Cron(Component[CronResources, CronCustomizationDict]):
def __init__(self, name, schedule, ..., *, tags=None, customize=None):
super().__init__("stelvio:aws:Cron", name, tags=tags, customize=customize)
_validate_schedule(schedule)
self._schedule = schedule
def _create_resources(self) -> CronResources:
rule = cloudwatch.EventRule(...)
...
return CronResources(rule=rule, target=target, permission=permission, function=fn)
The rules:
- Validate in
__init__with module-level pure functions. RaiseValueErrororTypeErrornaming the bad value and the accepted shapes. Fail when the object is constructed, not halfway through a deploy; what needs a cloud lookup (Vpc's AZ check) is the exception. _create_resources()is pure: readsself._x, sets nothing, returns everything.- Resources land in a frozen
@finaldataclass named{Component}Resources. Expose what a user might reference; machinery (route table associations) stays out. - Every component's
__init__must have keyword-onlycustomize, plustagsunless nothing it creates supports tags on AWS (TopicQueueSubscription), andparentwhen other components use it (likeFunction). Mark the class@final.
The four dataclasses
A component brings up to four supporting types, named by convention:
{X}Resources: what_create_resources()returns. Always.{X}CustomizationDict: the validcustomize=keys, one per resource. Mirrors{X}Resourcesfields (singular where those are lists); a shared test keeps them in sync.{X}Configand{X}ConfigDict: when a component takes too many extra params. The constructor already carriesname,tags,customize; two or three extras are the max (Cron'sschedule,enabled,payload), over that, group them into a dataclass with a plain-dict twin (NatConfig,DynamoTableConfig). Validate in__post_init__, normalize dict-or-dataclass once in__init__, so the rest of the code sees one type. Keep the twins in sync withassert_config_dict_matches_dataclassin the component's tests.
They live in the component's file; function/ splits into modules only because of size.
Child resources
Every Pulumi resource gets opts=self._resource_opts(): parent, provider, and migration
alias in one place. depends_on goes through it too. Don't build ResourceOptions by hand.
Child Stelvio components (a wrapped Function) instead take parent=self, tags=self.tags,
and their slice of customization: customize=self._customize.get("function").
Components are the user-facing units; Pulumi resources are the machinery Stelvio runs for
you. Vpc's Route and RouteTableAssociation are machinery, so they stay hidden, not even
in VpcResources. TopicSubscription is a component for an architectural reason: at deploy
Stelvio creates every registered component's resources independently, so each subscription
is its own unit. Its Function, subscription and permission come up on their own;
Topic._create_resources() never knows how many subscriptions exist. Own unit also means
own customization, tags, and group in deploy output.
Customization and tags
Every resource's args go through the customizer:
rule = cloudwatch.EventRule(
rule_name,
**self._customizer("rule", {"schedule_expression": self._schedule}, inject_tags=True),
opts=self._resource_opts(),
)
Declare the keys in a CustomizationDict TypedDict (the second type parameter); the base
class validates them. Merge is shallow, the user's value replaces yours. inject_tags=True
on taggable resources only, and keep it at the callsite where you can see it.
Naming
Before naming a resource, know where the string ends up. Three destinations:
- Pulumi state: the logical name, the resource's first constructor arg. Always exists, no limit to worry about.
- AWS physical name, if the resource has one, in one of two ways. Don't set the
resource's
namearg and Pulumi derives it from the logical name plus a random suffix. Setname=yourself and the string goes to AWS exactly as is (Topic does, because FIFO names must end in.fifo). - The
Nametag: some resources (VPC, subnets, gateways) have no AWS name at all. The human-readable name is a tag, and tag values cap at 256.
safe_name(prefix, name, max_length, suffix, pulumi_suffix_length) builds the string for
all of these: app-env prefix plus your name, and when that would blow max_length it
truncates the name's tail and stamps a 7-char hash to keep it unique. Pick params from the
destination:
max_length: the limit where the string lands. The AWS name limit for the resource type (64 for EventBridge rules, 256 for SNS topics), or 256 when it only lands in a tag.pulumi_suffix_length: 8 (default) when Pulumi will append its random suffix, meaning the resource has an AWS name you didn't set explicitly. 0 otherwise.suffix: anything that must survive truncation intact; it's re-appended after the hash..fifois the case that forced the param.
Repeated safe_name calls with the same params are worth a local helper (Vpc's
_safe_name). DRY applies here like everywhere.
Linking
If other components will link to yours, add LinkableMixin and a default creator:
@link_config_creator(Topic)
def default_topic_link(topic: Topic) -> LinkConfig:
t = topic.resources.topic
return LinkConfig(
properties={"topic_arn": t.arn, "topic_name": t.name},
permissions=[AwsPermission(actions=["sns:Publish"], resources=[t.arn])],
)
Properties become STLV_ env vars on the linked Function, and typed accessors in the
stlv_resources.py Stelvio generates into its Lambda package. Permissions become IAM
statements on its role. Least privilege: the actions a user of the component needs, not
sns:*. Not everything links; Vpc has no creator because there's nothing to call and
nothing to permit.
Public surface
Two kinds of properties belong on the class:
- Shortcuts to resource outputs users wire elsewhere:
topic.arnisself.resources.topic.arn. Same forurl,stream_arn, names. - The parsed config, as one
configproperty (DynamoTable), not a mirror property per field. A field shortcut likepartition_keyonly when it earns its traffic.
self.register_outputs({...}) keys show in the CLI after deploy. The bar is high: so far
only URLs, {"url": url}, the one thing a user goes looking for. Most components skip the
call.
Checklist
Code: validation, _create_resources, _resource_opts everywhere, customization keys,
tags, safe_name, link creator if linkable. Then the part that gets forgotten:
- Export from the package
__init__.py. - Unit tests plus the four shared suites (see Writing unit tests), and integration tests.
- Creates persistent data? Add its types to
_DATA_LOSS_REPLACEMENT_TYPESinstelvio/rich_deployment_model.pyso replacements warn before eating data. - Docs page with a
zensical.tomlnav entry, README component list, changelog entry.