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/cron.py: the shape to copy. Validation, handler parsing, a wrapped Function.stelvio/aws/topic/topic.py: linking, properties, child components.stelvio/aws/vpc/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.
from stelvio.component import Component
from stelvio.provider import ProviderStore
class Cron(Component[CronResources, CronCustomizationDict]):
def __init__(self, name, schedule, ..., *, tags=None, customize=None):
super().__init__(
ProviderStore.aws(), "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-onlycustomizeandtags, andparentwhen other components create it (likeFunction). Two exceptions: nocustomizewhen it creates no AWS resource (Url), notagswhen nothing it creates supports tags on AWS (TopicQueueSubscription).tests/aws/test_keyword_only_constructor_params.pypins the exceptions. 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 a user might want to change. It doesn't mirror{X}Resources, which holds only what users read or pass on, so a key without a matching field is normal.{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__. In__init__, resolveconfigand the kwargs withparse_config(XConfig, config, opts)fromstelvio.component, 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 stelvio/aws/<name>/<name>.py; __init__.py re-exports the public API with __all__; extra modules only when a component outgrows one file (function/, s3/, and the other already-split packages).
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) take parent=self and tags=self.tags. Their
customization comes from the method call that creates them: subscribe(customize=...),
notify_function(customize=...). Methods like route accept a ready Function instead. A parent
slice (customize=self._customize.get("function")) is only for an intrinsic child, like Cron's
function. Type that key ChildCustomization[FunctionCustomizationDict] (from
stelvio.customize): app-wide customize can't reach a child, so the config check rejects keys
typed that way, and test_app_wide_rejects_exactly_the_keys_handed_to_a_child fails for a
handed-down key typed any other way.
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.
Several children of one Pulumi type print with a suffix in stlv diff/deploy: the logical
name minus app, env and component prefix, Subnet (public-subnet-a). Register
@child_label("Vpc") next to the class to shorten it: it gets that short name and returns the
label (Vpc: public-subnet-a becomes public-a); falsy keeps the short name. Lone children get
no suffix.
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, three recipes:
- Pulumi state, AWS derives the rest. The logical name is the resource's first
constructor arg. Most resources with an AWS name go this way: don't pass
name=, and pulumi-aws derives the AWS name from the logical name plus an 8-char random suffix. The suffix is what makes replacements safe: the new resource never collides with the one being deleted. - Deterministic AWS name. Only when the provider requires the name input
(
IdentityPool,LayerVersion, the SES configuration set inEmail). Pass one string as both the logical name and the name input, built withpulumi_suffix_length=0. - The
Nametag. Some AWS resources have no name at all. The human-readable name is a tag, and tag values cap at 256.
resource_name(base, *, limit, suffix="", pulumi_suffix_length=8) in stelvio.component
builds the string for the first two: app-env prefix plus your base, and when that would
blow limit it truncates the base's tail and stamps a 7-char hash. The third destination
uses it too, with limit=256 and pulumi_suffix_length=0. Any AWS-facing name built
without it is a bug waiting for a long app name.
limit: where the string lands. The AWS limit for the resource type (63 for buckets, 128 for user pools), or the provider's own cap when that is lower: pulumi-aws cuts SQS and SNS autonames at 80 even though SNS allows 256. For tag-only names, 256.pulumi_suffix_length: keep the default 8 when Pulumi appends its suffix (recipe 1), so the final AWS name still fits. 0 for recipes 2 and 3.suffix: a tail that must survive truncation; it is re-appended after the hash. Only needed for names Stelvio sets itself. For autonamed FIFO queues and topics the provider appends.fifoafter its random suffix, so the component strips.fifofrom the logical name instead andQueuereserves 5 chars (limit=MAX_QUEUE_NAME_LENGTH - len(".fifo"));Topichas room to spare (80 + 5 < 256).
Pulumi rejects a logical name that overflows the limit at preview time, so the guard in
recipe 1 is load-bearing, not cosmetic. Repeated same-param calls are worth a local helper
(Vpc's _resource_name).
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, resource_name, link creator if linkable. Then the part that gets forgotten:
- Export from the package
__init__.py. - Unit tests plus the 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. - New resource type? Add it to
RESOURCE_TYPE_NAMESin the same file or the CLI prints the raw Pulumi type. - Docs page with a
zensical.tomlnav entry, README component list, changelog entry.