State migrations
State migrations let you evolve a component’s internal resources while preserving the infrastructure it already manages. Attach a migration callback to the component to translate its saved state and the state of its children into the representation expected by the new implementation. Pulumi runs the migration before calculating resource changes.
The state migrations API is experimental and may change.
When to use a state migration
Use a state migration when a component upgrade needs to translate its children’s saved properties or reorganize resources that already represent the same physical infrastructure. Keep the migration in the component implementation so consumers can upgrade the component without editing their stack state manually.
For simpler changes, use the corresponding resource option:
| Change | Mechanism |
|---|---|
| Rename a resource or change its parent while retaining compatible state | Aliases |
| Transform resource inputs or options as the program registers resources | Transforms |
| Bring an existing cloud resource under Pulumi management | Import |
| Translate saved properties or merge state entries during a component upgrade | State migrations |
A migration rewrites state only. It does not create, import, update, or delete cloud resources. After the migration, Pulumi compares the updated program with the migrated state and performs any remaining provider operations normally. An incorrect translation can still lead to an unwanted update or replacement, so review the preview before applying it.
Write a migration callback
A component state migration is a pure function that transforms the saved state of a component and its children into the state expected by a new component version. It returns the updated state records and successor mappings for any changed URNs, or no result if no migration is needed.
The option reference explains the callback API. The state entries use the checkpoint resource format, including fields such as urn, type, id, parent, provider, inputs, and outputs.
Return the complete migrated subtree, including the component and unchanged descendants. For every resource in the callback’s input, do exactly one of the following:
- Return it under the same URN, with any desired state changes.
- Omit its old URN and map that URN to a successor present in the returned state.
Several old URNs can map to one successor. A resource cannot both remain in the result and have a successor mapping, and a mapping cannot point to a resource outside the result. Express state renames through successors rather than adding aliases to returned checkpoint entries.
Write callbacks that recognize the old representation and return no result when it’s already migrated. Keep earlier callbacks when publishing later migrations so users can upgrade from older component versions. Reject unexpected state rather than guessing how to translate it.
Preserve identity and secrets
For managed custom resources, a successor must preserve the physical ID, provider reference, extension reference, ownership, and lifecycle flags. When merging resources, preserve protect and retainOnDelete if either predecessor has them. Provider resource entries must remain unchanged. A migration cannot introduce a new managed physical object by inventing an ID.
The returned state must form a valid subtree, with unique URNs and resolvable structural references. When changing a resource type, update both its type field and the type in its URN. Preserve fields you are not intentionally translating, and copy maps before editing them.
Example: upgrade a versioned bucket component
Consider a storage component with these two direct children:
aws:s3/bucketV2:BucketV2, representing an S3 bucket.aws:s3/bucketVersioningV2:BucketVersioningV2, managing versioning for that same bucket.
The updated component registers one aws:s3/bucket:Bucket with inline versioning. Both old state entries have the same physical bucket ID and provider reference, which allows them to name the same successor.
The migration preserves the component and other descendants, converts the bucket’s type and properties, and removes the separate versioning entry. Both old URNs map to the new bucket URN:
| Prior state entry | Returned state entry | Successor mapping |
|---|---|---|
| Component | Unchanged component | None |
BucketV2 | Bucket with the same physical ID | Old bucket URN → new bucket URN |
BucketVersioningV2 | Omitted | Versioning URN → new bucket URN |
Translate the saved state
The callbacks below implement this migration. Each translates both inputs and outputs. Each uses the separate versioning resource’s outputs as the source of versioning settings and converts the bucket’s plural singleton lists to the new resource’s singular objects. These conversions are specific to the two AWS resource schemas, they are not a general conversion between arbitrary resource types.
import type * as pulumi from "@pulumi/pulumi";
export const componentType = "example:storage:VersionedBucket";
const oldBucketType = "aws:s3/bucketV2:BucketV2";
const bucketType = "aws:s3/bucket:Bucket";
const versioningType = "aws:s3/bucketVersioningV2:BucketVersioningV2";
function stateString(state: Record<string, any>, key: string): string {
const value = state[key];
if (typeof value !== "string" || value === "") {
throw new Error(`State resource has missing or invalid ${key}`);
}
return value;
}
export function migrateBucketState(args: pulumi.StateMigrationArgs): pulumi.StateMigrationResult | undefined {
if (args.oldState.length === 0) {
return undefined;
}
const root = args.oldState[0];
if (root.type !== componentType) {
throw new Error("Expected a VersionedBucket migration root");
}
// Find the bucket and versioning sidecar directly parented to this component.
let index = -1;
let sidecarIndex = -1;
args.oldState.forEach((state, i) => {
if (state.parent !== root.urn) {
return;
}
if (state.type === oldBucketType || state.type === bucketType) {
if (index !== -1) {
throw new Error("Expected only one bucket");
}
index = i;
} else if (state.type === versioningType) {
if (sidecarIndex !== -1) {
throw new Error("Expected only one versioning sidecar");
}
sidecarIndex = i;
}
});
// Leave fresh or already-migrated state unchanged.
if (sidecarIndex === -1 && (index === -1 || args.oldState[index].type === bucketType)) {
return undefined;
}
if (index === -1 || sidecarIndex === -1 || args.oldState[index].type !== oldBucketType) {
throw new Error("Expected BucketV2 and its versioning sidecar");
}
const oldBucket = args.oldState[index];
const sidecar = args.oldState[sidecarIndex];
// Both state entries must refer to the same physical bucket and provider.
for (const key of ["id", "provider"]) {
if (sidecar[key] !== stateString(oldBucket, key)) {
throw new Error(`Bucket and sidecar must have the same ${key}`);
}
}
// Only migrate the versioning configuration supported by this example.
const configuration = sidecar.outputs?.versioningConfiguration;
if (configuration?.status !== "Enabled" || configuration?.mfaDelete !== "Disabled") {
throw new Error("This example requires enabled versioning without MFA delete");
}
const sidecarURN = stateString(sidecar, "urn");
// Copy the bucket state and change its logical type in both the URN and
// type field, preserving its physical ID and other metadata.
const bucket = { ...oldBucket };
const oldURN = stateString(bucket, "urn");
const oldType = `$${oldBucketType}::`;
if (!oldURN.includes(oldType)) {
throw new Error("Bucket URN does not match BucketV2");
}
const newURN = oldURN.replace(oldType, `$${bucketType}::`);
bucket.urn = newURN;
bucket.type = bucketType;
// Translate both saved inputs and observed outputs to the new schema.
for (const field of ["inputs", "outputs"]) {
const oldProperties = bucket[field];
if (oldProperties === null || typeof oldProperties !== "object" || Array.isArray(oldProperties)) {
throw new Error(`Expected bucket ${field} object`);
}
const properties = { ...oldProperties };
delete properties.__pulumi_raw_state_delta;
delete properties.versionings;
// Populate the new bucket's inline versioning settings from the old
// versioning sidecar, which managed this configuration before migration.
properties.versioning = {
enabled: configuration.status === "Enabled",
mfaDelete: configuration.mfaDelete === "Enabled",
};
// BucketV2 uses plural singleton lists; Bucket uses singular objects.
for (const [plural, singular] of Object.entries({
loggings: "logging",
replicationConfigurations: "replicationConfiguration",
serverSideEncryptionConfigurations: "serverSideEncryptionConfiguration",
websites: "website",
})) {
const blocks = properties[plural];
delete properties[plural];
if (blocks === undefined || blocks === null) {
continue;
}
if (!Array.isArray(blocks) || blocks.length > 1) {
throw new Error(`Expected at most one ${plural} block`);
}
if (blocks.length === 1) {
properties[singular] = blocks[0];
}
}
bucket[field] = properties;
}
// Preserve the rest of the subtree and remove the separate versioning entry.
// Map both old URNs to the new bucket so Pulumi can rewrite references to them.
const newState = args.oldState.slice();
newState[index] = bucket;
newState.splice(sidecarIndex, 1);
return {
newState,
successors: { [oldURN]: newURN, [sidecarURN]: newURN },
};
}
from typing import Any
import pulumi
COMPONENT_TYPE = "example:storage:VersionedBucket"
OLD_BUCKET_TYPE = "aws:s3/bucketV2:BucketV2"
BUCKET_TYPE = "aws:s3/bucket:Bucket"
VERSIONING_TYPE = "aws:s3/bucketVersioningV2:BucketVersioningV2"
def state_string(state: dict[str, Any], key: str) -> str:
value = state.get(key)
if not isinstance(value, str) or not value:
raise ValueError(f"State resource has missing or invalid {key}")
return value
def migrate_bucket_state(
args: pulumi.StateMigrationArgs,
) -> pulumi.StateMigrationResult | None:
if not args.old_state:
return None
root = args.old_state[0]
if root.get("type") != COMPONENT_TYPE:
raise ValueError("Expected a VersionedBucket migration root")
# Find the bucket and versioning sidecar directly parented to this component.
index, sidecar_index = -1, -1
for i, state in enumerate(args.old_state):
if state.get("parent") != root.get("urn"):
continue
if state.get("type") in (OLD_BUCKET_TYPE, BUCKET_TYPE):
if index != -1:
raise ValueError("Expected only one bucket")
index = i
elif state.get("type") == VERSIONING_TYPE:
if sidecar_index != -1:
raise ValueError("Expected only one versioning sidecar")
sidecar_index = i
# Leave fresh or already-migrated state unchanged.
if sidecar_index == -1 and (
index == -1 or args.old_state[index].get("type") == BUCKET_TYPE
):
return None
if index == -1 or sidecar_index == -1 or args.old_state[index].get("type") != OLD_BUCKET_TYPE:
raise ValueError("Expected BucketV2 and its versioning sidecar")
old_bucket, sidecar = args.old_state[index], args.old_state[sidecar_index]
# Both state entries must refer to the same physical bucket and provider.
for key in ("id", "provider"):
if sidecar.get(key) != state_string(old_bucket, key):
raise ValueError(f"Bucket and sidecar must have the same {key}")
# Only migrate the versioning configuration supported by this example.
outputs = sidecar.get("outputs")
configuration = outputs.get("versioningConfiguration") if isinstance(outputs, dict) else None
if (
not isinstance(configuration, dict)
or configuration.get("status") != "Enabled"
or configuration.get("mfaDelete") != "Disabled"
):
raise ValueError("This example requires enabled versioning without MFA delete")
sidecar_urn = state_string(sidecar, "urn")
# Copy the bucket state and change its logical type in both the URN and
# type field, preserving its physical ID and other metadata.
bucket = dict(old_bucket)
old_urn = state_string(bucket, "urn")
old_type = f"${OLD_BUCKET_TYPE}::"
if old_type not in old_urn:
raise ValueError("Bucket URN does not match BucketV2")
new_urn = old_urn.replace(old_type, f"${BUCKET_TYPE}::", 1)
bucket["urn"], bucket["type"] = new_urn, BUCKET_TYPE
# Translate both saved inputs and observed outputs to the new schema.
for field in ("inputs", "outputs"):
old_properties = bucket.get(field)
if not isinstance(old_properties, dict):
raise ValueError(f"Expected bucket {field} object")
properties = dict(old_properties)
properties.pop("__pulumi_raw_state_delta", None)
properties.pop("versionings", None)
# Populate the new bucket's inline versioning settings from the old
# versioning sidecar, which managed this configuration before migration.
properties["versioning"] = {
"enabled": configuration["status"] == "Enabled",
"mfaDelete": configuration["mfaDelete"] == "Enabled",
}
# BucketV2 uses plural singleton lists; Bucket uses singular objects.
for plural, singular in {
"loggings": "logging",
"replicationConfigurations": "replicationConfiguration",
"serverSideEncryptionConfigurations": "serverSideEncryptionConfiguration",
"websites": "website",
}.items():
blocks = properties.pop(plural, None)
if blocks is None:
continue
if not isinstance(blocks, list) or len(blocks) > 1:
raise ValueError(f"Expected at most one {plural} block")
if blocks:
properties[singular] = blocks[0]
bucket[field] = properties
# Preserve the rest of the subtree and remove the separate versioning entry.
# Map both old URNs to the new bucket so Pulumi can rewrite references to them.
new_state = list(args.old_state)
new_state[index] = bucket
del new_state[sidecar_index]
return pulumi.StateMigrationResult(
new_state=new_state,
successors={old_urn: new_urn, sidecar_urn: new_urn},
)
package main
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
const (
componentType = "example:storage:VersionedBucket"
oldBucketType = "aws:s3/bucketV2:BucketV2"
bucketType = "aws:s3/bucket:Bucket"
versioningType = "aws:s3/bucketVersioningV2:BucketVersioningV2"
)
func stateString(state map[string]any, key string) (string, error) {
value, ok := state[key].(string)
if !ok || value == "" {
return "", fmt.Errorf("state resource has missing or invalid %s", key)
}
return value, nil
}
func migrateBucketState(_ context.Context, args *pulumi.StateMigrationArgs) (*pulumi.StateMigrationResult, error) {
if len(args.OldState) == 0 {
return nil, nil
}
root := args.OldState[0]
if root["type"] != componentType {
return nil, fmt.Errorf("expected a VersionedBucket migration root")
}
// Find the bucket and versioning sidecar directly parented to this component.
index, sidecarIndex := -1, -1
for i, state := range args.OldState {
if state["parent"] != root["urn"] {
continue
}
switch state["type"] {
case oldBucketType, bucketType:
if index != -1 {
return nil, fmt.Errorf("expected only one bucket")
}
index = i
case versioningType:
if sidecarIndex != -1 {
return nil, fmt.Errorf("expected only one versioning sidecar")
}
sidecarIndex = i
}
}
// Leave fresh or already-migrated state unchanged.
if sidecarIndex == -1 && (index == -1 || args.OldState[index]["type"] == bucketType) {
return nil, nil
}
if index == -1 || sidecarIndex == -1 || args.OldState[index]["type"] != oldBucketType {
return nil, fmt.Errorf("expected BucketV2 and its versioning sidecar")
}
oldBucket, sidecar := args.OldState[index], args.OldState[sidecarIndex]
// Both state entries must refer to the same physical bucket and provider.
for _, key := range []string{"id", "provider"} {
value, err := stateString(oldBucket, key)
if err != nil {
return nil, err
}
if sidecar[key] != value {
return nil, fmt.Errorf("bucket and sidecar must have the same %s", key)
}
}
// Only migrate the versioning configuration supported by this example.
outputs, _ := sidecar["outputs"].(map[string]any)
configuration, _ := outputs["versioningConfiguration"].(map[string]any)
if configuration["status"] != "Enabled" || configuration["mfaDelete"] != "Disabled" {
return nil, fmt.Errorf("this example requires enabled versioning without MFA delete")
}
sidecarURN, err := stateString(sidecar, "urn")
if err != nil {
return nil, err
}
// Copy the bucket state and change its logical type in both the URN and
// type field, preserving its physical ID and other metadata.
bucket := maps.Clone(args.OldState[index])
oldURN, err := stateString(bucket, "urn")
if err != nil {
return nil, err
}
oldType := "$" + oldBucketType + "::"
if !strings.Contains(oldURN, oldType) {
return nil, fmt.Errorf("bucket URN does not match BucketV2")
}
newURN := strings.Replace(oldURN, oldType, "$"+bucketType+"::", 1)
bucket["urn"], bucket["type"] = newURN, bucketType
// Translate both saved inputs and observed outputs to the new schema.
for _, field := range []string{"inputs", "outputs"} {
oldProperties, ok := bucket[field].(map[string]any)
if !ok {
return nil, fmt.Errorf("expected bucket %s object", field)
}
properties := maps.Clone(oldProperties)
delete(properties, "__pulumi_raw_state_delta")
delete(properties, "versionings")
// Populate the new bucket's inline versioning settings from the old
// versioning sidecar, which managed this configuration before migration.
properties["versioning"] = map[string]any{
"enabled": configuration["status"] == "Enabled",
"mfaDelete": configuration["mfaDelete"] == "Enabled",
}
// BucketV2 uses plural singleton lists; Bucket uses singular objects.
for plural, singular := range map[string]string{
"loggings": "logging",
"replicationConfigurations": "replicationConfiguration",
"serverSideEncryptionConfigurations": "serverSideEncryptionConfiguration",
"websites": "website",
} {
value := properties[plural]
delete(properties, plural)
if value == nil {
continue
}
blocks, ok := value.([]any)
if !ok || len(blocks) > 1 {
return nil, fmt.Errorf("expected at most one %s block", plural)
}
if len(blocks) == 1 {
properties[singular] = blocks[0]
}
}
bucket[field] = properties
}
// Preserve the rest of the subtree and remove the separate versioning entry.
// Map both old URNs to the new bucket so Pulumi can rewrite references to them.
newState := slices.Clone(args.OldState)
newState[index] = bucket
newState = slices.Delete(newState, sidecarIndex, sidecarIndex+1)
return &pulumi.StateMigrationResult{
NewState: newState,
Successors: map[string]string{oldURN: newURN, sidecarURN: newURN},
}, nil
}
Register the migration
The component constructor attaches the callback and registers the new bucket as its child.
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
import { componentType, migrateBucketState } from "./migration";
export class VersionedBucket extends pulumi.ComponentResource {
readonly bucketName: pulumi.Output<string>;
readonly bucketId: pulumi.Output<string>;
readonly bucketUrn: pulumi.Output<string>;
constructor(name: string, bucketName: string, opts?: pulumi.ComponentResourceOptions) {
super(componentType, name, {}, pulumi.mergeOptions(opts, {
stateMigrations: [migrateBucketState],
}));
const bucket = new aws.s3.Bucket(name, {
bucket: bucketName,
forceDestroy: true,
tags: {
example: "s3-bucket-state-migration",
"managed-by": "pulumi",
},
versioning: { enabled: true },
}, { parent: this });
this.bucketName = bucket.bucket;
this.bucketId = bucket.id;
this.bucketUrn = bucket.urn;
this.registerOutputs({ bucketName: this.bucketName });
}
}
import pulumi
import pulumi_aws as aws
from migration import COMPONENT_TYPE, migrate_bucket_state
class VersionedBucket(pulumi.ComponentResource):
def __init__(
self, name: str, bucket_name: str, opts: pulumi.ResourceOptions | None = None
):
opts = pulumi.ResourceOptions.merge(
opts, pulumi.ResourceOptions(state_migrations=[migrate_bucket_state])
)
super().__init__(COMPONENT_TYPE, name, {}, opts)
bucket = aws.s3.Bucket(
name,
bucket=bucket_name,
force_destroy=True,
tags={"example": "s3-bucket-state-migration", "managed-by": "pulumi"},
versioning={"enabled": True},
opts=pulumi.ResourceOptions(parent=self),
)
self.bucket_name = bucket.bucket
self.bucket_id = bucket.id
self.bucket_urn = bucket.urn
self.register_outputs({"bucketName": self.bucket_name})
package main
import (
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/s3"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type VersionedBucket struct {
pulumi.ResourceState
BucketName pulumi.StringOutput
BucketID pulumi.IDOutput
BucketURN pulumi.URNOutput
}
func NewVersionedBucket(
ctx *pulumi.Context,
name string,
bucketName string,
opts ...pulumi.ResourceOption,
) (*VersionedBucket, error) {
opts = append(opts, pulumi.StateMigrations([]pulumi.StateMigration{
migrateBucketState,
}))
component := &VersionedBucket{}
if err := ctx.RegisterComponentResource(componentType, name, component, opts...); err != nil {
return nil, err
}
childOpts := []pulumi.ResourceOption{pulumi.Parent(component)}
tags := pulumi.StringMap{
"example": pulumi.String("s3-bucket-state-migration"),
"managed-by": pulumi.String("pulumi"),
}
bucket, err := s3.NewBucket(ctx, name, &s3.BucketArgs{
Bucket: pulumi.String(bucketName),
ForceDestroy: pulumi.Bool(true),
Tags: tags,
Versioning: s3.BucketVersioningTypeArgs{
Enabled: pulumi.Bool(true),
},
}, childOpts...)
if err != nil {
return nil, err
}
component.BucketName = bucket.Bucket
component.BucketID = bucket.ID()
component.BucketURN = bucket.URN()
if err := ctx.RegisterResourceOutputs(component, pulumi.Map{
"bucketName": component.BucketName,
}); err != nil {
return nil, err
}
return component, nil
}
The migration does not issue a provider delete for the omitted versioning entry. The new bucket resource takes responsibility for that configuration.
How migrations run
- During
pulumi previeworpulumi up, Pulumi finds the component’s prior state by URN or alias. If there is no prior state, it skips the callbacks. - Pulumi supplies the component itself first, followed by its descendants in snapshot order. This is the subtree defined by parent relationships, not every resource that depends on it.
- Callbacks run in the order supplied. Each receives the state produced by earlier callbacks. A callback that returns no result leaves its input unchanged and allows later callbacks to run.
- Pulumi validates the result and uses successor mappings to rewrite structural references and serialized resource references, including references from outside the subtree. Ordinary string values are not rewritten.
- Pulumi diffs later resource registrations against the migrated state.
A preview evaluates the migration without saving it. An update persists the migration before proceeding with the affected resources. If a later operation fails, the migration may already be saved and callbacks must handle that state on the next run.
Migrations do not run during standalone refresh or destroy operations, including those using --run-program.
Unit test a migration
Think of a migration as a state[] → state[] transformation, with successor mappings alongside the returned state. Unit testing it means supplying an array of prior state records, calling the callback directly, and comparing the returned array with the expected state. This can be tested without the Pulumi runtime, provider mocks, running stack, or cloud credentials.
These test sketches call the S3 migration directly with inline arrays of JSON-compatible state records. The expected array keeps the component and combines the bucket and versioning entries into one bucket entry with inline versioning. The sketches omit imports and the URN definitions; ... comments stand for the remaining checkpoint fields, including parents, IDs, providers, and inputs. Include those fields in a runnable test.
test("migrates component state", () => {
const before = [
{ urn: componentUrn, type: "example:storage:VersionedBucket" },
{ urn: oldBucketUrn, type: "aws:s3/bucketV2:BucketV2", /* ... */ },
{
urn: versioningUrn, type: "aws:s3/bucketVersioningV2:BucketVersioningV2",
outputs: { versioningConfiguration: { status: "Enabled", mfaDelete: "Disabled" } },
/* ... */
},
];
const expected = [
{ urn: componentUrn, type: "example:storage:VersionedBucket" },
{
urn: bucketUrn, type: "aws:s3/bucket:Bucket",
outputs: { versioning: { enabled: true, mfaDelete: false } },
/* ... */
},
];
const result = migrateBucketState({ urn: before[0].urn, oldState: before });
assert.ok(result);
assert.deepEqual(result.newState, expected);
// Both prior resources have the inline bucket as their successor.
assert.deepEqual(result.successors, {
[oldBucketUrn]: bucketUrn,
[versioningUrn]: bucketUrn,
});
});
def test_migrates_component_state():
before = [
{"urn": component_urn, "type": "example:storage:VersionedBucket"},
{
"urn": old_bucket_urn, "type": "aws:s3/bucketV2:BucketV2",
# ...
},
{
"urn": versioning_urn, "type": "aws:s3/bucketVersioningV2:BucketVersioningV2",
"outputs": {"versioningConfiguration": {"status": "Enabled", "mfaDelete": "Disabled"}},
# ...
},
]
expected = [
{"urn": component_urn, "type": "example:storage:VersionedBucket"},
{
"urn": bucket_urn, "type": "aws:s3/bucket:Bucket",
"outputs": {"versioning": {"enabled": True, "mfaDelete": False}},
# ...
},
]
result = migrate_bucket_state(
pulumi.StateMigrationArgs(urn=before[0]["urn"], old_state=before)
)
assert result is not None
assert result.new_state == expected
# Both prior resources have the inline bucket as their successor.
assert result.successors == {
old_bucket_urn: bucket_urn,
versioning_urn: bucket_urn,
}
func TestMigratesComponentState(t *testing.T) {
before := []map[string]any{
{"urn": componentURN, "type": "example:storage:VersionedBucket"},
{"urn": oldBucketURN, "type": "aws:s3/bucketV2:BucketV2", /* ... */},
{
"urn": versioningURN, "type": "aws:s3/bucketVersioningV2:BucketVersioningV2",
"outputs": map[string]any{
"versioningConfiguration": map[string]any{"status": "Enabled", "mfaDelete": "Disabled"},
},
// ...
},
}
expected := []map[string]any{
{"urn": componentURN, "type": "example:storage:VersionedBucket"},
{
"urn": bucketURN, "type": "aws:s3/bucket:Bucket",
"outputs": map[string]any{
"versioning": map[string]any{"enabled": true, "mfaDelete": false},
},
// ...
},
}
result, err := migrateBucketState(context.Background(), &pulumi.StateMigrationArgs{
URN: pulumi.URN(before[0]["urn"].(string)), OldState: before,
})
if err != nil {
t.Fatal(err)
}
if result == nil || !reflect.DeepEqual(result.NewState, expected) {
t.Fatal("migration did not produce the expected state")
}
// Both prior resources have the inline bucket as their successor.
expectedSuccessors := map[string]string{
oldBucketURN: bucketURN,
versioningURN: bucketURN,
}
if !reflect.DeepEqual(result.Successors, expectedSuccessors) {
t.Fatal("migration did not map both prior resources to the inline bucket")
}
}
The successor assertions check that both the old bucket and the versioning resource map to the new inline bucket’s URN. You can pass the migrated state back into the callback and assert that it returns no changes. Use a preview and update to verify how the engine and provider apply the complete component upgrade.
For a runnable TypeScript example, see the AWSX VPC state migration. It upgrades a VPC from legacy AWSX to modern AWSX and then to plain AWS resources, preserving the existing AWS resource IDs. It includes migration callbacks, tests, and instructions for running each version against the same stack.
Restrictions
State-changing migrations require a full update. Pulumi rejects them in these situations:
- Targeted or excluded updates, including
--target,--exclude, and--replace. - Generating or applying an update plan. Apply the migration without a saved plan before returning to a plan-based workflow.
- Pending operations in the snapshot. Resolve them with
pulumi refreshbefore migrating. - Resources pending deletion in the subtree. Complete the unfinished deletion with the migration returning no changes before retrying.
- Persisted snippet references that would need to change. Update or remove those snippets first.
A callback that returns no result can remain attached when using update plans or targeted updates, the restrictions on state-changing results do not require removing an already-applied migration.