From ccf12501e06f094036490712204aea3e390bab72 Mon Sep 17 00:00:00 2001 From: Marshall Ford Date: Fri, 20 Dec 2024 08:12:11 -0600 Subject: [PATCH 1/2] adds support for ephemeral resources --- ephemeral/timeouts/schema.go | 112 +++++++++++++ ephemeral/timeouts/schema_test.go | 247 ++++++++++++++++++++++++++++ ephemeral/timeouts/timeouts.go | 145 ++++++++++++++++ ephemeral/timeouts/timeouts_test.go | 234 ++++++++++++++++++++++++++ 4 files changed, 738 insertions(+) create mode 100644 ephemeral/timeouts/schema.go create mode 100644 ephemeral/timeouts/schema_test.go create mode 100644 ephemeral/timeouts/timeouts.go create mode 100644 ephemeral/timeouts/timeouts_test.go diff --git a/ephemeral/timeouts/schema.go b/ephemeral/timeouts/schema.go new file mode 100644 index 0000000..a17e913 --- /dev/null +++ b/ephemeral/timeouts/schema.go @@ -0,0 +1,112 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package timeouts + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/internal/validators" +) + +const ( + attributeNameOpen = "open" +) + +// Opts is used as an argument to BlockWithOpts and AttributesWithOpts to indicate +// whether supplied descriptions should override default descriptions. +type Opts struct { + OpenDescription string +} + +// BlockWithOpts returns a schema.Block containing attributes for `Open`, which is +// defined as types.StringType and optional. A validator is used to verify +// that the value assigned to `Open` can be parsed as time.Duration. The supplied +// Opts are used to override defaults. +func BlockWithOpts(ctx context.Context, opts Opts) schema.Block { + return schema.SingleNestedBlock{ + Attributes: attributesMap(opts), + CustomType: Type{ + ObjectType: types.ObjectType{ + AttrTypes: attrTypesMap(), + }, + }, + } +} + +// Block returns a schema.Block containing attributes for `Open`, which is +// defined as types.StringType and optional. A validator is used to verify +// that the value assigned to `Open` can be parsed as time.Duration. +func Block(ctx context.Context) schema.Block { + return schema.SingleNestedBlock{ + Attributes: attributesMap(Opts{}), + CustomType: Type{ + ObjectType: types.ObjectType{ + AttrTypes: attrTypesMap(), + }, + }, + } +} + +// AttributesWithOpts returns a schema.SingleNestedAttribute which contains an +// attribute for `Open`, which is defined as types.StringType and optional. +// A validator is used to verify that the value assigned to an attribute +// can be parsed as time.Duration. The supplied Opts are used to override defaults. +func AttributesWithOpts(ctx context.Context, opts Opts) schema.Attribute { + return schema.SingleNestedAttribute{ + Attributes: attributesMap(opts), + CustomType: Type{ + ObjectType: types.ObjectType{ + AttrTypes: attrTypesMap(), + }, + }, + Optional: true, + } +} + +// Attributes returns a schema.SingleNestedAttribute which contains an +// attribute for `Open`, which is defined as types.StringType and optional. +// A validator is used to verify that the value assigned to an attribute +// can be parsed as time.Duration. +func Attributes(ctx context.Context) schema.Attribute { + return schema.SingleNestedAttribute{ + Attributes: attributesMap(Opts{}), + CustomType: Type{ + ObjectType: types.ObjectType{ + AttrTypes: attrTypesMap(), + }, + }, + Optional: true, + } +} + +func attributesMap(opts Opts) map[string]schema.Attribute { + attribute := schema.StringAttribute{ + Optional: true, + Description: `A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) ` + + `consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are ` + + `"s" (seconds), "m" (minutes), "h" (hours).`, + Validators: []validator.String{ + validators.TimeDuration(), + }, + } + + if opts.OpenDescription != "" { + attribute.Description = opts.OpenDescription + } + + return map[string]schema.Attribute{ + attributeNameOpen: attribute, + } +} + +func attrTypesMap() map[string]attr.Type { + return map[string]attr.Type{ + attributeNameOpen: types.StringType, + } +} diff --git a/ephemeral/timeouts/schema_test.go b/ephemeral/timeouts/schema_test.go new file mode 100644 index 0000000..319d4cb --- /dev/null +++ b/ephemeral/timeouts/schema_test.go @@ -0,0 +1,247 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package timeouts_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/ephemeral/timeouts" + "github.com/hashicorp/terraform-plugin-framework-timeouts/internal/validators" +) + +func TestBlockWithOpts(t *testing.T) { + t.Parallel() + + type testCase struct { + opts timeouts.Opts + expected schema.Block + } + tests := map[string]testCase{ + "empty-opts": { + opts: timeouts.Opts{}, + expected: schema.SingleNestedBlock{ + CustomType: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + Attributes: map[string]schema.Attribute{ + "open": schema.StringAttribute{ + Optional: true, + Description: `A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) ` + + `consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are ` + + `"s" (seconds), "m" (minutes), "h" (hours).`, + Validators: []validator.String{ + validators.TimeDuration(), + }, + }, + }, + }, + }, + "open-opts-description": { + opts: timeouts.Opts{ + OpenDescription: "open description", + }, + expected: schema.SingleNestedBlock{ + CustomType: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + Attributes: map[string]schema.Attribute{ + "open": schema.StringAttribute{ + Optional: true, + Description: "open description", + Validators: []validator.String{ + validators.TimeDuration(), + }, + }, + }, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + actual := timeouts.BlockWithOpts(context.Background(), test.opts) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } + }) + } +} + +func TestBlock(t *testing.T) { + t.Parallel() + + type testCase struct { + expected schema.Block + } + tests := map[string]testCase{ + "open": { + expected: schema.SingleNestedBlock{ + CustomType: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + Attributes: map[string]schema.Attribute{ + "open": schema.StringAttribute{ + Optional: true, + Description: `A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) ` + + `consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are ` + + `"s" (seconds), "m" (minutes), "h" (hours).`, + Validators: []validator.String{ + validators.TimeDuration(), + }, + }, + }, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + actual := timeouts.Block(context.Background()) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } + }) + } +} + +func TestAttributesWithOpts(t *testing.T) { + t.Parallel() + + type testCase struct { + opts timeouts.Opts + expected schema.Attribute + } + tests := map[string]testCase{ + "empty-opts": { + opts: timeouts.Opts{}, + expected: schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "open": schema.StringAttribute{ + Optional: true, + Description: `A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) ` + + `consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are ` + + `"s" (seconds), "m" (minutes), "h" (hours).`, + Validators: []validator.String{ + validators.TimeDuration(), + }, + }, + }, + CustomType: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + Optional: true, + }, + }, + "open-opts-description": { + opts: timeouts.Opts{ + OpenDescription: "open description", + }, + expected: schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "open": schema.StringAttribute{ + Optional: true, + Description: "open description", + Validators: []validator.String{ + validators.TimeDuration(), + }, + }, + }, + CustomType: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + Optional: true, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + actual := timeouts.AttributesWithOpts(context.Background(), test.opts) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } + }) + } +} + +func TestAttributes(t *testing.T) { + t.Parallel() + + type testCase struct { + expected schema.Attribute + } + tests := map[string]testCase{ + "open": { + expected: schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "open": schema.StringAttribute{ + Optional: true, + Description: `A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) ` + + `consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are ` + + `"s" (seconds), "m" (minutes), "h" (hours).`, + Validators: []validator.String{ + validators.TimeDuration(), + }, + }, + }, + CustomType: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + Optional: true, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + actual := timeouts.Attributes(context.Background()) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } + }) + } +} diff --git a/ephemeral/timeouts/timeouts.go b/ephemeral/timeouts/timeouts.go new file mode 100644 index 0000000..f09e70e --- /dev/null +++ b/ephemeral/timeouts/timeouts.go @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package timeouts + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ basetypes.ObjectTypable = Type{} + _ basetypes.ObjectValuable = Value{} +) + +// Type is an attribute type that represents timeouts. +type Type struct { + basetypes.ObjectType +} + +// String returns a human-readable representation of the type. +func (t Type) String() string { + return "timeouts.Type" +} + +// ValueFromObject returns a Value given a basetypes.ObjectValue. +func (t Type) ValueFromObject(_ context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + value := Value{ + Object: in, + } + + return value, nil +} + +// ValueFromTerraform returns a Value given a tftypes.Value. +// Value embeds the types.Object value returned from calling ValueFromTerraform on the +// types.ObjectType embedded in Type. +func (t Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + val, err := t.ObjectType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + + obj, ok := val.(types.Object) + if !ok { + return nil, fmt.Errorf("%T cannot be used as types.Object", val) + } + + return Value{ + obj, + }, err +} + +// ValueType returns the associated Value type for debugging. +func (t Type) ValueType(context.Context) attr.Value { + // It does not need to be a fully valid implementation of the type. + return Value{} +} + +// Equal returns true if `candidate` is also a Type and has the same +// AttributeTypes. +func (t Type) Equal(candidate attr.Type) bool { + other, ok := candidate.(Type) + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +// Value represents an object containing values to be used as time.Duration for timeouts. +type Value struct { + types.Object +} + +// Equal returns true if the Value is considered semantically equal +// (same type and same value) to the attr.Value passed as an argument. +func (t Value) Equal(c attr.Value) bool { + other, ok := c.(Value) + + if !ok { + return false + } + + return t.Object.Equal(other.Object) +} + +// ToObjectValue returns the underlying ObjectValue. +func (v Value) ToObjectValue(_ context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + return v.Object, nil +} + +// Type returns a Type with the same attribute types as `t`. +func (t Value) Type(ctx context.Context) attr.Type { + return Type{ + types.ObjectType{ + AttrTypes: t.AttributeTypes(ctx), + }, + } +} + +// Open attempts to retrieve the "open" attribute and parse it as time.Duration. +// If any diagnostics are generated they are returned along with the supplied default timeout. +func (t Value) Open(ctx context.Context, defaultTimeout time.Duration) (time.Duration, diag.Diagnostics) { + return t.getTimeout(ctx, attributeNameOpen, defaultTimeout) +} + +func (t Value) getTimeout(ctx context.Context, timeoutName string, defaultTimeout time.Duration) (time.Duration, diag.Diagnostics) { + var diags diag.Diagnostics + + value, ok := t.Object.Attributes()[timeoutName] + if !ok { + tflog.Info(ctx, timeoutName+" timeout configuration not found, using provided default") + + return defaultTimeout, diags + } + + if value.IsNull() || value.IsUnknown() { + tflog.Info(ctx, timeoutName+" timeout configuration is null or unknown, using provided default") + + return defaultTimeout, diags + } + + // No type assertion check is required as the schema guarantees that the object attributes + // are types.String. + timeout, err := time.ParseDuration(value.(types.String).ValueString()) + if err != nil { + diags.Append(diag.NewErrorDiagnostic( + "Timeout Cannot Be Parsed", + fmt.Sprintf("timeout for %q cannot be parsed, %s", timeoutName, err), + )) + + return defaultTimeout, diags + } + + return timeout, diags +} diff --git a/ephemeral/timeouts/timeouts_test.go b/ephemeral/timeouts/timeouts_test.go new file mode 100644 index 0000000..3590699 --- /dev/null +++ b/ephemeral/timeouts/timeouts_test.go @@ -0,0 +1,234 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package timeouts_test + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/ephemeral/timeouts" +) + +func TestTimeoutsTypeValueFromTerraform(t *testing.T) { + t.Parallel() + + type testCase struct { + receiver timeouts.Type + input tftypes.Value + expected attr.Value + expectedErr string + } + tests := map[string]testCase{ + "basic-object": { + receiver: timeouts.Type{ + ObjectType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "open": types.StringType, + }, + }, + }, + input: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "open": tftypes.String, + }, + }, map[string]tftypes.Value{ + "open": tftypes.NewValue(tftypes.String, "30m"), + }), + expected: timeouts.Value{ + Object: types.ObjectValueMust( + map[string]attr.Type{ + "open": types.StringType, + }, + map[string]attr.Value{ + "open": types.StringValue("30m"), + }, + ), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := test.receiver.ValueFromTerraform(context.Background(), test.input) + if err != nil { + if test.expectedErr == "" { + t.Errorf("Unexpected error: %s", err.Error()) + return + } + if err.Error() != test.expectedErr { + t.Errorf("Expected error to be %q, got %q", test.expectedErr, err.Error()) + return + } + } + + if diff := cmp.Diff(test.expected, got); diff != "" { + t.Errorf("unexpected result (-expected, +got): %s", diff) + } + }) + } +} + +func TestTimeoutsTypeEqual(t *testing.T) { + t.Parallel() + + type testCase struct { + receiver timeouts.Type + input attr.Type + expected bool + } + tests := map[string]testCase{ + "equal": { + receiver: timeouts.Type{ObjectType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "a": types.StringType, + "b": types.NumberType, + "c": types.BoolType, + "d": types.ListType{ + ElemType: types.StringType, + }, + }}}, + input: timeouts.Type{ObjectType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "a": types.StringType, + "b": types.NumberType, + "c": types.BoolType, + "d": types.ListType{ + ElemType: types.StringType, + }, + }}}, + expected: true, + }, + "missing-attr": { + receiver: timeouts.Type{ObjectType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "a": types.StringType, + "b": types.NumberType, + "c": types.BoolType, + "d": types.ListType{ + ElemType: types.StringType, + }, + }}}, + input: timeouts.Type{ObjectType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "a": types.StringType, + "b": types.NumberType, + "d": types.ListType{ + ElemType: types.StringType, + }, + }}}, + expected: false, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := test.receiver.Equal(test.input) + if test.expected != got { + t.Errorf("Expected %v, got %v", test.expected, got) + } + }) + } +} + +func TestTimeoutsValueOpen(t *testing.T) { + t.Parallel() + + type testCase struct { + timeoutsValue timeouts.Value + expectedTimeout time.Duration + expectedDiags diag.Diagnostics + } + tests := map[string]testCase{ + "open": { + timeoutsValue: timeouts.Value{ + Object: types.ObjectValueMust( + map[string]attr.Type{ + "open": types.StringType, + }, + map[string]attr.Value{ + "open": types.StringValue("10m"), + }, + ), + }, + expectedTimeout: 10 * time.Minute, + expectedDiags: nil, + }, + "open-not-set": { + timeoutsValue: timeouts.Value{ + Object: types.Object{}, + }, + expectedTimeout: 20 * time.Minute, + }, + "open-null": { + timeoutsValue: timeouts.Value{ + Object: types.ObjectValueMust( + map[string]attr.Type{ + "open": types.StringType, + }, + map[string]attr.Value{ + "open": types.StringNull(), + }, + ), + }, + expectedTimeout: 20 * time.Minute, + }, + "open-unknown": { + timeoutsValue: timeouts.Value{ + Object: types.ObjectValueMust( + map[string]attr.Type{ + "open": types.StringType, + }, + map[string]attr.Value{ + "open": types.StringUnknown(), + }, + ), + }, + expectedTimeout: 20 * time.Minute, + }, + "open-not-parseable-as-time-duration": { + timeoutsValue: timeouts.Value{ + Object: types.ObjectValueMust( + map[string]attr.Type{ + "open": types.StringType, + }, + map[string]attr.Value{ + "open": types.StringValue("10x"), + }, + ), + }, + expectedTimeout: 20 * time.Minute, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Timeout Cannot Be Parsed", + `timeout for "open" cannot be parsed, time: unknown unit "x" in duration "10x"`, + ), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotTimeout, gotErr := test.timeoutsValue.Open(context.Background(), 20*time.Minute) + + if diff := cmp.Diff(gotTimeout, test.expectedTimeout); diff != "" { + t.Errorf("unexpected timeout difference: %s", diff) + } + + if diff := cmp.Diff(gotErr, test.expectedDiags); diff != "" { + t.Errorf("unexpected err difference: %s", diff) + } + }) + } +} From 5e74bc2d97c2b51f22208a5ebd1d7e378e00d294 Mon Sep 17 00:00:00 2001 From: Marshall Ford Date: Mon, 13 Jan 2025 19:49:12 -0600 Subject: [PATCH 2/2] adds changelog entry --- .changes/unreleased/FEATURES-20250113-194903.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20250113-194903.yaml diff --git a/.changes/unreleased/FEATURES-20250113-194903.yaml b/.changes/unreleased/FEATURES-20250113-194903.yaml new file mode 100644 index 0000000..4da48fe --- /dev/null +++ b/.changes/unreleased/FEATURES-20250113-194903.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'ephemeral/timeouts: Adds functions and types for ephemeral resource timeouts' +time: 2025-01-13T19:49:03.365811628-06:00 +custom: + Issue: "157"