exanubes
Q&A

Pulumi Overview #3 Pulumi: Creating a custom component resource

This article is part of a series

  1. Pulumi: Managing Cloud Infrastructure as Code
  2. Pulumi: Configuration and secrets
  3. Pulumi: Creating a custom component resource

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.

You can also view the resource hierarchy in the CLI and Pulumi Cloud

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.

AWS resource-based policies, as opposed to identity-based policies, are attached directly to AWS resources, such as S3 buckets or DynamoDB Tables, to define permissions and control access to those resources, specifying who can access them and what actions they can perform. You can see an exhaustive list in aws documentation
 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.

You can find a list of service principals in this gist

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.

FAQ