diff --git a/flatmap/expand.go b/flatmap/expand.go new file mode 100644 index 000000000..2c0a9266b --- /dev/null +++ b/flatmap/expand.go @@ -0,0 +1,73 @@ +package flatmap + +import ( + "fmt" + "strconv" + "strings" +) + +// Expand takes a map and a key (prefix) and expands that value into +// a more complex structure. This is the reverse of the Flatten operation. +func Expand(m map[string]string, key string) interface{} { + // If the key is exactly a key in the map, just return it + if v, ok := m[key]; ok { + if num, err := strconv.ParseInt(v, 0, 0); err == nil { + return int(num) + } else if v == "true" { + return true + } else if v == "false" { + return false + } + + return v + } + + // Check if the key is an array, and if so, expand the array + if _, ok := m[key+".#"]; ok { + return expandArray(m, key) + } + + // Check if this is a prefix in the map + prefix := key + "." + for k, _ := range m { + if strings.HasPrefix(k, prefix) { + return expandMap(m, prefix) + } + } + + return nil +} + +func expandArray(m map[string]string, prefix string) []interface{} { + num, err := strconv.ParseInt(m[prefix+".#"], 0, 0) + if err != nil { + panic(err) + } + + result := make([]interface{}, num) + for i := 0; i < int(num); i++ { + result[i] = Expand(m, fmt.Sprintf("%s.%d", prefix, i)) + } + + return result +} + +func expandMap(m map[string]string, prefix string) map[string]interface{} { + result := make(map[string]interface{}) + for k, _ := range m { + if !strings.HasPrefix(k, prefix) { + continue + } + + key := k[len(prefix):] + idx := strings.Index(key, ".") + if idx == -1 { + idx = len(k) + } + + // It contains a period, so it is a more complex structure + result[key] = Expand(m, k[:idx]) + } + + return result +} diff --git a/flatmap/expand_test.go b/flatmap/expand_test.go new file mode 100644 index 000000000..6ad54945e --- /dev/null +++ b/flatmap/expand_test.go @@ -0,0 +1,65 @@ +package flatmap + +import ( + "reflect" + "testing" +) + +func TestExpand(t *testing.T) { + cases := []struct { + Map map[string]string + Key string + Output interface{} + }{ + { + Map: map[string]string{ + "foo": "bar", + "bar": "baz", + }, + Key: "foo", + Output: "bar", + }, + + { + Map: map[string]string{ + "foo.#": "2", + "foo.0": "one", + "foo.1": "two", + }, + Key: "foo", + Output: []interface{}{ + "one", + "two", + }, + }, + + { + Map: map[string]string{ + "foo.#": "1", + "foo.0.name": "bar", + "foo.0.port": "3000", + "foo.0.enabled": "true", + }, + Key: "foo", + Output: []interface{}{ + map[string]interface{}{ + "name": "bar", + "port": 3000, + "enabled": true, + }, + }, + }, + } + + for _, tc := range cases { + actual := Expand(tc.Map, tc.Key) + if !reflect.DeepEqual(actual, tc.Output) { + t.Fatalf( + "Key: %v\nMap:\n\n%#v\n\nOutput:\n\n%#v\n\nExpected:\n\n%#v\n", + tc.Key, + tc.Map, + actual, + tc.Output) + } + } +} diff --git a/flatmap/flatten.go b/flatmap/flatten.go new file mode 100644 index 000000000..71b249117 --- /dev/null +++ b/flatmap/flatten.go @@ -0,0 +1,71 @@ +package flatmap + +import ( + "fmt" + "reflect" +) + +// Flatten takes a structure and turns into a flat map[string]string. +// +// Within the "thing" parameter, only primitive values are allowed. Structs are +// not supported. Therefore, it can only be slices, maps, primitives, and +// any combination of those together. +// +// See the tests for examples of what inputs are turned into. +func Flatten(thing map[string]interface{}) map[string]string { + result := make(map[string]string) + + for k, raw := range thing { + flatten(result, k, reflect.ValueOf(raw)) + } + + return result +} + +func flatten(result map[string]string, prefix string, v reflect.Value) { + if v.Kind() == reflect.Interface { + v = v.Elem() + } + + switch v.Kind() { + case reflect.Bool: + if v.Bool() { + result[prefix] = "true" + } else { + result[prefix] = "false" + } + case reflect.Int: + result[prefix] = fmt.Sprintf("%d", v.Int()) + case reflect.Map: + flattenMap(result, prefix, v) + case reflect.Slice: + flattenSlice(result, prefix, v) + case reflect.String: + result[prefix] = v.String() + default: + panic(fmt.Sprintf("Unknown: %s", v)) + } +} + +func flattenMap(result map[string]string, prefix string, v reflect.Value) { + for _, k := range v.MapKeys() { + if k.Kind() == reflect.Interface { + k = k.Elem() + } + + if k.Kind() != reflect.String { + panic(fmt.Sprintf("%s: map key is not string: %s", prefix, k)) + } + + flatten(result, fmt.Sprintf("%s.%s", prefix, k.String()), v.MapIndex(k)) + } +} + +func flattenSlice(result map[string]string, prefix string, v reflect.Value) { + prefix = prefix + "." + + result[prefix+"#"] = fmt.Sprintf("%d", v.Len()) + for i := 0; i < v.Len(); i++ { + flatten(result, fmt.Sprintf("%s%d", prefix, i), v.Index(i)) + } +} diff --git a/flatmap/flatten_test.go b/flatmap/flatten_test.go new file mode 100644 index 000000000..0bd15e882 --- /dev/null +++ b/flatmap/flatten_test.go @@ -0,0 +1,67 @@ +package flatmap + +import ( + "reflect" + "testing" +) + +func TestFlatten(t *testing.T) { + cases := []struct { + Input map[string]interface{} + Output map[string]string + }{ + { + Input: map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + Output: map[string]string{ + "foo": "bar", + "bar": "baz", + }, + }, + + { + Input: map[string]interface{}{ + "foo": []string{ + "one", + "two", + }, + }, + Output: map[string]string{ + "foo.#": "2", + "foo.0": "one", + "foo.1": "two", + }, + }, + + { + Input: map[string]interface{}{ + "foo": []map[interface{}]interface{}{ + map[interface{}]interface{}{ + "name": "bar", + "port": 3000, + "enabled": true, + }, + }, + }, + Output: map[string]string{ + "foo.#": "1", + "foo.0.name": "bar", + "foo.0.port": "3000", + "foo.0.enabled": "true", + }, + }, + } + + for _, tc := range cases { + actual := Flatten(tc.Input) + if !reflect.DeepEqual(actual, tc.Output) { + t.Fatalf( + "Input:\n\n%#v\n\nOutput:\n\n%#v\n\nExpected:\n\n%#v\n", + tc.Input, + actual, + tc.Output) + } + } +} diff --git a/helper/diff/resource_builder.go b/helper/diff/resource_builder.go index b07f4aa63..7fe565ac6 100644 --- a/helper/diff/resource_builder.go +++ b/helper/diff/resource_builder.go @@ -1,14 +1,43 @@ package diff import ( + "strings" + + "github.com/hashicorp/terraform/flatmap" "github.com/hashicorp/terraform/terraform" ) +// AttrType is an enum that tells the ResourceBuilder what type of attribute +// an attribute is, affecting the overall diff output. +// +// The valid values are: +// +// * AttrTypeCreate - This attribute can only be set or updated on create. +// This means that if this attribute is changed, it will require a new +// resource to be created if it is already created. +// +// * AttrTypeUpdate - This attribute can be set at create time or updated +// in-place. Changing this attribute does not require a new resource. +// +type AttrType byte + +const ( + AttrTypeUnknown AttrType = iota + AttrTypeCreate + AttrTypeUpdate +) + // ResourceBuilder is a helper that knows about how a single resource // changes and how those changes affect the diff. type ResourceBuilder struct { - CreateComputedAttrs []string - RequiresNewAttrs []string + // Attrs are the mapping of attributes that can be set from the + // configuration, and the affect they have. See the documentation for + // AttrType for more info. + Attrs map[string]AttrType + + // ComputedAttrs are the attributes that are computed at + // resource creation time. + ComputedAttrs []string } // Diff returns the ResourceDiff for a resource given its state and @@ -18,45 +47,52 @@ func (b *ResourceBuilder) Diff( c *terraform.ResourceConfig) (*terraform.ResourceDiff, error) { attrs := make(map[string]*terraform.ResourceAttrDiff) - requiresNewSet := make(map[string]struct{}) - for _, k := range b.RequiresNewAttrs { - requiresNewSet[k] = struct{}{} - } - // We require a new resource if the ID is empty. Or, later, we set // this to true if any configuration changed that triggers a new resource. requiresNew := s.ID == "" - // Go through the configuration and find the changed attributes - for k, v := range c.Raw { - newV := v.(string) + // Flatten the raw and processed configuration + flatRaw := flatmap.Flatten(c.Raw) + flatConfig := flatmap.Flatten(c.Config) + + for k, v := range flatRaw { + // Make sure this is an attribute that actually affects + // the diff in some way. + var attr AttrType + for ak, at := range b.Attrs { + if strings.HasPrefix(k, ak) { + attr = at + break + } + } + if attr == AttrTypeUnknown { + continue + } // If this key is in the cleaned config, then use that value // because it'll have its variables properly interpolated - if cleanV, ok := c.Config[k]; ok { - newV = cleanV.(string) + if cleanV, ok := flatConfig[k]; ok { + v = cleanV } - var oldV string - var ok bool - if oldV, ok = s.Attributes[k]; ok { - // Old value exists! We check to see if there is a change - if oldV == newV { - continue - } + oldV, ok := s.Attributes[k] + + // If there is an old value and they're the same, no change + if ok && oldV == v { + continue } - // There has been a change. Record it + // Record the change attrs[k] = &terraform.ResourceAttrDiff{ - Old: oldV, - New: newV, + Old: oldV, + New: v, + Type: terraform.DiffAttrInput, } // If this requires a new resource, record that and flag our // boolean. - if _, ok := requiresNewSet[k]; ok { + if attr == AttrTypeCreate { attrs[k].RequiresNew = true - attrs[k].Type = terraform.DiffAttrInput requiresNew = true } } @@ -64,7 +100,7 @@ func (b *ResourceBuilder) Diff( // If we require a new resource, then process all the attributes // that will be changing due to the creation of the resource. if requiresNew { - for _, k := range b.CreateComputedAttrs { + for _, k := range b.ComputedAttrs { old := s.Attributes[k] attrs[k] = &terraform.ResourceAttrDiff{ Old: old, diff --git a/helper/diff/resource_builder_test.go b/helper/diff/resource_builder_test.go index 1cd8a685a..d62f674a3 100644 --- a/helper/diff/resource_builder_test.go +++ b/helper/diff/resource_builder_test.go @@ -7,9 +7,51 @@ import ( "github.com/hashicorp/terraform/terraform" ) +func TestResourceBuilder_complex(t *testing.T) { + rb := &ResourceBuilder{ + Attrs: map[string]AttrType{ + "listener": AttrTypeUpdate, + }, + } + + state := &terraform.ResourceState{ + ID: "foo", + Attributes: map[string]string{ + "ignore": "1", + "listener.#": "1", + "listener.0.port": "80", + }, + } + + c := testConfig(t, map[string]interface{}{ + "listener": []interface{}{ + map[interface{}]interface{}{ + "port": 3000, + }, + }, + }, nil) + + diff, err := rb.Diff(state, c) + if err != nil { + t.Fatalf("err: %s", err) + } + if diff == nil { + t.Fatal("should not be nil") + } + + actual := testResourceDiffStr(diff) + expected := testRBComplexDiff + if actual != expected { + t.Fatalf("bad: %s", actual) + } +} + func TestResourceBuilder_new(t *testing.T) { rb := &ResourceBuilder{ - CreateComputedAttrs: []string{"private_ip"}, + Attrs: map[string]AttrType{ + "foo": AttrTypeUpdate, + }, + ComputedAttrs: []string{"private_ip"}, } state := &terraform.ResourceState{} @@ -35,8 +77,10 @@ func TestResourceBuilder_new(t *testing.T) { func TestResourceBuilder_requiresNew(t *testing.T) { rb := &ResourceBuilder{ - CreateComputedAttrs: []string{"private_ip"}, - RequiresNewAttrs: []string{"ami"}, + ComputedAttrs: []string{"private_ip"}, + Attrs: map[string]AttrType{ + "ami": AttrTypeCreate, + }, } state := &terraform.ResourceState{ @@ -68,7 +112,7 @@ func TestResourceBuilder_requiresNew(t *testing.T) { func TestResourceBuilder_same(t *testing.T) { rb := &ResourceBuilder{ - CreateComputedAttrs: []string{"private_ip"}, + ComputedAttrs: []string{"private_ip"}, } state := &terraform.ResourceState{ @@ -92,7 +136,11 @@ func TestResourceBuilder_same(t *testing.T) { } func TestResourceBuilder_unknown(t *testing.T) { - rb := &ResourceBuilder{} + rb := &ResourceBuilder{ + Attrs: map[string]AttrType{ + "foo": AttrTypeUpdate, + }, + } state := &terraform.ResourceState{} @@ -119,7 +167,11 @@ func TestResourceBuilder_unknown(t *testing.T) { } func TestResourceBuilder_vars(t *testing.T) { - rb := &ResourceBuilder{} + rb := &ResourceBuilder{ + Attrs: map[string]AttrType{ + "foo": AttrTypeUpdate, + }, + } state := &terraform.ResourceState{} @@ -144,6 +196,10 @@ func TestResourceBuilder_vars(t *testing.T) { } } +const testRBComplexDiff = `UPDATE + IN listener.0.port: "80" => "3000" +` + const testRBNewDiff = `UPDATE IN foo: "" => "bar" OUT private_ip: "" => ""