Skip to main content

Pulumi Ep 12: Authoring Dynamic Providers

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 12: This Article
Imagine your company has a proprietary internal REST API for registering new employee workstations. There is no @pulumi/internal-it NPM package. To automate this via IaC, we must write a Dynamic Provider. A Dynamic Provider allows you to teach the Pulumi Engine how to Create, Read, Update, and Delete (CRUD) any arbitrary resource using standard JavaScript functions.

1. The Dynamic Provider Interface
#

A Dynamic Provider is just a JavaScript object that implements the pulumi.dynamic.ResourceProvider interface.

To satisfy the Pulumi Engine, you must provide implementations for up to 5 lifecycle functions:

  1. create(): Called when a resource doesn’t exist in state. Must return an ID.
  2. diff(): (Optional) Called to check if the current state differs from the desired state.
  3. update(): (Optional) Called if diff returns true, allowing you to update in place.
  4. delete(): Called during pulumi destroy.
  5. read(): (Optional) Called to refresh state from the remote API.

2. Practice: Creating a Custom Provider
#

Let’s build a Dynamic Provider that talks to a hypothetical internal API: https://api.internal.company.com/workstations.

Step 1: Define the Arguments
#

First, we define the inputs our resource will accept, and the outputs it will generate.

import * as pulumi from "@pulumi/pulumi";
import axios from "axios"; // We will use axios to make HTTP calls

// The inputs the user will provide
export interface WorkstationArgs {
    employeeName: pulumi.Input<string>;
    department: pulumi.Input<string>;
}

Step 2: Implement the Provider Logic
#

Now we write the CRUD logic. Because this runs at deployment time, you can use standard Node.js libraries like axios.

const workstationProvider: pulumi.dynamic.ResourceProvider = {
    // 1. CREATE LOGIC
    async create(inputs: any) {
        // Make the physical API call to the internal system
        const response = await axios.post("https://api.internal.company.com/workstations", {
            name: inputs.employeeName,
            dept: inputs.department
        });

        // The API returns a database ID for the workstation
        const workstationId = response.data.id;

        // You MUST return an 'id' string, plus any outputs you want to save to state
        return {
            id: workstationId,
            outs: {
                ...inputs,
                ipAddress: response.data.allocatedIp // Save the assigned IP!
            }
        };
    },

    // 2. DELETE LOGIC
    async delete(id: string, outs: any) {
        // Pulumi passes the 'id' we returned during create()
        await axios.delete(`https://api.internal.company.com/workstations/${id}`);
    },

    // 3. DIFF LOGIC
    async diff(id: string, olds: any, news: any) {
        // If the employee changes departments, we need to update
        if (olds.department !== news.department) {
            return {
                changes: true,
                // Tell Pulumi this requires a full replacement (delete then create)
                replaces: ["department"] 
            };
        }
        return { changes: false };
    }
};

Step 3: Wrap it in a Custom Resource
#

To make this user-friendly, we wrap our Provider object inside a standard pulumi.dynamic.Resource class. This is what end-users will actually instantiate.

export class Workstation extends pulumi.dynamic.Resource {
    // Expose the outputs for other resources to consume
    public readonly ipAddress!: pulumi.Output<string>;

    constructor(name: string, args: WorkstationArgs, opts?: pulumi.CustomResourceOptions) {
        // Pass our custom Provider to the super constructor
        super(workstationProvider, name, { ...args, ipAddress: undefined }, opts);
    }
}

3. Consuming the Dynamic Provider
#

Now, your custom resource behaves exactly like a native AWS resource! It integrates perfectly into the Pulumi dependency graph, and gracefully handles Output temporal paradoxes.

// index.ts
import * as aws from "@pulumi/aws";
import { Workstation } from "./workstation-provider";

// 1. Create our custom resource
const devMachine = new Workstation("rhidayat-machine", {
    employeeName: "rhidayat",
    department: "Platform Engineering"
});

// 2. Pass its Output to an AWS Resource!
const devSecurityGroup = new aws.ec2.SecurityGroup("dev-access", {
    ingress: [{
        protocol: "tcp",
        fromPort: 22,
        toPort: 22,
        // We reference the internal IP allocated by the custom API!
        cidrBlocks: [pulumi.interpolate`${devMachine.ipAddress}/32`]
    }]
});

When you run pulumi up, Pulumi will:

  1. Wait for Workstation to finish its create() logic.
  2. Unpack the ipAddress from the returned outs object.
  3. Pass that string into the aws.ec2.SecurityGroup.

You just bridged a proprietary, undocumented internal database with global AWS Infrastructure in 50 lines of TypeScript.


4. The Serialization Caveat
#

Dynamic Providers are incredibly powerful, but they have one massive quirk: Serialization.

When you run pulumi up, the Pulumi Engine actually serializes your workstationProvider object into a JSON string, sends it to the language host, and deserializes it to run it.

This means your provider object cannot capture outer scope variables.

The Wrong Way:
#

const apiToken = process.env.API_TOKEN; // Outer scope!

const badProvider: pulumi.dynamic.ResourceProvider = {
    async create(inputs) {
        // ERROR! apiToken will be undefined when this is serialized and run!
        await axios.post(url, data, { headers: { Auth: apiToken } });
    }
};

The Right Way:
#

Pass any required configuration (like API tokens) directly into the inputs object of the resource, so they are explicitly serialized by Pulumi and passed into the create() function safely.


Conclusion & Next Steps
#

You have now broken the boundaries of the official Pulumi Registry. With Dynamic Providers, you can orchestrate databases, physical IoT devices, internal APIs, and SaaS endpoints, bringing everything under the declarative control of Pulumi State.

However, as our custom code becomes more complex, how do we ensure we didn’t introduce bugs? A typo in a Dynamic Provider could delete the wrong database.

In Episode 13: Unit Testing Infrastructure, we will learn how to write lightning-fast offline Unit Tests using the Jest framework to validate our IaC logic before we ever hit the cloud.

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