Manage External SaaS Resources with Pulumi Dynamic Providers
Not every resource you depend on has a first-class Pulumi provider — feature flags, DNS records at a niche registrar, a SaaS project, or a monitoring dashboard often live behind a plain REST API. This guide, part of the dynamic providers and custom resources cluster under Pulumi patterns and provider management, shows how to wrap such an API in a typed Python dynamic provider so the external resource participates in pulumi up, state, and previews like any other.
Why This Matters
When a resource lives outside your state file, it drifts silently: someone edits it in a web console, and nothing in your infrastructure code notices. A dynamic provider brings that resource under the same lifecycle as your cloud resources — create, read, update, delete — so its desired shape is version-controlled and every change shows up in a preview diff.
The cost is that you own the CRUD logic. Pulumi calls your create, diff, update, and delete methods; you translate those into REST calls and return an id plus the outputs Pulumi should record. Getting the diff and delete semantics right is what separates a toy from something you can run in production.
Prerequisites
- Python 3.9+ with
pulumi>=3.0andrequests>=2.31pinned in your project - An API token for the SaaS platform exported as an environment variable (never hard-coded)
- A resource with a stable id returned on creation — the id is how Pulumi finds it again
Verify your interpreter and SDK before writing any provider code:
# CLI: confirm the Pulumi Python SDK is importable
python -c "import pulumi, requests; print(pulumi.__version__)"
Implementing the CRUD Provider
A dynamic provider is a class implementing pulumi.dynamic.ResourceProvider. Each method receives and returns plain dictionaries; Pulumi handles serialisation into state. Keep the HTTP client thin and pass the token through resource inputs marked secret.
# saas_flag.py — a dynamic provider for a feature-flag SaaS resource
# CLI: pulumi up (Pulumi invokes these methods; do not call them yourself)
from typing import Any
import requests
import pulumi
from pulumi.dynamic import ResourceProvider, CreateResult, DiffResult, UpdateResult, Resource
API = "https://api.example-flags.com/v1/flags"
class _FlagProvider(ResourceProvider):
def create(self, props: dict[str, Any]) -> CreateResult:
r = requests.post(API, json={"key": props["key"], "on": props["on"]},
headers={"Authorization": f"Bearer {props['token']}"}, timeout=15)
r.raise_for_status()
flag = r.json()
# Provider note: the returned id must be stable for the life of the resource.
return CreateResult(id_=flag["id"], outs={**props, "url": flag["url"]})
def diff(self, id_: str, old: dict, new: dict) -> DiffResult:
changed = [k for k in ("on", "key") if old.get(k) != new.get(k)]
# State implication: 'key' is immutable upstream, so a change forces replacement.
return DiffResult(changes=bool(changed), replaces=["key"] if "key" in changed else [])
def update(self, id_: str, old: dict, new: dict) -> UpdateResult:
requests.patch(f"{API}/{id_}", json={"on": new["on"]},
headers={"Authorization": f"Bearer {new['token']}"}, timeout=15).raise_for_status()
return UpdateResult(outs={**new})
def delete(self, id_: str, props: dict) -> None:
requests.delete(f"{API}/{id_}",
headers={"Authorization": f"Bearer {props['token']}"}, timeout=15)
class FeatureFlag(Resource):
url: pulumi.Output[str]
def __init__(self, name: str, key: str, on: bool, token: pulumi.Input[str], opts=None):
super().__init__(_FlagProvider(), name,
{"key": key, "on": on, "token": token, "url": None}, opts)
Verification
Run a preview first: it should show the resource as a create with no errors, and a second pulumi up with no code changes should report zero updates — proof your diff is stable.
# CLI: preview, apply, then confirm the second preview is a no-op
pulumi preview --diff
pulumi up --yes
pulumi preview # expect: no changes
If the second preview wants to update every time, your diff is comparing fields the API normalises (e.g. it lower-cased the key). Normalise both sides before comparing.
Gotchas & Edge Cases
Secrets leak into state. Inputs passed to a dynamic resource are serialised into state. Mark the token secret with pulumi.Output.secret or configure it through pulumi config set --secret so it is encrypted, as covered in securing Pulumi secrets.
Delete must be idempotent. If the resource was already removed upstream, a DELETE returning 404 should not fail the destroy. Swallow the not-found case.
Provider code is pickled. Pulumi serialises the provider class; keep module-level imports at the top and avoid closures over unpicklable objects.
FAQ
Do dynamic providers support import?
Yes, but you must implement the read method so pulumi import can hydrate state from the live resource. Without read, import and refresh cannot reconcile external changes.
How is this different from a ComponentResource?
A ComponentResource groups existing resources; a dynamic provider defines a brand-new resource type with its own CRUD against an external system.
Can I unit test the provider?
Yes — the CRUD methods are plain functions taking dicts. Mock requests and assert the payloads, exactly as in unit testing Pulumi programs with mocks.
Related
- Writing a Pulumi Dynamic Provider in Python — the foundational walkthrough of the ResourceProvider interface.
- Dynamic Providers & Custom Resources — the parent cluster on extending Pulumi with Python.
- Unit Testing Pulumi Programs with Mocks — how to test CRUD logic without hitting the live API.