CoursesPulumiComponent resources & reuse

Component resources & reuse

Package infra into classes.

Intermediate12 min · lesson 7 of 12

A fire door is a kit, not a slab of wood. Door leaf, steel frame, self-closing hinge, and a thin strip of intumescent seal (a plastic band that swells up and plugs the gap around the door once it gets hot). Those parts are tested and certified together, as one assembly, and the certificate covers the assembly rather than any single piece of it. Order the assembly and everything arrives on the pallet. Order a door, tell yourself you will add the seal next week, and a building ends up with something that looks right and does nothing on the night it matters. A component resource in Pulumi is that assembly. Pulumi builds cloud infrastructure from code you write in a normal programming language, TypeScript or Python or Go, and a component is a class that wraps several cloud resources into one thing your teammates order by name.

Mechanically it is a subclass of pulumi.ComponentResource. You build the child resources inside its constructor, attach each one to the component, and publish a few outputs. What comes back is one node in the resource tree with everything else hanging underneath it.

Here is the part people miss. The component has no cloud API behind it (an API, application programming interface, being the endpoint a tool calls to make real infrastructure appear). Nobody at Amazon implements CreateSecureBucket. No plugin runs create, update, or delete on the component's behalf. Pulumi records it in state as a label and a place to hang children from. It shows up in a deployment plan with the word create next to it and provisions exactly nothing. Everything that costs money or holds data is one of its children.

Three Lines Of Wiring, Then Ordinary Code

Three lines turn a plain class into a component. First, the constructor calls super() with four things: a type token, the instance name, an object of registered inputs (an empty object is fine for a component that lives inside one program), and the options the caller handed you. Forward those options. Drop them and your callers lose the ability to set a provider or a protect flag on your component at all, with nothing printed to tell them so.

The type token is the part number stamped on the assembly, and it gets printed on every part in the box. Three colon-separated pieces, package:module:Type. Pulumi writes it into state on the component and into the unique address of every resource nested underneath it. If you later package the component so Python and Go programs can consume it, the middle piece has to be the literal word index, as in secopslog:index:SecureBucket. Pulumi's own docs call that a required implementation detail, and the packaging tooling matches on it exactly.

Second, every child resource is created with { parent: this }. Third, the constructor ends with this.registerOutputs(), which publishes the values callers read and signals to the engine that this component has finished registering children. Between those three lines you write ordinary code: loops, conditionals, helper functions, whatever the language gives you. The component below builds one bucket in S3 (Simple Storage Service, Amazon's object storage) plus the settings that decide whether that bucket is safe.

components/secureBucket.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// The only knobs a caller gets. Everything else is decided in here.
export interface SecureBucketArgs {
kmsKeyArn?: pulumi.Input<string>; // omit it and you get the S3-managed key
tags?: pulumi.Input<{ [k: string]: pulumi.Input<string> }>;
}
export class SecureBucket extends pulumi.ComponentResource {
public readonly bucket: aws.s3.BucketV2;
public readonly bucketName: pulumi.Output<string>;
public readonly arn: pulumi.Output<string>;
constructor(name: string, args: SecureBucketArgs = {},
opts?: pulumi.ComponentResourceOptions) {
// package:module:Type. Stamped into every child address below.
super("secopslog:storage:SecureBucket", name, {}, opts);
const child = { parent: this }; // every child gets this, no exceptions
const useKms = args.kmsKeyArn !== undefined;
this.bucket = new aws.s3.BucketV2(`${name}-bucket`, {
tags: args.tags,
}, child);
new aws.s3.BucketVersioningV2(`${name}-versioning`, {
bucket: this.bucket.id,
versioningConfiguration: { status: "Enabled" },
}, child);
new aws.s3.BucketServerSideEncryptionConfigurationV2(`${name}-sse`, {
bucket: this.bucket.id,
rules: [{
applyServerSideEncryptionByDefault: useKms
? { sseAlgorithm: "aws:kms", kmsMasterKeyId: args.kmsKeyArn }
: { sseAlgorithm: "AES256" },
// S3 Bucket Keys only apply to KMS. Set it under AES256 and the API
// hands back false, which reads as a permanent diff on every preview.
bucketKeyEnabled: useKms,
}],
}, child);
// The four public-access switches, written down so drift is visible and
// gets corrected on the next update. No argument turns them off.
new aws.s3.BucketPublicAccessBlock(`${name}-block`, {
bucket: this.bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
}, child);
this.bucketName = this.bucket.id;
this.arn = this.bucket.arn;
// Publishes the outputs AND tells the engine construction is finished
this.registerOutputs({ bucketName: this.bucketName, arn: this.arn });
}
}

Read the argument interface before you read anything else, because that is the security surface of the whole thing. A caller can set a KMS key (Key Management Service, the AWS service that stores encryption keys and logs every use of them) and some tags. A caller cannot set blockPublicAcls, cannot skip versioning, and cannot choose no encryption, because none of those are arguments. That is the fire door. The seal is in the box whether or not the person ordering remembers it exists.

Notice as well that every child name is built from the component's own name. That is not a style preference. Two sections down it becomes a hard error.

What The Parent Link Carries

The { parent: this } on each child looks like bookkeeping. It behaves more like plugging a lamp into a power strip. Whatever the strip is connected to, the lamp inherits: same circuit, same switch, same fuse.

Specific things travel down that link. A child inherits its parent's provider, the object that decides which cloud account, which region, and which credentials get used, so you can hand the component one provider pinned to a second AWS account in eu-west-1 and every child lands there without repeating the option nine times. It inherits protect, so protecting the component protects the whole subtree from deletion. It inherits aliases, which is what lets you rename a component later without Pulumi treating its children as brand new resources. Transforms (functions that rewrite resource properties as each one is registered) run on the parent and on every descendant. deletedWith is inherited too: set it on the component and every resource beneath it skips its own delete call when the named resource is going away anyway.

Other options stay where you put them. dependsOn is a fact about one resource's ordering. ignoreChanges is a fact about one property you have decided to stop fighting over. additionalSecretOutputs and import are typed onto custom resources and do not exist on a component. Set those on the individual child that needs them.

One naming detail is worth getting right, because half the internet has it stale. Pass provider, singular, to a component and current Pulumi honors it: children of that same package take it as their default. Pass providers, plural, and you hand the component a bag of providers keyed by package name, which is what you want when the subtree spans two packages, say AWS and Kubernetes under one component. Older Pulumi releases ignored the singular form on components and printed nothing, which is why plenty of blog posts still tell you it does nothing at all. Check the behavior of the SDK version you actually run, and reach for the plural form on components when you want the intent obvious to the next reader.

What a child gets through { parent: this }
Flows down from the component
provider
which account, region, credentials
protect
delete protection across the subtree
aliases
renaming the parent keeps the children
transforms
run on the parent and every child
deletedWith
skip child deletes with the parent
Stays put, set it per child
dependsOn
ordering is a per-resource fact
ignoreChanges
drift you accept, per property
additionalSecretOutputs
custom resources only, not components
import
adopting one existing resource
Leave off parent and a child gets none of the left column. It still deploys, using the default provider, which is how a resource ends up in the wrong account.

Put two instances of the class in one program and the plan explains itself. Eight real S3 resources, two components, one stack.

terminal
$ pulumi preview
output
Previewing update (dev):
Type Name Plan
+ pulumi:pulumi:Stack storage-dev create
+ ├─ secopslog:storage:SecureBucket audit create
+ │ ├─ aws:s3:BucketV2 audit-bucket create
+ │ ├─ aws:s3:BucketVersioningV2 audit-versioning create
+ │ ├─ aws:s3:BucketServerSideEncryptionConfigurationV2 audit-sse create
+ │ └─ aws:s3:BucketPublicAccessBlock audit-block create
+ └─ secopslog:storage:SecureBucket artifacts create
+ ├─ aws:s3:BucketV2 artifacts-bucket create
+ ├─ aws:s3:BucketVersioningV2 artifacts-versioning create
+ ├─ aws:s3:BucketServerSideEncryptionConfigurationV2 artifacts-sse create
+ └─ aws:s3:BucketPublicAccessBlock artifacts-block create
Resources:
+ 11 to create

Audit The Tree, Not The Code

Confirming by eye that every child got a parent works for about one repository and does not survive the next pull request. Read the state instead. It is the difference between auditing a warehouse by reading purchase orders and walking the aisles counting boxes.

Every resource Pulumi manages carries a URN (uniform resource name), the address the engine assigns it. Like a postal address it points at exactly one thing, and it is assembled from parts: the stack, the project, the chain of parent types, the resource type, and the logical name you wrote in code. The parent chain is the interesting part, and a dollar sign separates it from the resource type. A bucket built inside SecureBucket carries secopslog:storage:SecureBucket$ in the middle of its URN. A bucket built at the top of your program does not. One character is the entire audit. Pull the state out and slice it with jq, the command line tool for picking fields out of JSON.

terminal
$ pulumi stack export \
| jq -r '.deployment.resources[]
| select(.type == "aws:s3/bucketV2:BucketV2")
| .urn'
output
urn:pulumi:dev::storage::secopslog:storage:SecureBucket$aws:s3/bucketV2:BucketV2::audit-bucket
urn:pulumi:dev::storage::secopslog:storage:SecureBucket$aws:s3/bucketV2:BucketV2::artifacts-bucket
urn:pulumi:dev::storage::aws:s3/bucketV2:BucketV2::scratch

Three buckets, and the third one is wearing no uniform. scratch was created directly in index.ts, so nothing in your code says anything about it beyond the fact that it exists.

Be precise about what that means, because the frightening version of this sentence is out of date. Since April 2023 AWS creates new buckets with all four Block Public Access settings on and ACLs disabled, and since January 2023 every new object is encrypted with an S3-managed key by default. scratch is not born open to the world. What it is, is unmanaged. Versioning is off, because versioning is off by default and nobody asked for it. Objects are encrypted with the account's default S3 key rather than the KMS key your compliance folder specifies. And the public access block is on only because AWS put it there. Your code never declares it, so if somebody flips it off in the console next quarter, no preview shows a diff, no update puts it back, and no reviewer sees a changed line. The bucket inside the component differs in exactly one way that counts: its settings are written down, so drift becomes a diff and the next pulumi up corrects it.

That is the shape of the finding you actually want. Rarely an attacker defeating your component. Almost always a resource that never went through one, created by a hurried engineer or by a pull request nobody read closely. Widen the query into a sweep by listing everything parented straight to the stack root.

terminal
$ pulumi stack export \
| jq -r '.deployment.resources[]
| select((.parent // "") | endswith("::pulumi:pulumi:Stack::storage-dev"))
| "\(.type) \(.urn | split("::")[3])"'
output
pulumi:providers:aws default_6_54_0
secopslog:storage:SecureBucket audit
secopslog:storage:SecureBucket artifacts
aws:s3/bucketV2:BucketV2 scratch

Anything in that list other than default providers and your top-level components deserves a sentence of explanation in the pull request. Wire the query into CI (continuous integration, the automation that runs on every change) straight after a deploy and you have a standing check that new infrastructure came off the paved road. The interactive equivalent is pulumi stack --show-urns, which prints the same tree with each resource's URN underneath it.

A missing parent fails silently
A child created without { parent: this } still deploys. Nothing errors, no warning prints, and the resource works fine. It lands at the stack root instead of inside your component, and none of the inherited options reach it. Hand the component a provider for a second AWS account and the un-parented child uses the default provider instead, so it deploys to the wrong account or the wrong region and you find out from a bill or an AccessDenied. protect, aliases, transforms, and deletedWith break the same way for the same reason. The URN check above is the only reliable way to catch it, because the code compiles and the deploy succeeds.

One Class, Two Instances, One Ugly Error

Because a component is a class, reuse is ordinary code reuse. Export it from a shared module, or publish it to your internal npm registry (npm, the Node package manager, the tool that installs JavaScript dependencies), and every project imports the same reviewed bundle. Create as many instances as you want. There is one condition, and it is precisely what those templated child names were protecting you from. Hard-code a child name and the second instance dies.

components/secureBucket.ts
// WRONG: the child name does not depend on the component's name
this.bucket = new aws.s3.BucketV2("bucket", {}, child);
terminal
$ pulumi up
output
Previewing update (dev):
Type Name Plan Info
+ pulumi:pulumi:Stack storage-dev create 1 error
+ ├─ secopslog:storage:SecureBucket audit create
+ └─ secopslog:storage:SecureBucket artifacts create
Diagnostics:
pulumi:pulumi:Stack (storage-dev):
error: Duplicate resource URN 'urn:pulumi:dev::storage::secopslog:storage:SecureBucket$aws:s3/bucketV2:BucketV2::bucket'; try giving it a unique name

Look at what that URN does not contain. It has the parent's type, secopslog:storage:SecureBucket, and nowhere does it carry the parent's name. audit and artifacts are two components with different names and one identical type path, so both children compute the same address and the engine refuses to go on. Templating the name is what turns them into audit-bucket and artifacts-bucket.

There is a second payoff, and it is quiet. When you rename a component and give it an alias, Pulumi has to work out the old address of each child as well. It does that by assuming children are named after their parent, so it swaps the old parent name back into the child name whenever the child name starts with the parent name. Write ${name}-bucket and that assumption holds, so a rename carries the whole subtree with it. The convention in the SDK and the convention in your constructor have to be the same convention.

A Paved Road Is Not A Fence

Now the uncomfortable part. A week after you ship SecureBucket, somebody needs one that serves a static website, and the tempting fix is a boolean.

components/secureBucket.ts
export interface SecureBucketArgs {
kmsKeyArn?: pulumi.Input<string>;
tags?: pulumi.Input<{ [k: string]: pulumi.Input<string> }>;
publicWebsite?: boolean; // the escape hatch
indexDocument?: string;
}
// public readonly websiteEndpoint?: pulumi.Output<string>; // declared on the class
// ...inside the constructor, replacing the unconditional block:
if (args.publicWebsite) {
const web = new aws.s3.BucketWebsiteConfigurationV2(`${name}-web`, {
bucket: this.bucket.id,
indexDocument: { suffix: args.indexDocument ?? "index.html" },
}, child);
this.websiteEndpoint = web.websiteEndpoint;
// and the four blockPublic* switches have to come off for anonymous reads
} else {
new aws.s3.BucketPublicAccessBlock(`${name}-block`, {
bucket: this.bucket.id,
blockPublicAcls: true, blockPublicPolicy: true,
ignorePublicAcls: true, restrictPublicBuckets: true,
}, child);
}

One flag, and now there are two populations of SecureBucket carrying the same type token, the same shape in the tree, and no way to tell them apart by name. One population is readable by anyone on the internet. A component makes the safe thing the cheap thing, which is worth a great deal, and it still cannot make the unsafe thing impossible, because any caller can ignore your class and write raw resources beside it.

Enforcement is a separate job, done by a policy pack: a set of rules the engine checks the plan against, resource by resource, no matter who created them. Run pulumi preview --policy-pack ./policy and pulumi up --policy-pack ./policy and the run fails when a rule is broken, whether the resource came out of your component or out of somebody's afternoon. A stack-level rule saying every bucket in this stack must sit under secopslog:storage:SecureBucket is exactly the sentence a policy pack can say and a component cannot.

One more thing to stay awake to. A Pulumi program is a real program, and a deploy runs it with whatever cloud credentials the shell or the CI runner is holding. Importing a component package means executing that maintainer's code with those credentials. A compromised version can register an extra IAM role (Identity and Access Management, the AWS service that decides who is allowed to do what) inside its own subtree, and it will look like a normal part of the assembly, because registering resources is what a component is supposed to do. Pin versions. Install from a lockfile with npm ci rather than npm install. Mirror the components you depend on into a registry you control. Read the preview for resources nobody asked for.

Renaming The Type Token Is A Replace

Rename the type token after deployment, say from secopslog:storage:SecureBucket to acme:storage:SecureBucket, and you rewrite the address of the component and of every child under it, because the parent type chain is baked into each child URN. Pulumi does not see a rename. It sees one set of resources deleted and a different set created. A change you expected to be cosmetic becomes a destroy and recreate of live buckets, databases, or volumes.

If you have to change it, give the component an alias so the engine knows the old identity maps to the new one: aliases: [{ type: "secopslog:storage:SecureBucket" }]. Children inherit the parent's aliases, so the whole subtree comes along. Then preview it and count the replaces before you confirm.

Quick check
01A SecureBucket component hard-codes its child as new aws.s3.BucketV2("bucket", {}, { parent: this }). One instance deploys fine. Adding a second instance, with a different component name, fails with "Duplicate resource URN". Why do they collide?
Correct — The URN embeds the parent's type token, not its instance name, so both children come out as ...SecureBucket$aws:s3/bucketV2:BucketV2::bucket. Templating the child name with the component's name parameter is what separates them.
Incorrect — No. Pulumi auto-names the physical bucket by appending a random suffix to the logical name, so the real bucket names would never clash. The error comes from the engine during resource registration, before AWS is contacted at all.
Incorrect — No. registerOutputs publishes the component's outputs and signals that construction is finished. It never inspects or limits children.
Incorrect — No. Provider inheritance has nothing to do with URN uniqueness, and the failure happens during registration, before the provider is asked to do anything.
02A child resource created with { parent: this } inside a component automatically inherits several options from the component. Which of these is NOT inherited, so you must set it on the individual child that needs it?
Incorrect — protect flows down the parent link, so protecting the component protects the whole subtree beneath it.
Incorrect — provider is inherited; that is how one provider handed to the component lands on every child without repeating the option.
Correct — dependsOn is a fact about one resource's ordering, so it stays where you put it and is not inherited by children.
Incorrect — transforms run on the parent and on every descendant, so they are inherited down the subtree.
03You bump a shared SecureBucket component to a new version with npm install, run pulumi preview, and notice an aws:iam:Role you never asked for appearing inside the component's subtree. What is the most likely explanation and the right response?
Incorrect — Pulumi does not sandbox component code; it runs with whatever cloud credentials the deploy holds, and the role in the plan is real.
Incorrect — The component itself provisions nothing, but its children are real resources, and an injected IAM role would be exactly such a child.
Incorrect — The component in this lesson creates a bucket, versioning, encryption, and a public-access block — no IAM role — so a new role is unexpected, not required.
Correct — a component package is code that executes with your credentials, so a compromised release can quietly add resources; pinning, npm ci, and reading the preview are the defenses.

Make the check routine rather than heroic. After any change that touches a component, a new child, a moved parent, a renamed token, run pulumi preview and read the Plan column for the word replace before you answer yes. Once the update lands, run the URN sweep against the deployed stack and have the CI job fail if the list holds anything but default providers and your top-level components. That is a five-line jq filter, and it is the only check that notices the bucket somebody created beside your paved road at six on a Friday.

Try this

Run pulumi preview on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a missing parent fails silently. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related