diff --git a/.changes/unreleased/BREAKING CHANGES-20260319-132441.yaml b/.changes/unreleased/BREAKING CHANGES-20260319-132441.yaml new file mode 100644 index 00000000..bc210eaa --- /dev/null +++ b/.changes/unreleased/BREAKING CHANGES-20260319-132441.yaml @@ -0,0 +1,5 @@ +kind: BREAKING CHANGES +body: 'ProtocolV6: only Terraform 1.0+ is supported for this version of the provider. Earlier versions of Terraform will need to pin the provider version to 3.5.0' +time: 2026-03-19T13:24:41.398465-04:00 +custom: + Issue: "602" diff --git a/.changes/unreleased/ENHANCEMENTS-20260319-132302.yaml b/.changes/unreleased/ENHANCEMENTS-20260319-132302.yaml new file mode 100644 index 00000000..a78c1ff6 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20260319-132302.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'statestore: New State Store created for HTTP replacing the Terraform HTTP Backend' +time: 2026-03-19T13:23:02.04345-04:00 +custom: + Issue: "602" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61c02856..9d78bf39 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,7 +64,7 @@ jobs: - macos-latest - windows-latest - ubuntu-latest - terraform: ${{ fromJSON(vars.TF_VERSIONS_PROTOCOL_V5) }} + terraform: ${{ fromJSON(vars.TF_VERSIONS_PROTOCOL_V6) }} steps: - name: Check out code diff --git a/go.mod b/go.mod index 3c3e3066..c0aaf05a 100644 --- a/go.mod +++ b/go.mod @@ -9,13 +9,13 @@ require ( github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 github.com/hashicorp/terraform-plugin-log v0.10.0 - github.com/hashicorp/terraform-plugin-testing v1.15.0 + github.com/hashicorp/terraform-plugin-testing v1.15.1-0.20260317164847-8b21c912cc7c golang.org/x/net v0.52.0 ) require ( - github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/agext/levenshtein v1.2.2 // indirect + github.com/ProtonMail/go-crypto v1.4.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/fatih/color v1.18.0 // indirect @@ -37,7 +37,7 @@ require ( github.com/hashicorp/terraform-json v0.27.2 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect github.com/hashicorp/terraform-registry-address v0.4.0 // indirect - github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/terraform-svchost v0.2.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -46,19 +46,19 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/oklog/run v1.1.0 // indirect + github.com/oklog/run v1.2.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/zclconf/go-cty v1.17.0 // indirect + github.com/zclconf/go-cty v1.18.0 // indirect golang.org/x/crypto v0.49.0 // indirect - golang.org/x/mod v0.33.0 // indirect + golang.org/x/mod v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index bb80de9a..59b3292a 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,10 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= -github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= +github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= @@ -94,12 +94,12 @@ github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3Obj github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= -github.com/hashicorp/terraform-plugin-testing v1.15.0 h1:/fimKyl0YgD7aAtJkuuAZjwBASXhCIwWqMbDLnKLMe4= -github.com/hashicorp/terraform-plugin-testing v1.15.0/go.mod h1:bGXMw7bE95EiZhSBV3rM2W8TiffaPTDuLS+HFI/lIYs= +github.com/hashicorp/terraform-plugin-testing v1.15.1-0.20260317164847-8b21c912cc7c h1:OBFaIxndkZ7V95pxeHSU3Kg3C49z6UcsBXhCXSVBKbA= +github.com/hashicorp/terraform-plugin-testing v1.15.1-0.20260317164847-8b21c912cc7c/go.mod h1:9LnLMXLqK5T/utOtBVQbC8l7nfoxAVqTNZ+JJg/W7I0= github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= -github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= -github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q= +github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -112,9 +112,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -133,8 +132,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -160,8 +159,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= -github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -181,8 +180,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -218,8 +217,8 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= @@ -227,8 +226,8 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/internal/provider/data_source_http_test.go b/internal/provider/data_source_http_test.go index 5fda4270..7ff57166 100644 --- a/internal/provider/data_source_http_test.go +++ b/internal/provider/data_source_http_test.go @@ -38,7 +38,7 @@ func TestDataSource_200(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -71,7 +71,7 @@ func TestDataSource_200_SlashInPath(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -98,7 +98,7 @@ func TestDataSource_404(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -129,7 +129,7 @@ func TestDataSource_withAuthorizationRequestHeader_200(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -159,7 +159,7 @@ func TestDataSource_withAuthorizationRequestHeader_403(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -190,7 +190,7 @@ func TestDataSource_utf8_200(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -218,7 +218,7 @@ func TestDataSource_utf16_200(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -245,7 +245,7 @@ func TestDataSource_x509cert(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), TerraformVersionChecks: []tfversion.TerraformVersionCheck{ //test fails in TF 0.14.x due to https://github.com/hashicorp/terraform-provider-http/issues/58 tfversion.SkipBetween(tfversion.Version0_14_0, tfversion.Version0_15_0), @@ -299,7 +299,7 @@ func TestDataSource_UpgradeFromVersion2_2_0(t *testing.T) { ), }, { - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Config: fmt.Sprintf(` data "http" "http_test" { url = "%s" @@ -307,7 +307,7 @@ func TestDataSource_UpgradeFromVersion2_2_0(t *testing.T) { PlanOnly: true, }, { - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Config: fmt.Sprintf(` data "http" "http_test" { url = "%s" @@ -337,7 +337,7 @@ func TestDataSource_Provisioner(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), ExternalProviders: map[string]resource.ExternalProvider{ "null": { VersionConstraint: "3.1.1", @@ -386,7 +386,7 @@ func TestDataSource_POST_200(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -415,7 +415,7 @@ func TestDataSource_HEAD_204(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -441,7 +441,7 @@ func TestDataSource_UnsupportedMethod(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -462,7 +462,7 @@ func TestDataSource_WithCACertificate(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -505,7 +505,7 @@ func TestDataSource_WithClientCert(t *testing.T) { defer testServer.Close() resource.UnitTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { // Note: %q is used to handle backspaces in the filepath on windows (\ becomes \\) @@ -541,7 +541,7 @@ func TestDataSource_WithCACertificateFalse(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -563,7 +563,7 @@ func TestDataSource_InsecureTrue(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -587,7 +587,7 @@ func TestDataSource_InsecureFalse(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -616,7 +616,7 @@ func TestDataSource_InsecureUnconfigured(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -642,7 +642,7 @@ func TestDataSource_UnsupportedInsecureCaCert(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -678,7 +678,7 @@ func TestDataSource_HostRequestHeaderOverride_200(t *testing.T) { defer testServer.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -741,7 +741,7 @@ func TestDataSource_HTTPViaProxyWithEnv(t *testing.T) { t.Setenv("HTTPS_PROXY", proxy.URL) resource.Test(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { @@ -766,7 +766,7 @@ func TestDataSource_Timeout(t *testing.T) { defer svr.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -784,7 +784,7 @@ func TestDataSource_Retry(t *testing.T) { uid := uuid.New() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -824,7 +824,7 @@ func TestDataSource_MinDelay(t *testing.T) { defer svr.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -853,7 +853,7 @@ func TestDataSource_MaxDelay(t *testing.T) { defer svr.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -879,7 +879,7 @@ func TestDataSource_MaxDelayAtLeastEqualToMinDelay(t *testing.T) { defer svr.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -927,7 +927,7 @@ func TestDataSource_RequestBody(t *testing.T) { defer svr.Close() resource.UnitTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -970,7 +970,7 @@ func TestDataSource_ResponseBodyText(t *testing.T) { defer svr.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` @@ -998,7 +998,7 @@ func TestDataSource_ResponseBodyBinary(t *testing.T) { defer svr.Close() resource.ParallelTest(t, resource.TestCase{ - ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), TerraformVersionChecks: []tfversion.TerraformVersionCheck{ //test fails in TF 0.14.x due to quirk in behavior //where a warning results in nothing being written to output. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0e73b193..5cb6a86b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -9,15 +9,21 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/statestore" ) func New() provider.Provider { return &httpProvider{} } -var _ provider.Provider = (*httpProvider)(nil) +var ( + _ provider.Provider = (*httpProvider)(nil) + _ provider.ProviderWithStateStores = (*httpProvider)(nil) +) -type httpProvider struct{} +type httpProvider struct { + terraformVersion string +} func (p *httpProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "http" @@ -26,7 +32,8 @@ func (p *httpProvider) Metadata(_ context.Context, _ provider.MetadataRequest, r func (p *httpProvider) Schema(context.Context, provider.SchemaRequest, *provider.SchemaResponse) { } -func (p *httpProvider) Configure(context.Context, provider.ConfigureRequest, *provider.ConfigureResponse) { +func (p *httpProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + p.terraformVersion = req.TerraformVersion } func (p *httpProvider) Resources(context.Context) []func() resource.Resource { @@ -38,3 +45,9 @@ func (p *httpProvider) DataSources(context.Context) []func() datasource.DataSour NewHttpDataSource, } } + +func (p *httpProvider) StateStores(context.Context) []func() statestore.StateStore { + return []func() statestore.StateStore{ + func() statestore.StateStore { return NewHttpStateStore(p.terraformVersion) }, + } +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index e849ba6f..64112a33 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -5,12 +5,12 @@ package provider import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) //nolint:unparam -func protoV5ProviderFactories() map[string]func() (tfprotov5.ProviderServer, error) { - return map[string]func() (tfprotov5.ProviderServer, error){ - "http": providerserver.NewProtocol5WithError(New()), +func protoV6ProviderFactories() map[string]func() (tfprotov6.ProviderServer, error) { + return map[string]func() (tfprotov6.ProviderServer, error){ + "http": providerserver.NewProtocol6WithError(New()), } } diff --git a/internal/provider/state_store_http.go b/internal/provider/state_store_http.go new file mode 100644 index 00000000..dd98d9f3 --- /dev/null +++ b/internal/provider/state_store_http.go @@ -0,0 +1,810 @@ +// Copyright IBM Corp. 2017, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/statestore" + "github.com/hashicorp/terraform-plugin-framework/statestore/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "golang.org/x/net/http/httpproxy" +) + +var ( + _ statestore.StateStore = (*httpStateStore)(nil) + _ statestore.StateStoreWithConfigure = (*httpStateStore)(nil) +) + +const defaultWorkspaceName = "default" + +const ( + envHTTPAddress = "TF_HTTP_ADDRESS" + envHTTPUpdateMethod = "TF_HTTP_UPDATE_METHOD" + envHTTPLockAddress = "TF_HTTP_LOCK_ADDRESS" + envHTTPUnlockAddress = "TF_HTTP_UNLOCK_ADDRESS" + envHTTPLockMethod = "TF_HTTP_LOCK_METHOD" + envHTTPUnlockMethod = "TF_HTTP_UNLOCK_METHOD" + envHTTPUsername = "TF_HTTP_USERNAME" + envHTTPPassword = "TF_HTTP_PASSWORD" + envHTTPRetryMax = "TF_HTTP_RETRY_MAX" + envHTTPRetryWaitMin = "TF_HTTP_RETRY_WAIT_MIN" + envHTTPRetryWaitMax = "TF_HTTP_RETRY_WAIT_MAX" + envHTTPClientCACertificate = "TF_HTTP_CLIENT_CA_CERTIFICATE_PEM" + envHTTPClientCertificate = "TF_HTTP_CLIENT_CERTIFICATE_PEM" + envHTTPClientPrivateKeyPEM = "TF_HTTP_CLIENT_PRIVATE_KEY_PEM" +) + +type httpLockInfo struct { + ID string + Operation string + Who string + Created time.Time + Version string + Path string + Info string +} + +type httpStateStore struct { + client *httpStateStoreClient + terraformVersion string +} + +func NewHttpStateStore(terraformVersion string) statestore.StateStore { + return &httpStateStore{ + terraformVersion: terraformVersion, + } +} + +func (s *httpStateStore) Metadata(ctx context.Context, req statestore.MetadataRequest, resp *statestore.MetadataResponse) { + resp.TypeName = "http" +} + +func (s *httpStateStore) Schema(ctx context.Context, req statestore.SchemaRequest, resp *statestore.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "HTTP state store for managing Terraform state via HTTP endpoints", + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Description: "The address of the HTTP endpoint for state storage. May also be set via TF_HTTP_ADDRESS.", + Optional: true, + }, + "update_method": schema.StringAttribute{ + Description: "HTTP method to use when updating state. Defaults to POST", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("POST", "PUT"), + }, + }, + "lock_address": schema.StringAttribute{ + Description: "The address of the HTTP endpoint for state locking. Optional.", + Optional: true, + }, + "lock_method": schema.StringAttribute{ + Description: "HTTP method to use when locking state. Defaults to LOCK", + Optional: true, + }, + "unlock_address": schema.StringAttribute{ + Description: "The address of the HTTP endpoint for unlocking state. Defaults to lock_address if not set.", + Optional: true, + }, + "unlock_method": schema.StringAttribute{ + Description: "HTTP method to use when unlocking state. Defaults to UNLOCK", + Optional: true, + }, + "username": schema.StringAttribute{ + Description: "Username for HTTP basic authentication", + Optional: true, + }, + "password": schema.StringAttribute{ + Description: "Password for HTTP basic authentication", + Optional: true, + }, + "skip_cert_verification": schema.BoolAttribute{ + Description: "Whether to skip TLS certificate verification", + Optional: true, + }, + "retry_max": schema.Int64Attribute{ + Description: "Maximum number of retries. Defaults to 2", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "retry_wait_min": schema.Int64Attribute{ + Description: "Minimum time in seconds to wait between retries. Defaults to 1", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "retry_wait_max": schema.Int64Attribute{ + Description: "Maximum time in seconds to wait between retries. Defaults to 30", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "client_ca_certificate_pem": schema.StringAttribute{ + Description: "PEM-encoded CA certificate for TLS verification", + Optional: true, + }, + "client_certificate_pem": schema.StringAttribute{ + Description: "PEM-encoded client certificate for mTLS authentication", + Optional: true, + }, + "client_private_key_pem": schema.StringAttribute{ + Description: "PEM-encoded client private key for mTLS authentication", + Optional: true, + }, + }, + } +} + +// configModel represents the configuration for the HTTP state store. +type configModel struct { + Address types.String `tfsdk:"address"` + UpdateMethod types.String `tfsdk:"update_method"` + LockAddress types.String `tfsdk:"lock_address"` + LockMethod types.String `tfsdk:"lock_method"` + UnlockAddress types.String `tfsdk:"unlock_address"` + UnlockMethod types.String `tfsdk:"unlock_method"` + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + SkipCertVerification types.Bool `tfsdk:"skip_cert_verification"` + RetryMax types.Int64 `tfsdk:"retry_max"` + RetryWaitMin types.Int64 `tfsdk:"retry_wait_min"` + RetryWaitMax types.Int64 `tfsdk:"retry_wait_max"` + ClientCACertPEM types.String `tfsdk:"client_ca_certificate_pem"` + ClientCertPEM types.String `tfsdk:"client_certificate_pem"` + ClientPrivateKeyPEM types.String `tfsdk:"client_private_key_pem"` +} + +// httpStateStoreClient represents the configured HTTP client for state operations. +type httpStateStoreClient struct { + address string + updateMethod string + lockAddress string + lockMethod string + unlockAddress string + unlockMethod string + username string + password string + client *retryablehttp.Client + lockID string + lockData []byte +} + +func (s *httpStateStore) Initialize(ctx context.Context, req statestore.InitializeRequest, resp *statestore.InitializeResponse) { + var config configModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + address := stringValueOrEnv(config.Address, envHTTPAddress, "") + if address == "" { + resp.Diagnostics.AddError( + "Missing required configuration", + fmt.Sprintf("address argument is required or must be set via %s", envHTTPAddress), + ) + return + } + + if err := validateHTTPURL(address, "address"); err != nil { + resp.Diagnostics.AddError("Invalid configuration", err.Error()) + return + } + + updateMethod := stringValueOrEnv(config.UpdateMethod, envHTTPUpdateMethod, "POST") + lockAddress := stringValueOrEnv(config.LockAddress, envHTTPLockAddress, "") + if lockAddress != "" { + if err := validateHTTPURL(lockAddress, "lock_address"); err != nil { + resp.Diagnostics.AddError("Invalid configuration", err.Error()) + return + } + } + + lockMethod := stringValueOrEnv(config.LockMethod, envHTTPLockMethod, "LOCK") + unlockAddress := stringValueOrEnv(config.UnlockAddress, envHTTPUnlockAddress, lockAddress) + if unlockAddress != "" { + if err := validateHTTPURL(unlockAddress, "unlock_address"); err != nil { + resp.Diagnostics.AddError("Invalid configuration", err.Error()) + return + } + } + + unlockMethod := stringValueOrEnv(config.UnlockMethod, envHTTPUnlockMethod, "UNLOCK") + username := stringValueOrEnv(config.Username, envHTTPUsername, "") + password := stringValueOrEnv(config.Password, envHTTPPassword, "") + clientCACertPEM := stringValueOrEnv(config.ClientCACertPEM, envHTTPClientCACertificate, "") + clientCertPEM := stringValueOrEnv(config.ClientCertPEM, envHTTPClientCertificate, "") + clientPrivateKeyPEM := stringValueOrEnv(config.ClientPrivateKeyPEM, envHTTPClientPrivateKeyPEM, "") + + retryMax, err := int64ValueOrEnv(config.RetryMax, envHTTPRetryMax, 2) + if err != nil { + resp.Diagnostics.AddError("Invalid configuration", fmt.Sprintf("invalid retry_max: %s", err)) + return + } + + retryWaitMin, err := int64ValueOrEnv(config.RetryWaitMin, envHTTPRetryWaitMin, 1) + if err != nil { + resp.Diagnostics.AddError("Invalid configuration", fmt.Sprintf("invalid retry_wait_min: %s", err)) + return + } + + retryWaitMax, err := int64ValueOrEnv(config.RetryWaitMax, envHTTPRetryWaitMax, 30) + if err != nil { + resp.Diagnostics.AddError("Invalid configuration", fmt.Sprintf("invalid retry_wait_max: %s", err)) + return + } + + // Validate mutual TLS configuration + hasClientCert := clientCertPEM != "" + hasClientKey := clientPrivateKeyPEM != "" + + if hasClientCert && !hasClientKey { + resp.Diagnostics.AddError( + "Invalid mTLS Configuration", + "client_certificate_pem is set but client_private_key_pem is not", + ) + return + } + + if hasClientKey && !hasClientCert { + resp.Diagnostics.AddError( + "Invalid mTLS Configuration", + "client_private_key_pem is set but client_certificate_pem is not", + ) + return + } + + // Build HTTP transport (duplicated from data_source_http.go) + tr, ok := http.DefaultTransport.(*http.Transport) + if !ok { + resp.Diagnostics.AddError( + "Error configuring http transport", + "Error http: Can't configure http transport.", + ) + return + } + + // Clone transport to avoid shared state + clonedTr := tr.Clone() + + // Configure proxy from environment + clonedTr.Proxy = func(req *http.Request) (*url.URL, error) { + return httpproxy.FromEnvironment().ProxyFunc()(req.URL) + } + + if clonedTr.TLSClientConfig == nil { + clonedTr.TLSClientConfig = &tls.Config{} + } + + // Configure TLS skip verification + if !config.SkipCertVerification.IsNull() { + clonedTr.TLSClientConfig.InsecureSkipVerify = config.SkipCertVerification.ValueBool() + } + + // Configure CA certificate + if clientCACertPEM != "" { + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM([]byte(clientCACertPEM)); !ok { + resp.Diagnostics.AddAttributeError( + path.Root("client_ca_certificate_pem"), + "Error configuring TLS client", + "Error tls: Can't add the CA certificate to certificate pool. Only PEM encoded certificates are supported.", + ) + return + } + clonedTr.TLSClientConfig.RootCAs = caCertPool + } + + // Configure client certificate for mTLS + if hasClientCert && hasClientKey { + cert, err := tls.X509KeyPair( + []byte(clientCertPEM), + []byte(clientPrivateKeyPEM), + ) + if err != nil { + resp.Diagnostics.AddError( + "Error creating x509 key pair", + fmt.Sprintf("Error creating x509 key pair from provided pem blocks\n\nError: %s", err), + ) + return + } + clonedTr.TLSClientConfig.Certificates = []tls.Certificate{cert} + } + + // Create retryable HTTP client + retryClient := retryablehttp.NewClient() + retryClient.HTTPClient.Transport = clonedTr + retryClient.Logger = levelledLogger{ctx} + + // Configure retry parameters + retryClient.RetryMax = int(retryMax) + + retryClient.RetryWaitMin = time.Duration(retryWaitMin) * time.Second + + retryClient.RetryWaitMax = time.Duration(retryWaitMax) * time.Second + + // Create and store client + client := &httpStateStoreClient{ + address: address, + updateMethod: updateMethod, + lockAddress: lockAddress, + unlockAddress: unlockAddress, + lockMethod: lockMethod, + unlockMethod: unlockMethod, + username: username, + password: password, + client: retryClient, + } + + resp.StateStoreData = client +} + +func stringValueOrEnv(value types.String, envName, defaultValue string) string { + if !value.IsNull() && !value.IsUnknown() { + return value.ValueString() + } + + if envValue, ok := os.LookupEnv(envName); ok { + return envValue + } + + return defaultValue +} + +func int64ValueOrEnv(value types.Int64, envName string, defaultValue int64) (int64, error) { + if !value.IsNull() && !value.IsUnknown() { + return value.ValueInt64(), nil + } + + if envValue, ok := os.LookupEnv(envName); ok { + parsed, err := strconv.ParseInt(envValue, 10, 64) + if err != nil { + return 0, err + } + + return parsed, nil + } + + return defaultValue, nil +} + +func validateHTTPURL(rawURL, fieldName string) error { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("failed to parse %s URL: %s", fieldName, err) + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("%s must be HTTP or HTTPS", fieldName) + } + + return nil +} + +func (s *httpStateStore) Configure(ctx context.Context, req statestore.ConfigureRequest, resp *statestore.ConfigureResponse) { + if req.StateStoreData == nil { + return + } + + client, ok := req.StateStoreData.(*httpStateStoreClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected StateStore Configure Type", + fmt.Sprintf("Expected *httpStateStoreClient, got: %T. Please report this issue to the provider developers.", req.StateStoreData), + ) + return + } + + s.client = client +} + +func (s *httpStateStore) GetStates(ctx context.Context, req statestore.GetStatesRequest, resp *statestore.GetStatesResponse) { + // Terraform Core's HTTP backend does not support workspace enumeration and only operates on the default workspace. + resp.StateIDs = []string{defaultWorkspaceName} +} + +func (s *httpStateStore) Read(ctx context.Context, req statestore.ReadRequest, resp *statestore.ReadResponse) { + if req.StateID != defaultWorkspaceName { + resp.Diagnostics.Append(multipleWorkspacesNotSupportedDiag) + return + } + + tflog.Debug(ctx, "Reading state", map[string]interface{}{"address": s.client.address}) + + httpReq, err := retryablehttp.NewRequestWithContext(ctx, "GET", s.client.address, nil) + if err != nil { + resp.Diagnostics.AddError( + "Error creating HTTP request", + fmt.Sprintf("Error creating GET request to %s: %s", s.client.address, err), + ) + return + } + + // Set basic auth if configured + if s.client.username != "" && s.client.password != "" { + httpReq.SetBasicAuth(s.client.username, s.client.password) + } + + httpResp, err := s.client.client.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError( + "Error reading state", + fmt.Sprintf("Error making GET request to %s: %s", s.client.address, err), + ) + return + } + defer httpResp.Body.Close() + + // 404 means no state exists yet - this is OK + if httpResp.StatusCode == http.StatusNotFound { + tflog.Debug(ctx, "No state exists yet (404)") + resp.StateBytes = nil + return + } + + // 200 means state exists + if httpResp.StatusCode == http.StatusOK { + stateData, err := io.ReadAll(httpResp.Body) + if err != nil { + resp.Diagnostics.AddError( + "Error reading response body", + fmt.Sprintf("Error reading response body from %s: %s", s.client.address, err), + ) + return + } + resp.StateBytes = stateData + return + } + + // 204 means no content exists yet - this is OK + if httpResp.StatusCode == http.StatusNoContent { + tflog.Debug(ctx, "No content exists (204)") + resp.StateBytes = nil + return + } + + // Any other status code is an error + resp.Diagnostics.AddError( + "Unexpected HTTP status code", + fmt.Sprintf("Expected status 200 or 404, got %d from %s", httpResp.StatusCode, s.client.address), + ) +} + +func (s *httpStateStore) Write(ctx context.Context, req statestore.WriteRequest, resp *statestore.WriteResponse) { + if req.StateID != defaultWorkspaceName { + resp.Diagnostics.Append(multipleWorkspacesNotSupportedDiag) + return + } + + tflog.Debug(ctx, "Writing state", map[string]interface{}{ + "address": s.client.address, + "method": s.client.updateMethod, + "stateID": req.StateID, + }) + + writeURL := s.client.address + var err error + if s.client.lockID != "" { + writeURL, err = withQueryParam(s.client.address, "ID", s.client.lockID) + if err != nil { + resp.Diagnostics.AddError( + "Error creating write URL", + fmt.Sprintf("Error adding lock ID query parameter to %s: %s", s.client.address, err), + ) + return + } + } + + httpReq, err := retryablehttp.NewRequestWithContext(ctx, s.client.updateMethod, writeURL, bytes.NewReader(req.StateBytes)) + if err != nil { + resp.Diagnostics.AddError( + "Error creating HTTP request", + fmt.Sprintf("Error creating %s request to %s: %s", s.client.updateMethod, writeURL, err), + ) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + + // Set basic auth if configured + if s.client.username != "" && s.client.password != "" { + httpReq.SetBasicAuth(s.client.username, s.client.password) + } + + httpResp, err := s.client.client.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError( + "Error writing state", + fmt.Sprintf("Error making %s request to %s: %s", s.client.updateMethod, s.client.address, err), + ) + return + } + defer httpResp.Body.Close() + + // 200 OK, 201 Created, or 204 No Content are success. + if httpResp.StatusCode == http.StatusOK || httpResp.StatusCode == http.StatusCreated || httpResp.StatusCode == http.StatusNoContent { + tflog.Debug(ctx, "State written successfully", map[string]interface{}{"status": httpResp.StatusCode}) + return + } + + // Any other status code is an error + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "Unexpected HTTP status code", + fmt.Sprintf("Expected status 200, 201, or 204, got %d from %s: %s", httpResp.StatusCode, writeURL, string(body)), + ) +} + +func (s *httpStateStore) DeleteState(ctx context.Context, req statestore.DeleteStateRequest, resp *statestore.DeleteStateResponse) { + if req.StateID != defaultWorkspaceName { + resp.Diagnostics.Append(multipleWorkspacesNotSupportedDiag) + return + } + + tflog.Debug(ctx, "Deleting state", map[string]interface{}{"address": s.client.address}) + + httpReq, err := retryablehttp.NewRequestWithContext(ctx, "DELETE", s.client.address, nil) + if err != nil { + resp.Diagnostics.AddError( + "Error creating HTTP request", + fmt.Sprintf("Error creating DELETE request to %s: %s", s.client.address, err), + ) + return + } + + // Set basic auth if configured + if s.client.username != "" && s.client.password != "" { + httpReq.SetBasicAuth(s.client.username, s.client.password) + } + + httpResp, err := s.client.client.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting state", + fmt.Sprintf("Error making DELETE request to %s: %s", s.client.address, err), + ) + return + } + defer httpResp.Body.Close() + + // 200 OK, 204 No Content, or 404 Not Found are all success + // (404 means it was already deleted) + if httpResp.StatusCode == http.StatusOK || + httpResp.StatusCode == http.StatusNoContent || + httpResp.StatusCode == http.StatusNotFound { + tflog.Debug(ctx, "State deleted successfully", map[string]interface{}{"status": httpResp.StatusCode}) + + return + } + + // Any other status code is an error + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "Unexpected HTTP status code", + fmt.Sprintf("Expected status 200, 204, or 404, got %d from %s: %s", httpResp.StatusCode, s.client.address, string(body)), + ) +} + +func (s *httpStateStore) Lock(ctx context.Context, req statestore.LockRequest, resp *statestore.LockResponse) { + if req.StateID != defaultWorkspaceName { + resp.Diagnostics.Append(multipleWorkspacesNotSupportedDiag) + return + } + + // If locking is not configured, return empty LockID to indicate no locking support + if s.client.lockAddress == "" { + tflog.Debug(ctx, "Locking not configured, skipping lock") + resp.LockID = "" + return + } + + tflog.Debug(ctx, "Locking state", map[string]interface{}{ + "address": s.client.lockAddress, + "method": s.client.lockMethod, + "stateID": req.StateID, + "operation": req.Operation, + }) + + // Create lock info + // lockInfo := statestore.NewLockInfo(req) + newLockInfo := statestore.NewLockInfo(req) + lockInfo := httpLockInfo{ + ID: newLockInfo.ID, + Operation: newLockInfo.Operation, + Who: newLockInfo.Who, + Created: newLockInfo.Created, + Version: s.terraformVersion, + Path: "", // leave blank as required but not available + Info: "", // leave blank as required but not available + } + + // Marshal lock info to JSON + lockData, err := json.Marshal(lockInfo) + if err != nil { + resp.Diagnostics.AddError( + "Error marshaling lock info", + fmt.Sprintf("Error marshaling lock info to JSON: %s", err), + ) + return + } + + httpReq, err := retryablehttp.NewRequestWithContext(ctx, s.client.lockMethod, s.client.lockAddress, bytes.NewReader(lockData)) + if err != nil { + resp.Diagnostics.AddError( + "Error creating HTTP request", + fmt.Sprintf("Error creating %s request to %s: %s", s.client.lockMethod, s.client.lockAddress, err), + ) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + + // Set basic auth if configured + if s.client.username != "" && s.client.password != "" { + httpReq.SetBasicAuth(s.client.username, s.client.password) + } + + httpResp, err := s.client.client.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError( + "Error locking state", + fmt.Sprintf("Error making %s request to %s: %s", s.client.lockMethod, s.client.lockAddress, err), + ) + return + } + defer httpResp.Body.Close() + + // 200 OK means lock acquired + if httpResp.StatusCode == http.StatusOK { + tflog.Debug(ctx, "State locked successfully", map[string]interface{}{"lockID": lockInfo.ID}) + s.client.lockID = lockInfo.ID + s.client.lockData = lockData + resp.LockID = lockInfo.ID + return + } + + // 423 Locked or 409 Conflict means already locked + if httpResp.StatusCode == http.StatusLocked || httpResp.StatusCode == http.StatusConflict { + body, _ := io.ReadAll(httpResp.Body) + + // Try to unmarshal existing lock info + var existingLock statestore.LockInfo + if err := json.Unmarshal(body, &existingLock); err == nil { + // Use helper to create formatted diagnostic + resp.Diagnostics.Append(statestore.WorkspaceAlreadyLockedDiagnostic(req, existingLock)) + } else { + // Fallback to basic error + resp.Diagnostics.AddError( + "State Already Locked", + fmt.Sprintf("State is already locked: %s", string(body)), + ) + } + return + } + + // Any other status code is an error + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "Unexpected HTTP status code", + fmt.Sprintf("Expected status 200, 423, or 409, got %d from %s: %s", httpResp.StatusCode, s.client.lockAddress, string(body)), + ) +} + +func (s *httpStateStore) Unlock(ctx context.Context, req statestore.UnlockRequest, resp *statestore.UnlockResponse) { + if req.StateID != defaultWorkspaceName { + resp.Diagnostics.Append(multipleWorkspacesNotSupportedDiag) + return + } + + // If locking is not configured, this shouldn't be called, but handle gracefully + if s.client.lockAddress == "" { + tflog.Debug(ctx, "Locking not configured, skipping unlock") + return + } + + tflog.Debug(ctx, "Unlocking state", map[string]interface{}{ + "address": s.client.unlockAddress, + "method": s.client.unlockMethod, + "stateID": req.StateID, + "lockID": req.LockID, + }) + + unlockData := s.client.lockData + if req.LockID != "" && (s.client.lockID == "" || s.client.lockID != req.LockID || len(unlockData) == 0) { + fallbackUnlockInfo := httpLockInfo{ + ID: req.LockID, + } + marshaledUnlockData, err := json.Marshal(fallbackUnlockInfo) + if err != nil { + resp.Diagnostics.AddError( + "Error marshaling unlock info", + fmt.Sprintf("Error marshaling unlock info to JSON: %s", err), + ) + return + } + unlockData = marshaledUnlockData + } + + httpReq, err := retryablehttp.NewRequestWithContext(ctx, s.client.unlockMethod, s.client.unlockAddress, bytes.NewReader(unlockData)) + if err != nil { + resp.Diagnostics.AddError( + "Error creating HTTP request", + fmt.Sprintf("Error creating %s request to %s: %s", s.client.unlockMethod, s.client.unlockAddress, err), + ) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + + // Set basic auth if configured + if s.client.username != "" && s.client.password != "" { + httpReq.SetBasicAuth(s.client.username, s.client.password) + } + + httpResp, err := s.client.client.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError( + "Error unlocking state", + fmt.Sprintf("Error making %s request to %s: %s", s.client.unlockMethod, s.client.unlockAddress, err), + ) + return + } + defer httpResp.Body.Close() + + // 200 OK means unlock successful + if httpResp.StatusCode == http.StatusOK { + tflog.Debug(ctx, "State unlocked successfully") + s.client.lockID = "" + s.client.lockData = nil + return + } + + // Any other status code is an error + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "Unexpected HTTP status code", + fmt.Sprintf("Expected status 200, got %d from %s: %s", httpResp.StatusCode, s.client.unlockAddress, string(body)), + ) +} + +var multipleWorkspacesNotSupportedDiag = diag.NewErrorDiagnostic( + "Multiple workspaces not supported", + "The http state store does not support multiple workspaces, use the \"default\" workspace", +) + +func withQueryParam(baseAddress, key, value string) (string, error) { + u, err := url.Parse(baseAddress) + if err != nil { + return "", err + } + + query := u.Query() + query.Set(key, value) + u.RawQuery = query.Encode() + + return u.String(), nil +} diff --git a/internal/provider/state_store_http_test.go b/internal/provider/state_store_http_test.go new file mode 100644 index 00000000..62c65787 --- /dev/null +++ b/internal/provider/state_store_http_test.go @@ -0,0 +1,1762 @@ +// Copyright IBM Corp. 2017, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "regexp" + "sync" + "testing" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform-plugin-framework/statestore" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestHTTPStateStore(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write(storedState) + if err != nil { + return + } + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: testAccStateStoreConfig(server.URL, "", "", ""), + }, + }, + }) +} + +func TestHTTPStateStore_WithLocking(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write(storedState) + if err != nil { + return + } + + case "POST": + if currentLock != nil && r.URL.Query().Get("ID") != currentLock.ID { + w.WriteHeader(http.StatusConflict) + return + } + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + + case "LOCK": + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + err := json.NewEncoder(w).Encode(currentLock) + if err != nil { + return + } + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &lockInfo) + if err != nil { + return + } + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + + case "UNLOCK": + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = nil + w.WriteHeader(http.StatusOK) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: testAccStateStoreConfig(server.URL, server.URL, "", ""), + }, + }, + }) +} + +func TestHTTPStateStore_WithBasicAuth(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var mu sync.Mutex + expectedUser := "testuser" + expectedPass := "testpass" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + // Check basic auth + user, pass, ok := r.BasicAuth() + if !ok || user != expectedUser || pass != expectedPass { + w.WriteHeader(http.StatusUnauthorized) + return + } + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write(storedState) + if err != nil { + return + } + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: testAccStateStoreConfig(server.URL, "", expectedUser, expectedPass), + }, + }, + }) +} + +func TestHTTPStateStore_WithEnvironmentConfiguration(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + expectedUser := "envuser" + expectedPass := "envpass" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + user, pass, ok := r.BasicAuth() + if !ok || user != expectedUser || pass != expectedPass { + w.WriteHeader(http.StatusUnauthorized) + return + } + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "PUT": + if currentLock != nil && r.URL.Query().Get("ID") != currentLock.ID { + w.WriteHeader(http.StatusConflict) + return + } + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + case "POST": + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(currentLock) + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &lockInfo) + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + case "PATCH": + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + t.Setenv("TF_HTTP_ADDRESS", server.URL) + t.Setenv("TF_HTTP_UPDATE_METHOD", "PUT") + t.Setenv("TF_HTTP_LOCK_ADDRESS", server.URL) + t.Setenv("TF_HTTP_UNLOCK_ADDRESS", server.URL) + t.Setenv("TF_HTTP_LOCK_METHOD", "POST") + t.Setenv("TF_HTTP_UNLOCK_METHOD", "PATCH") + t.Setenv("TF_HTTP_USERNAME", expectedUser) + t.Setenv("TF_HTTP_PASSWORD", expectedPass) + t.Setenv("TF_HTTP_RETRY_MAX", "2") + t.Setenv("TF_HTTP_RETRY_WAIT_MIN", "0") + t.Setenv("TF_HTTP_RETRY_WAIT_MAX", "0") + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: ` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + } +}`, + }, + }, + }) +} + +func TestHTTPStateStore_ConfigOverridesEnvironment(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + t.Setenv("TF_HTTP_UPDATE_METHOD", "PUT") + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: testAccStateStoreConfigWithUpdateMethod(server.URL, "POST"), + }, + }, + }) +} + +func TestHTTPStateStore_InvalidRetryEnvironment(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + t.Setenv("TF_HTTP_RETRY_MAX", "invalid") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: testAccStateStoreConfig(server.URL, "", "", ""), + ExpectError: regexp.MustCompile(`invalid retry_max`), + }, + }, + }) +} + +func TestHTTPStateStore_CustomUpdateMethod(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write(storedState) + if err != nil { + return + } + case "PUT": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + // Configured to use PUT method instead of POST + Config: testAccStateStoreConfigWithUpdateMethod(server.URL, "PUT"), + }, + }, + }) +} + +func TestHTTPStateStore_WriteNoContent(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusNoContent) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: testAccStateStoreConfig(server.URL, "", "", ""), + }, + }, + }) +} + +func TestHTTPStateStore_SkipCertVerification(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + certPath, keyPath := generateCert(t) + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + t.Fatalf("failed to load generated server cert/key: %v", err) + } + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + server.StartTLS() + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + skip_cert_verification = true + } +}`, + server.URL, + ), + }, + }, + }) +} + +func TestHTTPStateStore_NoSkipCertVerificationFails(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + certPath, keyPath := generateCert(t) + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + t.Fatalf("failed to load generated server cert/key: %v", err) + } + var storedState []byte + var mu sync.Mutex + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + server.StartTLS() + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + } +}`, + server.URL, + ), + ExpectError: regexp.MustCompile(`(?i)x509:.*(unknown authority|certificate)`), + }, + }, + }) +} + +func TestHTTPStateStore_ClientCertificateAuth(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + certPath, keyPath := generateCert(t) + + caData, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("failed to read generated CA cert: %v", err) + } + + clientCertData, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("failed to read generated client cert: %v", err) + } + + clientKeyData, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("failed to read generated client key: %v", err) + } + + serverCert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + t.Fatalf("failed to load generated server cert/key: %v", err) + } + + clientCAPool := x509.NewCertPool() + if ok := clientCAPool.AppendCertsFromPEM(caData); !ok { + t.Fatal("failed to append CA cert to client CA pool") + } + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + w.WriteHeader(http.StatusUnauthorized) + return + } + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + server.TLS = &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAPool, + } + server.StartTLS() + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + client_ca_certificate_pem = %q + client_certificate_pem = %q + client_private_key_pem = %q + } +}`, + server.URL, + string(caData), + string(clientCertData), + string(clientKeyData), + ), + }, + }, + }) +} + +func TestHTTPStateStore_NoClientCertificateFails(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + certPath, keyPath := generateCert(t) + + caData, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("failed to read generated CA cert: %v", err) + } + + serverCert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + t.Fatalf("failed to load generated server cert/key: %v", err) + } + + clientCAPool := x509.NewCertPool() + if ok := clientCAPool.AppendCertsFromPEM(caData); !ok { + t.Fatal("failed to append CA cert to client CA pool") + } + + var requestCount int + var mu sync.Mutex + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestCount++ + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + server.TLS = &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAPool, + } + server.StartTLS() + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + skip_cert_verification = true + } +}`, + server.URL, + ), + ExpectError: regexp.MustCompile(`(?i)(tls: certificate required|certificate required|handshake failure)`), + }, + }, + }) + + if requestCount != 0 { + t.Fatalf("expected TLS handshake to fail before any handler invocation, got %d requests", requestCount) + } +} + +func TestHTTPStateStore_NoLockSupport(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write(storedState) + if err != nil { + return + } + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + // Locking isn't configured, so this will cause a test assertion failure + VerifyStateStoreLock: true, + Config: testAccStateStoreConfig(server.URL, "", "", ""), + ExpectError: regexp.MustCompile(`Failed client lock assertion`), + }, + }, + }) +} + +func TestHTTPStateStore_InvalidUnlock(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write(storedState) + if err != nil { + return + } + + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + + case "LOCK": + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + err := json.NewEncoder(w).Encode(currentLock) + if err != nil { + return + } + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &lockInfo) + if err != nil { + return + } + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + + case "UNLOCK": + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + // This simulates a broken unlock implementation, since it doesn't clear currentLock + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: testAccStateStoreConfig(server.URL, server.URL, "", ""), + ExpectError: regexp.MustCompile(`(?s)(Workspace is currently locked|Error creating test resource)`), + }, + }, + }) +} + +func TestHTTPStateStore_CustomLockAndUnlockAddress(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + // State server + stateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer stateServer.Close() + + // Lock server + lockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if r.Method == "LOCK" { + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(currentLock) + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &lockInfo) + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + } + })) + defer lockServer.Close() + + // Unlock server + unlockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if r.Method == "UNLOCK" { + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = nil + w.WriteHeader(http.StatusOK) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer unlockServer.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: testAccStateStoreConfigWithLockAndUnlockAddress(stateServer.URL, lockServer.URL, unlockServer.URL), + }, + }, + }) +} + +func TestHTTPStateStore_LockUnlockAddressWithExistingQueryParams(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + const token = "abc123" + + stateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer stateServer.Close() + + lockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if r.URL.Query().Get("token") != token { + w.WriteHeader(http.StatusBadRequest) + return + } + + if r.Method != "LOCK" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(currentLock) + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &lockInfo) + if lockInfo.ID == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + if r.URL.Query().Get("ID") != "" { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + })) + defer lockServer.Close() + + unlockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if r.URL.Query().Get("token") != token { + w.WriteHeader(http.StatusBadRequest) + return + } + + if r.Method != "UNLOCK" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = nil + w.WriteHeader(http.StatusOK) + })) + defer unlockServer.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: testAccStateStoreConfigWithLockAndUnlockAddress( + stateServer.URL, + lockServer.URL+"?token="+token, + unlockServer.URL+"?token="+token, + ), + }, + }, + }) +} + +func TestHTTPStateStore_CustomLockMethod(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + + case "PUT": + // Custom lock method + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(currentLock) + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &lockInfo) + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + + case "UNLOCK": + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = nil + w.WriteHeader(http.StatusOK) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: testAccStateStoreConfigWithCustomMethods(server.URL, server.URL, "PUT", "UNLOCK"), + }, + }, + }) +} + +func TestHTTPStateStore_RetryConfiguration(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var mu sync.Mutex + var requestCount int + var postAttempts int + var failedPostAttempts int + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestCount++ + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + postAttempts++ + if postAttempts < 3 { + failedPostAttempts++ + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("temporary error")) + return + } + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: testAccStateStoreConfigWithRetry(server.URL, 2, 0, 0), + }, + }, + }) + + // Verify that retryable writes were attempted and eventually succeeded. + if requestCount == 0 { + t.Fatalf("expected at least 1 request, got %d", requestCount) + } + + if failedPostAttempts != 2 { + t.Fatalf("expected exactly 2 transient POST failures to trigger retries, got %d", failedPostAttempts) + } + + if postAttempts < 3 { + t.Fatalf("expected at least 3 POST attempts (initial + retries), got %d", postAttempts) + } + + if storedState == nil { + t.Fatal("expected state to be stored after retries succeeded") + } +} + +func TestHTTPStateStore_WebDAVPutCreatedThenNoContent(t *testing.T) { + var storedState []byte + var mu sync.Mutex + var putAttempts int + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "PUT": + body, _ := io.ReadAll(r.Body) + putAttempts++ + if string(storedState) == string(body) { + storedState = body + w.WriteHeader(http.StatusNoContent) + return + } + + storedState = body + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + store := &httpStateStore{ + client: &httpStateStoreClient{ + address: server.URL, + updateMethod: "PUT", + client: retryablehttp.NewClient(), + }, + } + + ctx := context.Background() + stateBytes := []byte(`{"version":4}`) + + firstResp := statestore.WriteResponse{} + store.Write(ctx, statestore.WriteRequest{StateID: defaultWorkspaceName, StateBytes: stateBytes}, &firstResp) + if firstResp.Diagnostics.HasError() { + t.Fatalf("expected first PUT write to succeed with 201, got diagnostics: %v", firstResp.Diagnostics) + } + + secondResp := statestore.WriteResponse{} + store.Write(ctx, statestore.WriteRequest{StateID: defaultWorkspaceName, StateBytes: stateBytes}, &secondResp) + if secondResp.Diagnostics.HasError() { + t.Fatalf("expected second PUT write to succeed with 204, got diagnostics: %v", secondResp.Diagnostics) + } + + if putAttempts != 2 { + t.Fatalf("expected exactly 2 PUT attempts, got %d", putAttempts) + } + + if string(storedState) != string(stateBytes) { + t.Fatalf("expected stored state %q, got %q", string(stateBytes), string(storedState)) + } +} + +func TestHTTPStateStore_SkipCertVerificationWithCACertificate(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + certPath, _ := generateCert(t) + + caData, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("failed to read generated CA cert: %v", err) + } + + var storedState []byte + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + case "POST": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + // Terraform Core backend allows skip_cert_verification alongside client_ca_certificate_pem. + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + skip_cert_verification = true + client_ca_certificate_pem = %q + } +}`, + server.URL, + string(caData), + ), + }, + }, + }) +} + +func TestHTTPStateStore_ClientCertificateWithoutKeyFails(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + client_certificate_pem = "dummy" + } +}`, + server.URL, + ), + ExpectError: regexp.MustCompile(`client_certificate_pem is set but client_private_key_pem is not`), + }, + }, + }) +} + +func TestHTTPStateStore_ClientKeyWithoutCertificateFails(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + Config: fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + client_private_key_pem = "dummy" + } +}`, + server.URL, + ), + ExpectError: regexp.MustCompile(`client_private_key_pem is set but client_certificate_pem is not`), + }, + }, + }) +} + +// Helper functions for test configs + +func testAccStateStoreConfig(address, lockAddress, username, password string) string { + additionalAttrs := "" + + if lockAddress != "" { + additionalAttrs += fmt.Sprintf("\nlock_address = %q", lockAddress) + } + + if username != "" && password != "" { + additionalAttrs += fmt.Sprintf("\nusername = %q", username) + additionalAttrs += fmt.Sprintf("\npassword = %q", password) + } + + config := fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + %s + } +}`, address, additionalAttrs) + + return config +} + +func testAccStateStoreConfigWithUpdateMethod(address, updateMethod string) string { + return fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + update_method = %q + } +}`, address, updateMethod) +} + +func testAccStateStoreConfigWithLockAndUnlockAddress(stateAddr, lockAddr, unlockAddr string) string { + return fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + lock_address = %q + unlock_address = %q + } +}`, stateAddr, lockAddr, unlockAddr) +} + +func testAccStateStoreConfigWithCustomMethods(stateAddr, lockAddr, lockMethod, unlockMethod string) string { + return fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + lock_address = %q + lock_method = %q + unlock_method = %q + } +}`, stateAddr, lockAddr, lockMethod, unlockMethod) +} + +func testAccStateStoreConfigWithRetry(address string, maxRetry, waitMin, waitMax int64) string { + return fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + retry_max = %d + retry_wait_min = %d + retry_wait_max = %d + } +}`, address, maxRetry, waitMin, waitMax) +} + +func TestHTTPStateStore_ComprehensiveConfiguration(t *testing.T) { + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + var storedState []byte + var currentLock *statestore.LockInfo + var mu sync.Mutex + + expectedUser := "testuser" + expectedPass := "testpass" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + // Check basic auth + user, pass, ok := r.BasicAuth() + if !ok || user != expectedUser || pass != expectedPass { + w.WriteHeader(http.StatusUnauthorized) + return + } + + switch r.Method { + case "GET": + if storedState == nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(storedState) + + case "PUT": + body, _ := io.ReadAll(r.Body) + storedState = body + w.WriteHeader(http.StatusOK) + + case "DELETE": + storedState = nil + w.WriteHeader(http.StatusOK) + + case "POST": + // Custom lock method + if currentLock != nil { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(currentLock) + return + } + + var lockInfo statestore.LockInfo + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &lockInfo) + currentLock = &lockInfo + w.WriteHeader(http.StatusOK) + + case "PATCH": + lockID := lockIDFromRequest(r) + if currentLock == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if currentLock.ID != lockID { + w.WriteHeader(http.StatusConflict) + return + } + currentLock = nil + w.WriteHeader(http.StatusOK) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + StateStore: true, + DefaultWorkspaceOnly: true, + VerifyStateStoreLock: true, + Config: testAccStateStoreConfigComprehensive(server.URL, expectedUser, expectedPass), + }, + }, + }) +} + +func testAccStateStoreConfigComprehensive(address, username, password string) string { + return fmt.Sprintf(` +terraform { + required_providers { + http = { + source = "registry.terraform.io/hashicorp/http" + } + } + state_store "http" { + provider "http" {} + address = %q + update_method = "PUT" + lock_address = %q + lock_method = "POST" + unlock_method = "PATCH" + username = %q + password = %q + retry_max = 2 + retry_wait_min = 1 + retry_wait_max = 10 + } +}`, address, address, username, password) +} + +func lockIDFromRequest(r *http.Request) string { + if lockID := r.URL.Query().Get("ID"); lockID != "" { + return lockID + } + + body, _ := io.ReadAll(r.Body) + if len(body) == 0 { + return "" + } + + var lockInfo statestore.LockInfo + if err := json.Unmarshal(body, &lockInfo); err != nil { + return "" + } + + return lockInfo.ID +} diff --git a/main.go b/main.go index 1f4ef85a..a1d319c8 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ func main() { err := providerserver.Serve(context.Background(), provider.New, providerserver.ServeOpts{ Address: "registry.terraform.io/hashicorp/http", Debug: debug, - ProtocolVersion: 5, + ProtocolVersion: 6, }) if err != nil { log.Fatal(err)