Skip to main content

Pulumi Ep 7: Abstraction with ComponentResources

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
pulumi - This article is part of a series.
Part 7: This Article
If your company mandates that every S3 bucket must have encryption, versioning, and public-access-block enabled, you shouldn’t rely on developers remembering to configure those 15 properties every time. Instead, you should author a SecureBucket class. Let’s learn how to create custom ComponentResources to encapsulate complexity.

1. The Problem with Raw Resources
#

In Episode 3, we created an AWS VPC and Subnet directly in index.ts.

If another team in your company needs a VPC, they would have to copy and paste your code. If the security team later mandates that all VPCs must have a Flow Log attached, you now have to track down every team that copied your code and force them to update it.

This violates the DRY (Don’t Repeat Yourself) principle.

In Terraform, you solve this by writing a Module. In Pulumi, you solve this by writing a ComponentResource.


2. Authoring a ComponentResource
#

A ComponentResource is simply a standard TypeScript class that extends pulumi.ComponentResource. It acts as a logical container that groups multiple raw AWS resources together in the state file.

Let’s build a SecureBucket component that automatically enforces enterprise security standards.

Create a new file named secureBucket.ts:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

// 1. Define the strongly-typed arguments for your component
export interface SecureBucketArgs {
    bucketName: string;
    // We can make properties optional!
    enableVersioning?: boolean; 
}

// 2. Extend the base ComponentResource class
export class SecureBucket extends pulumi.ComponentResource {
    
    // We expose the raw Bucket object so consumers can access its ARN/URL later
    public readonly bucket: aws.s3.Bucket;

    constructor(name: string, args: SecureBucketArgs, opts?: pulumi.ComponentResourceOptions) {
        // The first argument is a unique Type identifier for your component
        super("custom:x:SecureBucket", name, {}, opts);

        // 3. Create the raw AWS Resources, ensuring we pass `parent: this`
        this.bucket = new aws.s3.Bucket(args.bucketName, {
            bucket: args.bucketName,
            // Automatically enforce security requirements!
            acl: "private",
            versioning: {
                enabled: args.enableVersioning ?? true,
            }
        }, { parent: this }); // <--- CRITICAL

        // Block all public access unconditionally
        new aws.s3.BucketPublicAccessBlock(`${args.bucketName}-pab`, {
            bucket: this.bucket.id,
            blockPublicAcls: true,
            blockPublicPolicy: true,
            ignorePublicAcls: true,
            restrictPublicBuckets: true,
        }, { parent: this }); // <--- CRITICAL

        // 4. Register outputs to finalize the component
        this.registerOutputs({
            bucketName: this.bucket.bucket,
            bucketArn: this.bucket.arn,
        });
    }
}

The Importance of { parent: this }
#

Notice how every raw AWS resource inside the class is passed { parent: this } in its Options object.

If you forget this, the resources will be created successfully, but in the Pulumi State file, they will appear floating aimlessly at the root level. By passing { parent: this }, you instruct the Pulumi Engine to nest the Bucket and the AccessBlock underneath your SecureBucket logical component in the dependency tree. This makes pulumi destroy and stack visualization much cleaner.


3. Consuming the Component
#

Now that we have abstracted the complexity into secureBucket.ts, our main index.ts file becomes incredibly clean.

Open index.ts:

import { SecureBucket } from "./secureBucket";

// Any developer in the company can now provision a fully compliant 
// bucket in a single line of code.
const appData = new SecureBucket("app-data", {
    bucketName: "my-company-app-data-prod",
});

const logs = new SecureBucket("access-logs", {
    bucketName: "my-company-access-logs-prod",
    enableVersioning: false, // Override the default
});

When you run pulumi up, the CLI output will look like this:

     Type                          Name               Plan       
 +   pulumi:pulumi:Stack           my-project-dev     create     
 +   ├─ custom:x:SecureBucket      app-data           create     
 +   │  ├─ aws:s3:Bucket           app-data-prod      create     
 +   │  └─ aws:s3:PublicAccessBlock app-data-pab      create     
 +   └─ custom:x:SecureBucket      access-logs        create
 +      ├─ aws:s3:Bucket           logs-prod          create     
 +      └─ aws:s3:PublicAccessBlock logs-pab          create     

Notice how the tree structure proves that the raw AWS resources are properly nested under our custom component!


4. Multi-Layer Abstractions
#

Because ComponentResource is just a TypeScript class, you can nest them infinitely.

For example, you could author a Microservice component that provisions:

  1. An AWS ECS Fargate Cluster.
  2. An Application Load Balancer.
  3. A Route53 DNS Record.
  4. And a SecureBucket (our component from above!) for file storage.

You then publish this Microservice class to your company’s internal NPM registry. Application developers can npm install @my-company/infra and deploy a production-grade microservice with 10 lines of code, completely blind to the 500 lines of AWS API calls happening under the hood.

This is the holy grail of Platform Engineering.


Troubleshooting & Common Errors
#

  1. TypeError: Cannot read properties of undefined (reading 'registerOutputs')

    • Root Cause: You forgot to call super(...) inside the constructor of your class. TypeScript requires you to invoke the parent class constructor before using this.
    • Solution: Ensure super("my:module:Type", name, {}, opts) is the very first line inside the constructor.
  2. Resources are not visually grouped together in pulumi up

    • Root Cause: You forgot to pass { parent: this } as the 3rd argument to the AWS resources inside your component.
    • Solution: Add the ResourceOptions object to all child resources.

Conclusion & Next Steps
#

You have successfully replaced Terraform Modules with strictly-typed, Object-Oriented Classes. By authoring ComponentResources, you can enforce security boundaries and dramatically simplify the developer experience for your team.

However, even with reusable components, you rarely want to deploy your Database, your VPC, and your Frontend React App in the exact same index.ts file. A single typo in the frontend code shouldn’t risk tearing down the production database.

We need to split our architecture into smaller, isolated Stacks.

In Episode 8: Cross-Stack References, we will learn how to break a monolithic Pulumi project into micro-stacks, and how to safely pass data (like a VPC ID) between them.

pulumi - This article is part of a series.
Part 7: This Article