diff --git a/builtin/providers/aws/resource_aws_db_instance.go b/builtin/providers/aws/resource_aws_db_instance.go index fca7a8227f62..0d1d6036246b 100644 --- a/builtin/providers/aws/resource_aws_db_instance.go +++ b/builtin/providers/aws/resource_aws_db_instance.go @@ -25,6 +25,12 @@ func resourceAwsDbInstance() *schema.Resource { State: resourceAwsDbInstanceImport, }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(40 * time.Minute), + Update: schema.DefaultTimeout(80 * time.Minute), + Delete: schema.DefaultTimeout(40 * time.Minute), + }, + Schema: map[string]*schema.Schema{ "name": { Type: schema.TypeString, @@ -480,7 +486,7 @@ func resourceAwsDbInstanceCreate(d *schema.ResourceData, meta interface{}) error "maintenance", "renaming", "rebooting", "upgrading"}, Target: []string{"available"}, Refresh: resourceAwsDbInstanceStateRefreshFunc(d, meta), - Timeout: 40 * time.Minute, + Timeout: d.Timeout(schema.TimeoutCreate), MinTimeout: 10 * time.Second, Delay: 30 * time.Second, // Wait 30 secs before starting } @@ -638,7 +644,7 @@ func resourceAwsDbInstanceCreate(d *schema.ResourceData, meta interface{}) error "maintenance", "renaming", "rebooting", "upgrading", "configuring-enhanced-monitoring"}, Target: []string{"available"}, Refresh: resourceAwsDbInstanceStateRefreshFunc(d, meta), - Timeout: 40 * time.Minute, + Timeout: d.Timeout(schema.TimeoutCreate), MinTimeout: 10 * time.Second, Delay: 30 * time.Second, // Wait 30 secs before starting } @@ -811,7 +817,7 @@ func resourceAwsDbInstanceDelete(d *schema.ResourceData, meta interface{}) error "modifying", "deleting", "available"}, Target: []string{}, Refresh: resourceAwsDbInstanceStateRefreshFunc(d, meta), - Timeout: 40 * time.Minute, + Timeout: d.Timeout(schema.TimeoutDelete), MinTimeout: 10 * time.Second, Delay: 30 * time.Second, // Wait 30 secs before starting } @@ -978,7 +984,7 @@ func resourceAwsDbInstanceUpdate(d *schema.ResourceData, meta interface{}) error "maintenance", "renaming", "rebooting", "upgrading", "configuring-enhanced-monitoring", "moving-to-vpc"}, Target: []string{"available"}, Refresh: resourceAwsDbInstanceStateRefreshFunc(d, meta), - Timeout: 80 * time.Minute, + Timeout: d.Timeout(schema.TimeoutUpdate), MinTimeout: 10 * time.Second, Delay: 30 * time.Second, // Wait 30 secs before starting } diff --git a/helper/schema/resource.go b/helper/schema/resource.go index 51ddb14dcd73..c8105588c8e9 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -3,6 +3,7 @@ package schema import ( "errors" "fmt" + "log" "strconv" "github.com/hashicorp/terraform/terraform" @@ -94,6 +95,15 @@ type Resource struct { // This is a private interface for now, for use by DataSourceResourceShim, // and not for general use. (But maybe later...) deprecationMessage string + + // Timeouts allow users to specify specific time durations in which an + // operation should time out, to allow them to extend an action to suit their + // usage. For example, a user may specify a large Creation timeout for their + // AWS RDS Instance due to it's size, or restoring from a snapshot. + // Resource implementors must enable Timeout support by adding the allowed + // actions (Create, Read, Update, Delete, Default) to the Resource struct, and + // accessing them in the matching methods. + Timeouts *ResourceTimeout } // See Resource documentation. @@ -125,6 +135,18 @@ func (r *Resource) Apply( return s, err } + // Instance Diff shoould have the timeout info, need to copy it over to the + // ResourceData meta + rt := ResourceTimeout{} + if _, ok := d.Meta[TimeoutKey]; ok { + if err := rt.DiffDecode(d); err != nil { + log.Printf("[ERR] Error decoding ResourceTimeout: %s", err) + } + } else { + log.Printf("[DEBUG] No meta timeoutkey found in Apply()") + } + data.timeouts = &rt + if s == nil { // The Terraform API dictates that this should never happen, but // it doesn't hurt to be safe in this case. @@ -150,6 +172,8 @@ func (r *Resource) Apply( // Reset the data to be stateless since we just destroyed data, err = schemaMap(r.Schema).Data(nil, d) + // data was reset, need to re-apply the parsed timeouts + data.timeouts = &rt if err != nil { return nil, err } @@ -176,7 +200,28 @@ func (r *Resource) Apply( func (r *Resource) Diff( s *terraform.InstanceState, c *terraform.ResourceConfig) (*terraform.InstanceDiff, error) { - return schemaMap(r.Schema).Diff(s, c) + + t := &ResourceTimeout{} + err := t.ConfigDecode(r, c) + + if err != nil { + return nil, fmt.Errorf("[ERR] Error decoding timeout: %s", err) + } + + instanceDiff, err := schemaMap(r.Schema).Diff(s, c) + if err != nil { + return instanceDiff, err + } + + if instanceDiff != nil { + if err := t.DiffEncode(instanceDiff); err != nil { + log.Printf("[ERR] Error encoding timeout to instance diff: %s", err) + } + } else { + log.Printf("[DEBUG] Instance Diff is nil in Diff()") + } + + return instanceDiff, err } // Validate validates the resource configuration against the schema. @@ -226,10 +271,19 @@ func (r *Resource) Refresh( return nil, nil } + rt := ResourceTimeout{} + if _, ok := s.Meta[TimeoutKey]; ok { + if err := rt.StateDecode(s); err != nil { + log.Printf("[ERR] Error decoding ResourceTimeout: %s", err) + } + } + if r.Exists != nil { // Make a copy of data so that if it is modified it doesn't // affect our Read later. data, err := schemaMap(r.Schema).Data(s, nil) + data.timeouts = &rt + if err != nil { return s, err } @@ -252,6 +306,7 @@ func (r *Resource) Refresh( } data, err := schemaMap(r.Schema).Data(s, nil) + data.timeouts = &rt if err != nil { return s, err } diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index f20a6b386710..b2bc8f6c7c5a 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -5,6 +5,7 @@ import ( "reflect" "strings" "sync" + "time" "github.com/hashicorp/terraform/terraform" ) @@ -19,11 +20,12 @@ import ( // The most relevant methods to take a look at are Get, Set, and Partial. type ResourceData struct { // Settable (internally) - schema map[string]*Schema - config *terraform.ResourceConfig - state *terraform.InstanceState - diff *terraform.InstanceDiff - meta map[string]interface{} + schema map[string]*Schema + config *terraform.ResourceConfig + state *terraform.InstanceState + diff *terraform.InstanceDiff + meta map[string]interface{} + timeouts *ResourceTimeout // Don't set multiReader *MultiLevelFieldReader @@ -250,6 +252,12 @@ func (d *ResourceData) State() *terraform.InstanceState { return nil } + if d.timeouts != nil { + if err := d.timeouts.StateEncode(&result); err != nil { + log.Printf("[ERR] Error encoding Timeout meta to Instance State: %s", err) + } + } + // Look for a magic key in the schema that determines we skip the // integrity check of fields existing in the schema, allowing dynamic // keys to be created. @@ -331,6 +339,35 @@ func (d *ResourceData) State() *terraform.InstanceState { return &result } +// Timeout returns the data for the given timeout key +// Returns a duration of 20 minutes for any key not found, or not found and no default. +func (d *ResourceData) Timeout(key string) time.Duration { + key = strings.ToLower(key) + + var timeout *time.Duration + switch key { + case TimeoutCreate: + timeout = d.timeouts.Create + case TimeoutRead: + timeout = d.timeouts.Read + case TimeoutUpdate: + timeout = d.timeouts.Update + case TimeoutDelete: + timeout = d.timeouts.Delete + } + + if timeout != nil { + return *timeout + } + + if d.timeouts.Default != nil { + return *d.timeouts.Default + } + + // Return system default of 20 minutes + return 20 * time.Minute +} + func (d *ResourceData) init() { // Initialize the field that will store our new state var copyState terraform.InstanceState diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 40646dd7da1a..615a0f7f7a46 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -1,9 +1,11 @@ package schema import ( + "fmt" "math" "reflect" "testing" + "time" "github.com/hashicorp/terraform/terraform" ) @@ -1080,6 +1082,78 @@ func TestResourceDataGetOk(t *testing.T) { } } +func TestResourceDataTimeout(t *testing.T) { + cases := []struct { + Name string + Rd *ResourceData + Expected *ResourceTimeout + }{ + { + Name: "Basic example default", + Rd: &ResourceData{timeouts: timeoutForValues(10, 3, 0, 15, 0)}, + Expected: expectedTimeoutForValues(10, 3, 0, 15, 0), + }, + { + Name: "Resource and config match update, create", + Rd: &ResourceData{timeouts: timeoutForValues(10, 0, 3, 0, 0)}, + Expected: expectedTimeoutForValues(10, 0, 3, 0, 0), + }, + { + Name: "Resource provides default", + Rd: &ResourceData{timeouts: timeoutForValues(10, 0, 0, 0, 7)}, + Expected: expectedTimeoutForValues(10, 7, 7, 7, 7), + }, + { + Name: "Resource provides default and delete", + Rd: &ResourceData{timeouts: timeoutForValues(10, 0, 0, 15, 7)}, + Expected: expectedTimeoutForValues(10, 7, 7, 15, 7), + }, + { + Name: "Resource provides default, config overwrites other values", + Rd: &ResourceData{timeouts: timeoutForValues(10, 3, 0, 0, 13)}, + Expected: expectedTimeoutForValues(10, 3, 13, 13, 13), + }, + } + + keys := timeoutKeys() + for i, c := range cases { + t.Run(fmt.Sprintf("%d-%s", i, c.Name), func(t *testing.T) { + + for _, k := range keys { + got := c.Rd.Timeout(k) + var ex *time.Duration + switch k { + case TimeoutCreate: + ex = c.Expected.Create + case TimeoutRead: + ex = c.Expected.Read + case TimeoutUpdate: + ex = c.Expected.Update + case TimeoutDelete: + ex = c.Expected.Delete + case TimeoutDefault: + ex = c.Expected.Default + } + + if got > 0 && ex == nil { + t.Fatalf("Unexpected value in (%s), case %d check 1:\n\texpected: %#v\n\tgot: %#v", k, i, ex, got) + } + if got == 0 && ex != nil { + t.Fatalf("Unexpected value in (%s), case %d check 2:\n\texpected: %#v\n\tgot: %#v", k, i, *ex, got) + } + + // confirm values + if ex != nil { + if got != *ex { + t.Fatalf("Timeout %s case (%d) expected (%#v), got (%#v)", k, i, *ex, got) + } + } + } + + }) + } +} + func TestResourceDataHasChange(t *testing.T) { cases := []struct { Schema map[string]*Schema @@ -3081,6 +3155,24 @@ func TestResourceDataSetConnInfo(t *testing.T) { } } +func TestResourceDataSetMeta_Timeouts(t *testing.T) { + d := &ResourceData{} + d.SetId("foo") + + rt := ResourceTimeout{ + Create: DefaultTimeout(7 * time.Minute), + } + + d.timeouts = &rt + + expected := expectedForValues(7, 0, 0, 0, 0) + + actual := d.State() + if !reflect.DeepEqual(actual.Meta[TimeoutKey], expected) { + t.Fatalf("Bad Meta_timeout match:\n\texpected: %#v\n\tgot: %#v", expected, actual.Meta[TimeoutKey]) + } +} + func TestResourceDataSetId(t *testing.T) { d := &ResourceData{} d.SetId("foo") diff --git a/helper/schema/resource_test.go b/helper/schema/resource_test.go index 89cd8a46aeb4..f98aa5c43134 100644 --- a/helper/schema/resource_test.go +++ b/helper/schema/resource_test.go @@ -5,7 +5,9 @@ import ( "reflect" "strconv" "testing" + "time" + "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/terraform" ) @@ -62,6 +64,138 @@ func TestResourceApply_create(t *testing.T) { } } +func TestResourceApply_Timeout_state(t *testing.T) { + r := &Resource{ + SchemaVersion: 2, + Schema: map[string]*Schema{ + "foo": &Schema{ + Type: TypeInt, + Optional: true, + }, + }, + Timeouts: &ResourceTimeout{ + Create: DefaultTimeout(40 * time.Minute), + Update: DefaultTimeout(80 * time.Minute), + Delete: DefaultTimeout(40 * time.Minute), + }, + } + + called := false + r.Create = func(d *ResourceData, m interface{}) error { + called = true + d.SetId("foo") + return nil + } + + var s *terraform.InstanceState = nil + + d := &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{ + New: "42", + }, + }, + } + + diffTimeout := &ResourceTimeout{ + Create: DefaultTimeout(40 * time.Minute), + Update: DefaultTimeout(80 * time.Minute), + Delete: DefaultTimeout(40 * time.Minute), + } + + if err := diffTimeout.DiffEncode(d); err != nil { + t.Fatalf("Error encoding timeout to diff: %s", err) + } + + actual, err := r.Apply(s, d, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + + if !called { + t.Fatal("not called") + } + + expected := &terraform.InstanceState{ + ID: "foo", + Attributes: map[string]string{ + "id": "foo", + "foo": "42", + }, + Meta: map[string]interface{}{ + "schema_version": "2", + TimeoutKey: expectedForValues(40, 0, 80, 40, 0), + }, + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("Not equal in Timeout State:\n\texpected: %#v\n\tactual: %#v", expected.Meta, actual.Meta) + } +} + +func TestResourceDiff_Timeout_diff(t *testing.T) { + r := &Resource{ + Schema: map[string]*Schema{ + "foo": &Schema{ + Type: TypeInt, + Optional: true, + }, + }, + Timeouts: &ResourceTimeout{ + Create: DefaultTimeout(40 * time.Minute), + Update: DefaultTimeout(80 * time.Minute), + Delete: DefaultTimeout(40 * time.Minute), + }, + } + + r.Create = func(d *ResourceData, m interface{}) error { + d.SetId("foo") + return nil + } + + raw, err := config.NewRawConfig( + map[string]interface{}{ + "foo": 42, + "timeout": []map[string]interface{}{ + map[string]interface{}{ + "create": "2h", + }}, + }) + if err != nil { + t.Fatalf("err: %s", err) + } + + var s *terraform.InstanceState = nil + conf := terraform.NewResourceConfig(raw) + + actual, err := r.Diff(s, conf) + if err != nil { + t.Fatalf("err: %s", err) + } + + expected := &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{ + New: "42", + }, + }, + } + + diffTimeout := &ResourceTimeout{ + Create: DefaultTimeout(120 * time.Minute), + Update: DefaultTimeout(80 * time.Minute), + Delete: DefaultTimeout(40 * time.Minute), + } + + if err := diffTimeout.DiffEncode(expected); err != nil { + t.Fatalf("Error encoding timeout to diff: %s", err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("Not equal in Timeout Diff:\n\texpected: %#v\n\tactual: %#v", expected.Meta, actual.Meta) + } +} + func TestResourceApply_destroy(t *testing.T) { r := &Resource{ Schema: map[string]*Schema{ diff --git a/helper/schema/resource_timeout.go b/helper/schema/resource_timeout.go new file mode 100644 index 000000000000..61f03a4ada47 --- /dev/null +++ b/helper/schema/resource_timeout.go @@ -0,0 +1,233 @@ +package schema + +import ( + "fmt" + "log" + "time" + + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/copystructure" +) + +const TimeoutKey = "e2bfb730-ecaa-11e6-8f88-34363bc7c4c0" + +const ( + TimeoutCreate = "create" + TimeoutRead = "read" + TimeoutUpdate = "update" + TimeoutDelete = "delete" + TimeoutDefault = "default" +) + +func timeoutKeys() []string { + return []string{ + TimeoutCreate, + TimeoutRead, + TimeoutUpdate, + TimeoutDelete, + TimeoutDefault, + } +} + +// could be time.Duration, int64 or float64 +func DefaultTimeout(tx interface{}) *time.Duration { + var td time.Duration + switch raw := tx.(type) { + case time.Duration: + return &raw + case int64: + td = time.Duration(raw) + case float64: + td = time.Duration(int64(raw)) + default: + log.Printf("[WARN] Unknown type in DefaultTimeout: %#v", tx) + } + return &td +} + +type ResourceTimeout struct { + Create, Read, Update, Delete, Default *time.Duration +} + +// ConfigDecode takes a schema and the configuration (available in Diff) and +// validates, parses the timeouts into `t` +func (t *ResourceTimeout) ConfigDecode(s *Resource, c *terraform.ResourceConfig) error { + if s.Timeouts != nil { + raw, err := copystructure.Copy(s.Timeouts) + if err != nil { + log.Printf("[DEBUG] Error with deep copy: %s", err) + } + *t = *raw.(*ResourceTimeout) + } + + if raw, ok := c.Config["timeout"]; ok { + configTimeouts := raw.([]map[string]interface{}) + for _, timeoutValues := range configTimeouts { + // loop through each Timeout given in the configuration and validate they + // the Timeout defined in the resource + for timeKey, timeValue := range timeoutValues { + // validate that we're dealing with the normal CRUD actions + var found bool + for _, key := range timeoutKeys() { + if timeKey == key { + found = true + break + } + } + + if !found { + return fmt.Errorf("Unsupported Timeout configuration key found (%s)", timeKey) + } + + // Get timeout + rt, err := time.ParseDuration(timeValue.(string)) + if err != nil { + return fmt.Errorf("Error parsing Timeout for (%s): %s", timeKey, err) + } + + var timeout *time.Duration + switch timeKey { + case TimeoutCreate: + timeout = t.Create + case TimeoutUpdate: + timeout = t.Update + case TimeoutRead: + timeout = t.Read + case TimeoutDelete: + timeout = t.Delete + case TimeoutDefault: + timeout = t.Default + } + + // If the resource has not delcared this in the definition, then error + // with an unsupported message + if timeout == nil { + return unsupportedTimeoutKeyError(timeKey) + } + + *timeout = rt + } + } + } + + return nil +} + +func unsupportedTimeoutKeyError(key string) error { + return fmt.Errorf("Timeout Key (%s) is not supported", key) +} + +// DiffEncode, StateEncode, and MetaDecode are analogous to the Go stdlib JSONEncoder +// interface: they encode/decode a timeouts struct from an instance diff, which is +// where the timeout data is stored after a diff to pass into Apply. +// +// StateEncode encodes the timeout into the ResourceData's InstanceState for +// saving to state +// +func (t *ResourceTimeout) DiffEncode(id *terraform.InstanceDiff) error { + return t.metaEncode(id) +} + +func (t *ResourceTimeout) StateEncode(is *terraform.InstanceState) error { + return t.metaEncode(is) +} + +// metaEncode encodes the ResourceTimeout into a map[string]interface{} format +// and stores it in the Meta field of the interface it's given. +// Assumes the interface is either *terraform.InstanceState or +// *terraform.InstanceDiff, returns an error otherwise +func (t *ResourceTimeout) metaEncode(ids interface{}) error { + m := make(map[string]interface{}) + + if t.Create != nil { + m[TimeoutCreate] = t.Create.Nanoseconds() + } + if t.Read != nil { + m[TimeoutRead] = t.Read.Nanoseconds() + } + if t.Update != nil { + m[TimeoutUpdate] = t.Update.Nanoseconds() + } + if t.Delete != nil { + m[TimeoutDelete] = t.Delete.Nanoseconds() + } + if t.Default != nil { + m[TimeoutDefault] = t.Default.Nanoseconds() + // for any key above that is nil, if default is specified, we need to + // populate it with the default + for _, k := range timeoutKeys() { + if _, ok := m[k]; !ok { + m[k] = t.Default.Nanoseconds() + } + } + } + + // only add the Timeout to the Meta if we have values + if len(m) > 0 { + switch instance := ids.(type) { + case *terraform.InstanceDiff: + if instance.Meta == nil { + instance.Meta = make(map[string]interface{}) + } + instance.Meta[TimeoutKey] = m + case *terraform.InstanceState: + if instance.Meta == nil { + instance.Meta = make(map[string]interface{}) + } + instance.Meta[TimeoutKey] = m + default: + return fmt.Errorf("Error matching type for Diff Encode") + } + } + + return nil +} + +func (t *ResourceTimeout) StateDecode(id *terraform.InstanceState) error { + return t.metaDecode(id) +} +func (t *ResourceTimeout) DiffDecode(is *terraform.InstanceDiff) error { + return t.metaDecode(is) +} + +func (t *ResourceTimeout) metaDecode(ids interface{}) error { + var rawMeta interface{} + var ok bool + switch rawInstance := ids.(type) { + case *terraform.InstanceDiff: + rawMeta, ok = rawInstance.Meta[TimeoutKey] + if !ok { + return nil + } + case *terraform.InstanceState: + rawMeta, ok = rawInstance.Meta[TimeoutKey] + if !ok { + return nil + } + default: + return fmt.Errorf("Unknown or unsupported type in metaDecode: %#v", ids) + } + + times := rawMeta.(map[string]interface{}) + if len(times) == 0 { + return nil + } + + if v, ok := times[TimeoutCreate]; ok { + t.Create = DefaultTimeout(v) + } + if v, ok := times[TimeoutRead]; ok { + t.Read = DefaultTimeout(v) + } + if v, ok := times[TimeoutUpdate]; ok { + t.Update = DefaultTimeout(v) + } + if v, ok := times[TimeoutDelete]; ok { + t.Delete = DefaultTimeout(v) + } + if v, ok := times[TimeoutDefault]; ok { + t.Default = DefaultTimeout(v) + } + + return nil +} diff --git a/helper/schema/resource_timeout_test.go b/helper/schema/resource_timeout_test.go new file mode 100644 index 000000000000..6e6b2604ac31 --- /dev/null +++ b/helper/schema/resource_timeout_test.go @@ -0,0 +1,352 @@ +package schema + +import ( + "fmt" + "reflect" + "testing" + "time" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" +) + +func TestResourceTimeout_ConfigDecode_badkey(t *testing.T) { + cases := []struct { + Name string + // what the resource has defined in source + ResourceDefaultTimeout *ResourceTimeout + // configuration provider by user in tf file + Config []map[string]interface{} + // what we expect the parsed ResourceTimeout to be + Expected *ResourceTimeout + // Should we have an error (key not defined in source) + ShouldErr bool + }{ + { + Name: "Source does not define 'delete' key", + ResourceDefaultTimeout: timeoutForValues(10, 0, 5, 0, 0), + Config: expectedConfigForValues(2, 0, 0, 1, 0), + Expected: timeoutForValues(10, 0, 5, 0, 0), + ShouldErr: true, + }, + { + Name: "Config overrides create", + ResourceDefaultTimeout: timeoutForValues(10, 0, 5, 0, 0), + Config: expectedConfigForValues(2, 0, 7, 0, 0), + Expected: timeoutForValues(2, 0, 7, 0, 0), + ShouldErr: false, + }, + { + Name: "Config overrides create, default provided. Should still have zero values", + ResourceDefaultTimeout: timeoutForValues(10, 0, 5, 0, 3), + Config: expectedConfigForValues(2, 0, 7, 0, 0), + Expected: timeoutForValues(2, 0, 7, 0, 3), + ShouldErr: false, + }, + { + Name: "Use something besides 'minutes'", + ResourceDefaultTimeout: timeoutForValues(10, 0, 5, 0, 3), + Config: []map[string]interface{}{ + map[string]interface{}{ + "create": "2h", + }}, + Expected: timeoutForValues(120, 0, 5, 0, 3), + ShouldErr: false, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("%d-%s", i, c.Name), func(t *testing.T) { + r := &Resource{ + Timeouts: c.ResourceDefaultTimeout, + } + + raw, err := config.NewRawConfig( + map[string]interface{}{ + "foo": "bar", + "timeout": c.Config, + }) + if err != nil { + t.Fatalf("err: %s", err) + } + conf := terraform.NewResourceConfig(raw) + + timeout := &ResourceTimeout{} + decodeErr := timeout.ConfigDecode(r, conf) + if c.ShouldErr { + if decodeErr == nil { + t.Fatalf("ConfigDecode case (%d): Expected bad timeout key: %s", i, decodeErr) + } + // should error, err was not nil, continue + return + } else { + if decodeErr != nil { + // should not error, error was not nil, fatal + t.Fatalf("decodeError was not nil: %s", decodeErr) + } + } + + if !reflect.DeepEqual(c.Expected, timeout) { + t.Fatalf("ConfigDecode match error case (%d), expected:\n%#v\ngot:\n%#v", i, c.Expected, timeout) + } + }) + } +} + +func TestResourceTimeout_ConfigDecode(t *testing.T) { + r := &Resource{ + Timeouts: &ResourceTimeout{ + Create: DefaultTimeout(10 * time.Minute), + Update: DefaultTimeout(5 * time.Minute), + }, + } + + raw, err := config.NewRawConfig( + map[string]interface{}{ + "foo": "bar", + "timeout": []map[string]interface{}{ + map[string]interface{}{ + "create": "2m", + }, + map[string]interface{}{ + "update": "1m", + }, + }, + }) + if err != nil { + t.Fatalf("err: %s", err) + } + c := terraform.NewResourceConfig(raw) + + timeout := &ResourceTimeout{} + err = timeout.ConfigDecode(r, c) + if err != nil { + t.Fatalf("Expected good timeout returned:, %s", err) + } + + expected := &ResourceTimeout{ + Create: DefaultTimeout(2 * time.Minute), + Update: DefaultTimeout(1 * time.Minute), + } + + if !reflect.DeepEqual(timeout, expected) { + t.Fatalf("bad timeout decode, expected (%#v), got (%#v)", expected, timeout) + } +} + +func TestResourceTimeout_DiffEncode_basic(t *testing.T) { + cases := []struct { + Timeout *ResourceTimeout + Expected map[string]interface{} + // Not immediately clear when an error would hit + ShouldErr bool + }{ + // Two fields + { + Timeout: timeoutForValues(10, 0, 5, 0, 0), + Expected: map[string]interface{}{TimeoutKey: expectedForValues(10, 0, 5, 0, 0)}, + ShouldErr: false, + }, + // Two fields, one is Default + { + Timeout: timeoutForValues(10, 0, 0, 0, 7), + Expected: map[string]interface{}{TimeoutKey: expectedForValues(10, 0, 0, 0, 7)}, + ShouldErr: false, + }, + // All fields + { + Timeout: timeoutForValues(10, 3, 4, 1, 7), + Expected: map[string]interface{}{TimeoutKey: expectedForValues(10, 3, 4, 1, 7)}, + ShouldErr: false, + }, + // No fields + { + Timeout: &ResourceTimeout{}, + Expected: nil, + ShouldErr: false, + }, + } + + for _, c := range cases { + state := &terraform.InstanceDiff{} + err := c.Timeout.DiffEncode(state) + if err != nil && !c.ShouldErr { + t.Fatalf("Error, expected:\n%#v\n got:\n%#v\n", c.Expected, state.Meta) + } + + // should maybe just compare [TimeoutKey] but for now we're assuming only + // that in Meta + if !reflect.DeepEqual(state.Meta, c.Expected) { + t.Fatalf("Encode not equal, expected:\n%#v\n\ngot:\n%#v\n", c.Expected, state.Meta) + } + } + // same test cases but for InstanceState + for _, c := range cases { + state := &terraform.InstanceState{} + err := c.Timeout.StateEncode(state) + if err != nil && !c.ShouldErr { + t.Fatalf("Error, expected:\n%#v\n got:\n%#v\n", c.Expected, state.Meta) + } + + // should maybe just compare [TimeoutKey] but for now we're assuming only + // that in Meta + if !reflect.DeepEqual(state.Meta, c.Expected) { + t.Fatalf("Encode not equal, expected:\n%#v\n\ngot:\n%#v\n", c.Expected, state.Meta) + } + } +} + +func TestResourceTimeout_MetaDecode_basic(t *testing.T) { + cases := []struct { + State *terraform.InstanceDiff + Expected *ResourceTimeout + // Not immediately clear when an error would hit + ShouldErr bool + }{ + // Two fields + { + State: &terraform.InstanceDiff{Meta: map[string]interface{}{TimeoutKey: expectedForValues(10, 0, 5, 0, 0)}}, + Expected: timeoutForValues(10, 0, 5, 0, 0), + ShouldErr: false, + }, + // Two fields, one is Default + { + State: &terraform.InstanceDiff{Meta: map[string]interface{}{TimeoutKey: expectedForValues(10, 0, 0, 0, 7)}}, + Expected: timeoutForValues(10, 7, 7, 7, 7), + ShouldErr: false, + }, + // All fields + { + State: &terraform.InstanceDiff{Meta: map[string]interface{}{TimeoutKey: expectedForValues(10, 3, 4, 1, 7)}}, + Expected: timeoutForValues(10, 3, 4, 1, 7), + ShouldErr: false, + }, + // No fields + { + State: &terraform.InstanceDiff{}, + Expected: &ResourceTimeout{}, + ShouldErr: false, + }, + } + + for _, c := range cases { + rt := &ResourceTimeout{} + err := rt.DiffDecode(c.State) + if err != nil && !c.ShouldErr { + t.Fatalf("Error, expected:\n%#v\n got:\n%#v\n", c.Expected, rt) + } + + // should maybe just compare [TimeoutKey] but for now we're assuming only + // that in Meta + if !reflect.DeepEqual(rt, c.Expected) { + t.Fatalf("Encode not equal, expected:\n%#v\n\ngot:\n%#v\n", c.Expected, rt) + } + } +} + +func timeoutForValues(create, read, update, del, def int) *ResourceTimeout { + rt := ResourceTimeout{} + + if create != 0 { + rt.Create = DefaultTimeout(time.Duration(create) * time.Minute) + } + if read != 0 { + rt.Read = DefaultTimeout(time.Duration(read) * time.Minute) + } + if update != 0 { + rt.Update = DefaultTimeout(time.Duration(update) * time.Minute) + } + if del != 0 { + rt.Delete = DefaultTimeout(time.Duration(del) * time.Minute) + } + + if def != 0 { + rt.Default = DefaultTimeout(time.Duration(def) * time.Minute) + } + + return &rt +} + +// Generates a ResourceTimeout struct that should reflect the +// d.Timeout("key") results +func expectedTimeoutForValues(create, read, update, del, def int) *ResourceTimeout { + rt := ResourceTimeout{} + + defaultValues := []*int{&create, &read, &update, &del, &def} + for _, v := range defaultValues { + if *v == 0 { + *v = 20 + } + } + + if create != 0 { + rt.Create = DefaultTimeout(time.Duration(create) * time.Minute) + } + if read != 0 { + rt.Read = DefaultTimeout(time.Duration(read) * time.Minute) + } + if update != 0 { + rt.Update = DefaultTimeout(time.Duration(update) * time.Minute) + } + if del != 0 { + rt.Delete = DefaultTimeout(time.Duration(del) * time.Minute) + } + + if def != 0 { + rt.Default = DefaultTimeout(time.Duration(def) * time.Minute) + } + + return &rt +} + +func expectedForValues(create, read, update, del, def int) map[string]interface{} { + ex := make(map[string]interface{}) + + if create != 0 { + ex["create"] = DefaultTimeout(time.Duration(create) * time.Minute).Nanoseconds() + } + if read != 0 { + ex["read"] = DefaultTimeout(time.Duration(read) * time.Minute).Nanoseconds() + } + if update != 0 { + ex["update"] = DefaultTimeout(time.Duration(update) * time.Minute).Nanoseconds() + } + if del != 0 { + ex["delete"] = DefaultTimeout(time.Duration(del) * time.Minute).Nanoseconds() + } + + if def != 0 { + defNano := DefaultTimeout(time.Duration(def) * time.Minute).Nanoseconds() + ex["default"] = defNano + + for _, k := range timeoutKeys() { + if _, ok := ex[k]; !ok { + ex[k] = defNano + } + } + } + + return ex +} + +func expectedConfigForValues(create, read, update, delete, def int) []map[string]interface{} { + ex := make([]map[string]interface{}, 0) + + if create != 0 { + ex = append(ex, map[string]interface{}{"create": fmt.Sprintf("%dm", create)}) + } + if read != 0 { + ex = append(ex, map[string]interface{}{"read": fmt.Sprintf("%dm", read)}) + } + if update != 0 { + ex = append(ex, map[string]interface{}{"update": fmt.Sprintf("%dm", update)}) + } + if delete != 0 { + ex = append(ex, map[string]interface{}{"delete": fmt.Sprintf("%dm", delete)}) + } + + if def != 0 { + ex = append(ex, map[string]interface{}{"default": fmt.Sprintf("%dm", def)}) + } + return ex +} diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 7591721af44b..05d21c7ff178 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -1327,6 +1327,9 @@ func (m schemaMap) validateObject( if m, ok := raw.(map[string]interface{}); ok { for subk, _ := range m { if _, ok := schema[subk]; !ok { + if subk == "timeout" { + continue + } es = append(es, fmt.Errorf( "%s: invalid or unknown key: %s", k, subk)) } diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index f335c8aea4d3..4119b7ff5862 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -4773,6 +4773,23 @@ func TestSchemaMap_Validate(t *testing.T) { Err: false, }, + + "special timeout field": { + Schema: map[string]*Schema{ + "availability_zone": &Schema{ + Type: TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + }, + + Config: map[string]interface{}{ + "timeout": "bar", + }, + + Err: false, + }, } for tn, tc := range cases { diff --git a/website/source/docs/configuration/resources.html.md b/website/source/docs/configuration/resources.html.md index 0a89dded5fac..ab15b674423a 100644 --- a/website/source/docs/configuration/resources.html.md +++ b/website/source/docs/configuration/resources.html.md @@ -97,6 +97,43 @@ Additionally you can also use a single entry with a wildcard (e.g. `"*"`) which will match all attribute names. Using a partial string together with a wildcard (e.g. `"rout*"`) is **not** supported. + + + +### Timeouts + +Individual Resources may provide a `timeout` block to enable users to configure the +amount of time a specific operation is allowed to take before being considered +an error. For example, the +[aws_db_instance](/docs/providers/aws/r/db_instance.html#timeouts) +resource provides configurable timeouts for the +`create`, `update`, and `delete` operations. Any Resource that provies Timeouts +will document the default values for that operation, and users can overwrite +them in their configuration. + +Example overwriting the `create` and `delete` timeouts: + +``` +resource "aws_db_instance" "timeout_example" { + allocated_storage = 10 + engine = "mysql" + engine_version = "5.6.17" + instance_class = "db.t1.micro" + name = "mydb" + [...] + + timeout { + create = "60m" + delete = "2h" + } +} +``` + +Individual Resources must opt-in to providing configurable Timeouts, and +attempting to configure the timeout for a Resource that does not support +Timeouts, or overwriting a specific action that the Resource does not specify as +an option, will result in an error. Valid units of time are `s`, `m`, `h`. + ### Explicit Dependencies diff --git a/website/source/docs/providers/aws/r/db_instance.html.markdown b/website/source/docs/providers/aws/r/db_instance.html.markdown index 900017941042..eafba74177a5 100644 --- a/website/source/docs/providers/aws/r/db_instance.html.markdown +++ b/website/source/docs/providers/aws/r/db_instance.html.markdown @@ -144,6 +144,19 @@ On Oracle instances the following is exported additionally: * `character_set_name` - The character set used on Oracle instances. + + +## Timeouts + +`aws_db_instance` provides the following +[Timeouts](/docs/configuration/resources.html#timeouts) configuration options: + +- `create` - (Default `40 minutes`) Used for Creating Instances, Replicas, and +restoring from Snapshots +- `update` - (Default `80 minutes`) Used for Database modifications +- `delete` - (Default `40 minutes`) Used for destroying databases. This includes +the time required to take snapshots + [1]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Replication.html [2]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_UpgradeDBInstance.Maintenance.html