In Pulumi, resources are the fundamental building blocks of your infrastructure. They represent real-world cloud components such as databases, storage buckets, networks, and virtual machines. Pulumi provides two main kinds of resources: custom resources and component resources. Understanding the difference between them is key to effectively using Pulumi to manage your infrastructure.
In this article, I will focus on creating a custom component resource for provisioning Node.js Lambda Functions but first, let’s cover the difference between those two types of resources to know what we’re dealing with.
TOC
Custom Resources
A Custom Resource is a resource that directly maps to a specific type of infrastructure in a cloud provider. For example, an AWS S3 bucket or an Azure VM would be represented as a custom resource. These resources correspond to the resources you would create using the cloud provider’s API, CLI, or management console. When you create a custom resource in Pulumi, Pulumi will make the necessary API calls to create that resource in the cloud provider.
Custom resources are usually provided by Pulumi’s resource providers, which are plugins to the Pulumi engine that understand
how to interact with the cloud APIs. For example, the @pulumi/aws
package provides resources that map to AWS services.
All in all, every cloud component implements a Custom Resource.
Component Resources
A Component Resource is a higher-level abstraction that you define, which can encapsulate one or more custom resources. Component resources are useful for creating reusable and shareable abstractions. They allow you to group resources together into logical units that can be managed as a single entity. For example, you might create a NetworkComponent that includes a VPC, subnets, and security groups as part of a larger network configuration.
Component resources are defined in your Pulumi program and don’t correspond directly to a single cloud resource. Instead, they can contain any number of other resources, both custom and component, and manage them as a group.
Comparing this to CDK, a custom Component Resource is an L3 construct – in the pulumi ecosystem, the crosswalk aws package is a great example of component resources
Implementation
Let’s start with the implementation. We’re going to build a component for creating a nodejs lambda function and reduce boilerplate associated with it.
Creating a Component Resource
First, we have to extend the pulumi.ComponentResource
class, and pass it its props in super()
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
type Props = aws.lambda.FunctionArgs;
class NodejsFunction extends pulumi.ComponentResource {
constructor(name: string, props: Props, opts?: pulumi.ComponentResourceOptions) {
super('exanubes:aws:NodejsFunction', name, props, opts);
}
}
In Pulumi, resources are uniquely identified using a logical name, which follows the aws:lambda:NodejsFunction
pattern.
This naming convention is used to fully qualify a resource type and is composed of three parts:
package: This is the package name that corresponds to the Pulumi provider for the resource. For example, for AWS resources, this might be aws, and for Kubernetes resources, it could be kubernetes.
module: This refers to the module or namespace within the package. For instance, in AWS, ec2 or s3 might be used to represent resources within those specific services.
resource: This is the name of the resource type itself, such as Instance or Bucket.
This fully qualified name serves several purposes:
- Uniqueness: It ensures that the type of resource being defined is unique across all providers and modules, which helps avoid naming collisions when using multiple providers or modules within the same program.
- Clarity: It provides clarity about what kind of resource is being instantiated, which can be especially helpful in complex stacks with many resources.
- Namespace Organization: It helps organize resources into namespaces, making it easier to understand the structure of the infrastructure and how resources relate to one another.
Lambda role
Each lambda will need an IAM role, even if it’s just to give it permission to send logs to CloudWatch. This creates a fair amount of boilerplate, so I want to include it in this component.
class NodejsFunction extends pulumi.ComponentResource {
constructor() {/*...*/}
private createRole(name: string) {
return new aws.iam.Role(
`${name}_ExecutionRole`,
{
assumeRolePolicy: JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Action: 'sts:AssumeRole',
Effect: 'Allow',
Principal: {
Service: 'lambda.amazonaws.com'
}
}
]
})
},
{ parent: this }
);
}
}
What this code does is it’s gonna create a role with a trust policy that says only a lambda function can assume this role.
All subsequent permissions will be attached to this role. I’m also setting the custom resource options parent to be the instance of
NodejsFunction
and this is something I’ll keep doing for other resources. This way the resources are bound to the component
and will also inherit its options so if user specifies a different provider, all resources that are part of the component will
also use it.
Lambda function
With the role created we have everything we need to create the lambda resource
class NodejsFunction extends pulumi.ComponentResource {
readonly role: aws.iam.Role;
readonly handler: aws.lambda.Function;
constructor(name: string, props: NodejsFunctionArgs, opts?: pulumi.ComponentResourceOptions) {
super('exanubes:aws:NodejsFunction', name, props, opts);
const role = (this.role = this.createRole(name));
this.handler = new aws.lambda.Function(
`${name}_Function`,
{
runtime: props.runtime ?? Runtime.NodeJS20dX,
architectures: props.architectures ?? ['arm64'],
role: role.arn,
...props
},
{ parent: this }
);
}
}
Here, I’m including some convenient defaults, by setting the runtime and architecture if none was provided. The role is assigned to the lambda resource and, as advertised, we’re also setting the parent property.
Permissions
No matter what, each lambda at the very least needs permission to use CloudWatch. Instead of creating the policy
from scratch, I’ll use the AWSLambdaBasicExecutionRole
managed policy.
class NodejsFunction extends pulumi.ComponentResource {
constructor() {/*...*/}
addPolicy(name: string, policyArn: string): aws.iam.RolePolicyAttachment;
addPolicy(name: string, policy: PolicyArgs): aws.iam.RolePolicyAttachment;
addPolicy(name: string, policyArgs: PolicyArgs | string) {
const isArn = typeof policyArgs === "string";
const policy = isArn
? null
: new aws.iam.Policy(name, policyArgs, { parent: this });
return new aws.iam.RolePolicyAttachment(
`${name}_Attachment`,
{
role: this.role,
policyArn: isArn ? policyArgs : policy!.arn,
},
{ parent: this },
);
}
}
I’m killing two birds with one stone and also creating a public helper method for adding custom policies to the lambda role. I’m overloading the method signature and allowing either an existing policy’s arn, in which case it’s attached to the role, or a policy object to be created.
export class NodejsFunction extends pulumi.ComponentResource {
constructor(
private readonly name: string,
props: NodejsFunctionArgs,
options?: CustomResourceOptions
) {
super('exanubes:aws:NodejsFunction', name, props, options);
//...
this.addPolicy(
`${name}__CloudWatchRolePolicy`,
'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
);
if (props.policy) this.addPolicy(`${name}_RolePolicy`, props.policy);
}
}
Now, I’ll use that helper in the constructor to attach the aws managed policy and also allow users to pass a custom policy when creating the resource
Resource-based policy
To allow other services, like Api Gateway, to invoke a lambda, we first need to grant it access to said lambda function. To do that, we’ll define a resource based policy on the lambda to grant access to the selected service.
grantInvoke(principal: string, arn?: pulumi.Output<string>) {
return new aws.lambda.Permission(`${this.name}_InvokeLambdaPermission`, {
action: "lambda:InvokeFunction",
function: this.handler.arn,
principal,
sourceArn: arn,
}, { parent: this });
}
As you can see, this method requires a principal – e.g., apigateway.amazonaws.com – and optionally, we can also specify the resource arn to further limit which, in this case, Api Gateway can invoke that function. So instead of saying this user or role can invoke this lambda function we attach the permission to the resource itself – the function – and it determines that only an api gateway with this arn can invoke it.
Demo
All that’s left is to actually check if it works.
import { NodejsFunction } from '@exanubes/pulumi-nodejs-function';
import { FileArchive } from '@pulumi/pulumi/asset';
const lambda = new NodejsFunction('hello_world', {
code: new FileArchive('./functions'),
handler: 'hello_world.handler'
});
lambda.grantInvoke('apigateway.amazonaws.com');
lambda.addPolicy('some-policy', {
name: 'my-awesome-policy',
policy: {
Version: '2012-10-17',
Statement: [
{
Action: ['dynamodb:PutItem'],
Effect: 'Allow',
Resource: '*'
}
]
}
});
export const lambda_arn = lambda.handler.arn;
So we’re creating the NodejsFunction
resource, then granting invoke permission to Api Gateway and then
also adding a policy that will allow the lambda to add items in a dynamodb table. Now we can preview or provision
the stack to see if everything’s ok.
$ pulumi up
Type Name Plan
+ pulumi:pulumi:Stack pulumi-nodejs-function-dev create
+ └─ exanubes:aws:NodejsFunction hello-world create
+ ├─ aws:iam:Policy some-policy create
+ ├─ aws:iam:Role hello-world_ExecutionRole create
+ ├─ aws:iam:RolePolicyAttachment some-policy_Attachment create
+ ├─ aws:iam:RolePolicyAttachment hello-world_CloudWatchRolePolicy_Attachment create
+ ├─ aws:lambda:Function hello-world_Function create
+ └─ aws:lambda:Permission hello-world_InvokeLambdaPermission create
As a result we get the execution role for the lambda, the basic execution role policy for accessing CloudWatch, and the function itself. Also,
the policy and invoke permission that were added later are all part of the NodejsFunction
resource and the diagram reflects it.
Conclusion
In Pulumi, resources are the core elements representing cloud infrastructure components like databases, storage, and virtual machines. There are two main types: custom resources and component resources. Custom resources map directly to cloud infrastructure types, such as an AWS S3 bucket or an Azure VM, and are managed by Pulumi’s resource providers, which interact with cloud APIs.
Component resources are higher-level abstractions that group multiple custom resources into logical units, making them reusable and easier to manage. For example, a NetworkComponent could include a VPC, subnets, and security groups. These are similar to L2 and L3 constructs in AWS CDK which often include very good defaults for easier configuration and provision multiple resources.