Skip to content

Commit

Permalink
Add support for state migrations in infer (#215)
Browse files Browse the repository at this point in the history
Adds support for state migrations in the `infer` provider. Resources opt
into state migrations when they implement the
`infer.CustomStateMigrations[O]` interface:

```go
type CustomStateMigrations[O any] interface {
	// StateMigrations is the list of know migrations.
	//
	// Each migration should return a valid State object.
	//
	// The first migration to return a non-nil Result will be used.
	StateMigrations(ctx p.Context) []StateMigrationFunc[O]
}

// StateMigrationFunc represents a stateless mapping from an old state shape to a new
// state shape. Each StateMigrationFunc is parameterized by the shape of the type it
// produces, ensuring that all successful migrations end up in a valid state.
//
// To create a StateMigrationFunc, use [StateMigration].
type StateMigrationFunc[New any] interface {} // Sealed

// StateMigration creates a mapping from an old state shape (type Old) to a new state
// shape (type New).
//
// If Old = [resource.PropertyMap], then the migration is always run.
//
// Example:
//
//	type MyResource struct{}
//
//	type MyInput struct{}
//
//	type MyStateV1 struct {
//		SomeInt *int `pulumi:"someInt,optional"`
//	}
//
//	type MyStateV2 struct {
//		AString string `pulumi:"aString"`
//		AInt    *int   `pulumi:"aInt,optional"`
//	}
//
//	func migrateFromV1(ctx p.Context, v1 StateV1) (infer.MigrationResult[MigrateStateV2], error) {
//		return infer.MigrationResult[MigrateStateV2]{
//			Result: &MigrateStateV2{
//				AString: "default-string", // Add a new required field
//				AInt: v1.SomeInt, // Rename an existing field
//			},
//		}, nil
//	}
//
//	// Associate your migration with the resource it encapsulates.
//	func (*MyResource) StateMigrations(p.Context) []infer.StateMigrationFunc[MigrateStateV2] {
//		return []infer.StateMigrationFunc[MigrateStateV2]{
//			infer.StateMigration(migrateFromV1),
//		}
//	}
func StateMigration[Old, New any, F func(p.Context, Old) (MigrationResult[New], error)](f F) StateMigrationFunc[New] { ... }


// MigrationResult represents the result of a migration.
type MigrationResult[T any] struct {
	// Result is the result of the migration.
	//
	// If Result is nil, then the migration is considered to have been unnecessary.
	//
	// If Result is non-nil, then the migration is considered to have completed and
	// the new value state value will be *Result.
	Result *T
}
```

This allows each resource to define a list of (possibly typed)
migrations from old to new state. This design makes the common case of
migrating from StateV1 to StateV2 fully typed, but permits an untyped to
typed escape hatch.

Fixes #193
  • Loading branch information
iwahbe authored Apr 13, 2024
1 parent 14a82f8 commit 80e4b89
Show file tree
Hide file tree
Showing 6 changed files with 697 additions and 50 deletions.
3 changes: 1 addition & 2 deletions infer/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,7 @@ func objectSchema(t reflect.Type) (*pschema.ObjectTypeSpec, error) {
}

func (r *derivedInvokeController[F, I, O]) Invoke(ctx p.Context, req p.InvokeRequest) (p.InvokeResponse, error) {
var i I
encoder, mapErr := ende.Decode(req.Args, &i)
encoder, i, mapErr := ende.Decode[I](req.Args)
mapFailures, err := checkFailureFromMapError(mapErr)
if err != nil {
return p.InvokeResponse{}, err
Expand Down
16 changes: 12 additions & 4 deletions infer/internal/ende/ende.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ type Encoder struct{ *ende }
// The returned mapper can restore the metadata it removed when translating `dst` back to
// a property map. If the shape of `T` matches `m`, then this will be a no-op:
//
// var value T
// encoder, _ := Decode(m, &value)
// encoder, value, _ := Decode(m)
// m, _ = encoder.Encode(value)
func Decode[T any](m resource.PropertyMap, dst T) (Encoder, mapper.MappingError) {
return decode(m, dst, false, false)
func Decode[T any](m resource.PropertyMap) (Encoder, T, mapper.MappingError) {
var dst T
enc, err := decode(m, &dst, false, false)
return enc, dst, err
}

// DecodeTolerateMissing is like Decode, but doesn't return an error for a missing value.
Expand Down Expand Up @@ -62,6 +63,10 @@ func decode(

}

func DecodeAny(m resource.PropertyMap, dst any) (Encoder, mapper.MappingError) {
return decode(m, dst, false, false)
}

// An ENcoder DEcoder
type ende struct{ changes []change }

Expand Down Expand Up @@ -302,6 +307,9 @@ func (e *ende) Encode(src any) (resource.PropertyMap, mapper.MappingError) {
"NewPropertyMapFromMap cannot produce unknown values")
contract.Assertf(!m.ContainsSecrets(),
"NewPropertyMapFromMap cannot produce secrets")
if e == nil {
return m.ObjectValue(), nil
}
for _, s := range e.changes {
v, ok := s.path.Get(m)
if !ok && s.emptyAction == isNil {
Expand Down
3 changes: 1 addition & 2 deletions infer/internal/ende/ende_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ import (
func testRoundTrip[T any](t *testing.T, pMap func() r.PropertyMap) {
t.Run("", func(t *testing.T) {
t.Parallel()
var typeInfo T
toDecode := pMap()
encoder, err := Decode(toDecode, &typeInfo)
encoder, typeInfo, err := Decode[T](toDecode)
require.NoError(t, err)

assert.Equalf(t, pMap(), toDecode, "mutated decode map")
Expand Down
Loading

0 comments on commit 80e4b89

Please sign in to comment.