StateUpgrade redux

It turns out that state upgrades need to be handled differently since
providers are going to be backwards compatible. This means that new
state upgrades may still be stored in the flatmap format when used wih
terraform 0.11. Because we can't account for the specific version which
could produce a legacy state, all future state upgrades need to record
the schema types for decoding.

Rather than defining a single Upgrade function for states, we now have a
list of functions, each of which handle upgrading a specific version to
the next. In practice this isn't much different from the way many
resources implement upgrades themselves, with a separate function for
each version dispatched from the MigrateState function. The only added
burden is the recording of the schema type, and we intend to supply
tools and helper function to prevent the need to copy the entire
existing schema in all cases.
This commit is contained in:
James Bardin 2018-08-01 21:27:36 -04:00 committed by Martin Atkins
parent 9eef5e3f91
commit 0c33b26e04
2 changed files with 148 additions and 69 deletions

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"sort"
"strconv" "strconv"
"github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/config"
@ -45,17 +46,8 @@ type Resource struct {
// their Versioning at any integer >= 1 // their Versioning at any integer >= 1
SchemaVersion int SchemaVersion int
// LegacySchema is a record of the last schema version and type that
// existed before the addition of an UpgradeState function.
//
// This allows the resource schema to continue to evolve, while providing a
// record of how to decode a legacy state to be upgraded.
//
// LegacySchema is required when implementing UpgradeState.
LegacySchema LegacySchemaVersion
// MigrateState is deprecated and any new changes to a resource's schema // MigrateState is deprecated and any new changes to a resource's schema
// should be handled by UpgradeState. Existing MigrateState implementations // should be handled by StateUpgraders. Existing MigrateState implementations
// should remain for compatibility with existing state. MigrateState will // should remain for compatibility with existing state. MigrateState will
// still be called if the stored SchemaVersion is lower than the // still be called if the stored SchemaVersion is lower than the
// LegacySchema.Version value. // LegacySchema.Version value.
@ -72,16 +64,16 @@ type Resource struct {
// needs to make any remote API calls. // needs to make any remote API calls.
MigrateState StateMigrateFunc MigrateState StateMigrateFunc
// UpgradeState is responsible for upgrading an existing state with an old // StateUpgraders contains the functions responsible for upgrading an
// schema version to the current schema. It is called specifically by // existing state with an old schema version to a newer schema. It is
// Terraform when the stored schema version is less than the current // called specifically by Terraform when the stored schema version is less
// SchemaVersion of the Resource. // than the current SchemaVersion of the Resource.
// //
// StateUpgradeFunc takes the schema version, the state decoded using the // StateUpgraders map specific schema versions to an StateUpgrader
// default json types in a map[string]interface{}, and the provider meta // function. The registered versions are expected to be consecutive values.
// value. The returned map value should encode into the proper format json // The initial value may be greater than 0 to account for legacy schemas
// to match the current provider schema. // that weren't recorded and can be handled by MigrateState.
UpgradeState StateUpgradeFunc StateUpgraders []StateUpgrader
// The functions below are the CRUD operations for this resource. // The functions below are the CRUD operations for this resource.
// //
@ -163,11 +155,6 @@ type Resource struct {
Timeouts *ResourceTimeout Timeouts *ResourceTimeout
} }
type LegacySchemaVersion struct {
Version int
Type cty.Type
}
// See Resource documentation. // See Resource documentation.
type CreateFunc func(*ResourceData, interface{}) error type CreateFunc func(*ResourceData, interface{}) error
@ -187,8 +174,23 @@ type ExistsFunc func(*ResourceData, interface{}) (bool, error)
type StateMigrateFunc func( type StateMigrateFunc func(
int, *terraform.InstanceState, interface{}) (*terraform.InstanceState, error) int, *terraform.InstanceState, interface{}) (*terraform.InstanceState, error)
type StateUpgrader struct {
// Version is the version schema that this Upgrader will handle, converting
// it to Version+1.
Version int
// Type describes the schema that this function can upgrade. Type is
// required to decode the schema if the state was stored in a legacy
// flatmap format.
Type cty.Type
// Upgrade takes the JSON encoded state and the provider meta value, and
// upgrades the state one single schema version.
Upgrade StateUpgradeFunc
}
// See Resource documentation. // See Resource documentation.
type StateUpgradeFunc func(int, map[string]interface{}, interface{}) (map[string]interface{}, error) type StateUpgradeFunc func(map[string]interface{}, interface{}) (map[string]interface{}, error)
// See Resource documentation. // See Resource documentation.
type CustomizeDiffFunc func(*ResourceDiff, interface{}) error type CustomizeDiffFunc func(*ResourceDiff, interface{}) error
@ -472,12 +474,34 @@ func (r *Resource) InternalValidate(topSchemaMap schemaMap, writable bool) error
} }
} }
if r.LegacySchema.Version >= r.SchemaVersion { // verify state upgraders are consecutive and have registered schema types
return errors.New("LegacySchema.Version cannot be >= SchemaVersion") sort.Slice(r.StateUpgraders, func(i, j int) bool {
return r.StateUpgraders[i].Version < r.StateUpgraders[j].Version
})
lastVersion := -1
for _, u := range r.StateUpgraders {
if lastVersion >= 0 && u.Version-lastVersion > 1 {
return fmt.Errorf("missing schema version between %d and %d", lastVersion, u.Version)
}
if u.Version >= r.SchemaVersion {
return fmt.Errorf("StateUpgrader version %d is >= current version %d", u.Version, r.SchemaVersion)
}
if !u.Type.IsObjectType() {
return fmt.Errorf("StateUpgrader %d type is not cty.Object", u.Version)
}
if u.Upgrade == nil {
return fmt.Errorf("StateUpgrader %d missing StateUpgradeFunc", u.Version)
}
lastVersion = u.Version
} }
if r.UpgradeState != nil && !r.LegacySchema.Type.IsObjectType() { if lastVersion >= 0 && lastVersion != r.SchemaVersion-1 {
return fmt.Errorf("LegacySchema.Type requires a cty.Object, got: %#v", r.LegacySchema.Type) return fmt.Errorf("missing StateUpgrader between %d and %d", lastVersion, r.SchemaVersion)
} }
// Data source // Data source

View File

@ -1375,7 +1375,9 @@ func TestResourceData_timeouts(t *testing.T) {
} }
func TestResource_UpgradeState(t *testing.T) { func TestResource_UpgradeState(t *testing.T) {
// Schema v2 it deals only in newfoo, which tracks foo as an int // While this really only calls itself and therefore doesn't test any of
// the Resource code directly, it still serves as an example of registering
// a StateUpgrader.
r := &Resource{ r := &Resource{
SchemaVersion: 2, SchemaVersion: 2,
Schema: map[string]*Schema{ Schema: map[string]*Schema{
@ -1386,25 +1388,25 @@ func TestResource_UpgradeState(t *testing.T) {
}, },
} }
r.LegacySchema.Version = 1 r.StateUpgraders = []StateUpgrader{
r.LegacySchema.Type = cty.Object(map[string]cty.Type{ {
"id": cty.String, Version: 1,
"oldfoo": cty.Number, Type: cty.Object(map[string]cty.Type{
}) "id": cty.String,
"oldfoo": cty.Number,
}),
Upgrade: func(m map[string]interface{}, meta interface{}) (map[string]interface{}, error) {
r.UpgradeState = func( oldfoo, ok := m["oldfoo"].(float64)
v int, if !ok {
m map[string]interface{}, t.Fatalf("expected 1.2, got %#v", m["oldfoo"])
meta interface{}) (map[string]interface{}, error) { }
m["newfoo"] = int(oldfoo * 10)
delete(m, "oldfoo")
oldfoo, ok := m["oldfoo"].(float64) return m, nil
if !ok { },
t.Fatalf("expected 1.2, got %#v", m["oldfoo"]) },
}
m["newfoo"] = int(oldfoo * 10)
delete(m, "oldfoo")
return m, nil
} }
oldStateAttrs := map[string]string{ oldStateAttrs := map[string]string{
@ -1413,11 +1415,12 @@ func TestResource_UpgradeState(t *testing.T) {
} }
// convert the legacy flatmap state to the json equivalent // convert the legacy flatmap state to the json equivalent
val, err := hcl2shim.HCL2ValueFromFlatmap(oldStateAttrs, r.LegacySchema.Type) ty := r.StateUpgraders[0].Type
val, err := hcl2shim.HCL2ValueFromFlatmap(oldStateAttrs, ty)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
js, err := ctyjson.Marshal(val, r.LegacySchema.Type) js, err := ctyjson.Marshal(val, ty)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -1428,7 +1431,7 @@ func TestResource_UpgradeState(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
actual, err := r.UpgradeState(2, m, nil) actual, err := r.StateUpgraders[0].Upgrade(m, nil)
if err != nil { if err != nil {
t.Fatalf("err: %s", err) t.Fatalf("err: %s", err)
} }
@ -1445,7 +1448,7 @@ func TestResource_UpgradeState(t *testing.T) {
func TestResource_ValidateUpgradeState(t *testing.T) { func TestResource_ValidateUpgradeState(t *testing.T) {
r := &Resource{ r := &Resource{
SchemaVersion: 2, SchemaVersion: 3,
Schema: map[string]*Schema{ Schema: map[string]*Schema{
"newfoo": &Schema{ "newfoo": &Schema{
Type: TypeInt, Type: TypeInt,
@ -1458,26 +1461,78 @@ func TestResource_ValidateUpgradeState(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
r.LegacySchema.Version = 2 r.StateUpgraders = append(r.StateUpgraders, StateUpgrader{
if err := r.InternalValidate(nil, true); err == nil { Version: 2,
t.Fatal("LegacySchema.Version cannot be >= SchemaVersion") Type: cty.Object(map[string]cty.Type{
} "id": cty.String,
}),
r.LegacySchema.Version = 1 Upgrade: func(m map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
return m, nil
r.UpgradeState = func(v int, m map[string]interface{}, _ interface{}) (map[string]interface{}, error) { },
return m, nil
}
if err := r.InternalValidate(nil, true); err == nil {
t.Fatal("UpgradeState requires LegacySchema.Type")
}
r.LegacySchema.Type = cty.Object(map[string]cty.Type{
"id": cty.String,
}) })
if err := r.InternalValidate(nil, true); err != nil { if err := r.InternalValidate(nil, true); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// check for missing type
r.StateUpgraders[0].Type = cty.Type{}
if err := r.InternalValidate(nil, true); err == nil {
t.Fatal("StateUpgrader must have type")
}
r.StateUpgraders[0].Type = cty.Object(map[string]cty.Type{
"id": cty.String,
})
// check for missing Upgrade func
r.StateUpgraders[0].Upgrade = nil
if err := r.InternalValidate(nil, true); err == nil {
t.Fatal("StateUpgrader must have an Upgrade func")
}
r.StateUpgraders[0].Upgrade = func(m map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
return m, nil
}
// check for skipped version
r.StateUpgraders[0].Version = 0
r.StateUpgraders = append(r.StateUpgraders, StateUpgrader{
Version: 2,
Type: cty.Object(map[string]cty.Type{
"id": cty.String,
}),
Upgrade: func(m map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
return m, nil
},
})
if err := r.InternalValidate(nil, true); err == nil {
t.Fatal("StateUpgraders cannot skip versions")
}
// add the missing version
// out of order upgraders should be OK
r.StateUpgraders = append(r.StateUpgraders, StateUpgrader{
Version: 1,
Type: cty.Object(map[string]cty.Type{
"id": cty.String,
}),
Upgrade: func(m map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
return m, nil
},
})
if err := r.InternalValidate(nil, true); err != nil {
t.Fatal(err)
}
// can't add an upgrader for a schema >= the current version
r.StateUpgraders = append(r.StateUpgraders, StateUpgrader{
Version: 3,
Type: cty.Object(map[string]cty.Type{
"id": cty.String,
}),
Upgrade: func(m map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
return m, nil
},
})
if err := r.InternalValidate(nil, true); err == nil {
t.Fatal("StateUpgraders cannot have a version >= current SchemaVersion")
}
} }