diff --git a/.changelog/5.txt b/.changelog/5.txt new file mode 100644 index 0000000..232d3a8 --- /dev/null +++ b/.changelog/5.txt @@ -0,0 +1,3 @@ +```release-note:feature +Introduced `timeouts` package with `Block()`, `BlockAll()`, `Attributes()` and `AttributesAll()` schema mutation functions +``` \ No newline at end of file diff --git a/.github/workflows/add-content-to-project.yml b/.github/workflows/add-content-to-project.yml index 02bca6e..30e89c5 100644 --- a/.github/workflows/add-content-to-project.yml +++ b/.github/workflows/add-content-to-project.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Set Issue to 'Priority = Triage Next'" - uses: leonsteinhaeuser/project-beta-automations@v1.2.1 + uses: leonsteinhaeuser/project-beta-automations@v2.0.0 if: github.event_name == 'issues' with: gh_token: ${{ secrets.TF_DEVEX_PROJECT_GITHUB_TOKEN }} @@ -29,7 +29,7 @@ jobs: operation_mode: custom_field custom_field_values: '[{\"name\":\"Priority\",\"type\":\"single_select\",\"value\":\"Triage Next\"}]' - name: "Set Pull Request to 'Priority = Triage Next'" - uses: leonsteinhaeuser/project-beta-automations@v1.2.1 + uses: leonsteinhaeuser/project-beta-automations@v2.0.0 if: github.event_name == 'pull_request_target' with: gh_token: ${{ secrets.TF_DEVEX_PROJECT_GITHUB_TOKEN }} diff --git a/.github/workflows/ci-github-actions.yml b/.github/workflows/ci-github-actions.yml index ba79046..0b6e2d3 100644 --- a/.github/workflows/ci-github-actions.yml +++ b/.github/workflows/ci-github-actions.yml @@ -14,11 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - id: go-version - # Reference: https://github.com/actions/setup-go/issues/23 - run: echo "::set-output name=version::$(cat ./.go-version)" - uses: actions/setup-go@v3 with: - go-version: ${{ steps.go-version.outputs.version }} + go-version-file: 'go.mod' - run: go install github.com/rhysd/actionlint/cmd/actionlint@latest - run: actionlint diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index ddc03c4..2ca040a 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -21,32 +21,14 @@ jobs: with: go-version-file: 'go.mod' - run: go mod download - - uses: golangci/golangci-lint-action@v3.2.0 - with: - skip-go-installation: true - terraform-provider-corner: - defaults: - run: - working-directory: terraform-provider-corner - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/checkout@v3 - with: - path: terraform-provider-corner - repository: hashicorp/terraform-provider-corner - - uses: actions/setup-go@v3 - with: - go-version-file: 'go.mod' - - run: go mod edit -replace=github.com/hashicorp/terraform-plugin-framework-timeouts=../ - - run: go mod tidy - - run: go test -v ./internal/frameworkprovider + - uses: golangci/golangci-lint-action@v3 + test: name: test (Go v${{ matrix.go-version }}) runs-on: ubuntu-latest strategy: matrix: - go-version: [ '1.18', '1.17' ] + go-version: [ '1.19', '1.18' ] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 diff --git a/.github/workflows/ci-goreleaser.yml b/.github/workflows/ci-goreleaser.yml index ecf7740..c47009a 100644 --- a/.github/workflows/ci-goreleaser.yml +++ b/.github/workflows/ci-goreleaser.yml @@ -14,12 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - id: go-version - # Reference: https://github.com/actions/setup-go/issues/23 - run: echo "::set-output name=version::$(cat ./.go-version)" - uses: actions/setup-go@v3 with: - go-version: ${{ steps.go-version.outputs.version }} - - uses: goreleaser/goreleaser-action@v2 + go-version-file: 'go.mod' + - uses: goreleaser/goreleaser-action@v3 with: args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f37e2a..b245304 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,16 +19,13 @@ jobs: with: # Required for release notes fetch-depth: 0 - - id: go-version - # Reference: https://github.com/actions/setup-go/issues/23 - run: echo "::set-output name=version::$(cat ./.go-version)" - uses: actions/setup-go@v3 with: - go-version: ${{ steps.go-version.outputs.version }} + go-version-file: 'go.mod' - name: Generate Release Notes # Fetch CHANGELOG.md contents up to Git tag prior to this release, skipping top two lines run: sed -n -e "1{/# /d;}" -e "2{/^$/d;}" -e "/# $(git describe --abbrev=0 --exclude="$(git describe --abbrev=0 --match='v*.*.*' --tags)" --match='v*.*.*' --tags | tr -d v)/q;p" CHANGELOG.md > /tmp/release-notes.txt - - uses: goreleaser/goreleaser-action@v2 + - uses: goreleaser/goreleaser-action@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf2a4c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +default: build + +build: + go build -v ./... + +lint: + golangci-lint run + +fmt: + gofmt -s -w -e . + +test: + go test -v -cover -timeout=120s -parallel=4 ./... + +.PHONY: build lint fmt test diff --git a/README.md b/README.md index c571e6e..59e9ad5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,129 @@ This Go module is typically kept up to date with the latest `terraform-plugin-fr This Go module follows `terraform-plugin-framework` Go compatibility. -Currently that means Go **1.18** must be used when developing and testing code. +Currently, that means Go **1.18** must be used when developing and testing code. + +## Usage + +Usage of this module requires the following changes in the provider code: + +- [Schema Mutation](#schema-mutation) +- [Updating Models](#updating-models) +- [Accessing Timeouts in CRUD Functions](#accessing-timeouts-in-crud-functions) + +### Schema Mutation + +Timeouts can be defined using either nested blocks or nested attributes. + +If you are writing a new provider using [terraform-plugin-framework](https://github.com/hashicorp/terraform-plugin-framework) +then we recommend using nested attributes. + +If you are [migrating a provider from SDKv2 to the Framework](https://www.terraform.io/plugin/framework/migrating) and +you are already using timeouts you can either continue to use block syntax, or switch to using nested attributes. +However, switching to using nested attributes will require that practitioners that are using your provider update their +Terraform configuration. + +#### Block + +If your configuration is using a nested block to define timeouts, such as the following: + +```terraform +resource "timeouts_example" "example" { + /* ... */ + + timeouts { + create = "60m" + } +} +``` + +You can use this module to mutate the `tfsdk.Schema` as follows: + +```go +func (t *exampleResource) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + /* ... */ + + Blocks: map[string]tfsdk.Block{ + "timeouts": timeouts.Block(ctx, timeouts.Opts{ + Create: true, + }), + }, +``` + +#### Attribute + +If your configuration is using nested attributes to define timeouts, such as the following: + +```terraform +resource "timeouts_example" "example" { + /* ... */ + + timeouts = { + create = "60m" + } +} +``` + +You can use this module to mutate the `tfsdk.Schema` as follows: + +```go +func (t *exampleResource) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + /* ... */ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + }), + }, +``` + +### Updating Models + +In functions in which the config, state or plan is being unmarshalled, for instance, the `Create` function: + +```go +func (r exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data exampleResourceData + + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) +``` + +The model that is being used, `exampleResourceData` in this example, will need to be modified to include a field for +timeouts which is of `types.Object`. For example: + +```go +type exampleResourceData struct { + /* ... */ + Timeouts types.Object `tfsdk:"timeouts"` +``` + +### Accessing Timeouts in CRUD Functions + +Once the model has been populated with the config, state or plan the duration of the timeout can be accessed by calling +the appropriate helper function and then used to configure timeout behaviour, for instance: + +```go +func (r exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data exampleResourceData + + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + defaultCreateTimeout := 20 * time.Minutes + + createTimeout := timeouts.Create(ctx, data.Timeouts, defaultCreateTimeout) + + ctx, cancel := context.WithTimeout(ctx, createTimeout) + defer cancel() + + /* ... */ +} +``` ## Contributing diff --git a/go.mod b/go.mod index fa19ddd..0014830 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,25 @@ module github.com/hashicorp/terraform-plugin-framework-timeouts go 1.18 + +require ( + github.com/google/go-cmp v0.5.9 + github.com/hashicorp/terraform-plugin-framework v0.13.0 +) + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/hashicorp/go-hclog v1.2.1 // indirect + github.com/hashicorp/terraform-plugin-go v0.14.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect + github.com/vmihailenco/tagparser v0.1.1 // indirect + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect + golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect + google.golang.org/appengine v1.6.5 // indirect + google.golang.org/protobuf v1.28.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63e2aee --- /dev/null +++ b/go.sum @@ -0,0 +1,71 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-hclog v1.2.1 h1:YQsLlGDJgwhXFpucSPyVbCBviQtjlHv3jLTlp8YmtEw= +github.com/hashicorp/go-hclog v1.2.1/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/terraform-plugin-framework v0.13.0 h1:tGnqttzZwU3FKc+HasHr2Yi5L81FcQbdc8zQhbBD9jQ= +github.com/hashicorp/terraform-plugin-framework v0.13.0/go.mod h1:wcZdk4+Uef6Ng+BiBJjGAcIPlIs5bhlEV/TA1k6Xkq8= +github.com/hashicorp/terraform-plugin-go v0.14.0 h1:ttnSlS8bz3ZPYbMb84DpcPhY4F5DsQtcAS7cHo8uvP4= +github.com/hashicorp/terraform-plugin-go v0.14.0/go.mod h1:2nNCBeRLaenyQEi78xrGrs9hMbulveqG/zDMQSvVJTE= +github.com/hashicorp/terraform-plugin-log v0.7.0 h1:SDxJUyT8TwN4l5b5/VkiTIaQgY6R+Y2BQ0sRZftGKQs= +github.com/hashicorp/terraform-plugin-log v0.7.0/go.mod h1:p4R1jWBXRTvL4odmEkFfDdhUjHf9zcs/BCoNHAc7IK4= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce h1:RPclfga2SEJmgMmz2k+Mg7cowZ8yv4Trqw9UsJby758= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/vmihailenco/msgpack/v4 v4.3.12 h1:07s4sz9IReOgdikxLTKNbBdqDMLsjPKXwvCazn8G65U= +github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= +github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY= +github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/validators/timeduration.go b/internal/validators/timeduration.go new file mode 100644 index 0000000..7847638 --- /dev/null +++ b/internal/validators/timeduration.go @@ -0,0 +1,55 @@ +package validators + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ tfsdk.AttributeValidator = timeDurationValidator{} + +// timeDurationValidator validates that a string Attribute's value is parseable as time.Duration. +type timeDurationValidator struct { +} + +// Description describes the validation in plain text formatting. +func (validator timeDurationValidator) Description(_ context.Context) string { + return `must be a string containing a sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".` +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator timeDurationValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator timeDurationValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { + s := request.AttributeConfig.(types.String) + + if s.Unknown || s.Null { + return + } + + if _, err := time.ParseDuration(s.Value); err != nil { + response.Diagnostics.Append(diag.NewAttributeErrorDiagnostic( + request.AttributePath, + "Invalid Attribute Value Time Duration", + fmt.Sprintf("%q %s", s.Value, validator.Description(ctx))), + ) + return + } +} + +// TimeDuration returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is parseable as time duration. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func TimeDuration() tfsdk.AttributeValidator { + return timeDurationValidator{} +} diff --git a/internal/validators/timeduration_test.go b/internal/validators/timeduration_test.go new file mode 100644 index 0000000..b051609 --- /dev/null +++ b/internal/validators/timeduration_test.go @@ -0,0 +1,64 @@ +package validators_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/internal/validators" +) + +func TestTimeDuration(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.String + expectedDiagnostics diag.Diagnostics + } + + tests := map[string]testCase{ + "unknown": { + val: types.String{Unknown: true}, + }, + "null": { + val: types.String{Null: true}, + }, + "valid": { + val: types.String{Value: "20m"}, + }, + "invalid": { + val: types.String{Value: "20x"}, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Time Duration", + `"20x" must be a string containing a sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, + ), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := tfsdk.ValidateAttributeRequest{ + AttributePath: path.Root("test"), + AttributePathExpression: path.MatchRoot("test"), + AttributeConfig: test.val, + } + + response := tfsdk.ValidateAttributeResponse{} + + validators.TimeDuration().Validate(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/timeouts/parser.go b/timeouts/parser.go new file mode 100644 index 0000000..7eeab79 --- /dev/null +++ b/timeouts/parser.go @@ -0,0 +1,128 @@ +package timeouts + +import ( + "context" + "time" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Create interrogates the supplied types.Object and if the object.Attrs contains an +// entry for "create" that can be parsed then time.Duration is returned. If object.Attrs +// does not contain "create" the supplied default will be returned. +func Create(ctx context.Context, obj types.Object, def time.Duration) time.Duration { + if _, ok := obj.Attrs[attributeNameCreate]; !ok { + return def + } + + createTimeout := obj.Attrs[attributeNameCreate] + + if createTimeout.IsNull() { + return def + } + + // Although the schema mutation functions guarantee that the type for create timeout + // is a string, this function accepts any types.Object. + if _, ok := createTimeout.(types.String); !ok { + return def + } + + // Although the schema validation guarantees that the type for create timeout + // is parseable as a time.Duration, this function accepts any types.Object. + duration, err := time.ParseDuration(createTimeout.(types.String).Value) + if err != nil { + return def + } + + return duration +} + +// Read interrogates the supplied types.Object and if the object.Attrs contains an +// entry for "read" that can be parsed then time.Duration is returned. If object.Attrs +// does not contain "read" the supplied default will be returned. +func Read(ctx context.Context, obj types.Object, def time.Duration) time.Duration { + if _, ok := obj.Attrs[attributeNameRead]; !ok { + return def + } + + readTimeout := obj.Attrs[attributeNameRead] + + if readTimeout.IsNull() { + return def + } + + // Although the schema mutation functions guarantee that the type for read timeout + // is a string, this function accepts any types.Object. + if _, ok := readTimeout.(types.String); !ok { + return def + } + + // Although the schema validation guarantees that the type for read timeout + // is parseable as a time.Duration, this function accepts any types.Object. + duration, err := time.ParseDuration(readTimeout.(types.String).Value) + if err != nil { + return def + } + + return duration +} + +// Update interrogates the supplied types.Object and if the object.Attrs contains an +// entry for "update" that can be parsed then time.Duration is returned. If object.Attrs +// does not contain "update" the supplied default will be returned. +func Update(ctx context.Context, obj types.Object, def time.Duration) time.Duration { + if _, ok := obj.Attrs[attributeNameUpdate]; !ok { + return def + } + + updateTimeout := obj.Attrs[attributeNameUpdate] + + if updateTimeout.IsNull() { + return def + } + + // Although the schema mutation functions guarantee that the type for update timeout + // is a string, this function accepts any types.Object. + if _, ok := updateTimeout.(types.String); !ok { + return def + } + + // Although the schema validation guarantees that the type for update timeout + // is parseable as a time.Duration, this function accepts any types.Object. + duration, err := time.ParseDuration(updateTimeout.(types.String).Value) + if err != nil { + return def + } + + return duration +} + +// Delete interrogates the supplied types.Object and if the object.Attrs contains an +// entry for "delete" that can be parsed then time.Duration is returned. If object.Attrs +// does not contain "delete" the supplied default will be returned. +func Delete(ctx context.Context, obj types.Object, def time.Duration) time.Duration { + if _, ok := obj.Attrs[attributeNameDelete]; !ok { + return def + } + + deleteTimeout := obj.Attrs[attributeNameDelete] + + if deleteTimeout.IsNull() { + return def + } + + // Although the schema mutation functions guarantee that the type for delete timeout + // is a string, this function accepts any types.Object. + if _, ok := deleteTimeout.(types.String); !ok { + return def + } + + // Although the schema validation guarantees that the type for delete timeout + // is parseable as a time.Duration, this function accepts any types.Object. + duration, err := time.ParseDuration(deleteTimeout.(types.String).Value) + if err != nil { + return def + } + + return duration +} diff --git a/timeouts/parser_test.go b/timeouts/parser_test.go new file mode 100644 index 0000000..b19c932 --- /dev/null +++ b/timeouts/parser_test.go @@ -0,0 +1,373 @@ +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/types" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/timeouts" +) + +func TestCreate(t *testing.T) { + t.Parallel() + + def := 20 * time.Minute + + type testCase struct { + obj types.Object + expected time.Duration + } + + tests := map[string]testCase{ + "create-not-present": { + obj: types.Object{}, + expected: def, + }, + "create-is-null": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "create": types.String{Null: true}, + }, + AttrTypes: map[string]attr.Type{ + "create": types.StringType, + }, + }, + expected: def, + }, + "create-not-string": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "create": types.Bool{}, + }, + AttrTypes: map[string]attr.Type{ + "create": types.BoolType, + }, + }, + expected: def, + }, + "create-not-parseable-empty": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "create": types.String{ + Value: "", + }, + }, + AttrTypes: map[string]attr.Type{ + "create": types.StringType, + }, + }, + expected: def, + }, + "create-not-parseable": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "create": types.String{ + Value: "60x", + }, + }, + AttrTypes: map[string]attr.Type{ + "create": types.StringType, + }, + }, + expected: def, + }, + "create-valid": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "create": types.String{ + Value: "60m", + }, + }, + AttrTypes: map[string]attr.Type{ + "create": types.StringType, + }, + }, + expected: 60 * time.Minute, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + actual := timeouts.Create(context.Background(), test.obj, def) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected duration difference: %s", diff) + } + }) + } +} + +func TestRead(t *testing.T) { + t.Parallel() + + def := 20 * time.Minute + + type testCase struct { + obj types.Object + expected time.Duration + } + + tests := map[string]testCase{ + "read-not-present": { + obj: types.Object{}, + expected: def, + }, + "read-is-null": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "read": types.String{Null: true}, + }, + AttrTypes: map[string]attr.Type{ + "read": types.StringType, + }, + }, + expected: def, + }, + "read-not-string": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "read": types.Bool{}, + }, + AttrTypes: map[string]attr.Type{ + "read": types.BoolType, + }, + }, + expected: def, + }, + "read-not-parseable-empty": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "read": types.String{ + Value: "", + }, + }, + AttrTypes: map[string]attr.Type{ + "read": types.StringType, + }, + }, + expected: def, + }, + "read-not-parseable": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "read": types.String{ + Value: "60x", + }, + }, + AttrTypes: map[string]attr.Type{ + "read": types.StringType, + }, + }, + expected: def, + }, + "read-valid": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "read": types.String{ + Value: "60m", + }, + }, + AttrTypes: map[string]attr.Type{ + "read": types.StringType, + }, + }, + expected: 60 * time.Minute, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + actual := timeouts.Read(context.Background(), test.obj, def) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected duration difference: %s", diff) + } + }) + } +} + +func TestUpdate(t *testing.T) { + t.Parallel() + + def := 20 * time.Minute + + type testCase struct { + obj types.Object + expected time.Duration + } + + tests := map[string]testCase{ + "update-not-present": { + obj: types.Object{}, + expected: def, + }, + "update-is-null": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "update": types.String{Null: true}, + }, + AttrTypes: map[string]attr.Type{ + "update": types.StringType, + }, + }, + expected: def, + }, + "update-not-string": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "update": types.Bool{}, + }, + AttrTypes: map[string]attr.Type{ + "update": types.BoolType, + }, + }, + expected: def, + }, + "update-not-parseable-empty": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "update": types.String{ + Value: "", + }, + }, + AttrTypes: map[string]attr.Type{ + "update": types.StringType, + }, + }, + expected: def, + }, + "update-not-parseable": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "update": types.String{ + Value: "60x", + }, + }, + AttrTypes: map[string]attr.Type{ + "update": types.StringType, + }, + }, + expected: def, + }, + "update-valid": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "update": types.String{ + Value: "60m", + }, + }, + AttrTypes: map[string]attr.Type{ + "update": types.StringType, + }, + }, + expected: 60 * time.Minute, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + actual := timeouts.Update(context.Background(), test.obj, def) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected duration difference: %s", diff) + } + }) + } +} + +func TestDelete(t *testing.T) { + t.Parallel() + + def := 20 * time.Minute + + type testCase struct { + obj types.Object + expected time.Duration + } + + tests := map[string]testCase{ + "delete-not-present": { + obj: types.Object{}, + expected: def, + }, + "delete-is-null": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "delete": types.String{Null: true}, + }, + AttrTypes: map[string]attr.Type{ + "delete": types.StringType, + }, + }, + expected: def, + }, + "delete-not-string": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "delete": types.Bool{}, + }, + AttrTypes: map[string]attr.Type{ + "delete": types.BoolType, + }, + }, + expected: def, + }, + "delete-not-parseable-empty": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "delete": types.String{ + Value: "", + }, + }, + AttrTypes: map[string]attr.Type{ + "delete": types.StringType, + }, + }, + expected: def, + }, + "delete-not-parseable": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "delete": types.String{ + Value: "60x", + }, + }, + AttrTypes: map[string]attr.Type{ + "delete": types.StringType, + }, + }, + expected: def, + }, + "delete-valid": { + obj: types.Object{ + Attrs: map[string]attr.Value{ + "delete": types.String{ + Value: "60m", + }, + }, + AttrTypes: map[string]attr.Type{ + "delete": types.StringType, + }, + }, + expected: 60 * time.Minute, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + actual := timeouts.Delete(context.Background(), test.obj, def) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected duration difference: %s", diff) + } + }) + } +} diff --git a/timeouts/schema.go b/timeouts/schema.go new file mode 100644 index 0000000..bfccf25 --- /dev/null +++ b/timeouts/schema.go @@ -0,0 +1,103 @@ +package timeouts + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/internal/validators" +) + +const ( + attributeNameCreate = "create" + attributeNameRead = "read" + attributeNameUpdate = "update" + attributeNameDelete = "delete" +) + +// Opts is used as an argument to Block and Attributes to indicate which attributes +// should be created. +type Opts struct { + Create bool + Read bool + Update bool + Delete bool +} + +// Block returns a tfsdk.Block containing attributes for each of the fields +// in Opts which are set to true. Each attribute 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 Block(ctx context.Context, opts Opts) tfsdk.Block { + return tfsdk.Block{ + Attributes: attributesMap(opts), + NestingMode: tfsdk.BlockNestingModeSingle, + } +} + +// BlockAll returns a tfsdk.Block containing attributes for each of create, read, +// update and delete. Each attribute 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 BlockAll(ctx context.Context) tfsdk.Block { + return Block(ctx, Opts{ + Create: true, + Read: true, + Update: true, + Delete: true, + }) +} + +// Attributes returns a tfsdk.Attribute containing a tfsdk.SingleNestedAttributes +// which contains attributes for each of the fields in Opts which are set to true. +// Each attribute 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, opts Opts) tfsdk.Attribute { + return tfsdk.Attribute{ + Optional: true, + Attributes: tfsdk.SingleNestedAttributes(attributesMap(opts)), + } +} + +// AttributesAll returns a tfsdk.Attribute containing a tfsdk.SingleNestedAttributes +// which contains attributes for each of create, read, update and delete. Each +// attribute 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 AttributesAll(ctx context.Context) tfsdk.Attribute { + return Attributes(ctx, Opts{ + Create: true, + Read: true, + Update: true, + Delete: true, + }) +} + +func attributesMap(opts Opts) map[string]tfsdk.Attribute { + attributes := map[string]tfsdk.Attribute{} + attribute := tfsdk.Attribute{ + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + } + + if opts.Create { + attributes[attributeNameCreate] = attribute + } + + if opts.Read { + attributes[attributeNameRead] = attribute + } + + if opts.Update { + attributes[attributeNameUpdate] = attribute + } + + if opts.Delete { + attributes[attributeNameDelete] = attribute + } + + return attributes +} diff --git a/timeouts/schema_test.go b/timeouts/schema_test.go new file mode 100644 index 0000000..af10dc5 --- /dev/null +++ b/timeouts/schema_test.go @@ -0,0 +1,243 @@ +package timeouts_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/internal/validators" + "github.com/hashicorp/terraform-plugin-framework-timeouts/timeouts" +) + +func TestBlock(t *testing.T) { + t.Parallel() + + type testCase struct { + opts timeouts.Opts + expected tfsdk.Block + } + tests := map[string]testCase{ + "empty-opts": { + opts: timeouts.Opts{}, + expected: tfsdk.Block{ + Attributes: map[string]tfsdk.Attribute{}, + NestingMode: tfsdk.BlockNestingModeSingle, + }, + }, + "create-opts": { + opts: timeouts.Opts{ + Create: true, + }, + expected: tfsdk.Block{ + Attributes: map[string]tfsdk.Attribute{ + "create": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + }, + NestingMode: tfsdk.BlockNestingModeSingle, + }, + }, + "create-update-opts": { + opts: timeouts.Opts{ + Create: true, + Update: true, + }, + expected: tfsdk.Block{ + Attributes: map[string]tfsdk.Attribute{ + "create": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "update": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + }, + NestingMode: tfsdk.BlockNestingModeSingle, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + actual := timeouts.Block(context.Background(), test.opts) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } + }) + } +} + +func TestBlockAll(t *testing.T) { + t.Parallel() + + actual := timeouts.BlockAll(context.Background()) + + expected := tfsdk.Block{ + Attributes: map[string]tfsdk.Attribute{ + "create": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "read": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "update": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "delete": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + }, + NestingMode: tfsdk.BlockNestingModeSingle, + } + + if diff := cmp.Diff(actual, expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } +} + +func TestAttributes(t *testing.T) { + t.Parallel() + + type testCase struct { + opts timeouts.Opts + expected tfsdk.Attribute + } + tests := map[string]testCase{ + "empty-opts": { + opts: timeouts.Opts{}, + expected: tfsdk.Attribute{ + Optional: true, + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{}), + }, + }, + "create-opts": { + opts: timeouts.Opts{ + Create: true, + }, + expected: tfsdk.Attribute{ + Optional: true, + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "create": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + }), + }, + }, + "create-update-opts": { + opts: timeouts.Opts{ + Create: true, + Update: true, + }, + expected: tfsdk.Attribute{ + Optional: true, + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "create": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "update": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + }), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + actual := timeouts.Attributes(context.Background(), test.opts) + + if diff := cmp.Diff(actual, test.expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } + }) + } +} + +func TestAttributesAll(t *testing.T) { + t.Parallel() + + actual := timeouts.AttributesAll(context.Background()) + + expected := tfsdk.Attribute{ + Optional: true, + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "create": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "read": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "update": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + "delete": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + validators.TimeDuration(), + }, + }, + }), + } + + if diff := cmp.Diff(actual, expected); diff != "" { + t.Errorf("unexpected block difference: %s", diff) + } +}