Skip to content

Commit 3d4b55e

Browse files
author
Paul Hinze
committed
helper/schema: schema versioning & migration
Providers get a per-resource SchemaVersion integer that they can bump when a resource's schema changes format. Each InstanceState with an older recorded SchemaVersion than the cureent one is yielded to a `MigrateSchema` function to be transformed such that it can be addressed by the current version of the resource's Schema.
1 parent 9270af6 commit 3d4b55e

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

helper/schema/resource.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package schema
33
import (
44
"errors"
55
"fmt"
6+
"strconv"
67

78
"github.com/hashicorp/terraform/terraform"
89
)
@@ -24,6 +25,31 @@ type Resource struct {
2425
// resource.
2526
Schema map[string]*Schema
2627

28+
// SchemaVersion is the version number for this resource's Schema
29+
// definition. The current SchemaVersion stored in the state for each
30+
// resource. Provider authors can increment this version number
31+
// when Schema semantics change. If the State's SchemaVersion is less than
32+
// the current SchemaVersion, the InstanceState is yielded to the
33+
// MigrateState callback, where the provider can make whatever changes it
34+
// needs to update the state to be compatible to the latest version of the
35+
// Schema.
36+
//
37+
// When unset, SchemaVersion defaults to 0, so provider authors can start
38+
// their Versioning at any integer >= 1
39+
SchemaVersion int
40+
41+
// MigrateState is responsible for updating an InstanceState with an old
42+
// version to the format expected by the current version of the Schema.
43+
//
44+
// It is called during Refresh if the State's stored SchemaVersion is less
45+
// than the current SchemaVersion of the Resource.
46+
//
47+
// The function is yielded the state's stored SchemaVersion and a pointer to
48+
// the InstanceState that needs updating, as well as the configured
49+
// provider's configured meta interface{}, in case the migration process
50+
// needs to make any remote API calls.
51+
MigrateState StateMigrateFunc
52+
2753
// The functions below are the CRUD operations for this resource.
2854
//
2955
// The only optional operation is Update. If Update is not implemented,
@@ -69,6 +95,10 @@ type DeleteFunc func(*ResourceData, interface{}) error
6995
// See Resource documentation.
7096
type ExistsFunc func(*ResourceData, interface{}) (bool, error)
7197

98+
// See Resource documentation.
99+
type StateMigrateFunc func(
100+
int, *terraform.InstanceState, interface{}) (*terraform.InstanceState, error)
101+
72102
// Apply creates, updates, and/or deletes a resource.
73103
func (r *Resource) Apply(
74104
s *terraform.InstanceState,
@@ -158,6 +188,14 @@ func (r *Resource) Refresh(
158188
}
159189
}
160190

191+
needsMigration, stateSchemaVersion := r.checkSchemaVersion(s)
192+
if needsMigration && r.MigrateState != nil {
193+
s, err := r.MigrateState(stateSchemaVersion, s, meta)
194+
if err != nil {
195+
return s, err
196+
}
197+
}
198+
161199
data, err := schemaMap(r.Schema).Data(s, nil)
162200
if err != nil {
163201
return s, err
@@ -169,6 +207,13 @@ func (r *Resource) Refresh(
169207
state = nil
170208
}
171209

210+
if state != nil && r.SchemaVersion > 0 {
211+
if state.Meta == nil {
212+
state.Meta = make(map[string]string)
213+
}
214+
state.Meta["schema_version"] = strconv.Itoa(r.SchemaVersion)
215+
}
216+
172217
return state, err
173218
}
174219

@@ -189,3 +234,10 @@ func (r *Resource) InternalValidate() error {
189234

190235
return schemaMap(r.Schema).InternalValidate()
191236
}
237+
238+
// Determines if a given InstanceState needs to be migrated by checking the
239+
// stored version number with the current SchemaVersion
240+
func (r *Resource) checkSchemaVersion(is *terraform.InstanceState) (bool, int) {
241+
stateSchemaVersion, _ := strconv.Atoi(is.Meta["schema_version"])
242+
return stateSchemaVersion < r.SchemaVersion, stateSchemaVersion
243+
}

helper/schema/resource_test.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package schema
33
import (
44
"fmt"
55
"reflect"
6+
"strconv"
67
"testing"
78

89
"github.com/hashicorp/terraform/terraform"
@@ -478,3 +479,218 @@ func TestResourceRefresh_noExists(t *testing.T) {
478479
t.Fatalf("should have no state")
479480
}
480481
}
482+
483+
func TestResourceRefresh_needsMigration(t *testing.T) {
484+
// Schema v2 it deals only in newfoo, which tracks foo as an int
485+
r := &Resource{
486+
SchemaVersion: 2,
487+
Schema: map[string]*Schema{
488+
"newfoo": &Schema{
489+
Type: TypeInt,
490+
Optional: true,
491+
},
492+
},
493+
}
494+
495+
r.Read = func(d *ResourceData, m interface{}) error {
496+
return d.Set("newfoo", d.Get("newfoo").(int)+1)
497+
}
498+
499+
r.MigrateState = func(
500+
v int,
501+
s *terraform.InstanceState,
502+
meta interface{}) (*terraform.InstanceState, error) {
503+
// Real state migration functions will probably switch on this value,
504+
// but we'll just assert on it for now.
505+
if v != 1 {
506+
t.Fatalf("Expected StateSchemaVersion to be 1, got %d", v)
507+
}
508+
509+
if meta != 42 {
510+
t.Fatal("Expected meta to be passed through to the migration function")
511+
}
512+
513+
oldfoo, err := strconv.ParseFloat(s.Attributes["oldfoo"], 64)
514+
if err != nil {
515+
t.Fatalf("err: %#v", err)
516+
}
517+
s.Attributes["newfoo"] = strconv.Itoa((int(oldfoo * 10)))
518+
delete(s.Attributes, "oldfoo")
519+
520+
return s, nil
521+
}
522+
523+
// State is v1 and deals in oldfoo, which tracked foo as a float at 1/10th
524+
// the scale of newfoo
525+
s := &terraform.InstanceState{
526+
ID: "bar",
527+
Attributes: map[string]string{
528+
"oldfoo": "1.2",
529+
},
530+
Meta: map[string]string{
531+
"schema_version": "1",
532+
},
533+
}
534+
535+
actual, err := r.Refresh(s, 42)
536+
if err != nil {
537+
t.Fatalf("err: %s", err)
538+
}
539+
540+
expected := &terraform.InstanceState{
541+
ID: "bar",
542+
Attributes: map[string]string{
543+
"id": "bar",
544+
"newfoo": "13",
545+
},
546+
Meta: map[string]string{
547+
"schema_version": "2",
548+
},
549+
}
550+
551+
if !reflect.DeepEqual(actual, expected) {
552+
t.Fatalf("bad:\n\nexpected: %#v\ngot: %#v", expected, actual)
553+
}
554+
}
555+
556+
func TestResourceRefresh_noMigrationNeeded(t *testing.T) {
557+
r := &Resource{
558+
SchemaVersion: 2,
559+
Schema: map[string]*Schema{
560+
"newfoo": &Schema{
561+
Type: TypeInt,
562+
Optional: true,
563+
},
564+
},
565+
}
566+
567+
r.Read = func(d *ResourceData, m interface{}) error {
568+
return d.Set("newfoo", d.Get("newfoo").(int)+1)
569+
}
570+
571+
r.MigrateState = func(
572+
v int,
573+
s *terraform.InstanceState,
574+
meta interface{}) (*terraform.InstanceState, error) {
575+
t.Fatal("Migrate function shouldn't be called!")
576+
return nil, nil
577+
}
578+
579+
s := &terraform.InstanceState{
580+
ID: "bar",
581+
Attributes: map[string]string{
582+
"newfoo": "12",
583+
},
584+
Meta: map[string]string{
585+
"schema_version": "2",
586+
},
587+
}
588+
589+
actual, err := r.Refresh(s, nil)
590+
if err != nil {
591+
t.Fatalf("err: %s", err)
592+
}
593+
594+
expected := &terraform.InstanceState{
595+
ID: "bar",
596+
Attributes: map[string]string{
597+
"id": "bar",
598+
"newfoo": "13",
599+
},
600+
Meta: map[string]string{
601+
"schema_version": "2",
602+
},
603+
}
604+
605+
if !reflect.DeepEqual(actual, expected) {
606+
t.Fatalf("bad:\n\nexpected: %#v\ngot: %#v", expected, actual)
607+
}
608+
}
609+
610+
func TestResourceRefresh_stateSchemaVersionUnset(t *testing.T) {
611+
r := &Resource{
612+
// Version 1 > Version 0
613+
SchemaVersion: 1,
614+
Schema: map[string]*Schema{
615+
"newfoo": &Schema{
616+
Type: TypeInt,
617+
Optional: true,
618+
},
619+
},
620+
}
621+
622+
r.Read = func(d *ResourceData, m interface{}) error {
623+
return d.Set("newfoo", d.Get("newfoo").(int)+1)
624+
}
625+
626+
r.MigrateState = func(
627+
v int,
628+
s *terraform.InstanceState,
629+
meta interface{}) (*terraform.InstanceState, error) {
630+
s.Attributes["newfoo"] = s.Attributes["oldfoo"]
631+
return s, nil
632+
}
633+
634+
s := &terraform.InstanceState{
635+
ID: "bar",
636+
Attributes: map[string]string{
637+
"oldfoo": "12",
638+
},
639+
}
640+
641+
actual, err := r.Refresh(s, nil)
642+
if err != nil {
643+
t.Fatalf("err: %s", err)
644+
}
645+
646+
expected := &terraform.InstanceState{
647+
ID: "bar",
648+
Attributes: map[string]string{
649+
"id": "bar",
650+
"newfoo": "13",
651+
},
652+
Meta: map[string]string{
653+
"schema_version": "1",
654+
},
655+
}
656+
657+
if !reflect.DeepEqual(actual, expected) {
658+
t.Fatalf("bad:\n\nexpected: %#v\ngot: %#v", expected, actual)
659+
}
660+
}
661+
662+
func TestResourceRefresh_migrateStateErr(t *testing.T) {
663+
r := &Resource{
664+
SchemaVersion: 2,
665+
Schema: map[string]*Schema{
666+
"newfoo": &Schema{
667+
Type: TypeInt,
668+
Optional: true,
669+
},
670+
},
671+
}
672+
673+
r.Read = func(d *ResourceData, m interface{}) error {
674+
t.Fatal("Read should never be called!")
675+
return nil
676+
}
677+
678+
r.MigrateState = func(
679+
v int,
680+
s *terraform.InstanceState,
681+
meta interface{}) (*terraform.InstanceState, error) {
682+
return s, fmt.Errorf("triggering an error")
683+
}
684+
685+
s := &terraform.InstanceState{
686+
ID: "bar",
687+
Attributes: map[string]string{
688+
"oldfoo": "12",
689+
},
690+
}
691+
692+
_, err := r.Refresh(s, nil)
693+
if err == nil {
694+
t.Fatal("expected error, but got none!")
695+
}
696+
}

terraform/state.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,11 @@ type InstanceState struct {
832832
// that is necessary for the Terraform run to complete, but is not
833833
// persisted to a state file.
834834
Ephemeral EphemeralState `json:"-"`
835+
836+
// Meta is a simple K/V map that is persisted to the State but otherwise
837+
// ignored by Terraform core. It's meant to be used for accounting by
838+
// external client code.
839+
Meta map[string]string `json:"meta,omitempty"`
835840
}
836841

837842
func (i *InstanceState) init() {

0 commit comments

Comments
 (0)