Provisioning DR Sandboxes with Pulumi

This page implements one concrete task inside the broader sandbox provisioning automation workflow: a full Pulumi program, written in real Python, that materializes an isolated, snapshot-backed restore-drill sandbox and hands its endpoint to the validation runner. Because the program is ordinary Python rather than a declarative template, snapshot discovery, the point-in-time recovery targeting coordinate, and resource construction all live in one control flow — a boto3 call resolves the newest eligible snapshot inline, and its result flows straight into the resource graph without a separate data-source indirection. The sandbox is confined behind the security boundaries for DR environments your policy mandates, comes up inside the window your RTO and RPO mapping allows, and is destroyed on a TTL tag so no drill leaves cost or attack surface behind before the checksum validation pipeline is allowed to run against it.

Architecture and Execution Model

Pulumi program flow for a snapshot-backed DR sandbox A vertical flow of six stages: resolve the newest snapshot with boto3, create an isolated VPC and security group, restore the RDS cluster from the snapshot, tag it for TTL and isolation, export the endpoint to the validation runner, and destroy the stack when the TTL expires. Resolve newest snapshot Create isolated VPC + SG Restore RDS from snapshot Tag TTL + isolation Export endpoint to runner Destroy on TTL

Figure. The program resolves a snapshot in Python before any resource exists, then builds the isolated network, restores the database cluster, tags it, exports the endpoint, and defers destruction to a TTL-driven reaper.

Where the sibling Terraform pattern pushes snapshot discovery out into a data "external" shell-out and sequences resources through depends_on edges, Pulumi keeps everything inside one imperative program. The resolver is a plain function call whose return value is a Python dictionary; the snapshot id is a string variable, not a graph reference resolved during a separate plan phase. That difference matters operationally: a raise in the resolver aborts the whole pulumi up before a single cloud API is called, and ordinary Python control flow — comprehensions, max(), exception handling — replaces the HCL functions and provider gymnastics the declarative approach needs to express the same logic.

Prerequisites

  • Python 3.8+ and the Pulumi CLI (pulumi on PATH, logged in to a state backend such as Pulumi Cloud or a self-managed S3 backend).

  • Program dependencies installed into the project virtualenv Pulumi runs the program with:

    bash
    pip install pulumi pulumi-aws boto3
    
  • A drill IAM principal whose credentials the program and CLI assume, permitted to call rds:DescribeDBClusterSnapshots, rds:RestoreDBClusterFromSnapshot, kms:Decrypt, kms:CreateGrant, and the ec2:* actions the isolated VPC constructs require.

  • A KMS grant on the source snapshot’s encryption key for the restore, so an encrypted automated snapshot decrypts into the sandbox cluster.

  • Stack configuration carrying the source cluster id, the recovery-point epoch, the orchestrator CIDR, and the TTL epoch — set per drill with pulumi config set. The stack name doubles as the drill identifier, so each concurrent drill uses its own stack and its own isolated state.

Production Implementation

The program body is the Pulumi entrypoint (__main__.py). It reads stack config, calls boto3 to resolve the newest non-terminal automated snapshot at or before the target epoch, and only then declares resources. Because resolution happens first, a missing snapshot raises pulumi.RunError and aborts the update before the VPC, security group, or cluster is ever created — there is no partial sandbox to clean up.

python
#!/usr/bin/env python3
"""Pulumi program: stand up an isolated, snapshot-backed restore-drill sandbox.

Run with `pulumi up`. The program resolves the newest eligible automated
snapshot at runtime with boto3, builds an isolated VPC/subnet plus a security
group that admits only the validation orchestrator CIDR, restores an Aurora
cluster from that snapshot, tags it for TTL garbage collection, and exports the
writer endpoint for the validation runner.

Snapshot resolution runs before any resource is declared, so a missing snapshot
raises and `pulumi up` aborts non-zero with no partial sandbox created.
"""
import boto3
from botocore.exceptions import BotoCoreError, ClientError

import pulumi
import pulumi_aws as aws

config = pulumi.Config()
source_cluster = config.require("sourceClusterId")
pitr_target_epoch = config.require_int("pitrTargetEpoch")
orchestrator_cidr = config.require("orchestratorCidr")
ttl_epoch = config.require_int("sandboxTtlEpoch")
region = config.get("primaryRegion") or "us-east-1"
drill_id = pulumi.get_stack()


def resolve_newest_snapshot(cluster_id: str, target_epoch: int, aws_region: str) -> dict:
    """Return the newest non-terminal automated snapshot at or before target_epoch."""
    client = boto3.client("rds", region_name=aws_region)
    paginator = client.get_paginator("describe_db_cluster_snapshots")
    eligible = []
    try:
        for page in paginator.paginate(DBClusterIdentifier=cluster_id, SnapshotType="automated"):
            for snap in page["DBClusterSnapshots"]:
                created = snap.get("SnapshotCreateTime")
                if created is None or snap["Status"] not in ("available", "creating", "copying"):
                    continue
                if created.timestamp() <= target_epoch:
                    eligible.append(snap)
    except (BotoCoreError, ClientError) as exc:
        raise pulumi.RunError(f"snapshot discovery failed for {cluster_id}: {exc}")
    if not eligible:
        raise pulumi.RunError(
            f"no automated snapshot for {cluster_id} at or before epoch {target_epoch}"
        )
    return max(eligible, key=lambda s: s["SnapshotCreateTime"])


snapshot = resolve_newest_snapshot(source_cluster, pitr_target_epoch, region)
snapshot_id = snapshot["DBClusterSnapshotIdentifier"]
kms_key_id = snapshot.get("KmsKeyId")
pulumi.log.info(f"resolved snapshot {snapshot_id} (status={snapshot['Status']})")

common_tags = {
    "Environment": "dr-sandbox",
    "DrillID": drill_id,
    "TTL": str(ttl_epoch),
    "ManagedBy": "pulumi",
}

vpc = aws.ec2.Vpc(
    "dr-sandbox-vpc",
    cidr_block="10.240.0.0/16",
    enable_dns_support=True,
    enable_dns_hostnames=True,
    tags={**common_tags, "Name": f"dr-sandbox-{drill_id}"},
)

azs = aws.get_availability_zones(state="available")

subnet_a = aws.ec2.Subnet(
    "dr-sandbox-subnet-a",
    vpc_id=vpc.id,
    cidr_block="10.240.1.0/24",
    availability_zone=azs.names[0],
    map_public_ip_on_launch=False,
    tags=common_tags,
)

subnet_b = aws.ec2.Subnet(
    "dr-sandbox-subnet-b",
    vpc_id=vpc.id,
    cidr_block="10.240.2.0/24",
    availability_zone=azs.names[1],
    map_public_ip_on_launch=False,
    tags=common_tags,
)

subnet_group = aws.rds.SubnetGroup(
    "dr-sandbox-subnet-group",
    subnet_ids=[subnet_a.id, subnet_b.id],
    tags=common_tags,
)

sandbox_sg = aws.ec2.SecurityGroup(
    "dr-sandbox-sg",
    vpc_id=vpc.id,
    description="Admit only the validation orchestrator CIDR to the DR sandbox",
    ingress=[aws.ec2.SecurityGroupIngressArgs(
        protocol="tcp",
        from_port=3306,
        to_port=3306,
        cidr_blocks=[orchestrator_cidr],
        description="Aurora/MySQL from the validation orchestrator only",
    )],
    egress=[aws.ec2.SecurityGroupEgressArgs(
        protocol="-1",
        from_port=0,
        to_port=0,
        cidr_blocks=["0.0.0.0/0"],
    )],
    tags=common_tags,
)

cluster = aws.rds.Cluster(
    "dr-sandbox-cluster",
    cluster_identifier=f"dr-sandbox-{drill_id}",
    engine="aurora-mysql",
    snapshot_identifier=snapshot_id,
    kms_key_id=kms_key_id,
    db_subnet_group_name=subnet_group.name,
    vpc_security_group_ids=[sandbox_sg.id],
    skip_final_snapshot=True,
    deletion_protection=False,
    apply_immediately=True,
    tags=common_tags,
)

instance = aws.rds.ClusterInstance(
    "dr-sandbox-instance",
    cluster_identifier=cluster.id,
    instance_class="db.r6g.large",
    engine="aurora-mysql",
    publicly_accessible=False,
    tags=common_tags,
)

pulumi.export("drill_id", drill_id)
pulumi.export("resolved_snapshot", snapshot_id)
pulumi.export("sandbox_endpoint", cluster.endpoint)
pulumi.export("sandbox_reader_endpoint", cluster.reader_endpoint)
pulumi.export("ttl_epoch", ttl_epoch)

Two design choices deserve emphasis. First, the security group opens a single ingress rule scoped to the orchestrator CIDR on the engine port and nothing else; the sandbox lives in a fresh 10.240.0.0/16 VPC that shares no route with production, so even a misconfigured application cannot reach a live host. Second, every resource carries the TTL tag and the DrillID, which are what the reaper and any cost-attribution query key on — Pulumi’s own state is the source of truth for teardown, but the tags make the sandbox discoverable from outside the tool as well.

The teardown path is a separate scheduled process built on the Pulumi Automation API. It enumerates the stacks in the project, reads each stack’s exported ttl_epoch, and calls stack.destroy() on any sandbox whose TTL has passed. It returns strict POSIX codes so a scheduler can alert on a failed reap rather than letting orphaned clusters accrue cost silently.

python
#!/usr/bin/env python3
"""Reap DR sandboxes whose Pulumi stacks have passed their TTL.

Teardown path for the sandbox program. Uses the Pulumi Automation API: for every
stack in the local project, read its `ttl_epoch` output and run stack.destroy()
once wall-clock time has passed it. Run this from a scheduled job so no sandbox
outlives its drill window.

Exit codes:
    0  every expired sandbox destroyed (or none were due yet)
    1  one or more destroys failed
    2  usage / configuration error
"""
import sys
import time

from pulumi.automation import LocalWorkspace, select_stack
from pulumi.automation.errors import CommandError


def reap(work_dir: str) -> int:
    ws = LocalWorkspace(work_dir=work_dir)
    try:
        stacks = ws.list_stacks()
    except CommandError as exc:
        print(f"cannot list stacks in {work_dir}: {exc}", file=sys.stderr)
        return 2

    now = int(time.time())
    reaped = 0
    failures = 0
    for summary in stacks:
        stack = select_stack(stack_name=summary.name, work_dir=work_dir)
        ttl = stack.outputs().get("ttl_epoch")
        if ttl is None or int(ttl.value) > now:
            continue
        try:
            stack.destroy(on_output=print)
            reaped += 1
            print(f"destroyed expired sandbox stack {summary.name}")
        except CommandError as exc:
            print(f"destroy failed for {summary.name}: {exc}", file=sys.stderr)
            failures += 1

    print(f"reaped {reaped} sandbox(es); {failures} failure(s)")
    return 1 if failures else 0


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("usage: reap_expired_sandboxes.py <pulumi_project_work_dir>", file=sys.stderr)
        sys.exit(2)
    sys.exit(reap(sys.argv[1]))

Step-by-Step Execution Walkthrough

  1. Create the per-drill stack. pulumi stack init drill-20260718-01 gives the drill its own state and its own DrillID; concurrent drills never collide because each has a distinct stack.
  2. Set the drill config. pulumi config set sourceClusterId prod-aurora, pulumi config set --secret orchestratorCidr 10.30.4.0/28, and the pitrTargetEpoch / sandboxTtlEpoch integers emitted by the point-in-time targeting stage.
  3. Run pulumi up. The program resolves the snapshot first; if none qualifies it raises pulumi.RunError and the update aborts non-zero before any resource is created. Otherwise Pulumi shows a preview and applies it.
  4. Read the exported endpoint. pulumi stack output sandbox_endpoint returns the writer endpoint; the orchestrator passes it and the DrillID to the validation runner as the input contract.
  5. Let the drill run. The runner reaches the sandbox only from the orchestrator CIDR; every other source is dropped at the security group.
  6. Reap on TTL. A scheduled job runs reap_expired_sandboxes.py against the project directory; it destroys any stack whose ttl_epoch has passed and exits non-zero if a destroy fails, so a stuck teardown is surfaced.

Verification and Expected Output

A clean provisioning run logs the resolved snapshot, previews the resource additions, and exports the endpoint:

text
Diagnostics:
  pulumi:pulumi:Stack (dr-sandbox-drill-20260718-01):
    resolved snapshot rds:prod-aurora-2026-07-18-06-00 (status=available)

     Type                        Name                          Status
 +   pulumi:pulumi:Stack         dr-sandbox-drill-20260718-01  created
 +   ├─ aws:ec2:Vpc              dr-sandbox-vpc                created
 +   ├─ aws:ec2:SecurityGroup    dr-sandbox-sg                 created
 +   ├─ aws:rds:Cluster          dr-sandbox-cluster            created
 +   └─ aws:rds:ClusterInstance  dr-sandbox-instance           created

Outputs:
    sandbox_endpoint: "dr-sandbox-drill-20260718-01.cluster-abc.us-east-1.rds.amazonaws.com"
    resolved_snapshot: "rds:prod-aurora-2026-07-18-06-00"
    ttl_epoch: 1752868800

Resources:
    + 7 created

Success means three things held: the resolver returned exactly one snapshot id and logged it, pulumi up finished with + 7 created and no **failed** markers, and sandbox_endpoint is populated. A non-zero pulumi up exit is the contract the orchestrator branches on — treat it like a failed validation gate and quarantine the drill rather than advancing to smoke tests. When the drill completes, the reaper output reaped 1 sandbox(es); 0 failure(s) and its exit 0 confirm the sandbox is gone.

Failure Modes and Troubleshooting

Symptom Cause Remediation
pulumi up aborts, log no automated snapshot for ... No automated snapshot at or before the target epoch Widen pitrTargetEpoch, or confirm automated backups are retained past the drill’s recovery point
RunError: snapshot discovery failed ... AccessDenied Drill principal lacks rds:DescribeDBClusterSnapshots Grant the describe action; the resolver runs before any resource so this fails fast
InvalidDBClusterSnapshotStateFault during restore A creating/copying snapshot was resolved and consumed before it finished Re-run once the snapshot reaches available, or filter the resolver to available only for stricter gating
KMSKeyNotAccessibleFault Restore role lacks a grant on the snapshot’s CMK Add a kms:Decrypt/kms:CreateGrant statement scoped to the KmsKeyId the resolver returned
Sandbox reachable from production hosts orchestratorCidr set too broadly Scope ingress to the orchestrator’s /28 or narrower; never reuse a production subnet or CIDR
Orphaned cluster after the drill Reaper did not run or its destroy failed Alert on the non-zero reaper exit; query Environment=dr-sandbox resources older than their TTL tag
error: [409] StackAlreadyExists on init A prior drill left the stack Reuse the stack for a retry, or pulumi stack rm --force after confirming its resources are destroyed

Integration Notes

The program and its reaper both return process exit codes, so any scheduler can gate on them without extra glue:

  • Airflow — wrap pulumi up in a BashOperator (or drive the Automation API directly from a PythonOperator); a non-zero update fails the task and short-circuits the downstream validation task, and the DAG run history becomes the drill’s provisioning audit trail.
  • Celery — dispatch the update from a task that raises on non-zero return so event-driven drills, triggered when a fresh backup lands, get low-latency provisioning and the broker records every failure.
  • cron / systemd — schedule the reaper directly; because it exits 0/non-zero cleanly, an OnFailure handler can route a teardown alert with no wrapper.

Feed the classified provisioning outcome into your error categorization frameworks so a snapshot-resolution failure and a KMS-grant failure escalate as distinct events, and pass the exported endpoint and DrillID forward as the input contract for the validation runner. For authoritative API behavior consult the Pulumi Automation API documentation, the AWS RDS restore-from-snapshot reference, and the Boto3 RDS reference for the snapshot-description call the resolver makes.

Frequently Asked Questions

Why choose Pulumi's Python model over the Terraform data source for snapshot resolution?

Pulumi runs your program as ordinary Python, so the boto3 lookup is a plain function call whose return value flows straight into resource arguments — no data "external" shell-out, no strict stdin/stdout JSON contract, and no separate plan phase. A raise in the resolver aborts pulumi up before any cloud API is called, and you get comprehensions, max(), and normal exception handling instead of HCL functions to express the same selection logic.

What happens to `pulumi up` when no eligible snapshot exists?

The resolver runs before any resource is declared, so pulumi.RunError propagates out of the program and the update exits non-zero without creating a VPC, security group, or cluster. There is nothing to roll back and no partial sandbox to clean up. The orchestrator treats that non-zero exit exactly like a failed validation gate and quarantines the drill.

How is the sandbox torn down when the drill ends?

Every stack exports a ttl_epoch and tags each resource Environment=dr-sandbox. A scheduled reaper built on the Automation API lists the project's stacks, reads each ttl_epoch, and calls stack.destroy() on any that have expired. It exits non-zero if a destroy fails, so a stuck teardown is alerted rather than silently accruing cost.

How does one stack per drill keep concurrent drills isolated?

The stack name is the DrillID, and Pulumi keeps a separate state file per stack, so two drills running at once never share resources or contend on state. Each stack carries its own config — source cluster, recovery epoch, orchestrator CIDR, and TTL — and its own isolated VPC, so a fault in one drill cannot reach another sandbox or production.

This script is one component of the broader Sandbox Provisioning Automation workflow.