How to write reusable infrastructure code with Pulumi
Pulumi
The Pulumi SDK is an open-source tool to write infrastructure as code (IaC). IaC can be a powerful enabler for teams on their DevOps transformation journey. Using programming languages like Python, Go or Typescript, it’s possible to provision, track and manage cloud resources.
You can check the official homepage for more information.
In this blog post the examples are written with Typescript, but all the concepts I cover apply to every language supported by Pulumi.
An easy way to set up a new project is using the pulumi new command:
1 |
pulumi new azure-typescript --generate-only |
This will create all the basic files you need. There are also templates for other languages and clouds.
Component Resources
Writing reusable infrastructure code for Pulumi can be done by creating a new component resource. That’s a construct by Pulumi, intended to bundle multiple resource into a new resource.
Creating a new component resource is simple as this:
1 2 3 4 5 6 7 |
import * as pulumi from "@pulumi/pulumi"; export class MyComponent extends pulumi.ComponentResource { constructor(name: string, opts?: pulumi.ComponentResourceOptions) { super("my-module:class:MyComponent", name, {}, opts); } } |
Component resources can be used in all languages supported by Pulumi, but the exact syntax will differ. See the Pulumi docs for more information.
A component resource is treated by Pulumi like a normal resource. By calling the super method you can define the internal identifier for your component resource, which gets added to the URN (Universal Resource Name) of all the resources inside the component resource. URNs are able to globally identify every resource and allow to perform various actions on specific resources, like updating or destroying. You should make your identifier unique to avoid name conflicts.
Inside the constructor you can add your resources. In this case I want to create a storage account on Azure:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import * as pulumi from "@pulumi/pulumi"; import * as azure from "@pulumi/azure"; export class MyComponent extends pulumi.ComponentResource { constructor(name: string, opts?: pulumi.ComponentResourceOptions) { super("my-module:azure:storage-account", name, {}, opts); new azure.storage.Account("storageaccount", { resourceGroupName: "storageaccount_rg", accountTier: "Standard", accountReplicationType: "LRS", }, { parent: this // this binds the storage account to the component resource }); } } |
Adding the resource inside the constructor doesn’t automatically attach it to your component resource. Only by referencing the component resource as the parent does the resource get attached (see line 13).
In and Outputs
The component resource currently has only static content. As the next step you can add parameters to the constructor. The best way of doing this is by adding an interface.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
import * as pulumi from "@pulumi/pulumi"; import * as azure from "@pulumi/azure"; export class MyComponent extends pulumi.ComponentResource { constructor(name: string, props: myInterface, opts?: pulumi.ComponentResourceOptions) { super("my-module:azure:storage-account", name, {}, opts); new azure.storage.Account("storageaccount", { resourceGroupName: props.resourcegroupName, accountTier: props.accountTier || "Standard", accountReplicationType: props.accountReplicationType || "LRS", tags: props.tags, }, { parent: this }); } } interface myInterface { /** * Name of an existing resource group for the storage account. */ resourcegroupName: pulumi.Input<string> /** * Defines the tier to use for this storage account. * Valid options are `Standard` and `Premium`. * * Defaults to **Standard**. */ accountTier?: string /** * Defines the type of replication to use for this storage account. * Valid options are `LRS`, `GRS`, `RAGRS`, `ZRS`, `GZRS` and `RAGZRS`. * * Defaults to **LRS**. */ accountReplicationType?: string /** * Resource tags. */ tags?: pulumi.Input<{ [key: string]: pulumi.Input<string> }>; } |
Using an interface allows you to have auto-completion in many IDEs and also the option to add documentation via TSDoc.
Configuring default values can be done by using the OR operator, see line 10 and 11.
In the interface you can see two different types of parameters: the basic Typescript string and Pulumi.Input<string>.
Pulumi.Input and its complement Pulumi.Output are wrapper classes. They help in cases, where you deal with asynchronous values. Imagine you want to create a public IP and an A record pointing to the public IP. Before the public IP is created you won’t know the exact IP address to use for the A record. By wrapping the value with Pulumi.Input/Output, Pulumi will make sure that the public IP is created first. It will also pass the IP address to the A record resource once it is known.
To define outputs for a component resource you simply add a class property and assign a value in the constructor. Outputs for resources are usually wrapped by Pulumi.Output. It’s possible to do the same for component resource outputs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import * as pulumi from "@pulumi/pulumi"; import * as azure from "@pulumi/azure"; export class MyComponent extends pulumi.ComponentResource { // Export the connection string for the storage account connectionString: pulumi.Output<string>; constructor(name: string, props: myInterface, opts?: pulumi.ComponentResourceOptions) { super("my-module:azure:storage-account", name, {}, opts); const account = new azure.storage.Account("storageaccount", { resourceGroupName: props.resourcegroupName, accountTier: props.accountTier || "Standard", accountReplicationType: props.accountReplicationType || "LRS", tags: props.tags, }, { parent: this }); // account.primaryConnectionString is of type pulumi.Output<string> as well this.connectionString = account.primaryConnectionString; } } |
Conditional Resources
Depending on the use case, it can happen that only a subset or a specific number of resources inside the component resource are required. Instead of developing an entire new module it’s possible to use language features like an if clause to make a resource optional:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import * as pulumi from "@pulumi/pulumi"; import * as azure from "@pulumi/azure"; export class MyComponent extends pulumi.ComponentResource { connectionString: pulumi.Output<string>; constructor(name: string, props: myInterface, opts?: pulumi.ComponentResourceOptions) { super("my-module:azure:storage-account", name, {}, opts); const account = new azure.storage.Account(name, { resourceGroupName: props.resourcegroupName, accountTier: props.accountTier || "Standard", accountReplicationType: props.accountReplicationType || "LRS", }, { parent: this, }); this.connectionString = account.primaryConnectionString; if (props.enableNetworkRule) { new azure.storage.AccountNetworkRules(name, { defaultAction: "Deny", resourceGroupName: account.resourceGroupName, storageAccountName: account.name, ipRules: props.ipList, }, { parent: this, }) } } } |
One of the advantages of Pulumi supporting general purpose languages is the possibility to use control structures like if clauses. It allows for more variants in your infrastructure while keeping the code clean. Another example would be iterating over a list with a for loop to create a set of network rules.
While those language features make Pulumi really powerful, this always carries the risk of developing a complicated module over time, which is hard to maintain.
Usage
That’s all you need to know to develop your first component resource. You can then publish your code as a npm package to make it available to your team(s).
Using your component resource works similar to any other package and resource:
1 2 3 4 5 6 7 8 9 10 |
import { MyComponent } from "my.npm.package" import * as azure from "@pulumi/azure" let rg = new azure.core.ResourceGroup("example") new MyComponent("test", { resourcegroupName: rg.name, enableNetworkRule: true, ipList: ["127.0.0.1"], }) |
After provisioning you can check your stack structure via pulumi stack -u and will see something similar to this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Current stack resources (6): TYPE NAME pulumi:pulumi:Stack fixture-blog │ URN: urn:pulumi:blog::fixture::pulumi:pulumi:Stack::fixture-blog ├─ my-module:azure:storage-account example │ │ URN: urn:pulumi:blog::fixture::my-module:azure:storage-account::example │ ├─ azure:storage/account:Account example │ │ URN: urn:pulumi:blog::fixture::my-module:azure:storage-account$azure:storage/account:Account::example │ └─ azure:storage/accountNetworkRules:AccountNetworkRules example │ URN: urn:pulumi:blog::fixture::my-module:azure:storage-account$azure:storage/accountNetworkRules:AccountNetworkRules::example ├─ azure:core/resourceGroup:ResourceGroup example │ URN: urn:pulumi:blog::fixture::azure:core/resourceGroup:ResourceGroup::example └─ pulumi:providers:azure default_3_18_1 URN: urn:pulumi:blog::fixture::pulumi:providers:azure::default_3_18_1 |
The storage account (line 7+8) and network rule (line 9+10) are both bundled under the component resource (line 5+6).
And that’s how you can create reusable infrastructure code with Pulumi.
You can find the final version of the example on GitHub.
Comment article
Recent posts






Comments
Daniel Givens
Thank you so much for this. The docs on component resources completely leave out the the class property as how to expose resources defined within the component resources. They make it out such that you should use registerOutputs(), but that doesn’t work when you want to expose whole resources, such as when you create multiple aws.ec2.Instance’s within the component resource and want to return them as an array to be used by other resources outside of the component resource. I spent a fair amount of time trying to make that work they way they have it documented until I found this.
Müller Matthias
I’m glad I could help you.