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 topic 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.
Context
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.
SaaS APIs are not small cloud APIs, and the differences are exactly where a naive provider breaks. A cloud control plane usually gives you an opaque, immutable ARN or resource name; a SaaS platform frequently gives you a numeric id and a human-editable key, and lets an operator rename the key in the UI. A cloud API is usually strongly consistent for read-after-write within a region; a SaaS API often serves reads from a replica, so a GET issued a hundred milliseconds after a POST returns 404. Cloud APIs enforce quotas per account; SaaS APIs enforce a per-token request rate and answer 429 with a Retry-After header the moment a stack creates twenty flags in parallel. And a cloud DELETE normally destroys; a SaaS DELETE frequently archives, leaving the key reserved so re-creating the same resource returns 409.
Every one of those behaviours has to be absorbed by the provider, because Pulumi gives you no retry, no consistency window, and no rename tracking for free. The engine's contract is simple and unforgiving: whatever you return from create is the identity forever, whatever you return from diff decides whether the resource is updated or replaced, and whatever exception escapes a method aborts the deployment with the resource left in whatever state the SaaS actually reached.
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
- Write scope on the SaaS project or workspace the resources belong to, and read scope on the audit log so you can attribute out-of-band edits
- Documented rate limits for the token you will use; a provider that ignores them will fail intermittently under parallel creation
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__)"
Then probe the API by hand once. Knowing the exact shape of the create response — which field is the id, whether the body echoes fields you did not send, whether Retry-After is present — saves an afternoon of guessing inside diff:
# CLI: inspect the real create response before writing the provider
curl -sS -X POST https://api.example-flags.com/v1/flags \
-H "Authorization: Bearer $FLAGS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"key":"probe-only","on":false,"project":"checkout"}' | python -m json.tool
Implementation
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.
1. Pin the resource identity and the tenant scope
Before any HTTP code, decide which field becomes the Pulumi resource id. This choice cannot be revised later without hand-editing state, because the id is written into the checkpoint on the first pulumi up and every subsequent read, update, and delete is addressed by it.
Model the inputs as a frozen dataclass so the scope travels with the resource. A SaaS resource almost always lives inside a project or workspace, and forgetting to include that scope in the inputs means two stacks pointed at different projects will look identical to diff:
# providers/flag_args.py — imported by __main__.py
# CLI: pulumi up
from dataclasses import dataclass
@dataclass(frozen=True)
class FeatureFlagArgs:
key: str # human-editable slug; renaming it upstream is a rename, not a new flag
on: bool
project: str # tenant scope — must be part of state or diffs compare across projects
description: str = ""
# State implication: every field here is written to the checkpoint as an input and is
# what `diff` receives as `new`; omit a field and drift in it is permanently invisible.
2. Implement create, read, and diff against the flag API
The provider below builds its HTTP session inside each method. That is not stylistic: Pulumi pickles the provider instance into state, and a requests.Session holds an _thread.lock, so keeping one as an attribute fails the deployment before any request is sent.
# providers/flag_provider.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, ReadResult, DiffResult, UpdateResult, Resource,
)
API = "https://api.example-flags.com/v1/flags"
class _FlagProvider(ResourceProvider):
def _session(self, token: str) -> requests.Session:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Provider note: built per call — a Session holds an _thread.lock and cannot be pickled.
retry = Retry(total=5, backoff_factor=0.5, status_forcelist=(429, 502, 503),
allowed_methods=frozenset({"GET", "POST", "PATCH", "DELETE"}),
respect_retry_after_header=True)
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=retry))
s.headers.update({"Authorization": f"Bearer {token}"})
return s
def create(self, props: dict[str, Any]) -> CreateResult:
s = self._session(props["token"])
r = s.post(API, json={"key": props["key"], "on": props["on"],
"project": props["project"],
"description": props["description"]}, 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"],
"etag": r.headers.get("ETag", "")})
def read(self, id_: str, props: dict[str, Any]) -> ReadResult:
s = self._session(props["token"])
r = s.get(f"{API}/{id_}", timeout=15)
if r.status_code == 404:
# State implication: returning an empty id_ tells Pulumi the resource is gone,
# so `pulumi refresh` removes it from the checkpoint instead of leaving a ghost.
return ReadResult(id_=None, outs={})
r.raise_for_status()
body = r.json()
return ReadResult(id_=id_, outs={**props, "key": body["key"], "on": body["on"],
"description": body.get("description", ""),
"url": body["url"],
"etag": r.headers.get("ETag", "")})
def diff(self, id_: str, old: dict[str, Any], new: dict[str, Any]) -> DiffResult:
def norm(d: dict[str, Any]) -> dict[str, Any]:
# Provider note: the API lower-cases keys and trims descriptions on write,
# so compare normalised values or every preview reports a spurious change.
return {"key": str(d.get("key", "")).lower(),
"on": bool(d.get("on")),
"project": d.get("project"),
"description": str(d.get("description", "")).strip()}
a, b = norm(old), norm(new)
changed = [k for k in a if a[k] != b[k]]
# State implication: 'key' and 'project' are immutable upstream, so either forces
# a replacement — delete-then-create — rather than an in-place update.
replaces = [k for k in ("key", "project") if k in changed]
return DiffResult(changes=bool(changed), replaces=replaces,
stables=["project"], delete_before_replace=True)
Note the token field: it is an input, so it lands in state. Source it from pulumi.Config().require_secret("flagsApiToken") and Pulumi encrypts it in the checkpoint, exactly as described in securing Pulumi secrets.
3. Update and delete with SaaS archive semantics
Update is where SaaS platforms differ most from cloud APIs. Many expose optimistic concurrency through ETag/If-Match; sending the tag you last read turns a lost update into a 412 Precondition Failed you can surface instead of silently clobbering an operator's change. Delete is where teardown either works or strands the stack:
# providers/flag_provider.py (continued)
# CLI: pulumi up / pulumi destroy
class _FlagProvider(_FlagProvider): # shown separately for readability
def update(self, id_: str, old: dict[str, Any], new: dict[str, Any]) -> UpdateResult:
s = self._session(new["token"])
headers = {"If-Match": old["etag"]} if old.get("etag") else {}
r = s.patch(f"{API}/{id_}", json={"on": new["on"],
"description": new["description"]},
headers=headers, timeout=15)
if r.status_code == 412:
raise pulumi.RunError(
f"flag {id_} changed outside Pulumi since the last apply; "
"run `pulumi refresh` before re-applying")
r.raise_for_status()
return UpdateResult(outs={**new, "url": r.json()["url"],
"etag": r.headers.get("ETag", "")})
def delete(self, id_: str, props: dict[str, Any]) -> None:
s = self._session(props["token"])
r = s.delete(f"{API}/{id_}", timeout=15)
if r.status_code == 404:
# State implication: the flag was already archived upstream. Returning cleanly
# lets `pulumi destroy` finish instead of stranding the stack mid-teardown.
return
r.raise_for_status()
class FeatureFlag(Resource):
url: pulumi.Output[str]
def __init__(self, name: str, args: FeatureFlagArgs, token: pulumi.Input[str],
opts: pulumi.ResourceOptions | None = None) -> None:
super().__init__(_FlagProvider(), name,
{**vars(args), "token": token, "url": None, "etag": 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.
Three further checks are worth wiring into the loop before the provider reaches a shared stack. First, prove drift detection actually works: toggle the flag in the SaaS console, then run pulumi refresh and confirm the pending change appears. Second, prove teardown is clean by archiving the flag in the console and then running pulumi destroy — the 404 path in delete is the one nobody tests until it strands a stack. Third, assert the pickling constraint in a unit test so a future refactor that caches a session on the instance fails in CI rather than in production:
# tests/test_flag_provider.py
# CLI: python -m pytest tests/test_flag_provider.py -q
import pickle
from providers.flag_provider import _FlagProvider
def test_provider_is_picklable() -> None:
# Provider note: Pulumi serialises the provider into state; this must never regress.
assert pickle.loads(pickle.dumps(_FlagProvider())) is not None
def test_case_change_is_not_a_diff() -> None:
p = _FlagProvider()
old = {"key": "Checkout-V2", "on": True, "project": "web", "description": " x "}
new = {"key": "checkout-v2", "on": True, "project": "web", "description": "x"}
assert p.diff("f-1", old, new).changes is False
# CLI: prove refresh reconciles an out-of-band toggle
pulumi refresh --diff --yes
pulumi stack export --file state.json # inspect the stored outs, incl. the encrypted token
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. Caching a requests.Session on the instance produces TypeError: cannot pickle '_thread.lock' object before a single request leaves the machine.
Read-after-write races look like a broken create. A SaaS API serving reads from a replica answers the GET that follows a POST with 404, which surfaces as requests.exceptions.HTTPError: 404 Client Error: Not Found for url: https://api.example-flags.com/v1/flags/f-8812. Do not retry blindly inside create — return the id you already have from the POST body and let read reconcile on the next refresh.
Parallel creation trips the rate limit. Pulumi creates independent resources concurrently, so declaring thirty flags means thirty simultaneous POST calls. Without the Retry adapter above you get requests.exceptions.HTTPError: 429 Client Error: Too Many Requests for url: https://api.example-flags.com/v1/flags, and the stack half-applies. If the platform's limit is strict, chain resources with depends_on to serialise them deliberately rather than relying on backoff alone.
Archived keys are still reserved. After a destroy, re-creating the same key can fail with 409 Client Error: Conflict for url: https://api.example-flags.com/v1/flags because the SaaS archived rather than deleted it. Decide explicitly whether the provider should adopt the archived resource — usually it should not, because adopting silently makes Pulumi responsible for a resource it did not create.
A 15-second timeout is not always enough. A requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='api.example-flags.com', port=443): Read timed out. (read timeout=15) raised inside create leaves the flag created upstream but unknown to state. Prefer an idempotency key header if the platform supports one, so the retried POST returns the original resource instead of a duplicate.
Operational Notes
The provider is code that runs on every engineer's laptop and in every pipeline, so treat its failure modes as a small operations problem rather than a coding detail. The table below is the decision matrix worth encoding once and reusing across every SaaS resource you wrap.
Token rotation deserves its own habit. Because the token is a resource input, rotating it changes new on the next run — and if you left it out of the ignored set, diff reports a change on every resource that carries it. Either exclude the token from the comparison in diff (as the norm helper above does by omission) or pass it through pulumi.ResourceOptions(ignore_changes=["token"]). Rotating a token should be an event nobody notices in a preview.
Blast radius matters more here than with a cloud provider, because a SaaS platform rarely offers point-in-time recovery. A wrong replaces entry turns an innocuous rename into delete-then-create, which for a feature flag means the flag briefly disappears and every client falls back to its default. Before shipping a change to diff, run pulumi preview --diff against a scratch stack and read the plan for the word replace; if it appears where you expected an update, fix the diff, not the plan.
Finally, keep the provider's blast radius observable. Log the SaaS request id from the response headers into the resource outputs, so when an audit shows a flag changed at 03:14 you can match it to a specific pulumi up. Pair that with the mocked-request tests in unit testing Pulumi programs with mocks and the provider stops being the least trusted part of the stack.
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.
What happens if the SaaS API is down during pulumi up?
The exception propagates and the deployment fails partway, leaving already-created resources in state and the rest unattempted. That is the correct behaviour — re-running converges — provided your create is safe to retry, which is why an idempotency key or a read-before-create guard is worth the effort on any resource whose key must be unique.
Should the SaaS token be a resource input or read from the environment?
Make it an input sourced from pulumi.Config().require_secret(...). Reading os.environ inside a lifecycle method works on a laptop but breaks the moment the provider runs somewhere the variable is not exported, and it hides the credential from pulumi stack export, where an encrypted secret is actually easier to audit.
How do I rename a flag without destroying it?
You cannot, unless the platform exposes a rename endpoint — and if it does, drop key from replaces and issue the rename in update instead. Otherwise the honest answer is that a key change is a replacement, and the plan should say so rather than quietly mutating the remote resource.
Related
- Writing a Pulumi Dynamic Provider in Python — the foundational walkthrough of the ResourceProvider interface.
- Testing Pulumi Dynamic Providers in Python — the client seam and fake-API patterns behind the tests above.
- Dynamic Providers & Custom Resources — the parent topic on extending Pulumi with Python.
- Unit Testing Pulumi Programs with Mocks — how to test CRUD logic without hitting the live API.