diff --git a/go.mod b/go.mod index 646309e2a..be6b904fe 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ require ( cloud.google.com/go/storage v1.10.0 github.com/Azure/azure-sdk-for-go v52.5.0+incompatible github.com/Azure/go-autorest/autorest v0.11.18 + github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25 github.com/agext/levenshtein v1.2.3 github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190329064014-6e358769c32a github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190103054945-8205d1f41e70 @@ -22,7 +23,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/dylanmei/winrmtest v0.0.0-20190225150635-99b7fe2fddf1 github.com/go-test/deep v1.0.3 - github.com/golang/mock v1.5.0 + github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 github.com/google/go-cmp v0.5.5 github.com/google/uuid v1.2.0 @@ -39,9 +40,9 @@ require ( github.com/hashicorp/go-hclog v0.15.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 - github.com/hashicorp/go-retryablehttp v0.5.2 - github.com/hashicorp/go-tfe v0.15.0 - github.com/hashicorp/go-uuid v1.0.1 + github.com/hashicorp/go-retryablehttp v0.7.0 + github.com/hashicorp/go-tfe v0.19.1-0.20211020175229-e52963e079d0 + github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f github.com/hashicorp/hcl/v2 v2.10.1 @@ -143,9 +144,9 @@ require ( github.com/hashicorp/go-msgpack v0.5.4 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect - github.com/hashicorp/go-slug v0.4.1 // indirect + github.com/hashicorp/go-slug v0.7.0 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect - github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3 // indirect + github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect github.com/hashicorp/serf v0.9.5 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect github.com/huandu/xstrings v1.3.2 // indirect @@ -154,6 +155,7 @@ require ( github.com/jstemmer/go-junit-report v0.9.1 // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/klauspost/compress v1.11.2 // indirect + github.com/kr/pty v1.1.1 // indirect github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect github.com/mattn/go-colorable v0.1.6 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect diff --git a/go.sum b/go.sum index bd8b487fb..da6b2940b 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25 h1:hWfsqBaNZUHztXA78g7Y2Jj3rDQaTCZhhFwz43i2VlA= +github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25/go.mod h1:68ORG0HSEWDuH5Eh73AFbYWZ1zT4Y+b0vhOa+vZRUdI= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= @@ -241,8 +243,9 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -349,6 +352,7 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.5.2 h1:XDo8LiAcDisiqZdv0TKgz+HtX3WN7zA2JD1R1tjsabE= github.com/hashicorp/go-getter v1.5.2/go.mod h1:orNH3BTYLu/fIxGIdLjLoAJHWMDQ/UKQr5O4m3iBuoo= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.15.0 h1:qMuK0wxsoW4D0ddCCYwPSTm4KQv1X1ke3WmPWZ0Mvsk= @@ -364,22 +368,23 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM= github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= -github.com/hashicorp/go-retryablehttp v0.5.2 h1:AoISa4P4IsW0/m4T6St8Yw38gTl5GtBAgfkhYh1xAz4= -github.com/hashicorp/go-retryablehttp v0.5.2/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= +github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-slug v0.4.1 h1:/jAo8dNuLgSImoLXaX7Od7QB4TfYCVPam+OpAt5bZqc= -github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= +github.com/hashicorp/go-slug v0.7.0 h1:8HIi6oreWPtnhpYd8lIGQBgp4rXzDWQTOhfILZm+nok= +github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.15.0 h1:vdnz1NjOhvmap+cj8iPsL8SbS4iFFVuNYFkGpF5SdoA= -github.com/hashicorp/go-tfe v0.15.0/go.mod h1:c8glB5p6XzocEWLNkuy5RxcjqN5X2PpY6NF3f2W6nIo= +github.com/hashicorp/go-tfe v0.19.1-0.20211020175229-e52963e079d0 h1:64o1fy8p4a9J/5Gooy1S9iuEJQqXTVGYk5ZrXwFDZ8w= +github.com/hashicorp/go-tfe v0.19.1-0.20211020175229-e52963e079d0/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -393,8 +398,8 @@ github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= github.com/hashicorp/hcl/v2 v2.10.1 h1:h4Xx4fsrRE26ohAk/1iGF/JBqRQbyUqu5Lvj60U54ys= github.com/hashicorp/hcl/v2 v2.10.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= -github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3 h1:mzwkutymYIXR5oQT9YnfbLuuw7LZmksiHKRPUTN5ijo= -github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/memberlist v0.2.2 h1:5+RffWKwqJ71YPu9mWsF7ZOscZmwfasdA8kbdC7AO2g= @@ -452,6 +457,7 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= 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= @@ -610,8 +616,9 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.194/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.232 h1:kwsWbh4rEw42ZDe9/812ebhbwNZxlQyZ2sTmxBOKhN4= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.232/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= @@ -932,6 +939,7 @@ golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= diff --git a/internal/backend/init/init.go b/internal/backend/init/init.go index 5abc8754d..30a1ccfdd 100644 --- a/internal/backend/init/init.go +++ b/internal/backend/init/init.go @@ -27,6 +27,7 @@ import ( backendPg "github.com/hashicorp/terraform/internal/backend/remote-state/pg" backendS3 "github.com/hashicorp/terraform/internal/backend/remote-state/s3" backendSwift "github.com/hashicorp/terraform/internal/backend/remote-state/swift" + backendCloud "github.com/hashicorp/terraform/internal/cloud" ) // backends is the list of available backends. This is a global variable @@ -49,7 +50,6 @@ func Init(services *disco.Disco) { defer backendsLock.Unlock() backends = map[string]backend.InitFn{ - // Enhanced backends. "local": func() backend.Backend { return backendLocal.New() }, "remote": func() backend.Backend { return backendRemote.New(services) }, @@ -70,6 +70,10 @@ func Init(services *disco.Disco) { "s3": func() backend.Backend { return backendS3.New() }, "swift": func() backend.Backend { return backendSwift.New() }, + // Terraform Cloud 'backend' + // This is an implementation detail only, used for the cloud package + "cloud": func() backend.Backend { return backendCloud.New(services) }, + // Deprecated backends. "azure": func() backend.Backend { return deprecateBackend( diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index e15fe0bb3..1f0d319bf 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -14,6 +14,7 @@ import ( version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -308,11 +309,11 @@ func TestRemote_applyWithoutRefresh(t *testing.T) { // We should find a run inside the mock client that has refresh set // to false. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(false, run.Refresh); diff != "" { t.Errorf("wrong Refresh setting in the created run\n%s", diff) } @@ -377,11 +378,11 @@ func TestRemote_applyWithRefreshOnly(t *testing.T) { // We should find a run inside the mock client that has refresh-only set // to true. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) } @@ -448,11 +449,11 @@ func TestRemote_applyWithTarget(t *testing.T) { // We should find a run inside the mock client that has the same // target address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } @@ -523,11 +524,11 @@ func TestRemote_applyWithReplace(t *testing.T) { // We should find a run inside the mock client that has the same // refresh address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) } @@ -776,9 +777,7 @@ func TestRemote_applyApprovedExternally(t *testing.T) { wl, err := b.client.Workspaces.List( ctx, b.organization, - tfe.WorkspaceListOptions{ - ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, - }, + tfe.WorkspaceListOptions{}, ) if err != nil { t.Fatalf("unexpected error listing workspaces: %v", err) @@ -852,9 +851,7 @@ func TestRemote_applyDiscardedExternally(t *testing.T) { wl, err := b.client.Workspaces.List( ctx, b.organization, - tfe.WorkspaceListOptions{ - ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, - }, + tfe.WorkspaceListOptions{}, ) if err != nil { t.Fatalf("unexpected error listing workspaces: %v", err) diff --git a/internal/backend/remote/backend_plan_test.go b/internal/backend/remote/backend_plan_test.go index 147c68e9d..95aacea97 100644 --- a/internal/backend/remote/backend_plan_test.go +++ b/internal/backend/remote/backend_plan_test.go @@ -13,6 +13,7 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -313,11 +314,11 @@ func TestRemote_planWithoutRefresh(t *testing.T) { // We should find a run inside the mock client that has refresh set // to false. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(false, run.Refresh); diff != "" { t.Errorf("wrong Refresh setting in the created run\n%s", diff) } @@ -382,11 +383,11 @@ func TestRemote_planWithRefreshOnly(t *testing.T) { // We should find a run inside the mock client that has refresh-only set // to true. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) } @@ -432,7 +433,7 @@ func TestRemote_planWithTarget(t *testing.T) { // When the backend code creates a new run, we'll tweak it so that it // has a cost estimation object with the "skipped_due_to_targeting" status, // emulating how a real server is expected to behave in that case. - b.client.Runs.(*mockRuns).modifyNewRun = func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) { + b.client.Runs.(*cloud.MockRuns).ModifyNewRun = func(client *cloud.MockClient, options tfe.RunCreateOptions, run *tfe.Run) { const fakeID = "fake" // This is the cost estimate object embedded in the run itself which // the backend will use to learn the ID to request from the cost @@ -446,7 +447,7 @@ func TestRemote_planWithTarget(t *testing.T) { // the same ID indicated in the object above, where we'll then return // the status "skipped_due_to_targeting" to trigger the special skip // message in the backend output. - client.CostEstimates.estimations[fakeID] = &tfe.CostEstimate{ + client.CostEstimates.Estimations[fakeID] = &tfe.CostEstimate{ ID: fakeID, Status: "skipped_due_to_targeting", } @@ -483,11 +484,11 @@ func TestRemote_planWithTarget(t *testing.T) { // We should find a run inside the mock client that has the same // target address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } @@ -558,11 +559,11 @@ func TestRemote_planWithReplace(t *testing.T) { // We should find a run inside the mock client that has the same // refresh address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) } diff --git a/internal/backend/remote/backend_state_test.go b/internal/backend/remote/backend_state_test.go index 3a1769c30..0503936b8 100644 --- a/internal/backend/remote/backend_state_test.go +++ b/internal/backend/remote/backend_state_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/remote" "github.com/hashicorp/terraform/internal/states/statefile" @@ -39,7 +40,7 @@ func TestRemoteClient_stateLock(t *testing.T) { func TestRemoteClient_withRunID(t *testing.T) { // Set the TFE_RUN_ID environment variable before creating the client! - if err := os.Setenv("TFE_RUN_ID", generateID("run-")); err != nil { + if err := os.Setenv("TFE_RUN_ID", cloud.GenerateID("run-")); err != nil { t.Fatalf("error setting env var TFE_RUN_ID: %v", err) } diff --git a/internal/backend/remote/testing.go b/internal/backend/remote/testing.go index f3c66941a..7dbb9e9b2 100644 --- a/internal/backend/remote/testing.go +++ b/internal/backend/remote/testing.go @@ -8,12 +8,14 @@ import ( "net/http/httptest" "path" "testing" + "time" tfe "github.com/hashicorp/go-tfe" svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/auth" "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/httpclient" @@ -39,6 +41,26 @@ var ( }) ) +// mockInput is a mock implementation of terraform.UIInput. +type mockInput struct { + answers map[string]string +} + +func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { + v, ok := m.answers[opts.Id] + if !ok { + return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) + } + if v == "wait-for-external-update" { + select { + case <-ctx.Done(): + case <-time.After(time.Minute): + } + } + delete(m.answers, opts.Id) + return v, nil +} + func testInput(t *testing.T, answers map[string]string) *mockInput { return &mockInput{answers: answers} } @@ -111,7 +133,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) { } // Get a new mock client. - mc := newMockClient() + mc := cloud.NewMockClient() // Replace the services we use with our mock services. b.CLI = cli.NewMockUi() diff --git a/internal/backend/unparsed_value.go b/internal/backend/unparsed_value.go index abd16ef9e..91c982582 100644 --- a/internal/backend/unparsed_value.go +++ b/internal/backend/unparsed_value.go @@ -25,6 +25,121 @@ type UnparsedVariableValue interface { ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) } +// ParseUndeclaredVariableValues processes a map of unparsed variable values +// and returns an input values map of the ones not declared in the specified +// declaration map along with detailed diagnostics about values of undeclared +// variables being present, depending on the source of these values. If more +// than two undeclared values are present in file form (config, auto, -var-file) +// the remaining errors are summarized to avoid a massive list of errors. +func ParseUndeclaredVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := make(terraform.InputValues, len(vv)) + seenUndeclaredInFile := 0 + + for name, rv := range vv { + if _, declared := decls[name]; declared { + // Only interested in parsing undeclared variables + continue + } + + val, valDiags := rv.ParseVariableValue(configs.VariableParseLiteral) + if valDiags.HasErrors() { + continue + } + + ret[name] = val + + switch val.SourceType { + case terraform.ValueFromConfig, terraform.ValueFromAutoFile, terraform.ValueFromNamedFile: + // We allow undeclared names for variable values from files and warn in case + // users have forgotten a variable {} declaration or have a typo in their var name. + // Some users will actively ignore this warning because they use a .tfvars file + // across multiple configurations. + if seenUndeclaredInFile < 2 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Value for undeclared variable", + fmt.Sprintf("The root module does not declare a variable named %q but a value was found in file %q. If you meant to use this value, add a \"variable\" block to the configuration.\n\nTo silence these warnings, use TF_VAR_... environment variables to provide certain \"global\" settings to all configurations in your organization. To reduce the verbosity of these warnings, use the -compact-warnings option.", name, val.SourceRange.Filename), + )) + } + seenUndeclaredInFile++ + + case terraform.ValueFromEnvVar: + // We allow and ignore undeclared names for environment + // variables, because users will often set these globally + // when they are used across many (but not necessarily all) + // configurations. + case terraform.ValueFromCLIArg: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Value for undeclared variable", + fmt.Sprintf("A variable named %q was assigned on the command line, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + )) + default: + // For all other source types we are more vague, but other situations + // don't generally crop up at this layer in practice. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Value for undeclared variable", + fmt.Sprintf("A variable named %q was assigned a value, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + )) + } + } + + if seenUndeclaredInFile > 2 { + extras := seenUndeclaredInFile - 2 + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Values for undeclared variables", + Detail: fmt.Sprintf("In addition to the other similar warnings shown, %d other variable(s) defined without being declared.", extras), + }) + } + + return ret, diags +} + +// ParseDeclaredVariableValues processes a map of unparsed variable values +// and returns an input values map of the ones declared in the specified +// variable declaration mapping. Diagnostics will be populating with +// any variable parsing errors encountered within this collection. +func ParseDeclaredVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := make(terraform.InputValues, len(vv)) + + for name, rv := range vv { + var mode configs.VariableParsingMode + config, declared := decls[name] + + if declared { + mode = config.ParsingMode + } else { + // Only interested in parsing declared variables + continue + } + + val, valDiags := rv.ParseVariableValue(mode) + diags = diags.Append(valDiags) + if valDiags.HasErrors() { + continue + } + + ret[name] = val + } + + return ret, diags +} + +// Checks all given terraform.InputValues variable maps for the existance of +// a named variable +func isDefinedAny(name string, maps ...terraform.InputValues) bool { + for _, m := range maps { + if _, defined := m[name]; defined { + return true + } + } + return false +} + // ParseVariableValues processes a map of unparsed variable values by // correlating each one with the given variable declarations which should // be from a root module. @@ -42,87 +157,17 @@ type UnparsedVariableValue interface { // that were successfully processed, allowing for careful analysis of the // partial result. func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - ret := make(terraform.InputValues, len(vv)) + ret, diags := ParseDeclaredVariableValues(vv, decls) + undeclared, diagsUndeclared := ParseUndeclaredVariableValues(vv, decls) - // Currently we're generating only warnings for undeclared variables - // defined in files (see below) but we only want to generate a few warnings - // at a time because existing deployments may have lots of these and - // the result can therefore be overwhelming. - seenUndeclaredInFile := 0 - - for name, rv := range vv { - var mode configs.VariableParsingMode - config, declared := decls[name] - if declared { - mode = config.ParsingMode - } else { - mode = configs.VariableParseLiteral - } - - val, valDiags := rv.ParseVariableValue(mode) - diags = diags.Append(valDiags) - if valDiags.HasErrors() { - continue - } - - if !declared { - switch val.SourceType { - case terraform.ValueFromConfig, terraform.ValueFromAutoFile, terraform.ValueFromNamedFile: - // We allow undeclared names for variable values from files and warn in case - // users have forgotten a variable {} declaration or have a typo in their var name. - // Some users will actively ignore this warning because they use a .tfvars file - // across multiple configurations. - if seenUndeclaredInFile < 2 { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Value for undeclared variable", - fmt.Sprintf("The root module does not declare a variable named %q but a value was found in file %q. If you meant to use this value, add a \"variable\" block to the configuration.\n\nTo silence these warnings, use TF_VAR_... environment variables to provide certain \"global\" settings to all configurations in your organization. To reduce the verbosity of these warnings, use the -compact-warnings option.", name, val.SourceRange.Filename), - )) - } - seenUndeclaredInFile++ - - case terraform.ValueFromEnvVar: - // We allow and ignore undeclared names for environment - // variables, because users will often set these globally - // when they are used across many (but not necessarily all) - // configurations. - case terraform.ValueFromCLIArg: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Value for undeclared variable", - fmt.Sprintf("A variable named %q was assigned on the command line, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), - )) - default: - // For all other source types we are more vague, but other situations - // don't generally crop up at this layer in practice. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Value for undeclared variable", - fmt.Sprintf("A variable named %q was assigned a value, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), - )) - } - continue - } - - ret[name] = val - } - - if seenUndeclaredInFile > 2 { - extras := seenUndeclaredInFile - 2 - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Values for undeclared variables", - Detail: fmt.Sprintf("In addition to the other similar warnings shown, %d other variable(s) defined without being declared.", extras), - }) - } + diags = diags.Append(diagsUndeclared) // By this point we should've gathered all of the required root module // variables from one of the many possible sources. We'll now populate // any we haven't gathered as their defaults and fail if any of the // missing ones are required. for name, vc := range decls { - if _, defined := ret[name]; defined { + if isDefinedAny(name, ret, undeclared) { continue } diff --git a/internal/backend/unparsed_value_test.go b/internal/backend/unparsed_value_test.go index 6df7c226a..981c84a43 100644 --- a/internal/backend/unparsed_value_test.go +++ b/internal/backend/unparsed_value_test.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) -func TestParseVariableValuesUndeclared(t *testing.T) { +func TestUnparsedValue(t *testing.T) { vv := map[string]UnparsedVariableValue{ "undeclared0": testUnparsedVariableValue("0"), "undeclared1": testUnparsedVariableValue("1"), @@ -59,66 +59,164 @@ func TestParseVariableValuesUndeclared(t *testing.T) { }, } - gotVals, diags := ParseVariableValues(vv, decls) - for _, diag := range diags { - t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) - } - if got, want := len(diags), 4; got != want { - t.Fatalf("wrong number of diagnostics %d; want %d", got, want) - } - const undeclSingular = `Value for undeclared variable` const undeclPlural = `Values for undeclared variables` - const missingRequired = `No value for required variable` - if got, want := diags[0].Description().Summary, undeclSingular; got != want { - t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[1].Description().Summary, undeclSingular; got != want { - t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[2].Description().Summary, undeclPlural; got != want { - t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[2].Description().Detail, "3 other variable(s)"; !strings.Contains(got, want) { - t.Errorf("wrong detail for diagnostic 2\ngot: %s\nmust contain: %s", got, want) - } - if got, want := diags[3].Description().Summary, missingRequired; got != want { - t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) - } + t.Run("ParseDeclaredVariableValues", func(t *testing.T) { + gotVals, diags := ParseDeclaredVariableValues(vv, decls) - wantVals := terraform.InputValues{ - "declared1": { - Value: cty.StringVal("5"), - SourceType: terraform.ValueFromNamedFile, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tfvars", - Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + if got, want := len(diags), 0; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, }, - }, - "missing1": { - Value: cty.DynamicVal, - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tf", - Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + } + + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + + t.Run("ParseUndeclaredVariableValues", func(t *testing.T) { + gotVals, diags := ParseUndeclaredVariableValues(vv, decls) + + if got, want := len(diags), 3; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + if got, want := diags[0].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[1].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[2].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) + } + + wantVals := terraform.InputValues{ + "undeclared0": { + Value: cty.StringVal("0"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, }, - }, - "missing2": { - Value: cty.StringVal("default for missing2"), - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tf", - Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + "undeclared1": { + Value: cty.StringVal("1"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, }, - }, - } - if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { - t.Errorf("wrong result\n%s", diff) - } + "undeclared2": { + Value: cty.StringVal("2"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + "undeclared3": { + Value: cty.StringVal("3"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + "undeclared4": { + Value: cty.StringVal("4"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + } + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + + t.Run("ParseVariableValues", func(t *testing.T) { + gotVals, diags := ParseVariableValues(vv, decls) + for _, diag := range diags { + t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) + } + if got, want := len(diags), 4; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + const missingRequired = `No value for required variable` + + if got, want := diags[0].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[1].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[2].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[2].Description().Detail, "3 other variable(s)"; !strings.Contains(got, want) { + t.Errorf("wrong detail for diagnostic 2\ngot: %s\nmust contain: %s", got, want) + } + if got, want := diags[3].Description().Summary, missingRequired; got != want { + t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, + "missing1": { + Value: cty.DynamicVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + }, + }, + "missing2": { + Value: cty.StringVal("default for missing2"), + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + }, + }, + } + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) } type testUnparsedVariableValue string diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go new file mode 100644 index 000000000..7ae10b8cb --- /dev/null +++ b/internal/cloud/backend.go @@ -0,0 +1,1029 @@ +package cloud + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" + + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + tfversion "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" + + backendLocal "github.com/hashicorp/terraform/internal/backend/local" +) + +const ( + defaultHostname = "app.terraform.io" + defaultParallelism = 10 + tfeServiceID = "tfe.v2" + headerSourceKey = "X-Terraform-Integration" + headerSourceValue = "cloud" +) + +// Cloud is an implementation of EnhancedBackend in service of the Terraform Cloud/Enterprise +// integration for Terraform CLI. This backend is not intended to be surfaced at the user level and +// is instead an implementation detail of cloud.Cloud. +type Cloud struct { + // CLI and Colorize control the CLI output. If CLI is nil then no CLI + // output will be done. If CLIColor is nil then no coloring will be done. + CLI cli.Ui + CLIColor *colorstring.Colorize + + // ContextOpts are the base context options to set when initializing a + // new Terraform context. Many of these will be overridden or merged by + // Operation. See Operation for more details. + ContextOpts *terraform.ContextOpts + + // client is the Terraform Cloud/Enterprise API client. + client *tfe.Client + + // lastRetry is set to the last time a request was retried. + lastRetry time.Time + + // hostname of Terraform Cloud or Terraform Enterprise + hostname string + + // organization is the organization that contains the target workspaces. + organization string + + // WorkspaceMapping contains strategies for mapping CLI workspaces in the working directory + // to remote Terraform Cloud workspaces. + WorkspaceMapping WorkspaceMapping + + // services is used for service discovery + services *disco.Disco + + // local allows local operations, where Terraform Cloud serves as a state storage backend. + local backend.Enhanced + + // forceLocal, if true, will force the use of the local backend. + forceLocal bool + + // opLock locks operations + opLock sync.Mutex + + // ignoreVersionConflict, if true, will disable the requirement that the + // local Terraform version matches the remote workspace's configured + // version. This will also cause VerifyWorkspaceTerraformVersion to return + // a warning diagnostic instead of an error. + ignoreVersionConflict bool + + runningInAutomation bool +} + +var _ backend.Backend = (*Cloud)(nil) +var _ backend.Enhanced = (*Cloud)(nil) +var _ backend.Local = (*Cloud)(nil) + +// New creates a new initialized cloud backend. +func New(services *disco.Disco) *Cloud { + return &Cloud{ + services: services, + } +} + +// ConfigSchema implements backend.Enhanced. +func (b *Cloud) ConfigSchema() *configschema.Block { + return &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "hostname": { + Type: cty.String, + Optional: true, + Description: schemaDescriptionHostname, + }, + "organization": { + Type: cty.String, + Required: true, + Description: schemaDescriptionOrganization, + }, + "token": { + Type: cty.String, + Optional: true, + Description: schemaDescriptionToken, + }, + }, + + BlockTypes: map[string]*configschema.NestedBlock{ + "workspaces": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + Description: schemaDescriptionName, + }, + "tags": { + Type: cty.Set(cty.String), + Optional: true, + Description: schemaDescriptionTags, + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + } +} + +// PrepareConfig implements backend.Backend. +func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + if obj.IsNull() { + return obj, diags + } + + if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" { + diags = diags.Append(invalidOrganizationConfigMissingValue) + } + + WorkspaceMapping := WorkspaceMapping{} + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + if val := workspaces.GetAttr("name"); !val.IsNull() { + WorkspaceMapping.Name = val.AsString() + } + if val := workspaces.GetAttr("tags"); !val.IsNull() { + err := gocty.FromCtyValue(val, &WorkspaceMapping.Tags) + if err != nil { + log.Panicf("An unxpected error occurred: %s", err) + } + } + } + + switch WorkspaceMapping.Strategy() { + // Make sure have a workspace mapping strategy present + case WorkspaceNoneStrategy: + diags = diags.Append(invalidWorkspaceConfigMissingValues) + // Make sure that a workspace name is configured. + case WorkspaceInvalidStrategy: + diags = diags.Append(invalidWorkspaceConfigMisconfiguration) + } + + return obj, diags +} + +// Configure implements backend.Enhanced. +func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + if obj.IsNull() { + return diags + } + + diagErr := b.setConfigurationFields(obj) + if diagErr.HasErrors() { + return diagErr + } + + // Discover the service URL to confirm that it provides the Terraform Cloud/Enterprise API + service, err := b.discover() + + // Check for errors before we continue. + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + strings.ToUpper(err.Error()[:1])+err.Error()[1:], + "", // no description is needed here, the error is clear + cty.Path{cty.GetAttrStep{Name: "hostname"}}, + )) + return diags + } + + // Retrieve the token for this host as configured in the credentials + // section of the CLI Config File. + token, err := b.token() + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + strings.ToUpper(err.Error()[:1])+err.Error()[1:], + "", // no description is needed here, the error is clear + cty.Path{cty.GetAttrStep{Name: "hostname"}}, + )) + return diags + } + + // Get the token from the config if no token was configured for this + // host in credentials section of the CLI Config File. + if token == "" { + if val := obj.GetAttr("token"); !val.IsNull() { + token = val.AsString() + } + } + + // Return an error if we still don't have a token at this point. + if token == "" { + loginCommand := "terraform login" + if b.hostname != defaultHostname { + loginCommand = loginCommand + " " + b.hostname + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required token could not be found", + fmt.Sprintf( + "Run the following command to generate a token for %s:\n %s", + b.hostname, + loginCommand, + ), + )) + return diags + } + + cfg := &tfe.Config{ + Address: service.String(), + BasePath: service.Path, + Token: token, + Headers: make(http.Header), + RetryLogHook: b.retryLogHook, + } + + // Set the version header to the current version. + cfg.Headers.Set(tfversion.Header, tfversion.Version) + cfg.Headers.Set(headerSourceKey, headerSourceValue) + + // Create the TFC/E API client. + b.client, err = tfe.NewClient(cfg) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to create the Terraform Cloud/Enterprise client", + fmt.Sprintf( + `Encountered an unexpected error while creating the `+ + `Terraform Cloud/Enterprise client: %s.`, err, + ), + )) + return diags + } + + // Check if the organization exists by reading its entitlements. + entitlements, err := b.client.Organizations.Entitlements(context.Background(), b.organization) + if err != nil { + if err == tfe.ErrResourceNotFound { + err = fmt.Errorf("organization %q at host %s not found.\n\n"+ + "Please ensure that the organization and hostname are correct "+ + "and that your API token for %s is valid.", + b.organization, b.hostname, b.hostname) + } + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + fmt.Sprintf("Failed to read organization %q at host %s", b.organization, b.hostname), + fmt.Sprintf("Encountered an unexpected error while reading the "+ + "organization settings: %s", err), + cty.Path{cty.GetAttrStep{Name: "organization"}}, + )) + return diags + } + + // Check for the minimum version of Terraform Enterprise required. + // + // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, + // so if there's an error when parsing the RemoteAPIVersion, it's handled as + // equivalent to an API version < 2.3. + currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) + desiredAPIVersion, _ := version.NewVersion("2.5") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + log.Printf("[TRACE] API version check failed; want: >= %s, got: %s", desiredAPIVersion.Original(), currentAPIVersion) + if b.runningInAutomation { + // It should never be possible for this Terraform process to be mistakenly + // used internally within an unsupported Terraform Enterprise install - but + // just in case it happens, give an actionable error. + diags = diags.Append( + tfdiags.Sourceless( + tfdiags.Error, + "Unsupported Terraform Enterprise version", + cloudIntegrationUsedInUnsupportedTFE, + ), + ) + } else { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported Terraform Enterprise version", + `The 'cloud' option is not supported with this version of Terraform Enterprise.`, + ), + ) + } + } + + // Configure a local backend for when we need to run operations locally. + b.local = backendLocal.NewWithBackend(b) + b.forceLocal = b.forceLocal || !entitlements.Operations + + // Enable retries for server errors as the backend is now fully configured. + b.client.RetryServerErrors(true) + + return diags +} + +func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // Get the hostname. + if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { + b.hostname = val.AsString() + } else { + b.hostname = defaultHostname + } + + // Get the organization. + if val := obj.GetAttr("organization"); !val.IsNull() { + b.organization = val.AsString() + } + + // Get the workspaces configuration block and retrieve the + // default workspace name. + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + + // PrepareConfig checks that you cannot set both of these. + if val := workspaces.GetAttr("name"); !val.IsNull() { + b.WorkspaceMapping.Name = val.AsString() + } + if val := workspaces.GetAttr("tags"); !val.IsNull() { + var tags []string + err := gocty.FromCtyValue(val, &tags) + if err != nil { + log.Panicf("An unxpected error occurred: %s", err) + } + + b.WorkspaceMapping.Tags = tags + } + } + + // Determine if we are forced to use the local backend. + b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" + + return diags +} + +// discover the TFC/E API service URL and version constraints. +func (b *Cloud) discover() (*url.URL, error) { + hostname, err := svchost.ForComparison(b.hostname) + if err != nil { + return nil, err + } + + host, err := b.services.Discover(hostname) + if err != nil { + return nil, err + } + + service, err := host.ServiceURL(tfeServiceID) + // Return the error, unless its a disco.ErrVersionNotSupported error. + if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { + return nil, err + } + + return service, err +} + +// token returns the token for this host as configured in the credentials +// section of the CLI Config File. If no token was configured, an empty +// string will be returned instead. +func (b *Cloud) token() (string, error) { + hostname, err := svchost.ForComparison(b.hostname) + if err != nil { + return "", err + } + creds, err := b.services.CredentialsForHost(hostname) + if err != nil { + log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err) + return "", nil + } + if creds != nil { + return creds.Token(), nil + } + return "", nil +} + +// retryLogHook is invoked each time a request is retried allowing the +// backend to log any connection issues to prevent data loss. +func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) { + if b.CLI != nil { + // Ignore the first retry to make sure any delayed output will + // be written to the console before we start logging retries. + // + // The retry logic in the TFE client will retry both rate limited + // requests and server errors, but in the cloud backend we only + // care about server errors so we ignore rate limit (429) errors. + if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) { + // Reset the last retry time. + b.lastRetry = time.Now() + return + } + + if attemptNum == 1 { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(initialRetryError))) + } else { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace( + fmt.Sprintf(repeatedRetryError, time.Since(b.lastRetry).Round(time.Second))))) + } + } +} + +// Workspaces implements backend.Enhanced, returning a filtered list of workspace names according to +// the workspace mapping strategy configured. +func (b *Cloud) Workspaces() ([]string, error) { + // Create a slice to contain all the names. + var names []string + + // If configured for a single workspace, return that exact name only. The StateMgr for this + // backend will automatically create the remote workspace if it does not yet exist. + if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy { + names = append(names, b.WorkspaceMapping.Name) + return names, nil + } + + // Otherwise, multiple workspaces are being mapped. Query Terraform Cloud for all the remote + // workspaces by the provided mapping strategy. + options := tfe.WorkspaceListOptions{} + if b.WorkspaceMapping.Strategy() == WorkspaceTagsStrategy { + taglist := strings.Join(b.WorkspaceMapping.Tags, ",") + options.Tags = &taglist + } + + for { + wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) + if err != nil { + return nil, err + } + + for _, w := range wl.Items { + names = append(names, w.Name) + } + + // Exit the loop when we've seen all pages. + if wl.CurrentPage >= wl.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = wl.NextPage + } + + // Sort the result so we have consistent output. + sort.StringSlice(names).Sort() + + return names, nil +} + +// DeleteWorkspace implements backend.Enhanced. +func (b *Cloud) DeleteWorkspace(name string) error { + if name == backend.DefaultStateName { + return backend.ErrDefaultWorkspaceNotSupported + } + + if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy { + return backend.ErrWorkspacesNotSupported + } + + // Configure the remote workspace name. + client := &remoteClient{ + client: b.client, + organization: b.organization, + workspace: &tfe.Workspace{ + Name: name, + }, + } + + return client.Delete() +} + +// StateMgr implements backend.Enhanced. +func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { + var remoteTFVersion string + + if name == backend.DefaultStateName { + return nil, backend.ErrDefaultWorkspaceNotSupported + } + + if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy && name != b.WorkspaceMapping.Name { + return nil, backend.ErrWorkspacesNotSupported + } + + workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) + if err != nil && err != tfe.ErrResourceNotFound { + return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) + } + if workspace != nil { + remoteTFVersion = workspace.TerraformVersion + } + + if err == tfe.ErrResourceNotFound { + // Create a workspace + options := tfe.WorkspaceCreateOptions{ + Name: tfe.String(name), + } + + var tags []*tfe.Tag + for _, tag := range b.WorkspaceMapping.Tags { + t := tfe.Tag{Name: tag} + tags = append(tags, &t) + } + options.Tags = tags + + log.Printf("[TRACE] cloud: Creating Terraform Cloud workspace %s/%s", b.organization, name) + workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) + if err != nil { + return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) + } + + remoteTFVersion = workspace.TerraformVersion + + // Attempt to set the new workspace to use this version of Terraform. This + // can fail if there's no enabled tool_version whose name matches our + // version string, but that's expected sometimes -- just warn and continue. + versionOptions := tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(tfversion.String()), + } + _, err := b.client.Workspaces.UpdateByID(context.Background(), workspace.ID, versionOptions) + if err == nil { + remoteTFVersion = tfversion.String() + } else { + // TODO: Ideally we could rely on the client to tell us what the actual + // problem was, but we currently can't get enough context from the error + // object to do a nicely formatted message, so we're just assuming the + // issue was that the version wasn't available since that's probably what + // happened. + log.Printf("[TRACE] cloud: Attempted to select version %s for TFC workspace; unavailable, so %s will be used instead.", tfversion.String(), workspace.TerraformVersion) + if b.CLI != nil { + versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), workspace.TerraformVersion) + b.CLI.Output(b.Colorize().Color(versionUnavailable)) + } + } + } + + // This is a fallback error check. Most code paths should use other + // mechanisms to check the version, then set the ignoreVersionConflict + // field to true. This check is only in place to ensure that we don't + // accidentally upgrade state with a new code path, and the version check + // logic is coarser and simpler. + if !b.ignoreVersionConflict { + // Explicitly ignore the pseudo-version "latest" here, as it will cause + // plan and apply to always fail. + if remoteTFVersion != tfversion.String() && remoteTFVersion != "latest" { + return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", remoteTFVersion, tfversion.String()) + } + } + + client := &remoteClient{ + client: b.client, + organization: b.organization, + workspace: workspace, + + // This is optionally set during Terraform Enterprise runs. + runID: os.Getenv("TFE_RUN_ID"), + } + + return &remote.State{Client: client}, nil +} + +// Operation implements backend.Enhanced. +func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { + name := op.Workspace + + // Retrieve the workspace for this operation. + w, err := b.client.Workspaces.Read(ctx, b.organization, name) + if err != nil { + switch err { + case context.Canceled: + return nil, err + case tfe.ErrResourceNotFound: + return nil, fmt.Errorf( + "workspace %s not found\n\n"+ + "For security, Terraform Cloud returns '404 Not Found' responses for resources\n"+ + "for resources that a user doesn't have access to, in addition to resources that\n"+ + "do not exist. If the resource does exist, please check the permissions of the provided token.", + name, + ) + default: + return nil, fmt.Errorf( + "Terraform Cloud returned an unexpected error:\n\n%s", + err, + ) + } + } + + // Terraform remote version conflicts are not a concern for operations. We + // are in one of three states: + // + // - Running remotely, in which case the local version is irrelevant; + // - Workspace configured for local operations, in which case the remote + // version is meaningless; + // - Forcing local operations, which should only happen in the Terraform Cloud worker, in + // which case the Terraform versions by definition match. + b.IgnoreVersionConflict() + + // Check if we need to use the local backend to run the operation. + if b.forceLocal || !w.Operations { + // Record that we're forced to run operations locally to allow the + // command package UI to operate correctly + b.forceLocal = true + return b.local.Operation(ctx, op) + } + + // Set the remote workspace name. + op.Workspace = w.Name + + // Determine the function to call for our operation + var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error) + switch op.Type { + case backend.OperationTypePlan: + f = b.opPlan + case backend.OperationTypeApply: + f = b.opApply + case backend.OperationTypeRefresh: + // The `terraform refresh` command has been deprecated in favor of `terraform apply -refresh-state`. + // Rather than respond with an error telling the user to run the other command we can just run + // that command instead. We will tell the user what we are doing, and then do it. + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(refreshToApplyRefresh) + "\n")) + } + op.PlanMode = plans.RefreshOnlyMode + op.PlanRefresh = true + op.AutoApprove = true + f = b.opApply + default: + return nil, fmt.Errorf( + "\n\nTerraform Cloud does not support the %q operation.", op.Type) + } + + // Lock + b.opLock.Lock() + + // Build our running operation + // the runninCtx is only used to block until the operation returns. + runningCtx, done := context.WithCancel(context.Background()) + runningOp := &backend.RunningOperation{ + Context: runningCtx, + PlanEmpty: true, + } + + // stopCtx wraps the context passed in, and is used to signal a graceful Stop. + stopCtx, stop := context.WithCancel(ctx) + runningOp.Stop = stop + + // cancelCtx is used to cancel the operation immediately, usually + // indicating that the process is exiting. + cancelCtx, cancel := context.WithCancel(context.Background()) + runningOp.Cancel = cancel + + // Do it. + go func() { + defer done() + defer stop() + defer cancel() + + defer b.opLock.Unlock() + + r, opErr := f(stopCtx, cancelCtx, op, w) + if opErr != nil && opErr != context.Canceled { + var diags tfdiags.Diagnostics + diags = diags.Append(opErr) + op.ReportResult(runningOp, diags) + return + } + + if r == nil && opErr == context.Canceled { + runningOp.Result = backend.OperationFailure + return + } + + if r != nil { + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(cancelCtx, r.ID) + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(generalError("Failed to retrieve run", err)) + op.ReportResult(runningOp, diags) + return + } + + // Record if there are any changes. + runningOp.PlanEmpty = !r.HasChanges + + if opErr == context.Canceled { + if err := b.cancel(cancelCtx, op, r); err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(generalError("Failed to retrieve run", err)) + op.ReportResult(runningOp, diags) + return + } + } + + if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + runningOp.Result = backend.OperationFailure + } + } + }() + + // Return the running operation. + return runningOp, nil +} + +func (b *Cloud) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if r.Actions.IsCancelable { + // Only ask if the remote operation should be canceled + // if the auto approve flag is not set. + if !op.AutoApprove { + v, err := op.UIIn.Input(cancelCtx, &terraform.InputOpts{ + Id: "cancel", + Query: "\nDo you want to cancel the remote operation?", + Description: "Only 'yes' will be accepted to cancel.", + }) + if err != nil { + return generalError("Failed asking to cancel", err) + } + if v != "yes" { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled))) + } + return nil + } + } else { + if b.CLI != nil { + // Insert a blank line to separate the ouputs. + b.CLI.Output("") + } + } + + // Try to cancel the remote operation. + err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{}) + if err != nil { + return generalError("Failed to cancel run", err) + } + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled))) + } + } + + return nil +} + +// IgnoreVersionConflict allows commands to disable the fall-back check that +// the local Terraform version matches the remote workspace's configured +// Terraform version. This should be called by commands where this check is +// unnecessary, such as those performing remote operations, or read-only +// operations. It will also be called if the user uses a command-line flag to +// override this check. +func (b *Cloud) IgnoreVersionConflict() { + b.ignoreVersionConflict = true +} + +// VerifyWorkspaceTerraformVersion compares the local Terraform version against +// the workspace's configured Terraform version. If they are compatible, this +// means that there are no state compatibility concerns, so it returns no +// diagnostics. +// +// If the versions aren't compatible, it returns an error (or, if +// b.ignoreVersionConflict is set, a warning). +func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + workspace, err := b.getRemoteWorkspace(context.Background(), workspaceName) + if err != nil { + // If the workspace doesn't exist, there can be no compatibility + // problem, so we can return. This is most likely to happen when + // migrating state from a local backend to a new workspace. + if err == tfe.ErrResourceNotFound { + return nil + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error looking up workspace", + fmt.Sprintf("Workspace read failed: %s", err), + )) + return diags + } + + // If the workspace has the pseudo-version "latest", all bets are off. We + // cannot reasonably determine what the intended Terraform version is, so + // we'll skip version verification. + if workspace.TerraformVersion == "latest" { + return nil + } + + // If the workspace has remote operations disabled, the remote Terraform + // version is effectively meaningless, so we'll skip version verification. + if !workspace.Operations { + return nil + } + + remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion) + if err != nil { + message := fmt.Sprintf( + "The remote workspace specified an invalid Terraform version or constraint (%s), "+ + "and it isn't possible to determine whether the local Terraform version (%s) is compatible.", + workspace.TerraformVersion, + tfversion.String(), + ) + diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict)) + return diags + } + + // If the workspace has a literal Terraform version, see if we can use a + // looser version constraint. + remoteVersion, _ := version.NewSemver(workspace.TerraformVersion) + if remoteVersion != nil { + v014 := version.Must(version.NewSemver("0.14.0")) + v120 := version.Must(version.NewSemver("1.2.0")) + + // Versions from 0.14 through the early 1.x series should be compatible + // (though we don't know about 1.2 yet). + if remoteVersion.GreaterThanOrEqual(v014) && remoteVersion.LessThan(v120) { + early1xCompatible, err := version.NewConstraint(fmt.Sprintf(">= 0.14.0, < %s", v120.String())) + if err != nil { + panic(err) + } + remoteConstraint = early1xCompatible + } + + // Any future new state format will require at least a minor version + // increment, so x.y.* will always be compatible with each other. + if remoteVersion.GreaterThanOrEqual(v120) { + rwvs := remoteVersion.Segments64() + if len(rwvs) >= 3 { + // ~> x.y.0 + minorVersionCompatible, err := version.NewConstraint(fmt.Sprintf("~> %d.%d.0", rwvs[0], rwvs[1])) + if err != nil { + panic(err) + } + remoteConstraint = minorVersionCompatible + } + } + } + + // Re-parsing tfversion.String because tfversion.SemVer omits the prerelease + // prefix, and we want to allow constraints like `~> 1.2.0-beta1`. + fullTfversion := version.Must(version.NewSemver(tfversion.String())) + + if remoteConstraint.Check(fullTfversion) { + return diags + } + + message := fmt.Sprintf( + "The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).", + tfversion.String(), + b.organization, + workspace.Name, + workspace.TerraformVersion, + ) + diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict)) + return diags +} + +func (b *Cloud) IsLocalOperations() bool { + return b.forceLocal +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is guaranteed to always return a non-nil value and so useful +// as a helper to wrap any potentially colored strings. +// +// TODO SvH: Rename this back to Colorize as soon as we can pass -no-color. +//lint:ignore U1000 see above todo +func (b *Cloud) cliColorize() *colorstring.Colorize { + if b.CLIColor != nil { + return b.CLIColor + } + + return &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + } +} + +type WorkspaceMapping struct { + Name string + Tags []string +} + +type workspaceStrategy string + +const ( + WorkspaceTagsStrategy workspaceStrategy = "tags" + WorkspaceNameStrategy workspaceStrategy = "name" + WorkspaceNoneStrategy workspaceStrategy = "none" + WorkspaceInvalidStrategy workspaceStrategy = "invalid" +) + +func (wm WorkspaceMapping) Strategy() workspaceStrategy { + switch { + case len(wm.Tags) > 0 && wm.Name == "": + return WorkspaceTagsStrategy + case len(wm.Tags) == 0 && wm.Name != "": + return WorkspaceNameStrategy + case len(wm.Tags) == 0 && wm.Name == "": + return WorkspaceNoneStrategy + default: + // Any other combination is invalid as each strategy is mutually exclusive + return WorkspaceInvalidStrategy + } +} + +func generalError(msg string, err error) error { + var diags tfdiags.Diagnostics + + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } + + switch err { + case context.Canceled: + return err + case tfe.ErrResourceNotFound: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("%s: %v", msg, err), + "For security, Terraform Cloud returns '404 Not Found' responses for resources\n"+ + "for resources that a user doesn't have access to, in addition to resources that\n"+ + "do not exist. If the resource does exist, please check the permissions of the provided token.", + )) + return diags.Err() + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("%s: %v", msg, err), + `Terraform Cloud returned an unexpected error. Sometimes `+ + `this is caused by network connection problems, in which case you could retry `+ + `the command. If the issue persists please open a support ticket to get help `+ + `resolving the problem.`, + )) + return diags.Err() + } +} + +// The newline in this error is to make it look good in the CLI! +const initialRetryError = ` +[reset][yellow]There was an error connecting to Terraform Cloud. Please do not exit +Terraform to prevent data loss! Trying to restore the connection... +[reset] +` + +const repeatedRetryError = ` +[reset][yellow]Still trying to restore the connection... (%s elapsed)[reset] +` + +const operationCanceled = ` +[reset][red]The remote operation was successfully cancelled.[reset] +` + +const operationNotCanceled = ` +[reset][red]The remote operation was not cancelled.[reset] +` + +const refreshToApplyRefresh = `[bold][yellow]Proceeding with 'terraform apply -refresh-only -auto-approve'.[reset]` + +const unavailableTerraformVersion = ` +[reset][yellow]The local Terraform version (%s) is not available in Terraform Cloud, or your +organization does not have access to it. The new workspace will use %s. You can +change this later in the workspace settings.[reset]` + +const cloudIntegrationUsedInUnsupportedTFE = ` +This version of Terraform Cloud/Enterprise does not support the state mechanism +attempting to be used by the platform. This should never happen. + +Please reach out to HashiCorp Support to resolve this issue.` + +var ( + workspaceConfigurationHelp = fmt.Sprintf( + `The 'workspaces' block configures how Terraform CLI maps its workspaces for this single +configuration to workspaces within a Terraform Cloud organization. Three strategies are available: + +[bold]tags[reset] - %s + +[bold]name[reset] - %s`, schemaDescriptionTags, schemaDescriptionName) + + schemaDescriptionHostname = `The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io +for use with Terraform Cloud.` + + schemaDescriptionOrganization = `The name of the organization containing the targeted workspace(s).` + + schemaDescriptionToken = `The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not +be set, and 'terraform login' used instead; your credentials will then be fetched from your CLI +configuration file or configured credential helper.` + + schemaDescriptionTags = `A set of tags used to select remote Terraform Cloud workspaces to be used for this single +configuration. New workspaces will automatically be tagged with these tag values. Generally, this +is the primary and recommended strategy to use. This option conflicts with "name".` + + schemaDescriptionName = `The name of a single Terraform Cloud workspace to be used with this configuration When configured +only the specified workspace can be used. This option conflicts with "tags".` +) diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go new file mode 100644 index 000000000..85c447e60 --- /dev/null +++ b/internal/cloud/backend_apply.go @@ -0,0 +1,182 @@ +package cloud + +import ( + "bufio" + "context" + "io" + "log" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + log.Printf("[INFO] cloud: starting Apply operation") + + var diags tfdiags.Diagnostics + + // We should remove the `CanUpdate` part of this test, but for now + // (to remain compatible with tfe.v2.1) we'll leave it in here. + if !w.Permissions.CanUpdate && !w.Permissions.CanQueueApply { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to apply changes", + "The provided credentials have insufficient rights to apply changes. In order "+ + "to apply changes at least write permissions on the workspace are required.", + )) + return nil, diags.Err() + } + + if w.VCSRepo != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Apply not allowed for workspaces with a VCS connection", + "A workspace that is connected to a VCS requires the VCS-driven workflow "+ + "to ensure that the VCS remains the single source of truth.", + )) + return nil, diags.Err() + } + + if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Custom parallelism values are currently not supported", + `Terraform Cloud does not support setting a custom parallelism `+ + `value at this time.`, + )) + } + + if op.PlanFile != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Applying a saved plan is currently not supported", + `Terraform Cloud currently requires configuration to be present and `+ + `does not accept an existing saved plan as an argument at this time.`, + )) + } + + if !op.HasConfig() && op.PlanMode != plans.DestroyMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files found", + `Apply requires configuration to be present. Applying without a configuration `+ + `would mark everything for destruction, which is normally not what is desired. `+ + `If you would like to destroy everything, please run 'terraform destroy' which `+ + `does not require any configuration files.`, + )) + } + + // Return if there are any errors. + if diags.HasErrors() { + return nil, diags.Err() + } + + // Run the plan phase. + r, err := b.plan(stopCtx, cancelCtx, op, w) + if err != nil { + return r, err + } + + // This check is also performed in the plan method to determine if + // the policies should be checked, but we need to check the values + // here again to determine if we are done and should return. + if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + return r, nil + } + + // Retrieve the run to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if the run cannot be confirmed. + if !op.AutoApprove && !r.Actions.IsConfirmable { + return r, nil + } + + mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove + + if mustConfirm { + opts := &terraform.InputOpts{Id: "approve"} + + if op.PlanMode == plans.DestroyMode { + opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + + "There is no undo. Only 'yes' will be accepted to confirm." + } else { + opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will perform the actions described above.\n" + + "Only 'yes' will be accepted to approve." + } + + err = b.confirm(stopCtx, op, opts, r, "yes") + if err != nil && err != errRunApproved { + return r, err + } + } else { + // If we don't need to ask for confirmation, insert a blank + // line to separate the ouputs. + if b.CLI != nil { + b.CLI.Output("") + } + } + + if !op.AutoApprove && err != errRunApproved { + if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { + return r, generalError("Failed to approve the apply command", err) + } + } + + r, err = b.waitForRun(stopCtx, cancelCtx, op, "apply", r, w) + if err != nil { + return r, err + } + + logs, err := b.client.Applies.Logs(stopCtx, r.Apply.ID) + if err != nil { + return r, generalError("Failed to retrieve logs", err) + } + reader := bufio.NewReaderSize(logs, 64*1024) + + if b.CLI != nil { + skip := 0 + for next := true; next; { + var l, line []byte + + for isPrefix := true; isPrefix; { + l, isPrefix, err = reader.ReadLine() + if err != nil { + if err != io.EOF { + return r, generalError("Failed to read logs", err) + } + next = false + } + line = append(line, l...) + } + + // Skip the first 3 lines to prevent duplicate output. + if skip < 3 { + skip++ + continue + } + + if next || len(line) > 0 { + b.CLI.Output(b.Colorize().Color(string(line))) + } + } + } + + return r, nil +} + +const applyDefaultHeader = ` +[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C +will cancel the remote apply if it's still pending. If the apply started it +will stop streaming the logs, but will not stop the apply running remotely.[reset] + +Preparing the remote apply... +` diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go new file mode 100644 index 000000000..d741662cd --- /dev/null +++ b/internal/cloud/backend_apply_test.go @@ -0,0 +1,1612 @@ +package cloud + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + gomock "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" + tfversion "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" +) + +func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + return testOperationApplyWithTimeout(t, configDir, 0) +} + +func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) + operationView := views.NewOperation(arguments.ViewHuman, false, view) + + // Many of our tests use an overridden "null" provider that's just in-memory + // inside the test process, not a separate plugin on disk. + depLocks := depsfile.NewLocks() + depLocks.SetProviderOverridden(addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/null")) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypeApply, + View: operationView, + DependencyLocks: depLocks, + }, configCleanup, done +} + +func TestCloud_applyBasic(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after apply: %s", err.Error()) + } +} + +func TestCloud_applyCanceled(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Stop the run to simulate a Ctrl-C. + run.Stop() + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after cancelling apply: %s", err.Error()) + } +} + +func TestCloud_applyWithoutPermissions(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + // Create a named workspace without permissions. + w, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + w.Permissions.CanQueueApply = false + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Insufficient rights to apply changes") { + t.Fatalf("expected a permissions error, got: %v", errOutput) + } +} + +func TestCloud_applyWithVCS(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + // Create a named workspace with a VCS. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + VCSRepo: &tfe.VCSRepoOptions{}, + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "not allowed for workspaces with a VCS") { + t.Fatalf("expected a VCS error, got: %v", errOutput) + } +} + +func TestCloud_applyWithParallelism(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + if b.ContextOpts == nil { + b.ContextOpts = &terraform.ContextOpts{} + } + b.ContextOpts.Parallelism = 3 + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "parallelism values are currently not supported") { + t.Fatalf("expected a parallelism error, got: %v", errOutput) + } +} + +func TestCloud_applyWithPlan(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.PlanFile = &planfile.Reader{} + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "saved plan is currently not supported") { + t.Fatalf("expected a saved plan error, got: %v", errOutput) + } +} + +func TestCloud_applyWithoutRefresh(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + op.PlanRefresh = false + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has refresh set + // to false. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff(false, run.Refresh); diff != "" { + t.Errorf("wrong Refresh setting in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithRefreshOnly(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has refresh-only set + // to true. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { + t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithTarget(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected apply operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has the same + // target address we requested above. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { + t.Errorf("wrong TargetAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithReplace(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") + + op.ForceReplace = []addrs.AbsResourceInstance{addr} + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has the same + // refresh address we requested above. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { + t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithRequiredVariables(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-variables") + defer configCleanup() + defer done(t) + + op.Variables = testVariables(terraform.ValueFromNamedFile, "foo") // "bar" variable value missing + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + // The usual error of a required variable being missing is deferred and the operation + // is successful + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } +} + +func TestCloud_applyNoConfig(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/empty") + defer configCleanup() + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "configuration files found") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after failed apply: %s", err.Error()) + } +} + +func TestCloud_applyNoChanges(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-no-changes") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "No changes. Infrastructure is up-to-date.") { + t.Fatalf("expected no changes in plan summery: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } +} + +func TestCloud_applyNoApprove(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "no", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Apply discarded") { + t.Fatalf("expected an apply discarded error, got: %v", errOutput) + } +} + +func TestCloud_applyAutoApprove(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "no", + }) + + op.AutoApprove = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyApprovedExternally(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "wait-for-external-update", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + ctx := context.Background() + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Wait 50 milliseconds to make sure the run started. + time.Sleep(50 * time.Millisecond) + + wl, err := b.client.Workspaces.List( + ctx, + b.organization, + tfe.WorkspaceListOptions{}, + ) + if err != nil { + t.Fatalf("unexpected error listing workspaces: %v", err) + } + if len(wl.Items) != 1 { + t.Fatalf("expected 1 workspace, got %d workspaces", len(wl.Items)) + } + + rl, err := b.client.Runs.List(ctx, wl.Items[0].ID, tfe.RunListOptions{}) + if err != nil { + t.Fatalf("unexpected error listing runs: %v", err) + } + if len(rl.Items) != 1 { + t.Fatalf("expected 1 run, got %d runs", len(rl.Items)) + } + + err = b.client.Runs.Apply(context.Background(), rl.Items[0].ID, tfe.RunApplyOptions{}) + if err != nil { + t.Fatalf("unexpected error approving run: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "approved using the UI or API") { + t.Fatalf("expected external approval in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyDiscardedExternally(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "wait-for-external-update", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + ctx := context.Background() + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Wait 50 milliseconds to make sure the run started. + time.Sleep(50 * time.Millisecond) + + wl, err := b.client.Workspaces.List( + ctx, + b.organization, + tfe.WorkspaceListOptions{}, + ) + if err != nil { + t.Fatalf("unexpected error listing workspaces: %v", err) + } + if len(wl.Items) != 1 { + t.Fatalf("expected 1 workspace, got %d workspaces", len(wl.Items)) + } + + rl, err := b.client.Runs.List(ctx, wl.Items[0].ID, tfe.RunListOptions{}) + if err != nil { + t.Fatalf("unexpected error listing runs: %v", err) + } + if len(rl.Items) != 1 { + t.Fatalf("expected 1 run, got %d runs", len(rl.Items)) + } + + err = b.client.Runs.Discard(context.Background(), rl.Items[0].ID, tfe.RunDiscardOptions{}) + if err != nil { + t.Fatalf("unexpected error discarding run: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "discarded using the UI or API") { + t.Fatalf("expected external discard output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestCloud_applyWithAutoApprove(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock + + // Create a named workspace that auto applies. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "prod" + op.AutoApprove = true + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyForceLocal(t *testing.T) { + // Set TF_FORCE_LOCAL_BACKEND so the cloud backend will use + // the local backend with itself as embedded backend. + if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil { + t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err) + } + defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND") + + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } + if !run.State.HasManagedResourceInstanceObjects() { + t.Fatalf("expected resources in state") + } +} + +func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + ctx := context.Background() + + // Create a named workspace that doesn't allow operations. + _, err := b.client.Workspaces.Create( + ctx, + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("no-operations"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "no-operations" + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } + if !run.State.HasManagedResourceInstanceObjects() { + t.Fatalf("expected resources in state") + } +} + +func TestCloud_applyLockTimeout(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + ctx := context.Background() + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(ctx, b.organization, b.WorkspaceMapping.Name) + if err != nil { + t.Fatalf("error retrieving workspace: %v", err) + } + + // Create a new configuration version. + c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{}) + if err != nil { + t.Fatalf("error creating configuration version: %v", err) + } + + // Create a pending run to block this run. + _, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{ + ConfigurationVersion: c, + Workspace: w, + }) + if err != nil { + t.Fatalf("error creating pending run: %v", err) + } + + op, configCleanup, done := testOperationApplyWithTimeout(t, "./testdata/apply", 50*time.Millisecond) + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "cancel": "yes", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + _, err = b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGINT) + select { + case <-sigint: + // Stop redirecting SIGINT signals. + signal.Stop(sigint) + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected lock timeout after 50 milliseconds, waited 200 milliseconds") + } + + if len(input.answers) != 2 { + t.Fatalf("expected unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Lock timeout exceeded") { + t.Fatalf("expected lock timout error in output: %s", output) + } + if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("unexpected plan summery in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestCloud_applyDestroy(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-destroy") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.PlanMode = plans.DestroyMode + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "0 to add, 0 to change, 1 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "0 added, 0 changed, 1 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyDestroyNoConfig(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op, configCleanup, done := testOperationApply(t, "./testdata/empty") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.DestroyMode + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } +} + +func TestCloud_applyPolicyPass(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-passed") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyPolicyHardFail(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-hard-failed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answers, got: %v", input.answers) + } + + errOutput := viewOutput.Stderr() + if !strings.Contains(errOutput, "hard failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestCloud_applyPolicySoftFail(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op.AutoApprove = false + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + policyCheckMock := tfe.NewMockPolicyChecks(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(fmt.Sprintf("%s\n%s", sentinelSoftFail, applySuccessOneResourceAdded)) + + pc := &tfe.PolicyCheck{ + ID: "pc-1", + Actions: &tfe.PolicyActions{ + IsOverridable: true, + }, + Permissions: &tfe.PolicyPermissions{ + CanOverride: true, + }, + Scope: tfe.PolicyScopeOrganization, + Status: tfe.PolicySoftFailed, + } + policyCheckMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(pc, nil) + policyCheckMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + policyCheckMock.EXPECT().Override(gomock.Any(), gomock.Any()).Return(nil, nil) + b.client.PolicyChecks = policyCheckMock + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs = strings.NewReader("\n\n\n1 added, 0 changed, 0 destroyed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") + defer configCleanup() + + input := testInput(t, map[string]string{}) + + op.AutoApprove = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result != backend.OperationSuccess { + t.Fatal("expected apply operation to success due to auto-approve") + } + + if run.PlanEmpty { + t.Fatalf("expected plan to not be empty, plan opertion completed without error") + } + + if len(input.answers) != 0 { + t.Fatalf("expected no answers, got: %v", input.answers) + } + + errOutput := viewOutput.Stderr() + if strings.Contains(errOutput, "soft failed") { + t.Fatalf("expected no policy check errors, instead got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check to be false, insead got: %s", output) + } + if !strings.Contains(output, "Apply complete!") { + t.Fatalf("expected apply to be complete, instead got: %s", output) + } + + if !strings.Contains(output, "Resources: 1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected resources, instead got: %s", output) + } +} + +func TestCloud_applyPolicySoftFailAutoApprove(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock + + // Create a named workspace that auto applies. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "prod" + op.AutoApprove = true + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 2 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyWithRemoteError(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-with-error") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "null_resource.foo: 1 error") { + t.Fatalf("expected apply error in output: %s", output) + } +} + +func TestCloud_applyVersionCheck(t *testing.T) { + testCases := map[string]struct { + localVersion string + remoteVersion string + forceLocal bool + hasOperations bool + wantErr string + }{ + "versions can be different for remote apply": { + localVersion: "0.14.0", + remoteVersion: "0.13.5", + hasOperations: true, + }, + "versions can be different for local apply": { + localVersion: "0.14.0", + remoteVersion: "0.13.5", + hasOperations: false, + }, + "force local with remote operations and different versions is acceptable": { + localVersion: "0.14.0", + remoteVersion: "0.14.0-acme-provider-bundle", + forceLocal: true, + hasOperations: true, + }, + "no error if versions are identical": { + localVersion: "0.14.0", + remoteVersion: "0.14.0", + forceLocal: true, + hasOperations: true, + }, + "no error if force local but workspace has remote operations disabled": { + localVersion: "0.14.0", + remoteVersion: "0.13.5", + forceLocal: true, + hasOperations: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + // SETUP: Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // SETUP: Set local version for the test case + tfversion.Prerelease = "" + tfversion.Version = tc.localVersion + tfversion.SemVer = version.Must(version.NewSemver(tc.localVersion)) + + // SETUP: Set force local for the test case + b.forceLocal = tc.forceLocal + + ctx := context.Background() + + // SETUP: set the operations and Terraform Version fields on the + // remote workspace + _, err := b.client.Workspaces.Update( + ctx, + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + Operations: tfe.Bool(tc.hasOperations), + TerraformVersion: tfe.String(tc.remoteVersion), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + // RUN: prepare the apply operation and run it + op, configCleanup, opDone := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer opDone(t) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // RUN: wait for completion + <-run.Done() + output := done(t) + + if tc.wantErr != "" { + // ASSERT: if the test case wants an error, check for failure + // and the error message + if run.Result != backend.OperationFailure { + t.Fatalf("expected run to fail, but result was %#v", run.Result) + } + errOutput := output.Stderr() + if !strings.Contains(errOutput, tc.wantErr) { + t.Fatalf("missing error %q\noutput: %s", tc.wantErr, errOutput) + } + } else { + // ASSERT: otherwise, check for success and appropriate output + // based on whether the run should be local or remote + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + output := b.CLI.(*cli.MockUi).OutputWriter.String() + hasRemote := strings.Contains(output, "Running apply in Terraform Cloud") + hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed") + hasResources := run.State.HasManagedResourceInstanceObjects() + if !tc.forceLocal && tc.hasOperations { + if !hasRemote { + t.Errorf("missing TFC header in output: %s", output) + } + if !hasSummary { + t.Errorf("expected apply summary in output: %s", output) + } + } else { + if hasRemote { + t.Errorf("unexpected TFC header in output: %s", output) + } + if !hasResources { + t.Errorf("expected resources in state") + } + } + } + }) + } +} + +const applySuccessOneResourceAdded = ` +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. +` + +const sentinelSoftFail = ` +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" +` diff --git a/internal/cloud/backend_cli.go b/internal/cloud/backend_cli.go new file mode 100644 index 000000000..cd0549616 --- /dev/null +++ b/internal/cloud/backend_cli.go @@ -0,0 +1,21 @@ +package cloud + +import ( + "github.com/hashicorp/terraform/internal/backend" +) + +// CLIInit implements backend.CLI +func (b *Cloud) CLIInit(opts *backend.CLIOpts) error { + if cli, ok := b.local.(backend.CLI); ok { + if err := cli.CLIInit(opts); err != nil { + return err + } + } + + b.CLI = opts.CLI + b.CLIColor = opts.CLIColor + b.ContextOpts = opts.ContextOpts + b.runningInAutomation = opts.RunningInAutomation + + return nil +} diff --git a/internal/cloud/backend_colorize.go b/internal/cloud/backend_colorize.go new file mode 100644 index 000000000..6fb3c98c3 --- /dev/null +++ b/internal/cloud/backend_colorize.go @@ -0,0 +1,50 @@ +package cloud + +import ( + "regexp" + + "github.com/mitchellh/colorstring" +) + +// TODO SvH: This file should be deleted and the type cliColorize should be +// renamed back to Colorize as soon as we can pass -no-color to the backend. + +// colorsRe is used to find ANSI escaped color codes. +var colorsRe = regexp.MustCompile("\033\\[\\d{1,3}m") + +// Colorer is the interface that must be implemented to colorize strings. +type Colorer interface { + Color(v string) string +} + +// Colorize is used to print output when the -no-color flag is used. It will +// strip all ANSI escaped color codes which are set while the operation was +// executed in Terraform Enterprise. +// +// When Terraform Enterprise supports run specific variables, this code can be +// removed as we can then pass the CLI flag to the backend and prevent the color +// codes from being written to the output. +type Colorize struct { + cliColor *colorstring.Colorize +} + +// Color will strip all ANSI escaped color codes and return a uncolored string. +func (c *Colorize) Color(v string) string { + return colorsRe.ReplaceAllString(c.cliColor.Color(v), "") +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is guaranteed to always return a non-nil value and so is useful +// as a helper to wrap any potentially colored strings. +func (b *Cloud) Colorize() Colorer { + if b.CLIColor != nil && !b.CLIColor.Disable { + return b.CLIColor + } + if b.CLIColor != nil { + return &Colorize{cliColor: b.CLIColor} + } + return &Colorize{cliColor: &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + }} +} diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go new file mode 100644 index 000000000..2ca3fc1a2 --- /dev/null +++ b/internal/cloud/backend_common.go @@ -0,0 +1,536 @@ +package cloud + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math" + "strconv" + "strings" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/terraform" +) + +var ( + errApplyDiscarded = errors.New("Apply discarded.") + errDestroyDiscarded = errors.New("Destroy discarded.") + errRunApproved = errors.New("approved using the UI or API") + errRunDiscarded = errors.New("discarded using the UI or API") + errRunOverridden = errors.New("overridden using the UI or API") +) + +var ( + backoffMin = 1000.0 + backoffMax = 3000.0 + + runPollInterval = 3 * time.Second +) + +// backoff will perform exponential backoff based on the iteration and +// limited by the provided min and max (in milliseconds) durations. +func backoff(min, max float64, iter int) time.Duration { + backoff := math.Pow(2, float64(iter)/5) * min + if backoff > max { + backoff = max + } + return time.Duration(backoff) * time.Millisecond +} + +func (b *Cloud) waitForRun(stopCtx, cancelCtx context.Context, op *backend.Operation, opType string, r *tfe.Run, w *tfe.Workspace) (*tfe.Run, error) { + started := time.Now() + updated := started + for i := 0; ; i++ { + select { + case <-stopCtx.Done(): + return r, stopCtx.Err() + case <-cancelCtx.Done(): + return r, cancelCtx.Err() + case <-time.After(backoff(backoffMin, backoffMax, i)): + // Timer up, show status + } + + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if the run is no longer pending. + if r.Status != tfe.RunPending && r.Status != tfe.RunConfirmed { + if i == 0 && opType == "plan" && b.CLI != nil { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Waiting for the %s to start...\n", opType))) + } + if i > 0 && b.CLI != nil { + // Insert a blank line to separate the ouputs. + b.CLI.Output("") + } + return r, nil + } + + // Check if 30 seconds have passed since the last update. + current := time.Now() + if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) { + updated = current + position := 0 + elapsed := "" + + // Calculate and set the elapsed time. + if i > 0 { + elapsed = fmt.Sprintf( + " (%s elapsed)", current.Sub(started).Truncate(30*time.Second)) + } + + // Retrieve the workspace used to run this operation in. + w, err = b.client.Workspaces.Read(stopCtx, b.organization, w.Name) + if err != nil { + return nil, generalError("Failed to retrieve workspace", err) + } + + // If the workspace is locked the run will not be queued and we can + // update the status without making any expensive calls. + if w.Locked && w.CurrentRun != nil { + cr, err := b.client.Runs.Read(stopCtx, w.CurrentRun.ID) + if err != nil { + return r, generalError("Failed to retrieve current run", err) + } + if cr.Status == tfe.RunPending { + b.CLI.Output(b.Colorize().Color( + "Waiting for the manually locked workspace to be unlocked..." + elapsed)) + continue + } + } + + // Skip checking the workspace queue when we are the current run. + if w.CurrentRun == nil || w.CurrentRun.ID != r.ID { + found := false + options := tfe.RunListOptions{} + runlist: + for { + rl, err := b.client.Runs.List(stopCtx, w.ID, options) + if err != nil { + return r, generalError("Failed to retrieve run list", err) + } + + // Loop through all runs to calculate the workspace queue position. + for _, item := range rl.Items { + if !found { + if r.ID == item.ID { + found = true + } + continue + } + + // If the run is in a final state, ignore it and continue. + switch item.Status { + case tfe.RunApplied, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunErrored: + continue + case tfe.RunPlanned: + if op.Type == backend.OperationTypePlan { + continue + } + } + + // Increase the workspace queue position. + position++ + + // Stop searching when we reached the current run. + if w.CurrentRun != nil && w.CurrentRun.ID == item.ID { + break runlist + } + } + + // Exit the loop when we've seen all pages. + if rl.CurrentPage >= rl.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = rl.NextPage + } + + if position > 0 { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for %d run(s) to finish before being queued...%s", + position, + elapsed, + ))) + continue + } + } + + options := tfe.RunQueueOptions{} + search: + for { + rq, err := b.client.Organizations.RunQueue(stopCtx, b.organization, options) + if err != nil { + return r, generalError("Failed to retrieve queue", err) + } + + // Search through all queued items to find our run. + for _, item := range rq.Items { + if r.ID == item.ID { + position = item.PositionInQueue + break search + } + } + + // Exit the loop when we've seen all pages. + if rq.CurrentPage >= rq.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = rq.NextPage + } + + if position > 0 { + c, err := b.client.Organizations.Capacity(stopCtx, b.organization) + if err != nil { + return r, generalError("Failed to retrieve capacity", err) + } + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for %d queued run(s) to finish before starting...%s", + position-c.Running, + elapsed, + ))) + continue + } + + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for the %s to start...%s", opType, elapsed))) + } + } +} + +func (b *Cloud) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if r.CostEstimate == nil { + return nil + } + + msgPrefix := "Cost estimation" + started := time.Now() + updated := started + for i := 0; ; i++ { + select { + case <-stopCtx.Done(): + return stopCtx.Err() + case <-cancelCtx.Done(): + return cancelCtx.Err() + case <-time.After(backoff(backoffMin, backoffMax, i)): + } + + // Retrieve the cost estimate to get its current status. + ce, err := b.client.CostEstimates.Read(stopCtx, r.CostEstimate.ID) + if err != nil { + return generalError("Failed to retrieve cost estimate", err) + } + + // If the run is canceled or errored, but the cost-estimate still has + // no result, there is nothing further to render. + if ce.Status != tfe.CostEstimateFinished { + if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + return nil + } + } + + // checking if i == 0 so as to avoid printing this starting horizontal-rule + // every retry, and that it only prints it on the first (i=0) attempt. + if b.CLI != nil && i == 0 { + b.CLI.Output("\n------------------------------------------------------------------------\n") + } + + switch ce.Status { + case tfe.CostEstimateFinished: + delta, err := strconv.ParseFloat(ce.DeltaMonthlyCost, 64) + if err != nil { + return generalError("Unexpected error", err) + } + + sign := "+" + if delta < 0 { + sign = "-" + } + + deltaRepr := strings.Replace(ce.DeltaMonthlyCost, "-", "", 1) + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Resources: %d of %d estimated", ce.MatchedResourcesCount, ce.ResourcesCount))) + b.CLI.Output(b.Colorize().Color(fmt.Sprintf(" $%s/mo %s$%s", ce.ProposedMonthlyCost, sign, deltaRepr))) + + if len(r.PolicyChecks) == 0 && r.HasChanges && op.Type == backend.OperationTypeApply { + b.CLI.Output("\n------------------------------------------------------------------------") + } + } + + return nil + case tfe.CostEstimatePending, tfe.CostEstimateQueued: + // Check if 30 seconds have passed since the last update. + current := time.Now() + if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) { + updated = current + elapsed := "" + + // Calculate and set the elapsed time. + if i > 0 { + elapsed = fmt.Sprintf( + " (%s elapsed)", current.Sub(started).Truncate(30*time.Second)) + } + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + b.CLI.Output(b.Colorize().Color("Waiting for cost estimate to complete..." + elapsed + "\n")) + } + continue + case tfe.CostEstimateSkippedDueToTargeting: + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + b.CLI.Output("Not available for this plan, because it was created with the -target option.") + b.CLI.Output("\n------------------------------------------------------------------------") + return nil + case tfe.CostEstimateErrored: + b.CLI.Output(msgPrefix + " errored.\n") + b.CLI.Output("\n------------------------------------------------------------------------") + return nil + case tfe.CostEstimateCanceled: + return fmt.Errorf(msgPrefix + " canceled.") + default: + return fmt.Errorf("Unknown or unexpected cost estimate state: %s", ce.Status) + } + } +} + +func (b *Cloud) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------\n") + } + for i, pc := range r.PolicyChecks { + // Read the policy check logs. This is a blocking call that will only + // return once the policy check is complete. + logs, err := b.client.PolicyChecks.Logs(stopCtx, pc.ID) + if err != nil { + return generalError("Failed to retrieve policy check logs", err) + } + reader := bufio.NewReaderSize(logs, 64*1024) + + // Retrieve the policy check to get its current status. + pc, err := b.client.PolicyChecks.Read(stopCtx, pc.ID) + if err != nil { + return generalError("Failed to retrieve policy check", err) + } + + // If the run is canceled or errored, but the policy check still has + // no result, there is nothing further to render. + if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + switch pc.Status { + case tfe.PolicyPending, tfe.PolicyQueued, tfe.PolicyUnreachable: + continue + } + } + + var msgPrefix string + switch pc.Scope { + case tfe.PolicyScopeOrganization: + msgPrefix = "Organization policy check" + case tfe.PolicyScopeWorkspace: + msgPrefix = "Workspace policy check" + default: + msgPrefix = fmt.Sprintf("Unknown policy check (%s)", pc.Scope) + } + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + } + + if b.CLI != nil { + for next := true; next; { + var l, line []byte + + for isPrefix := true; isPrefix; { + l, isPrefix, err = reader.ReadLine() + if err != nil { + if err != io.EOF { + return generalError("Failed to read logs", err) + } + next = false + } + line = append(line, l...) + } + + if next || len(line) > 0 { + b.CLI.Output(b.Colorize().Color(string(line))) + } + } + } + + switch pc.Status { + case tfe.PolicyPasses: + if (r.HasChanges && op.Type == backend.OperationTypeApply || i < len(r.PolicyChecks)-1) && b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------") + } + continue + case tfe.PolicyErrored: + return fmt.Errorf(msgPrefix + " errored.") + case tfe.PolicyHardFailed: + return fmt.Errorf(msgPrefix + " hard failed.") + case tfe.PolicySoftFailed: + runUrl := fmt.Sprintf(runHeader, b.hostname, b.organization, op.Workspace, r.ID) + + if op.Type == backend.OperationTypePlan || op.UIOut == nil || op.UIIn == nil || + !pc.Actions.IsOverridable || !pc.Permissions.CanOverride { + return fmt.Errorf(msgPrefix + " soft failed.\n" + runUrl) + } + + if op.AutoApprove { + if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil { + return generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err) + } + } else { + opts := &terraform.InputOpts{ + Id: "override", + Query: "\nDo you want to override the soft failed policy check?", + Description: "Only 'override' will be accepted to override.", + } + err = b.confirm(stopCtx, op, opts, r, "override") + if err != nil && err != errRunOverridden { + return fmt.Errorf( + fmt.Sprintf("Failed to override: %s\n%s\n", err.Error(), runUrl), + ) + } + + if err != errRunOverridden { + if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil { + return generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err) + } + } else { + b.CLI.Output(fmt.Sprintf("The run needs to be manually overridden or discarded.\n%s\n", runUrl)) + } + } + + if b.CLI != nil { + b.CLI.Output("------------------------------------------------------------------------") + } + default: + return fmt.Errorf("Unknown or unexpected policy state: %s", pc.Status) + } + } + + return nil +} + +func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *terraform.InputOpts, r *tfe.Run, keyword string) error { + doneCtx, cancel := context.WithCancel(stopCtx) + result := make(chan error, 2) + + go func() { + // Make sure we cancel doneCtx before we return + // so the input command is also canceled. + defer cancel() + + for { + select { + case <-doneCtx.Done(): + return + case <-stopCtx.Done(): + return + case <-time.After(runPollInterval): + // Retrieve the run again to get its current status. + r, err := b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + result <- generalError("Failed to retrieve run", err) + return + } + + switch keyword { + case "override": + if r.Status != tfe.RunPolicyOverride { + if r.Status == tfe.RunDiscarded { + err = errRunDiscarded + } else { + err = errRunOverridden + } + } + case "yes": + if !r.Actions.IsConfirmable { + if r.Status == tfe.RunDiscarded { + err = errRunDiscarded + } else { + err = errRunApproved + } + } + } + + if err != nil { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color( + fmt.Sprintf("[reset][yellow]%s[reset]", err.Error()))) + } + + if err == errRunDiscarded { + err = errApplyDiscarded + if op.PlanMode == plans.DestroyMode { + err = errDestroyDiscarded + } + } + + result <- err + return + } + } + } + }() + + result <- func() error { + v, err := op.UIIn.Input(doneCtx, opts) + if err != nil && err != context.Canceled && stopCtx.Err() != context.Canceled { + return fmt.Errorf("Error asking %s: %v", opts.Id, err) + } + + // We return the error of our parent channel as we don't + // care about the error of the doneCtx which is only used + // within this function. So if the doneCtx was canceled + // because stopCtx was canceled, this will properly return + // a context.Canceled error and otherwise it returns nil. + if doneCtx.Err() == context.Canceled || stopCtx.Err() == context.Canceled { + return stopCtx.Err() + } + + // Make sure we cancel the context here so the loop that + // checks for external changes to the run is ended before + // we start to make changes ourselves. + cancel() + + if v != keyword { + // Retrieve the run again to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return generalError("Failed to retrieve run", err) + } + + // Make sure we discard the run if possible. + if r.Actions.IsDiscardable { + err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) + if err != nil { + if op.PlanMode == plans.DestroyMode { + return generalError("Failed to discard destroy", err) + } + return generalError("Failed to discard apply", err) + } + } + + // Even if the run was discarded successfully, we still + // return an error as the apply command was canceled. + if op.PlanMode == plans.DestroyMode { + return errDestroyDiscarded + } + return errApplyDiscarded + } + + return nil + }() + + return <-result +} diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go new file mode 100644 index 000000000..27afadd2f --- /dev/null +++ b/internal/cloud/backend_context.go @@ -0,0 +1,279 @@ +package cloud + +import ( + "context" + "fmt" + "log" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// LocalRun implements backend.Local +func (b *Cloud) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Full, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := &backend.LocalRun{ + PlanOpts: &terraform.PlanOpts{ + Mode: op.PlanMode, + Targets: op.Targets, + }, + } + + op.StateLocker = op.StateLocker.WithContext(context.Background()) + + // Get the remote workspace name. + remoteWorkspaceName := b.getRemoteWorkspaceName(op.Workspace) + + // Get the latest state. + log.Printf("[TRACE] cloud: requesting state manager for workspace %q", remoteWorkspaceName) + stateMgr, err := b.StateMgr(op.Workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error loading state: %w", err)) + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: requesting state lock for workspace %q", remoteWorkspaceName) + if diags := op.StateLocker.Lock(stateMgr, op.Type.String()); diags.HasErrors() { + return nil, nil, diags + } + + defer func() { + // If we're returning with errors, and thus not producing a valid + // context, we'll want to avoid leaving the remote workspace locked. + if diags.HasErrors() { + diags = diags.Append(op.StateLocker.Unlock()) + } + }() + + log.Printf("[TRACE] cloud: reading remote state for workspace %q", remoteWorkspaceName) + if err := stateMgr.RefreshState(); err != nil { + diags = diags.Append(fmt.Errorf("error loading state: %w", err)) + return nil, nil, diags + } + + // Initialize our context options + var opts terraform.ContextOpts + if v := b.ContextOpts; v != nil { + opts = *v + } + + // Copy set options from the operation + opts.UIInput = op.UIIn + + // Load the latest state. If we enter contextFromPlanFile below then the + // state snapshot in the plan file must match this, or else it'll return + // error diagnostics. + log.Printf("[TRACE] cloud: retrieving remote state snapshot for workspace %q", remoteWorkspaceName) + ret.InputState = stateMgr.State() + + log.Printf("[TRACE] cloud: loading configuration for the current working directory") + config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) + diags = diags.Append(configDiags) + if configDiags.HasErrors() { + return nil, nil, diags + } + ret.Config = config + + if op.AllowUnsetVariables { + // If we're not going to use the variables in an operation we'll be + // more lax about them, stubbing out any unset ones as unknown. + // This gives us enough information to produce a consistent context, + // but not enough information to run a real operation (plan, apply, etc) + ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, config.Module.Variables) + } else { + // The underlying API expects us to use the opaque workspace id to request + // variables, so we'll need to look that up using our organization name + // and workspace name. + remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.organization, remoteWorkspaceID) + tfeVariables, err := b.client.Variables.List(context.Background(), remoteWorkspaceID, tfe.VariableListOptions{}) + if err != nil && err != tfe.ErrResourceNotFound { + diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) + return nil, nil, diags + } + + if tfeVariables != nil { + if op.Variables == nil { + op.Variables = make(map[string]backend.UnparsedVariableValue) + } + for _, v := range tfeVariables.Items { + if v.Category == tfe.CategoryTerraform { + op.Variables[v.Key] = &remoteStoredVariableValue{ + definition: v, + } + } + } + } + + if op.Variables != nil { + variables, varDiags := backend.ParseVariableValues(op.Variables, config.Module.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, nil, diags + } + ret.PlanOpts.SetVariables = variables + } + } + + tfCtx, ctxDiags := terraform.NewContext(&opts) + diags = diags.Append(ctxDiags) + ret.Core = tfCtx + + log.Printf("[TRACE] cloud: finished building terraform.Context") + + return ret, stateMgr, diags +} + +func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string { + switch { + case localWorkspaceName == backend.DefaultStateName: + // The default workspace name is a special case + return b.WorkspaceMapping.Name + default: + return localWorkspaceName + } +} + +func (b *Cloud) getRemoteWorkspace(ctx context.Context, localWorkspaceName string) (*tfe.Workspace, error) { + remoteWorkspaceName := b.getRemoteWorkspaceName(localWorkspaceName) + + log.Printf("[TRACE] cloud: looking up workspace for %s/%s", b.organization, remoteWorkspaceName) + remoteWorkspace, err := b.client.Workspaces.Read(ctx, b.organization, remoteWorkspaceName) + if err != nil { + return nil, err + } + + return remoteWorkspace, nil +} + +func (b *Cloud) getRemoteWorkspaceID(ctx context.Context, localWorkspaceName string) (string, error) { + remoteWorkspace, err := b.getRemoteWorkspace(ctx, localWorkspaceName) + if err != nil { + return "", err + } + + return remoteWorkspace.ID, nil +} + +func stubAllVariables(vv map[string]backend.UnparsedVariableValue, decls map[string]*configs.Variable) terraform.InputValues { + ret := make(terraform.InputValues, len(decls)) + + for name, cfg := range decls { + raw, exists := vv[name] + if !exists { + ret[name] = &terraform.InputValue{ + Value: cty.UnknownVal(cfg.Type), + SourceType: terraform.ValueFromConfig, + } + continue + } + + val, diags := raw.ParseVariableValue(cfg.ParsingMode) + if diags.HasErrors() { + ret[name] = &terraform.InputValue{ + Value: cty.UnknownVal(cfg.Type), + SourceType: terraform.ValueFromConfig, + } + continue + } + ret[name] = val + } + + return ret +} + +// remoteStoredVariableValue is a backend.UnparsedVariableValue implementation +// that translates from the go-tfe representation of stored variables into +// the Terraform Core backend representation of variables. +type remoteStoredVariableValue struct { + definition *tfe.Variable +} + +var _ backend.UnparsedVariableValue = (*remoteStoredVariableValue)(nil) + +func (v *remoteStoredVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + var val cty.Value + + switch { + case v.definition.Sensitive: + // If it's marked as sensitive then it's not available for use in + // local operations. We'll use an unknown value as a placeholder for + // it so that operations that don't need it might still work, but + // we'll also produce a warning about it to add context for any + // errors that might result here. + val = cty.DynamicVal + if !v.definition.HCL { + // If it's not marked as HCL then we at least know that the + // value must be a string, so we'll set that in case it allows + // us to do some more precise type checking. + val = cty.UnknownVal(cty.String) + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + fmt.Sprintf("Value for var.%s unavailable", v.definition.Key), + fmt.Sprintf("The value of variable %q is marked as sensitive in the remote workspace. This operation always runs locally, so the value for that variable is not available.", v.definition.Key), + )) + + case v.definition.HCL: + // If the variable value is marked as being in HCL syntax, we need to + // parse it the same way as it would be interpreted in a .tfvars + // file because that is how it would get passed to Terraform CLI for + // a remote operation and we want to mimic that result as closely as + // possible. + var exprDiags hcl.Diagnostics + expr, exprDiags := hclsyntax.ParseExpression([]byte(v.definition.Value), "", hcl.Pos{Line: 1, Column: 1}) + if expr != nil { + var moreDiags hcl.Diagnostics + val, moreDiags = expr.Value(nil) + exprDiags = append(exprDiags, moreDiags...) + } else { + // We'll have already put some errors in exprDiags above, so we'll + // just stub out the value here. + val = cty.DynamicVal + } + + // We don't have sufficient context to return decent error messages + // for syntax errors in the remote values, so we'll just return a + // generic message instead for now. + // (More complete error messages will still result from true remote + // operations, because they'll run on the remote system where we've + // materialized the values into a tfvars file we can report from.) + if exprDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid expression for var.%s", v.definition.Key), + fmt.Sprintf("The value of variable %q is marked in the remote workspace as being specified in HCL syntax, but the given value is not valid HCL. Stored variable values must be valid literal expressions and may not contain references to other variables or calls to functions.", v.definition.Key), + )) + } + + default: + // A variable value _not_ marked as HCL is always be a string, given + // literally. + val = cty.StringVal(v.definition.Value) + } + + return &terraform.InputValue{ + Value: val, + + // We mark these as "from input" with the rationale that entering + // variable values into the Terraform Cloud or Enterprise UI is, + // roughly speaking, a similar idea to entering variable values at + // the interactive CLI prompts. It's not a perfect correspondance, + // but it's closer than the other options. + SourceType: terraform.ValueFromInput, + }, diags +} diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go new file mode 100644 index 000000000..e1001ee52 --- /dev/null +++ b/internal/cloud/backend_context_test.go @@ -0,0 +1,235 @@ +package cloud + +import ( + "context" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/zclconf/go-cty/cty" +) + +func TestRemoteStoredVariableValue(t *testing.T) { + tests := map[string]struct { + Def *tfe.Variable + Want cty.Value + WantError string + }{ + "string literal": { + &tfe.Variable{ + Key: "test", + Value: "foo", + HCL: false, + Sensitive: false, + }, + cty.StringVal("foo"), + ``, + }, + "string HCL": { + &tfe.Variable{ + Key: "test", + Value: `"foo"`, + HCL: true, + Sensitive: false, + }, + cty.StringVal("foo"), + ``, + }, + "list HCL": { + &tfe.Variable{ + Key: "test", + Value: `[]`, + HCL: true, + Sensitive: false, + }, + cty.EmptyTupleVal, + ``, + }, + "null HCL": { + &tfe.Variable{ + Key: "test", + Value: `null`, + HCL: true, + Sensitive: false, + }, + cty.NullVal(cty.DynamicPseudoType), + ``, + }, + "literal sensitive": { + &tfe.Variable{ + Key: "test", + HCL: false, + Sensitive: true, + }, + cty.UnknownVal(cty.String), + ``, + }, + "HCL sensitive": { + &tfe.Variable{ + Key: "test", + HCL: true, + Sensitive: true, + }, + cty.DynamicVal, + ``, + }, + "HCL computation": { + // This (stored expressions containing computation) is not a case + // we intentionally supported, but it became possible for remote + // operations in Terraform 0.12 (due to Terraform Cloud/Enterprise + // just writing the HCL verbatim into generated `.tfvars` files). + // We support it here for consistency, and we continue to support + // it in both places for backward-compatibility. In practice, + // there's little reason to do computation in a stored variable + // value because references are not supported. + &tfe.Variable{ + Key: "test", + Value: `[for v in ["a"] : v]`, + HCL: true, + Sensitive: false, + }, + cty.TupleVal([]cty.Value{cty.StringVal("a")}), + ``, + }, + "HCL syntax error": { + &tfe.Variable{ + Key: "test", + Value: `[`, + HCL: true, + Sensitive: false, + }, + cty.DynamicVal, + `Invalid expression for var.test: The value of variable "test" is marked in the remote workspace as being specified in HCL syntax, but the given value is not valid HCL. Stored variable values must be valid literal expressions and may not contain references to other variables or calls to functions.`, + }, + "HCL with references": { + &tfe.Variable{ + Key: "test", + Value: `foo.bar`, + HCL: true, + Sensitive: false, + }, + cty.DynamicVal, + `Invalid expression for var.test: The value of variable "test" is marked in the remote workspace as being specified in HCL syntax, but the given value is not valid HCL. Stored variable values must be valid literal expressions and may not contain references to other variables or calls to functions.`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + v := &remoteStoredVariableValue{ + definition: test.Def, + } + // This ParseVariableValue implementation ignores the parsing mode, + // so we'll just always parse literal here. (The parsing mode is + // selected by the remote server, not by our local configuration.) + gotIV, diags := v.ParseVariableValue(configs.VariableParseLiteral) + if test.WantError != "" { + if !diags.HasErrors() { + t.Fatalf("missing expected error\ngot: \nwant: %s", test.WantError) + } + errStr := diags.Err().Error() + if errStr != test.WantError { + t.Fatalf("wrong error\ngot: %s\nwant: %s", errStr, test.WantError) + } + } else { + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s\nwant: ", diags.Err().Error()) + } + got := gotIV.Value + if !test.Want.RawEquals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + } + }) + } +} + +func TestRemoteContextWithVars(t *testing.T) { + catTerraform := tfe.CategoryTerraform + catEnv := tfe.CategoryEnv + + tests := map[string]struct { + Opts *tfe.VariableCreateOptions + WantError string + }{ + "Terraform variable": { + &tfe.VariableCreateOptions{ + Category: &catTerraform, + }, + `Value for undeclared variable: A variable named "key" was assigned a value, but the root module does not declare a variable of that name. To use this value, add a "variable" block to the configuration.`, + }, + "environment variable": { + &tfe.VariableCreateOptions{ + Category: &catEnv, + }, + ``, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + configDir := "./testdata/empty" + + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + defer configCleanup() + + workspaceID, err := b.getRemoteWorkspaceID(context.Background(), testBackendSingleWorkspaceName) + if err != nil { + t.Fatal(err) + } + + streams, _ := terminal.StreamsForTesting(t) + view := views.NewStateLocker(arguments.ViewHuman, views.NewView(streams)) + + op := &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + StateLocker: clistate.NewLocker(0, view), + Workspace: testBackendSingleWorkspaceName, + } + + v := test.Opts + if v.Key == nil { + key := "key" + v.Key = &key + } + b.client.Variables.Create(context.TODO(), workspaceID, *v) + + _, _, diags := b.LocalRun(op) + + if test.WantError != "" { + if !diags.HasErrors() { + t.Fatalf("missing expected error\ngot: \nwant: %s", test.WantError) + } + errStr := diags.Err().Error() + if errStr != test.WantError { + t.Fatalf("wrong error\ngot: %s\nwant: %s", errStr, test.WantError) + } + // When Context() returns an error, it should unlock the state, + // so re-locking it is expected to succeed. + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state: %s", err.Error()) + } + } else { + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s\nwant: ", diags.Err().Error()) + } + // When Context() succeeds, this should fail w/ "workspace already locked" + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err == nil { + t.Fatal("unexpected success locking state after Context") + } + } + }) + } +} diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go new file mode 100644 index 000000000..1956a9c82 --- /dev/null +++ b/internal/cloud/backend_plan.go @@ -0,0 +1,373 @@ +package cloud + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +var planConfigurationVersionsPollInterval = 500 * time.Millisecond + +func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + log.Printf("[INFO] cloud: starting Plan operation") + + var diags tfdiags.Diagnostics + + if !w.Permissions.CanQueueRun { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to generate a plan", + "The provided credentials have insufficient rights to generate a plan. In order "+ + "to generate plans, at least plan permissions on the workspace are required.", + )) + return nil, diags.Err() + } + + if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Custom parallelism values are currently not supported", + `Terraform Cloud does not support setting a custom parallelism `+ + `value at this time.`, + )) + } + + if op.PlanFile != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Displaying a saved plan is currently not supported", + `Terraform Cloud currently requires configuration to be present and `+ + `does not accept an existing saved plan as an argument at this time.`, + )) + } + + if op.PlanOutPath != "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Saving a generated plan is currently not supported", + `Terraform Cloud does not support saving the generated execution `+ + `plan locally at this time.`, + )) + } + + if !op.HasConfig() && op.PlanMode != plans.DestroyMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files found", + `Plan requires configuration to be present. Planning without a configuration `+ + `would mark everything for destruction, which is normally not what is desired. `+ + `If you would like to destroy everything, please run plan with the "-destroy" `+ + `flag or create a single empty configuration file. Otherwise, please create `+ + `a Terraform configuration file in the path being executed and try again.`, + )) + } + + // Return if there are any errors. + if diags.HasErrors() { + return nil, diags.Err() + } + + return b.plan(stopCtx, cancelCtx, op, w) +} + +func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + if b.CLI != nil { + header := planDefaultHeader + if op.Type == backend.OperationTypeApply || op.Type == backend.OperationTypeRefresh { + header = applyDefaultHeader + } + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n")) + } + + configOptions := tfe.ConfigurationVersionCreateOptions{ + AutoQueueRuns: tfe.Bool(false), + Speculative: tfe.Bool(op.Type == backend.OperationTypePlan), + } + + cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions) + if err != nil { + return nil, generalError("Failed to create configuration version", err) + } + + var configDir string + if op.ConfigDir != "" { + // De-normalize the configuration directory path. + configDir, err = filepath.Abs(op.ConfigDir) + if err != nil { + return nil, generalError( + "Failed to get absolute path of the configuration directory: %v", err) + } + + // Make sure to take the working directory into account by removing + // the working directory from the current path. This will result in + // a path that points to the expected root of the workspace. + configDir = filepath.Clean(strings.TrimSuffix( + filepath.Clean(configDir), + filepath.Clean(w.WorkingDirectory), + )) + + // If the workspace has a subdirectory as its working directory then + // our configDir will be some parent directory of the current working + // directory. Users are likely to find that surprising, so we'll + // produce an explicit message about it to be transparent about what + // we are doing and why. + if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory { + if b.CLI != nil { + b.CLI.Output(fmt.Sprintf(strings.TrimSpace(` +The remote workspace is configured to work with configuration at +%s relative to the target repository. + +Terraform will upload the contents of the following directory, +excluding files or directories as defined by a .terraformignore file +at %s/.terraformignore (if it is present), +in order to capture the filesystem context the remote workspace expects: + %s +`), w.WorkingDirectory, configDir, configDir) + "\n") + } + } + + } else { + // We did a check earlier to make sure we either have a config dir, + // or the plan is run with -destroy. So this else clause will only + // be executed when we are destroying and doesn't need the config. + configDir, err = ioutil.TempDir("", "tf") + if err != nil { + return nil, generalError("Failed to create temporary directory", err) + } + defer os.RemoveAll(configDir) + + // Make sure the configured working directory exists. + err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700) + if err != nil { + return nil, generalError( + "Failed to create temporary working directory", err) + } + } + + err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir) + if err != nil { + return nil, generalError("Failed to upload configuration files", err) + } + + uploaded := false + for i := 0; i < 60 && !uploaded; i++ { + select { + case <-stopCtx.Done(): + return nil, context.Canceled + case <-cancelCtx.Done(): + return nil, context.Canceled + case <-time.After(planConfigurationVersionsPollInterval): + cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID) + if err != nil { + return nil, generalError("Failed to retrieve configuration version", err) + } + + if cv.Status == tfe.ConfigurationUploaded { + uploaded = true + } + } + } + + if !uploaded { + return nil, generalError( + "Failed to upload configuration files", errors.New("operation timed out")) + } + + runOptions := tfe.RunCreateOptions{ + ConfigurationVersion: cv, + Refresh: tfe.Bool(op.PlanRefresh), + Workspace: w, + AutoApply: tfe.Bool(op.AutoApprove), + } + + switch op.PlanMode { + case plans.NormalMode: + // okay, but we don't need to do anything special for this + case plans.RefreshOnlyMode: + runOptions.RefreshOnly = tfe.Bool(true) + case plans.DestroyMode: + runOptions.IsDestroy = tfe.Bool(true) + default: + // Shouldn't get here because we should update this for each new + // plan mode we add, mapping it to the corresponding RunCreateOptions + // field. + return nil, generalError( + "Invalid plan mode", + fmt.Errorf("Terraform Cloud doesn't support %s", op.PlanMode), + ) + } + + if len(op.Targets) != 0 { + runOptions.TargetAddrs = make([]string, 0, len(op.Targets)) + for _, addr := range op.Targets { + runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String()) + } + } + + if len(op.ForceReplace) != 0 { + runOptions.ReplaceAddrs = make([]string, 0, len(op.ForceReplace)) + for _, addr := range op.ForceReplace { + runOptions.ReplaceAddrs = append(runOptions.ReplaceAddrs, addr.String()) + } + } + + config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + if configDiags.HasErrors() { + return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0]) + } + variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables) + + if varDiags.HasErrors() { + return nil, varDiags.Err() + } + + runVariables := make([]*tfe.RunVariable, len(variables)) + for name, value := range variables { + runVariables = append(runVariables, &tfe.RunVariable{ + Key: name, + Value: value, + }) + } + runOptions.Variables = runVariables + + r, err := b.client.Runs.Create(stopCtx, runOptions) + if err != nil { + return r, generalError("Failed to create run", err) + } + + // When the lock timeout is set, if the run is still pending and + // cancellable after that period, we attempt to cancel it. + if lockTimeout := op.StateLocker.Timeout(); lockTimeout > 0 { + go func() { + select { + case <-stopCtx.Done(): + return + case <-cancelCtx.Done(): + return + case <-time.After(lockTimeout): + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(cancelCtx, r.ID) + if err != nil { + log.Printf("[ERROR] error reading run: %v", err) + return + } + + if r.Status == tfe.RunPending && r.Actions.IsCancelable { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr))) + } + + // We abuse the auto aprove flag to indicate that we do not + // want to ask if the remote operation should be canceled. + op.AutoApprove = true + + p, err := os.FindProcess(os.Getpid()) + if err != nil { + log.Printf("[ERROR] error searching process ID: %v", err) + return + } + p.Signal(syscall.SIGINT) + } + } + }() + } + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf( + runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n")) + } + + r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w) + if err != nil { + return r, err + } + + logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID) + if err != nil { + return r, generalError("Failed to retrieve logs", err) + } + reader := bufio.NewReaderSize(logs, 64*1024) + + if b.CLI != nil { + for next := true; next; { + var l, line []byte + + for isPrefix := true; isPrefix; { + l, isPrefix, err = reader.ReadLine() + if err != nil { + if err != io.EOF { + return r, generalError("Failed to read logs", err) + } + next = false + } + line = append(line, l...) + } + + if next || len(line) > 0 { + b.CLI.Output(b.Colorize().Color(string(line))) + } + } + } + + // Retrieve the run to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // If the run is canceled or errored, we still continue to the + // cost-estimation and policy check phases to ensure we render any + // results available. In the case of a hard-failed policy check, the + // status of the run will be "errored", but there is still policy + // information which should be shown. + + // Show any cost estimation output. + if r.CostEstimate != nil { + err = b.costEstimate(stopCtx, cancelCtx, op, r) + if err != nil { + return r, err + } + } + + // Check any configured sentinel policies. + if len(r.PolicyChecks) > 0 { + err = b.checkPolicy(stopCtx, cancelCtx, op, r) + if err != nil { + return r, err + } + } + + return r, nil +} + +const planDefaultHeader = ` +[reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C +will stop streaming the logs, but will not stop the plan running remotely.[reset] + +Preparing the remote plan... +` + +const runHeader = ` +[reset][yellow]To view this run in a browser, visit: +https://%s/app/%s/%s/runs/%s[reset] +` + +// The newline in this error is to make it look good in the CLI! +const lockTimeoutErr = ` +[reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation. +[reset] +` diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go new file mode 100644 index 000000000..ba2508091 --- /dev/null +++ b/internal/cloud/backend_plan_test.go @@ -0,0 +1,1114 @@ +package cloud + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/mitchellh/cli" +) + +func testOperationPlan(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + return testOperationPlanWithTimeout(t, configDir, 0) +} + +func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) + operationView := views.NewOperation(arguments.ViewHuman, false, view) + + // Many of our tests use an overridden "null" provider that's just in-memory + // inside the test process, not a separate plugin on disk. + depLocks := depsfile.NewLocks() + depLocks.SetProviderOverridden(addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/null")) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypePlan, + View: operationView, + DependencyLocks: depLocks, + }, configCleanup, done +} + +func TestCloud_planBasic(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after the operation finished + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after successful plan: %s", err.Error()) + } +} + +func TestCloud_planCanceled(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Stop the run to simulate a Ctrl-C. + run.Stop() + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after the operation finished + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after cancelled plan: %s", err.Error()) + } +} + +func TestCloud_planLongLine(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-long-line") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithoutPermissions(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + // Create a named workspace without permissions. + w, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + w.Permissions.CanQueueRun = false + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Insufficient rights to generate a plan") { + t.Fatalf("expected a permissions error, got: %v", errOutput) + } +} + +func TestCloud_planWithParallelism(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + if b.ContextOpts == nil { + b.ContextOpts = &terraform.ContextOpts{} + } + b.ContextOpts.Parallelism = 3 + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "parallelism values are currently not supported") { + t.Fatalf("expected a parallelism error, got: %v", errOutput) + } +} + +func TestCloud_planWithPlan(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.PlanFile = &planfile.Reader{} + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "saved plan is currently not supported") { + t.Fatalf("expected a saved plan error, got: %v", errOutput) + } +} + +func TestCloud_planWithPath(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.PlanOutPath = "./testdata/plan" + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "generated plan is currently not supported") { + t.Fatalf("expected a generated plan error, got: %v", errOutput) + } +} + +func TestCloud_planWithoutRefresh(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.PlanRefresh = false + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + // We should find a run inside the mock client that has refresh set + // to false. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff(false, run.Refresh); diff != "" { + t.Errorf("wrong Refresh setting in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithRefreshOnly(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + // We should find a run inside the mock client that has refresh-only set + // to true. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { + t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithTarget(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + // When the backend code creates a new run, we'll tweak it so that it + // has a cost estimation object with the "skipped_due_to_targeting" status, + // emulating how a real server is expected to behave in that case. + b.client.Runs.(*MockRuns).ModifyNewRun = func(client *MockClient, options tfe.RunCreateOptions, run *tfe.Run) { + const fakeID = "fake" + // This is the cost estimate object embedded in the run itself which + // the backend will use to learn the ID to request from the cost + // estimates endpoint. It's pending to simulate what a freshly-created + // run is likely to look like. + run.CostEstimate = &tfe.CostEstimate{ + ID: fakeID, + Status: "pending", + } + // The backend will then use the main cost estimation API to retrieve + // the same ID indicated in the object above, where we'll then return + // the status "skipped_due_to_targeting" to trigger the special skip + // message in the backend output. + client.CostEstimates.Estimations[fakeID] = &tfe.CostEstimate{ + ID: fakeID, + Status: "skipped_due_to_targeting", + } + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // testBackendDefault above attached a "mock UI" to our backend, so we + // can retrieve its non-error output via the OutputWriter in-memory buffer. + gotOutput := b.CLI.(*cli.MockUi).OutputWriter.String() + if wantOutput := "Not available for this plan, because it was created with the -target option."; !strings.Contains(gotOutput, wantOutput) { + t.Errorf("missing message about skipped cost estimation\ngot:\n%s\nwant substring: %s", gotOutput, wantOutput) + } + + // We should find a run inside the mock client that has the same + // target address we requested above. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { + t.Errorf("wrong TargetAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithReplace(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") + + op.ForceReplace = []addrs.AbsResourceInstance{addr} + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has the same + // refresh address we requested above. + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.Runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { + t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithRequiredVariables(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-variables") + defer configCleanup() + defer done(t) + + op.Variables = testVariables(terraform.ValueFromCLIArg, "foo") // "bar" variable value missing + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + // The usual error of a required variable being missing is deferred and the operation + // is successful + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } +} + +func TestCloud_planNoConfig(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/empty") + defer configCleanup() + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "configuration files found") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } +} + +func TestCloud_planNoChanges(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-no-changes") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "No changes. Infrastructure is up-to-date.") { + t.Fatalf("expected no changes in plan summary: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } +} + +func TestCloud_planForceLocal(t *testing.T) { + // Set TF_FORCE_LOCAL_BACKEND so the cloud backend will use + // the local backend with itself as embedded backend. + if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil { + t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err) + } + defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND") + + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithoutOperationsEntitlement(t *testing.T) { + b, bCleanup := testBackendNoOperations(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + ctx := context.Background() + + // Create a named workspace that doesn't allow operations. + _, err := b.client.Workspaces.Create( + ctx, + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String("no-operations"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = "no-operations" + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planLockTimeout(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + ctx := context.Background() + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(ctx, b.organization, b.WorkspaceMapping.Name) + if err != nil { + t.Fatalf("error retrieving workspace: %v", err) + } + + // Create a new configuration version. + c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{}) + if err != nil { + t.Fatalf("error creating configuration version: %v", err) + } + + // Create a pending run to block this run. + _, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{ + ConfigurationVersion: c, + Workspace: w, + }) + if err != nil { + t.Fatalf("error creating pending run: %v", err) + } + + op, configCleanup, done := testOperationPlanWithTimeout(t, "./testdata/plan", 50) + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "cancel": "yes", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + _, err = b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGINT) + select { + case <-sigint: + // Stop redirecting SIGINT signals. + signal.Stop(sigint) + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected lock timeout after 50 milliseconds, waited 200 milliseconds") + } + + if len(input.answers) != 2 { + t.Fatalf("expected unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Lock timeout exceeded") { + t.Fatalf("expected lock timout error in output: %s", output) + } + if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("unexpected plan summary in output: %s", output) + } +} + +func TestCloud_planDestroy(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.DestroyMode + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } +} + +func TestCloud_planDestroyNoConfig(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/empty") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.DestroyMode + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } +} + +func TestCloud_planWithWorkingDirectory(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + options := tfe.WorkspaceUpdateOptions{ + WorkingDirectory: tfe.String("terraform"), + } + + // Configure the workspace to use a custom working directory. + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.WorkspaceMapping.Name, options) + if err != nil { + t.Fatalf("error configuring working directory: %v", err) + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-with-working-directory/terraform") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "The remote workspace is configured to work with configuration") { + t.Fatalf("expected working directory warning: %s", output) + } + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + options := tfe.WorkspaceUpdateOptions{ + WorkingDirectory: tfe.String("terraform"), + } + + // Configure the workspace to use a custom working directory. + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.WorkspaceMapping.Name, options) + if err != nil { + t.Fatalf("error configuring working directory: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("error getting current working directory: %v", err) + } + + // We need to change into the configuration directory to make sure + // the logic to upload the correct slug is working as expected. + if err := os.Chdir("./testdata/plan-with-working-directory/terraform"); err != nil { + t.Fatalf("error changing directory: %v", err) + } + defer os.Chdir(wd) // Make sure we change back again when were done. + + // For this test we need to give our current directory instead of the + // full path to the configuration as we already changed directories. + op, configCleanup, done := testOperationPlan(t, ".") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planCostEstimation(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-cost-estimation") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Resources: 1 of 1 estimated") { + t.Fatalf("expected cost estimate result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planPolicyPass(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-passed") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planPolicyHardFail(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-hard-failed") + defer configCleanup() + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := viewOutput.Stderr() + if !strings.Contains(errOutput, "hard failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planPolicySoftFail(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-soft-failed") + defer configCleanup() + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := viewOutput.Stderr() + if !strings.Contains(errOutput, "soft failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithRemoteError(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-with-error") + defer configCleanup() + defer done(t) + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "null_resource.foo: 1 error") { + t.Fatalf("expected plan error in output: %s", output) + } +} + +func TestCloud_planOtherError(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = "network-error" // custom error response in backend_mock.go + + _, err := b.Operation(context.Background(), op) + if err == nil { + t.Errorf("expected error, got success") + } + + if !strings.Contains(err.Error(), + "Terraform Cloud returned an unexpected error:\n\nI'm a little teacup") { + t.Fatalf("expected error message, got: %s", err.Error()) + } +} diff --git a/internal/cloud/backend_refresh_test.go b/internal/cloud/backend_refresh_test.go new file mode 100644 index 000000000..3abb93577 --- /dev/null +++ b/internal/cloud/backend_refresh_test.go @@ -0,0 +1,79 @@ +package cloud + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/mitchellh/cli" +) + +func testOperationRefresh(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + return testOperationRefreshWithTimeout(t, configDir, 0) +} + +func testOperationRefreshWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) + operationView := views.NewOperation(arguments.ViewHuman, false, view) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypeRefresh, + View: operationView, + }, configCleanup, done +} + +func TestCloud_refreshBasicActuallyRunsApplyRefresh(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh") + defer configCleanup() + defer done(t) + + op.UIOut = b.CLI + b.CLIColor = b.cliColorize() + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Proceeding with 'terraform apply -refresh-only -auto-approve'") { + t.Fatalf("expected TFC header in output: %s", output) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after apply: %s", err.Error()) + } +} diff --git a/internal/cloud/backend_state.go b/internal/cloud/backend_state.go new file mode 100644 index 000000000..055a808e5 --- /dev/null +++ b/internal/cloud/backend_state.go @@ -0,0 +1,182 @@ +package cloud + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "fmt" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statefile" + "github.com/hashicorp/terraform/internal/states/statemgr" +) + +type remoteClient struct { + client *tfe.Client + lockInfo *statemgr.LockInfo + organization string + runID string + stateUploadErr bool + workspace *tfe.Workspace + forcePush bool +} + +// Get the remote state. +func (r *remoteClient) Get() (*remote.Payload, error) { + ctx := context.Background() + + sv, err := r.client.StateVersions.Current(ctx, r.workspace.ID) + if err != nil { + if err == tfe.ErrResourceNotFound { + // If no state exists, then return nil. + return nil, nil + } + return nil, fmt.Errorf("Error retrieving state: %v", err) + } + + state, err := r.client.StateVersions.Download(ctx, sv.DownloadURL) + if err != nil { + return nil, fmt.Errorf("Error downloading state: %v", err) + } + + // If the state is empty, then return nil. + if len(state) == 0 { + return nil, nil + } + + // Get the MD5 checksum of the state. + sum := md5.Sum(state) + + return &remote.Payload{ + Data: state, + MD5: sum[:], + }, nil +} + +// Put the remote state. +func (r *remoteClient) Put(state []byte) error { + ctx := context.Background() + + // Read the raw state into a Terraform state. + stateFile, err := statefile.Read(bytes.NewReader(state)) + if err != nil { + return fmt.Errorf("Error reading state: %s", err) + } + + options := tfe.StateVersionCreateOptions{ + Lineage: tfe.String(stateFile.Lineage), + Serial: tfe.Int64(int64(stateFile.Serial)), + MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))), + State: tfe.String(base64.StdEncoding.EncodeToString(state)), + Force: tfe.Bool(r.forcePush), + } + + // If we have a run ID, make sure to add it to the options + // so the state will be properly associated with the run. + if r.runID != "" { + options.Run = &tfe.Run{ID: r.runID} + } + + // Create the new state. + _, err = r.client.StateVersions.Create(ctx, r.workspace.ID, options) + if err != nil { + r.stateUploadErr = true + return fmt.Errorf("Error uploading state: %v", err) + } + + return nil +} + +// Delete the remote state. +func (r *remoteClient) Delete() error { + err := r.client.Workspaces.Delete(context.Background(), r.organization, r.workspace.Name) + if err != nil && err != tfe.ErrResourceNotFound { + return fmt.Errorf("Error deleting workspace %s: %v", r.workspace.Name, err) + } + + return nil +} + +// EnableForcePush to allow the remote client to overwrite state +// by implementing remote.ClientForcePusher +func (r *remoteClient) EnableForcePush() { + r.forcePush = true +} + +// Lock the remote state. +func (r *remoteClient) Lock(info *statemgr.LockInfo) (string, error) { + ctx := context.Background() + + lockErr := &statemgr.LockError{Info: r.lockInfo} + + // Lock the workspace. + _, err := r.client.Workspaces.Lock(ctx, r.workspace.ID, tfe.WorkspaceLockOptions{ + Reason: tfe.String("Locked by Terraform"), + }) + if err != nil { + if err == tfe.ErrWorkspaceLocked { + lockErr.Info = info + err = fmt.Errorf("%s (lock ID: \"%s/%s\")", err, r.organization, r.workspace.Name) + } + lockErr.Err = err + return "", lockErr + } + + r.lockInfo = info + + return r.lockInfo.ID, nil +} + +// Unlock the remote state. +func (r *remoteClient) Unlock(id string) error { + ctx := context.Background() + + // We first check if there was an error while uploading the latest + // state. If so, we will not unlock the workspace to prevent any + // changes from being applied until the correct state is uploaded. + if r.stateUploadErr { + return nil + } + + lockErr := &statemgr.LockError{Info: r.lockInfo} + + // With lock info this should be treated as a normal unlock. + if r.lockInfo != nil { + // Verify the expected lock ID. + if r.lockInfo.ID != id { + lockErr.Err = fmt.Errorf("lock ID does not match existing lock") + return lockErr + } + + // Unlock the workspace. + _, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID) + if err != nil { + lockErr.Err = err + return lockErr + } + + return nil + } + + // Verify the optional force-unlock lock ID. + if r.organization+"/"+r.workspace.Name != id { + lockErr.Err = fmt.Errorf( + "lock ID %q does not match existing lock ID \"%s/%s\"", + id, + r.organization, + r.workspace.Name, + ) + return lockErr + } + + // Force unlock the workspace. + _, err := r.client.Workspaces.ForceUnlock(ctx, r.workspace.ID) + if err != nil { + lockErr.Err = err + return lockErr + } + + return nil +} diff --git a/internal/cloud/backend_state_test.go b/internal/cloud/backend_state_test.go new file mode 100644 index 000000000..63c970438 --- /dev/null +++ b/internal/cloud/backend_state_test.go @@ -0,0 +1,58 @@ +package cloud + +import ( + "bytes" + "os" + "testing" + + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statefile" +) + +func TestRemoteClient_impl(t *testing.T) { + var _ remote.Client = new(remoteClient) +} + +func TestRemoteClient(t *testing.T) { + client := testRemoteClient(t) + remote.TestClient(t, client) +} + +func TestRemoteClient_stateLock(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + s1, err := b.StateMgr(testBackendSingleWorkspaceName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + s2, err := b.StateMgr(testBackendSingleWorkspaceName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + remote.TestRemoteLocks(t, s1.(*remote.State).Client, s2.(*remote.State).Client) +} + +func TestRemoteClient_withRunID(t *testing.T) { + // Set the TFE_RUN_ID environment variable before creating the client! + if err := os.Setenv("TFE_RUN_ID", GenerateID("run-")); err != nil { + t.Fatalf("error setting env var TFE_RUN_ID: %v", err) + } + + // Create a new test client. + client := testRemoteClient(t) + + // Create a new empty state. + sf := statefile.New(states.NewState(), "", 0) + var buf bytes.Buffer + statefile.Write(sf, &buf) + + // Store the new state to verify (this will be done + // by the mock that is used) that the run ID is set. + if err := client.Put(buf.Bytes()); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go new file mode 100644 index 000000000..87f7a91f2 --- /dev/null +++ b/internal/cloud/backend_test.go @@ -0,0 +1,857 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/tfdiags" + tfversion "github.com/hashicorp/terraform/version" + "github.com/zclconf/go-cty/cty" + + backendLocal "github.com/hashicorp/terraform/internal/backend/local" +) + +func TestCloud(t *testing.T) { + var _ backend.Enhanced = New(nil) + var _ backend.CLI = New(nil) +} + +func TestCloud_backendWithName(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("error: %v", err) + } + + if len(workspaces) != 1 || workspaces[0] != testBackendSingleWorkspaceName { + t.Fatalf("should only have a single configured workspace matching the configured 'name' strategy, but got: %#v", workspaces) + } + + if _, err := b.StateMgr("foo"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected fetching a state which is NOT the single configured workspace to have an ErrWorkspacesNotSupported error, but got: %v", err) + } + + if err := b.DeleteWorkspace(testBackendSingleWorkspaceName); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected deleting the single configured workspace name to result in an error, but got: %v", err) + } + + if err := b.DeleteWorkspace("foo"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected deleting a workspace which is NOT the configured workspace name to result in an error, but got: %v", err) + } +} + +func TestCloud_backendWithTags(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + backend.TestBackendStates(t, b) + + // Test pagination works + for i := 0; i < 25; i++ { + _, err := b.StateMgr(fmt.Sprintf("foo-%d", i+1)) + if err != nil { + t.Fatalf("error: %s", err) + } + } + + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("error: %s", err) + } + actual := len(workspaces) + if actual != 26 { + t.Errorf("expected 26 workspaces (over one standard paginated response), got %d", actual) + } +} + +func TestCloud_PrepareConfig(t *testing.T) { + cases := map[string]struct { + config cty.Value + expectedErr string + }{ + "null organization": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedErr: `Invalid organization value: The "organization" attribute value must not be empty.`, + }, + "null workspace": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.NullVal(cty.String), + }), + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, + }, + "workspace: empty tags, name": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, + }, + "workspace: name present": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, + }, + "workspace: name and tags present": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, + }, + } + + for name, tc := range cases { + s := testServer(t) + b := New(testDisco(s)) + + // Validate + _, valDiags := b.PrepareConfig(tc.config) + if valDiags.Err() != nil && tc.expectedErr != "" { + actualErr := valDiags.Err().Error() + if !strings.Contains(actualErr, tc.expectedErr) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } + } + } +} + +func TestCloud_config(t *testing.T) { + cases := map[string]struct { + config cty.Value + confErr string + valErr string + }{ + "with_a_nonexisting_organization": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("nonexisting"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + confErr: "organization \"nonexisting\" at host app.terraform.io not found", + }, + "with_an_unknown_host": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("nonexisting.local"), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + confErr: "Failed to request discovery document", + }, + // localhost advertises TFE services, but has no token in the credentials + "without_a_token": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("localhost"), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + confErr: "terraform login localhost", + }, + "with_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + }, + "with_a_name": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + }, + "without_a_name_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + valErr: `Missing workspace mapping strategy.`, + }, + "with_both_a_name_and_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + valErr: `Only one of workspace "tags" or "name" is allowed.`, + }, + "null config": { + config: cty.NullVal(cty.EmptyObject), + }, + } + + for name, tc := range cases { + s := testServer(t) + b := New(testDisco(s)) + + // Validate + _, valDiags := b.PrepareConfig(tc.config) + if (valDiags.Err() != nil || tc.valErr != "") && + (valDiags.Err() == nil || !strings.Contains(valDiags.Err().Error(), tc.valErr)) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } + + // Configure + confDiags := b.Configure(tc.config) + if (confDiags.Err() != nil || tc.confErr != "") && + (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.confErr)) { + t.Fatalf("%s: unexpected configure result: %v", name, confDiags.Err()) + } + } +} + +func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { + config := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }) + + handlers := map[string]func(http.ResponseWriter, *http.Request){ + "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.4") + }, + } + s := testServerWithHandlers(handlers) + + b := New(testDisco(s)) + + confDiags := b.Configure(config) + if confDiags.Err() == nil { + t.Fatalf("expected configure to error") + } + + expected := `The 'cloud' option is not supported with this version of Terraform Enterprise.` + if !strings.Contains(confDiags.Err().Error(), expected) { + t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) + } +} + +func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) { + config := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }) + + handlers := map[string]func(http.ResponseWriter, *http.Request){ + "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.4") + }, + } + s := testServerWithHandlers(handlers) + + b := New(testDisco(s)) + b.runningInAutomation = true + + confDiags := b.Configure(config) + if confDiags.Err() == nil { + t.Fatalf("expected configure to error") + } + + expected := `This version of Terraform Cloud/Enterprise does not support the state mechanism +attempting to be used by the platform. This should never happen.` + if !strings.Contains(confDiags.Err().Error(), expected) { + t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) + } +} + +func TestCloud_setUnavailableTerraformVersion(t *testing.T) { + // go-tfe returns an error IRL if you try to set a Terraform version that's + // not available in your TFC instance. To test this, tfe_client_mock errors if + // you try to set any Terraform version for this specific workspace name. + workspaceName := "unavailable-terraform-version" + + config := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("sometag"), + }, + ), + }), + }) + + b, bCleanup := testBackend(t, config) + defer bCleanup() + + // Make sure the workspace doesn't exist yet -- otherwise, we can't test what + // happens when a workspace gets created. This is why we can't use "name" in + // the backend config above, btw: if you do, testBackend() creates the default + // workspace before we get a chance to do anything. + _, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) + if err != tfe.ErrResourceNotFound { + t.Fatalf("the workspace we were about to try and create (%s/%s) already exists in the mocks somehow, so this test isn't trustworthy anymore", b.organization, workspaceName) + } + + _, err = b.StateMgr(workspaceName) + if err != nil { + t.Fatalf("expected no error from StateMgr, despite not being able to set remote Terraform version: %#v", err) + } + // Make sure the workspace was created: + workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) + if err != nil { + t.Fatalf("b.StateMgr() didn't actually create the desired workspace") + } + // Make sure our mocks still error as expected, using the same update function b.StateMgr() would call: + _, err = b.client.Workspaces.UpdateByID( + context.Background(), + workspace.ID, + tfe.WorkspaceUpdateOptions{TerraformVersion: tfe.String("1.1.0")}, + ) + if err == nil { + t.Fatalf("the mocks aren't emulating a nonexistent remote Terraform version correctly, so this test isn't trustworthy anymore") + } +} + +func TestCloud_setConfigurationFields(t *testing.T) { + originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND") + + cases := map[string]struct { + obj cty.Value + expectedHostname string + expectedOrganziation string + expectedWorkspaceName string + expectedWorkspaceTags []string + expectedForceLocal bool + setEnv func() + resetEnv func() + expectedErr string + }{ + "with hostname set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + }, + "with hostname not set, set to default hostname": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedHostname: defaultHostname, + expectedOrganziation: "hashicorp", + }, + "with workspace name set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspaceName: "prod", + }, + "with workspace tags set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspaceTags: []string{"billing"}, + }, + "with force local set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + setEnv: func() { + os.Setenv("TF_FORCE_LOCAL_BACKEND", "1") + }, + resetEnv: func() { + os.Setenv("TF_FORCE_LOCAL_BACKEND", originalForceBackendEnv) + }, + expectedForceLocal: true, + }, + } + + for name, tc := range cases { + b := &Cloud{} + + // if `setEnv` is set, then we expect `resetEnv` to also be set + if tc.setEnv != nil { + tc.setEnv() + defer tc.resetEnv() + } + + errDiags := b.setConfigurationFields(tc.obj) + if errDiags.HasErrors() || tc.expectedErr != "" { + actualErr := errDiags.Err().Error() + if !strings.Contains(actualErr, tc.expectedErr) { + t.Fatalf("%s: unexpected validation result: %v", name, errDiags.Err()) + } + } + + if tc.expectedHostname != "" && b.hostname != tc.expectedHostname { + t.Fatalf("%s: expected hostname %s to match configured hostname %s", name, b.hostname, tc.expectedHostname) + } + if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { + t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganziation) + } + if tc.expectedWorkspaceName != "" && b.WorkspaceMapping.Name != tc.expectedWorkspaceName { + t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName) + } + if len(tc.expectedWorkspaceTags) > 0 { + presentSet := make(map[string]struct{}) + for _, tag := range b.WorkspaceMapping.Tags { + presentSet[tag] = struct{}{} + } + + expectedSet := make(map[string]struct{}) + for _, tag := range tc.expectedWorkspaceTags { + expectedSet[tag] = struct{}{} + } + + var missing []string + var unexpected []string + + for _, expected := range tc.expectedWorkspaceTags { + if _, ok := presentSet[expected]; !ok { + missing = append(missing, expected) + } + } + + for _, actual := range b.WorkspaceMapping.Tags { + if _, ok := expectedSet[actual]; !ok { + unexpected = append(missing, actual) + } + } + + if len(missing) > 0 { + t.Fatalf("%s: expected workspace tag mapping (%s) to contain the following tags: %s", name, b.WorkspaceMapping.Tags, missing) + } + + if len(unexpected) > 0 { + t.Fatalf("%s: expected workspace tag mapping (%s) to NOT contain the following tags: %s", name, b.WorkspaceMapping.Tags, unexpected) + } + + } + if tc.expectedForceLocal != false && b.forceLocal != tc.expectedForceLocal { + t.Fatalf("%s: expected force local backend to be set ", name) + } + } +} + +func TestCloud_localBackend(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + local, ok := b.local.(*backendLocal.Local) + if !ok { + t.Fatalf("expected b.local to be \"*local.Local\", got: %T", b.local) + } + + cloud, ok := local.Backend.(*Cloud) + if !ok { + t.Fatalf("expected local.Backend to be *cloud.Cloud, got: %T", cloud) + } +} + +func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if err := b.DeleteWorkspace(testBackendSingleWorkspaceName); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } +} + +func TestCloud_StateMgr_versionCheck(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + // Some fixed versions for testing with. This logic is a simple string + // comparison, so we don't need many test cases. + v0135 := version.Must(version.NewSemver("0.13.5")) + v0140 := version.Must(version.NewSemver("0.14.0")) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // For this test, the local Terraform version is set to 0.14.0 + tfversion.Prerelease = "" + tfversion.Version = v0140.String() + tfversion.SemVer = v0140 + + // Update the mock remote workspace Terraform version to match the local + // Terraform version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(v0140.String()), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + // This should succeed + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Now change the remote workspace to a different Terraform version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(v0135.String()), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + // This should fail + want := `Remote workspace Terraform version "0.13.5" does not match local Terraform version "0.14.0"` + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err.Error() != want { + t.Fatalf("wrong error\n got: %v\nwant: %v", err.Error(), want) + } +} + +func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + v0140 := version.Must(version.NewSemver("0.14.0")) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // For this test, the local Terraform version is set to 0.14.0 + tfversion.Prerelease = "" + tfversion.Version = v0140.String() + tfversion.SemVer = v0140 + + // Update the remote workspace to the pseudo-version "latest" + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String("latest"), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + // This should succeed despite not being a string match + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { + testCases := []struct { + local string + remote string + operations bool + wantErr bool + }{ + {"0.13.5", "0.13.5", true, false}, + {"0.14.0", "0.13.5", true, true}, + {"0.14.0", "0.13.5", false, false}, + {"0.14.0", "0.14.1", true, false}, + {"0.14.0", "1.0.99", true, false}, + {"0.14.0", "1.1.0", true, false}, + {"0.14.0", "1.2.0", true, true}, + {"1.2.0", "1.2.99", true, false}, + {"1.2.0", "1.3.0", true, true}, + {"0.15.0", "latest", true, false}, + {"1.1.5", "~> 1.1.1", true, false}, + {"1.1.5", "> 1.1.0, < 1.3.0", true, false}, + {"1.1.5", "~> 1.0.1", true, true}, + // pre-release versions are comparable within their pre-release stage (dev, + // alpha, beta), but not comparable to different stages and not comparable + // to final releases. + {"1.1.0-beta1", "~> 1.1.0-beta", true, false}, + {"1.1.0", "~> 1.1.0-beta", true, true}, + {"1.1.0-beta1", "~> 1.1.0-dev", true, true}, + } + for _, tc := range testCases { + t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + local := version.Must(version.NewSemver(tc.local)) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // Override local version as specified + tfversion.Prerelease = "" + tfversion.Version = local.String() + tfversion.SemVer = local + + // Update the mock remote workspace Terraform version to the + // specified remote version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + Operations: tfe.Bool(tc.operations), + TerraformVersion: tfe.String(tc.remote), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) + if tc.wantErr { + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + if got := diags.Err().Error(); !strings.Contains(got, "Incompatible Terraform version") { + t.Fatalf("unexpected error: %s", got) + } + } else { + if len(diags) != 0 { + t.Fatalf("unexpected diags: %s", diags.Err()) + } + } + }) + } +} + +func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + // Attempting to check the version against a workspace which doesn't exist + // should result in no errors + diags := b.VerifyWorkspaceTerraformVersion("invalid-workspace") + if len(diags) != 0 { + t.Fatalf("unexpected error: %s", diags.Err()) + } + + // Use a special workspace ID to trigger a 500 error, which should result + // in a failed check + diags = b.VerifyWorkspaceTerraformVersion("network-error") + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Workspace read failed") { + t.Fatalf("unexpected error: %s", got) + } + + // Update the mock remote workspace Terraform version to an invalid version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String("1.0.cheetarah"), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + diags = b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) + + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + if got := diags.Err().Error(); !strings.Contains(got, "Incompatible Terraform version: The remote workspace specified") { + t.Fatalf("unexpected error: %s", got) + } +} + +func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + // If the ignore flag is set, the behaviour changes + b.IgnoreVersionConflict() + + // Different local & remote versions to cause an error + local := version.Must(version.NewSemver("0.14.0")) + remote := version.Must(version.NewSemver("0.13.5")) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // Override local version as specified + tfversion.Prerelease = "" + tfversion.Version = local.String() + tfversion.SemVer = local + + // Update the mock remote workspace Terraform version to the + // specified remote version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.WorkspaceMapping.Name, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(remote.String()), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong severity: got %#v, want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Incompatible Terraform version"; got != want { + t.Errorf("wrong summary: got %s, want %s", got, want) + } + wantDetail := "The local Terraform version (0.14.0) does not meet the version requirements for remote workspace hashicorp/app-prod (0.13.5)." + if got := diags[0].Description().Detail; got != wantDetail { + t.Errorf("wrong summary: got %s, want %s", got, wantDetail) + } +} diff --git a/internal/cloud/cloud_variables.go b/internal/cloud/cloud_variables.go new file mode 100644 index 000000000..9a9002828 --- /dev/null +++ b/internal/cloud/cloud_variables.go @@ -0,0 +1,52 @@ +package cloud + +import ( + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +func allowedSourceType(source terraform.ValueSourceType) bool { + return source == terraform.ValueFromNamedFile || source == terraform.ValueFromCLIArg || source == terraform.ValueFromEnvVar +} + +// ParseCloudRunVariables accepts a mapping of unparsed values and a mapping of variable +// declarations and returns a name/value variable map appropriate for an API run context, +// that is, containing declared string variables only sourced from non-file inputs like CLI args +// and environment variables. However, all variable parsing diagnostics are returned +// in order to allow callers to short circuit cloud runs that contain variable +// declaration or parsing errors. The only exception is that missing required values are not +// considered errors because they may be defined within the cloud workspace. +func ParseCloudRunVariables(vv map[string]backend.UnparsedVariableValue, decls map[string]*configs.Variable) (map[string]string, tfdiags.Diagnostics) { + declared, diags := backend.ParseDeclaredVariableValues(vv, decls) + _, undedeclaredDiags := backend.ParseUndeclaredVariableValues(vv, decls) + diags = diags.Append(undedeclaredDiags) + + ret := make(map[string]string, len(declared)) + + // Even if there are parsing or declaration errors, populate the return map with the + // variables that could be used for cloud runs + for name, v := range declared { + if !allowedSourceType(v.SourceType) { + continue + } + + valueData, err := ctyjson.Marshal(v.Value, v.Value.Type()) + if err != nil { + return nil, diags.Append(fmt.Errorf("error marshaling input variable value as json: %w", err)) + } + var variableValue string + if err = json.Unmarshal(valueData, &variableValue); err != nil { + // This should never happen since cty marshaled the value to begin with without error + return nil, diags.Append(fmt.Errorf("error unmarshaling run variable: %w", err)) + } + ret[name] = variableValue + } + + return ret, diags +} diff --git a/internal/cloud/cloud_variables_test.go b/internal/cloud/cloud_variables_test.go new file mode 100644 index 000000000..f8a8a5f1c --- /dev/null +++ b/internal/cloud/cloud_variables_test.go @@ -0,0 +1,119 @@ +package cloud + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestParseCloudRunVariables(t *testing.T) { + t.Run("populates variables from allowed sources", func(t *testing.T) { + vv := map[string]backend.UnparsedVariableValue{ + "undeclared": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: "0"}, + "declaredFromConfig": testUnparsedVariableValue{source: terraform.ValueFromConfig, value: "1"}, + "declaredFromNamedFile": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: "2"}, + "declaredFromCLIArg": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: "3"}, + "declaredFromEnvVar": testUnparsedVariableValue{source: terraform.ValueFromEnvVar, value: "4"}, + } + + decls := map[string]*configs.Variable{ + "declaredFromConfig": { + Name: "declaredFromConfig", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "declaredFromNamedFile": { + Name: "declaredFromNamedFile", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "declaredFromCLIArg": { + Name: "declaredFromCLIArg", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "declaredFromEnvVar": { + Name: "declaredFromEnvVar", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "missing": { + Name: "missing", + Type: cty.String, + ConstraintType: cty.String, + Default: cty.StringVal("2"), + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + } + wantVals := make(map[string]string) + + wantVals["declaredFromNamedFile"] = "2" + wantVals["declaredFromCLIArg"] = "3" + wantVals["declaredFromEnvVar"] = "4" + + gotVals, diags := ParseCloudRunVariables(vv, decls) + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + + if got, want := len(diags), 1; got != want { + t.Fatalf("expected 1 variable error: %v, got %v", diags.Err(), want) + } + + if got, want := diags[0].Description().Summary, "Value for undeclared variable"; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + }) +} + +type testUnparsedVariableValue struct { + source terraform.ValueSourceType + value string +} + +func (v testUnparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.StringVal(v.value), + SourceType: v.source, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, nil +} diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go new file mode 100644 index 000000000..535be368a --- /dev/null +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -0,0 +1,256 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "io/ioutil" + "log" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" + tfversion "github.com/hashicorp/terraform/version" +) + +func Test_terraform_apply_autoApprove(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "workspace manual apply, terraform apply without auto-approve, expect prompt": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfversion.String()), + AutoApply: tfe.Bool(false), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply without auto-approve, expect prompt": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfversion.String()), + AutoApply: tfe.Bool(true), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace manual apply, terraform apply with auto-approve, no prompt": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfversion.String()), + AutoApply: tfe.Bool(false), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply with auto-approve, no prompt": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfversion.String()), + AutoApply: tfe.Bool(true), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + } + for name, tc := range cases { + log.Println("Test: ", name) + + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} diff --git a/internal/cloud/e2e/backend_apply_before_init_test.go b/internal/cloud/e2e/backend_apply_before_init_test.go new file mode 100644 index 000000000..702b79bbc --- /dev/null +++ b/internal/cloud/e2e/backend_apply_before_init_test.go @@ -0,0 +1,143 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_backend_apply_before_init(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + cases := map[string]struct { + operations []operationSets + }{ + "terraform apply with cloud block - blank state": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"apply"}, + expectedCmdOutput: `Terraform Cloud initialization required, please run "terraform init"`, + expectError: true, + }, + }, + }, + }, + }, + "terraform apply with cloud block - local state": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"apply"}, + expectedCmdOutput: `Terraform Cloud initialization required, please run "terraform init"`, + expectError: true, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err) + } + } + } + } +} diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go new file mode 100644 index 000000000..0f4480ddf --- /dev/null +++ b/internal/cloud/e2e/helper_test.go @@ -0,0 +1,234 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/go-uuid" + tfversion "github.com/hashicorp/terraform/version" +) + +const ( + expectConsoleTimeout = 15 * time.Second +) + +type tfCommand struct { + command []string + expectedCmdOutput string + expectedErr string + expectError bool + userInput []string + postInputOutput []string +} + +type operationSets struct { + commands []tfCommand + prep func(t *testing.T, orgName, dir string) +} + +type testCases map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) +} + +func createOrganization(t *testing.T) (*tfe.Organization, func()) { + ctx := context.Background() + org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String("tst-" + randomString(t)), + Email: tfe.String(fmt.Sprintf("%s@tfe.local", randomString(t))), + CostEstimationEnabled: tfe.Bool(false), + }) + if err != nil { + t.Fatal(err) + } + + _, err = tfeClient.Admin.Organizations.Update(ctx, org.Name, tfe.AdminOrganizationUpdateOptions{ + AccessBetaTools: tfe.Bool(true), + }) + if err != nil { + t.Fatal(err) + } + + return org, func() { + if err := tfeClient.Organizations.Delete(ctx, org.Name); err != nil { + t.Errorf("Error destroying organization! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Organization: %s\nError: %s", org.Name, err) + } + } +} + +func createWorkspace(t *testing.T, orgName string, wOpts tfe.WorkspaceCreateOptions) *tfe.Workspace { + ctx := context.Background() + w, err := tfeClient.Workspaces.Create(ctx, orgName, wOpts) + if err != nil { + t.Fatal(err) + } + + return w +} + +func getWorkspace(workspaces []*tfe.Workspace, workspace string) (*tfe.Workspace, bool) { + for _, ws := range workspaces { + if ws.Name == workspace { + return ws, false + } + } + return nil, true +} + +func randomString(t *testing.T) string { + v, err := uuid.GenerateUUID() + if err != nil { + t.Fatal(err) + } + return v +} + +func terraformConfigLocalBackend() string { + return fmt.Sprintf(` +terraform { + backend "local" { + } +} + +output "val" { + value = "${terraform.workspace}" +} +`) +} + +func terraformConfigRemoteBackendName(org, name string) string { + return fmt.Sprintf(` +terraform { + backend "remote" { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +output "val" { + value = "${terraform.workspace}" +} +`, tfeHostname, org, name) +} + +func terraformConfigRemoteBackendPrefix(org, prefix string) string { + return fmt.Sprintf(` +terraform { + backend "remote" { + hostname = "%s" + organization = "%s" + + workspaces { + prefix = "%s" + } + } +} + +output "val" { + value = "${terraform.workspace}" +} +`, tfeHostname, org, prefix) +} + +func terraformConfigCloudBackendTags(org, tag string) string { + return fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + tags = ["%s"] + } + } +} + +output "tag_val" { + value = "%s" +} +`, tfeHostname, org, tag, tag) +} + +func terraformConfigCloudBackendName(org, name string) string { + return fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +output "val" { + value = "${terraform.workspace}" +} +`, tfeHostname, org, name) +} + +func writeMainTF(t *testing.T, block string, dir string) { + f, err := os.Create(fmt.Sprintf("%s/main.tf", dir)) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(block) + if err != nil { + t.Fatal(err) + } + f.Close() +} + +// Ensure that TFC/E has a particular terraform version. +func skipWithoutRemoteTerraformVersion(t *testing.T) { + version := tfversion.String() + opts := tfe.AdminTerraformVersionsListOptions{ + ListOptions: tfe.ListOptions{ + PageNumber: 1, + PageSize: 100, + }, + } + hasVersion := false + +findTfVersion: + for { + // TODO: update go-tfe Read() to retrieve a terraform version by name. + // Currently you can only retrieve by ID. + tfVersionList, err := tfeClient.Admin.TerraformVersions.List(context.Background(), opts) + if err != nil { + t.Fatalf("Could not retrieve list of terraform versions: %v", err) + } + for _, item := range tfVersionList.Items { + if item.Version == version { + hasVersion = true + break findTfVersion + } + } + + // Exit the loop when we've seen all pages. + if tfVersionList.CurrentPage >= tfVersionList.TotalPages { + break + } + + // Update the page number to get the next page. + opts.PageNumber = tfVersionList.NextPage + } + + if !hasVersion { + t.Skip(fmt.Sprintf("Skipping test because TFC/E does not have current Terraform version to test with (%s)", version)) + } +} diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go new file mode 100644 index 000000000..48e40b81f --- /dev/null +++ b/internal/cloud/e2e/main_test.go @@ -0,0 +1,141 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" + tfversion "github.com/hashicorp/terraform/version" +) + +var terraformBin string +var cliConfigFileEnv string + +var tfeClient *tfe.Client +var tfeHostname string +var tfeToken string + +func TestMain(m *testing.M) { + log.SetFlags(log.LstdFlags | log.Lshortfile) + if !accTest() { + // if TF_ACC is not set, we want to skip all these tests. + return + } + teardown := setup() + code := m.Run() + teardown() + + os.Exit(code) +} + +func accTest() bool { + // TF_ACC is set when we want to run acceptance tests, meaning it relies on + // network access. + return os.Getenv("TF_ACC") != "" +} + +func setup() func() { + setTfeClient() + teardown := setupBinary() + + return func() { + teardown() + } +} + +func setTfeClient() { + hostname := os.Getenv("TFE_HOSTNAME") + token := os.Getenv("TFE_TOKEN") + if hostname == "" { + log.Fatal("hostname cannot be empty") + } + if token == "" { + log.Fatal("token cannot be empty") + } + tfeHostname = hostname + tfeToken = token + + cfg := &tfe.Config{ + Address: fmt.Sprintf("https://%s", hostname), + Token: token, + } + + // Create a new TFE client. + client, err := tfe.NewClient(cfg) + if err != nil { + log.Fatal(err) + } + tfeClient = client +} + +func setupBinary() func() { + log.Println("Setting up terraform binary") + tmpTerraformBinaryDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + log.Fatal(err) + } + log.Println(tmpTerraformBinaryDir) + currentDir, err := os.Getwd() + defer os.Chdir(currentDir) + if err != nil { + log.Fatal(err) + } + // Getting top level dir + dirPaths := strings.Split(currentDir, "/") + log.Println(currentDir) + topLevel := len(dirPaths) - 3 + topDir := strings.Join(dirPaths[0:topLevel], "/") + + if err := os.Chdir(topDir); err != nil { + log.Fatal(err) + } + + cmd := exec.Command( + "go", + "build", + "-o", tmpTerraformBinaryDir, + "-ldflags", fmt.Sprintf("-X \"github.com/hashicorp/terraform/version.Prerelease=%s\"", tfversion.Prerelease), + ) + err = cmd.Run() + if err != nil { + log.Fatal(err) + } + + credFile := fmt.Sprintf("%s/dev.tfrc", tmpTerraformBinaryDir) + writeCredRC(credFile) + + terraformBin = fmt.Sprintf("%s/terraform", tmpTerraformBinaryDir) + cliConfigFileEnv = fmt.Sprintf("TF_CLI_CONFIG_FILE=%s", credFile) + + return func() { + os.RemoveAll(tmpTerraformBinaryDir) + } +} + +func writeCredRC(file string) { + creds := credentialBlock() + f, err := os.Create(file) + if err != nil { + log.Fatal(err) + } + _, err = f.WriteString(creds) + if err != nil { + log.Fatal(err) + } + f.Close() +} + +func credentialBlock() string { + return fmt.Sprintf(` +credentials "%s" { + token = "%s" +}`, tfeHostname, tfeToken) +} diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go new file mode 100644 index 000000000..a0a6b7430 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -0,0 +1,438 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating multiple workspaces to cloud using name strategy; current workspace is 'default'": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "default"}, + expectedCmdOutput: `Switched to workspace "default".`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, // this was the output of the current workspace selected before migration + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 1 { + t.Fatalf("Expected the number of workspaces to be 1, but got %d", len(wsList.Items)) + } + ws := wsList.Items[0] + // this workspace name is what exists in the cloud backend configuration block + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + "migrating multiple workspaces to cloud using name strategy; current workspace is 'prod'": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + // this workspace name is what exists in the cloud backend configuration block + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + tfCmd.command = append(tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } + +} + +func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating multiple workspaces to cloud using tags strategy; pattern is using prefix `app-*`": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "default"}, + expectedCmdOutput: `Switched to workspace "default".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, + }, + { + command: []string{"workspace", "select", "prod"}, + expectedCmdOutput: `Switched to workspace "prod".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, + userInput: []string{"dev", "1", "app-*", "1"}, + postInputOutput: []string{ + `Would you like to rename your workspaces?`, + "What pattern would you like to add to all your workspaces?", + "The currently selected workspace (prod) does not exist.", + "Terraform Cloud has been successfully initialized!"}, + }, + { + command: []string{"workspace", "select", "app-prod"}, + expectedCmdOutput: `Switched to workspace "app-prod".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + { + command: []string{"workspace", "select", "app-dev"}, + expectedCmdOutput: `Switched to workspace "app-dev".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Fatalf("Expected the number of workspaecs to be 2, but got %d", len(wsList.Items)) + } + expectedWorkspaceNames := []string{"app-prod", "app-dev"} + for _, ws := range wsList.Items { + hasName := false + for _, expectedNames := range expectedWorkspaceNames { + if expectedNames == ws.Name { + hasName = true + } + } + if !hasName { + t.Fatalf("Worksapce %s is not in the expected list of workspaces", ws.Name) + } + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + organization, cleanup := createOrganization(t) + t.Log(organization.Name) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + if output == "" { + continue + } + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go new file mode 100644 index 000000000..7f0a6fbc8 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -0,0 +1,925 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "backend name strategy, to cloud with name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "cloud-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + "backend name strategy, to cloud name strategy, using the same name": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "remote-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `remote-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "remote-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} + +func Test_migrate_remote_backend_name_to_tfc_name_different_org(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "backend name strategy, to cloud name strategy, using the same name, different organization": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "remote-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `remote-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "remote-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + orgOne, cleanupOne := createOrganization(t) + orgTwo, cleanupTwo := createOrganization(t) + defer cleanupOne() + defer cleanupTwo() + orgs := []string{orgOne.Name, orgTwo.Name} + var orgName string + for index, op := range tc.operations { + orgName = orgs[index] + op.prep(t, orgName, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, orgName) + } + } +} + +func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend name strategy, to cloud with tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `default`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, + userInput: []string{"cloud-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 1 { + t.Fatalf("Expected number of workspaces to be 1, but got %d", len(wsList.Items)) + } + ws := wsList.Items[0] + if ws.Name != "cloud-workspace" { + t.Fatalf("Expected workspace to be `cloud-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} + +func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend prefix strategy, to cloud with name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "cloud-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, + userInput: []string{"yes"}, + postInputOutput: []string{ + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + "multiple workspaces with backend prefix strategy, to cloud with name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-two")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform has been successfully initialized!`}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: "* one", // app name retrieved via prefix + }, + { + command: []string{"workspace", "select", "two"}, + expectedCmdOutput: `Switched to workspace "two".`, // app name retrieved via prefix + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "cloud-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes"}, + postInputOutput: []string{ + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 3 { + t.Fatalf("expected number of workspaces in this org to be 3, but got %d", len(wsList.Items)) + } + ws, empty := getWorkspace(wsList.Items, "cloud-workspace") + if empty { + t.Fatalf("expected workspaces to include 'cloud-workspace' but didn't.") + } + ws, empty = getWorkspace(wsList.Items, "app-one") + if empty { + t.Fatalf("expected workspaces to include 'app-one' but didn't.") + } + ws, empty = getWorkspace(wsList.Items, "app-two") + if empty { + t.Fatalf("expected workspaces to include 'app-two' but didn't.") + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} + +func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend prefix strategy, to cloud with tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, + userInput: []string{"cloud-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + "multiple workspaces with backend prefix strategy, to cloud with tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-two")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform has been successfully initialized!`}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "two"}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-two"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Would you like to rename your workspaces?`, + userInput: []string{"1", "*"}, + postInputOutput: []string{`What pattern would you like to add to all your workspaces?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: "two", // this comes from the original workspace name from the previous backend. + }, + { + command: []string{"workspace", "select", "one"}, + expectedCmdOutput: `Switched to workspace "one".`, // this comes from the original workspace name from the previous backend. + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Logf("Expected the number of workspaces to be 2, but got %d", len(wsList.Items)) + } + ws, empty := getWorkspace(wsList.Items, "one") + if empty { + t.Fatalf("expected workspaces to include 'one' but didn't.") + } + if len(ws.TagNames) == 0 { + t.Fatalf("expected workspaces 'one' to have tags.") + } + ws, empty = getWorkspace(wsList.Items, "two") + if empty { + t.Fatalf("expected workspaces to include 'two' but didn't.") + } + if len(ws.TagNames) == 0 { + t.Fatalf("expected workspaces 'two' to have tags.") + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go new file mode 100644 index 000000000..baf9126f4 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -0,0 +1,204 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_migrate_single_to_tfc(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrate using cloud workspace name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + "migrate using cloud workspace tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} diff --git a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go new file mode 100644 index 000000000..a7b807191 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go @@ -0,0 +1,117 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_migrate_tfc_to_other(t *testing.T) { + cases := map[string]struct { + operations []operationSets + }{ + "migrate from cloud to local backend": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`, + expectError: true, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err) + } + } + } + } +} diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go new file mode 100644 index 000000000..fff9b5416 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -0,0 +1,564 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" + tfversion "github.com/hashicorp/terraform/version" +) + +func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() + + cases := map[string]struct { + setup func(t *testing.T) (string, func()) + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating from name to name": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "prod" + // Creating the workspace here instead of it being created + // dynamically in the Cloud StateMgr because we want to ensure that + // the terraform version selected for the workspace matches the + // terraform version of this current branch. + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `prod`, // this comes from the `prep` function + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "dev" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `dev`, // this comes from the `prep` function + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + // this workspace name is what exists in the cloud backend configuration block + if len(wsList.Items) != 2 { + t.Fatal("Expected number of workspaces to be 2") + } + }, + }, + "migrating from name to tags": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "prod" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + // this workspace name is what exists in the cloud backend configuration block + if len(wsList.Items) != 1 { + t.Fatal("Expected number of workspaces to be 1") + } + }, + }, + "migrating from name to tags without ignore-version flag": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "prod" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + // This is only here to ensure that the updated terraform version is + // present in the workspace, and it does not default to a lower + // version that does not support `cloud`. + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("new-workspace"), + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, + expectError: true, + userInput: []string{"new-workspace", "yes"}, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + // The migration never occured, so we have no workspaces with this tag. + if len(wsList.Items) != 0 { + t.Fatalf("Expected number of workspaces to be 0, but got %d", len(wsList.Items)) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + orgName, cleanup := tc.setup(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, orgName, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err.Error()) + } + } + } + + if tc.validations != nil { + tc.validations(t, orgName) + } + } +} + +func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + ctx := context.Background() + + cases := map[string]struct { + setup func(t *testing.T) (string, func()) + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating from multiple workspaces via tags to name": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-prod"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfversion.String()), + }) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-staging"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "app-staging"}, + expectedCmdOutput: `Switched to workspace "app-staging".`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-staging"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"output"}, + expectedCmdOutput: `tag_val = "app"`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + name := "service" + // Doing this here instead of relying on dynamic workspace creation + // because we want to set the terraform version here so that it is + // using the right version for post init operations. + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(name), + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendName(orgName, name) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `service`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `tag_val = "app"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + ws, err := tfeClient.Workspaces.Read(ctx, orgName, "service") + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatal("Expected to workspace not be empty") + } + }, + }, + "migrating from multiple workspaces via tags to other tags": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-prod"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfversion.String()), + }) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-staging"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfversion.String()), + }) + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + { + command: []string{"workspace", "select", "app-staging"}, + expectedCmdOutput: `Switched to workspace "app-staging".`, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "billing" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + t.Log(orgName) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Would you like to rename your workspaces?`, + userInput: []string{"1", "new-*", "1"}, + postInputOutput: []string{ + `What pattern would you like to add to all your workspaces?`, + `The currently selected workspace (app-staging) does not exist.`, + `Terraform Cloud has been successfully initialized!`}, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("billing"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Logf("Expected the number of workspaces to be 2, but got %d", len(wsList.Items)) + } + _, empty := getWorkspace(wsList.Items, "new-app-prod") + if empty { + t.Fatalf("expected workspaces to include 'new-app-prod' but didn't.") + } + _, empty = getWorkspace(wsList.Items, "new-app-staging") + if empty { + t.Fatalf("expected workspaces to include 'new-app-staging' but didn't.") + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + orgName, cleanup := tc.setup(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, orgName, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + t.Log(cmd.Stderr) + err = cmd.Wait() + if err != nil { + t.Fatal(err.Error()) + } + } + } + + if tc.validations != nil { + tc.validations(t, orgName) + } + } +} diff --git a/internal/cloud/e2e/run_variables_test.go b/internal/cloud/e2e/run_variables_test.go new file mode 100644 index 000000000..c92f22e9b --- /dev/null +++ b/internal/cloud/e2e/run_variables_test.go @@ -0,0 +1,151 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + "github.com/hashicorp/terraform/internal/e2e" +) + +func terraformConfigRequiredVariable(org, name string) string { + return fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +variable "foo" { + type = string +} + +variable "baz" { + type = string +} + +output "test_cli" { + value = var.foo +} + +output "test_env" { + value = var.baz +} + +`, tfeHostname, org, name) +} + +func Test_cloud_run_variables(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + + cases := testCases{ + "run variables from CLI arg": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigRequiredVariable(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"plan", "-var", "foo=bar"}, + expectedCmdOutput: ` + test_cli = "bar"`, + }, + { + command: []string{"plan", "-var", "foo=bar"}, + expectedCmdOutput: ` + test_env = "qux"`, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv("TF_CLI_ARGS=-no-color") + tf.AddEnv("TF_VAR_baz=qux") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err) + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } + } +} diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go new file mode 100644 index 000000000..58494f70e --- /dev/null +++ b/internal/cloud/errors.go @@ -0,0 +1,45 @@ +package cloud + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +var ( + invalidOrganizationConfigMissingValue = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid organization value", + `The "organization" attribute value must not be empty.\n\n%s`, + cty.Path{cty.GetAttrStep{Name: "organization"}}, + ) + + invalidWorkspaceConfigMissingValues = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + fmt.Sprintf("Missing workspace mapping strategy. Either workspace \"tags\" or \"name\" is required.\n\n%s", workspaceConfigurationHelp), + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + ) + + invalidWorkspaceConfigMisconfiguration = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + fmt.Sprintf("Only one of workspace \"tags\" or \"name\" is allowed.\n\n%s", workspaceConfigurationHelp), + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + ) +) + +const ignoreRemoteVersionHelp = "If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." + +func incompatibleWorkspaceTerraformVersion(message string, ignoreVersionConflict bool) tfdiags.Diagnostic { + severity := tfdiags.Error + suggestion := ignoreRemoteVersionHelp + if ignoreVersionConflict { + severity = tfdiags.Warning + suggestion = "" + } + description := strings.TrimSpace(fmt.Sprintf("%s\n\n%s", message, suggestion)) + return tfdiags.Sourceless(severity, "Incompatible Terraform version", description) +} diff --git a/internal/cloud/remote_test.go b/internal/cloud/remote_test.go new file mode 100644 index 000000000..b0c44d60a --- /dev/null +++ b/internal/cloud/remote_test.go @@ -0,0 +1,25 @@ +package cloud + +import ( + "flag" + "os" + "testing" + "time" + + _ "github.com/hashicorp/terraform/internal/logging" +) + +func TestMain(m *testing.M) { + flag.Parse() + + // Make sure TF_FORCE_LOCAL_BACKEND is unset + os.Unsetenv("TF_FORCE_LOCAL_BACKEND") + + // Reduce delays to make tests run faster + backoffMin = 1.0 + backoffMax = 1.0 + planConfigurationVersionsPollInterval = 1 * time.Millisecond + runPollInterval = 1 * time.Millisecond + + os.Exit(m.Run()) +} diff --git a/internal/cloud/testdata/.gitignore b/internal/cloud/testdata/.gitignore new file mode 100644 index 000000000..15498bbfb --- /dev/null +++ b/internal/cloud/testdata/.gitignore @@ -0,0 +1 @@ +!*.log diff --git a/internal/cloud/testdata/apply-destroy/apply.log b/internal/cloud/testdata/apply-destroy/apply.log new file mode 100644 index 000000000..d126547d9 --- /dev/null +++ b/internal/cloud/testdata/apply-destroy/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Destroying... (ID: 8657651096157629581) +null_resource.hello: Destruction complete after 0s + +Apply complete! Resources: 0 added, 0 changed, 1 destroyed. diff --git a/internal/cloud/testdata/apply-destroy/main.tf b/internal/cloud/testdata/apply-destroy/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-destroy/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-destroy/plan.log b/internal/cloud/testdata/apply-destroy/plan.log new file mode 100644 index 000000000..1d38d4168 --- /dev/null +++ b/internal/cloud/testdata/apply-destroy/plan.log @@ -0,0 +1,22 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + - destroy + +Terraform will perform the following actions: + + - null_resource.hello + + +Plan: 0 to add, 0 to change, 1 to destroy. diff --git a/internal/cloud/testdata/apply-no-changes/main.tf b/internal/cloud/testdata/apply-no-changes/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-no-changes/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-no-changes/plan.log b/internal/cloud/testdata/apply-no-changes/plan.log new file mode 100644 index 000000000..704168151 --- /dev/null +++ b/internal/cloud/testdata/apply-no-changes/plan.log @@ -0,0 +1,17 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. diff --git a/internal/cloud/testdata/apply-no-changes/policy.log b/internal/cloud/testdata/apply-no-changes/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/apply-no-changes/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-policy-hard-failed/main.tf b/internal/cloud/testdata/apply-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-policy-hard-failed/plan.log b/internal/cloud/testdata/apply-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-policy-hard-failed/policy.log b/internal/cloud/testdata/apply-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-policy-passed/apply.log b/internal/cloud/testdata/apply-policy-passed/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply-policy-passed/main.tf b/internal/cloud/testdata/apply-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-policy-passed/plan.log b/internal/cloud/testdata/apply-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-policy-passed/policy.log b/internal/cloud/testdata/apply-policy-passed/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-policy-soft-failed/apply.log b/internal/cloud/testdata/apply-policy-soft-failed/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply-policy-soft-failed/main.tf b/internal/cloud/testdata/apply-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-policy-soft-failed/plan.log b/internal/cloud/testdata/apply-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-policy-soft-failed/policy.log b/internal/cloud/testdata/apply-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-variables/apply.log b/internal/cloud/testdata/apply-variables/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply-variables/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply-variables/main.tf b/internal/cloud/testdata/apply-variables/main.tf new file mode 100644 index 000000000..955e8b4c0 --- /dev/null +++ b/internal/cloud/testdata/apply-variables/main.tf @@ -0,0 +1,4 @@ +variable "foo" {} +variable "bar" {} + +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-variables/plan.log b/internal/cloud/testdata/apply-variables/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-variables/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-with-error/main.tf b/internal/cloud/testdata/apply-with-error/main.tf new file mode 100644 index 000000000..bc45f28f5 --- /dev/null +++ b/internal/cloud/testdata/apply-with-error/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + random = "${guid()}" + } +} diff --git a/internal/cloud/testdata/apply-with-error/plan.log b/internal/cloud/testdata/apply-with-error/plan.log new file mode 100644 index 000000000..4344a3722 --- /dev/null +++ b/internal/cloud/testdata/apply-with-error/plan.log @@ -0,0 +1,10 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... + +Error: null_resource.foo: 1 error(s) occurred: + +* null_resource.foo: 1:3: unknown function called: guid in: + +${guid()} diff --git a/internal/cloud/testdata/apply/apply.log b/internal/cloud/testdata/apply/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply/main.tf b/internal/cloud/testdata/apply/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply/plan.log b/internal/cloud/testdata/apply/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/empty/.gitignore b/internal/cloud/testdata/empty/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/internal/cloud/testdata/plan-cost-estimation/ce.log b/internal/cloud/testdata/plan-cost-estimation/ce.log new file mode 100644 index 000000000..e51fef1ed --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/ce.log @@ -0,0 +1,6 @@ ++---------+------+-----+-------------+----------------------+ +| PRODUCT | NAME | SKU | DESCRIPTION | DELTA | ++---------+------+-----+-------------+----------------------+ ++---------+------+-----+-------------+----------------------+ +| TOTAL | $0.000 USD / 720 HRS | ++---------+------+-----+-------------+----------------------+ diff --git a/internal/cloud/testdata/plan-cost-estimation/cost-estimate.log b/internal/cloud/testdata/plan-cost-estimation/cost-estimate.log new file mode 100644 index 000000000..67a50928c --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/cost-estimate.log @@ -0,0 +1,5 @@ +Cost estimation: + +Waiting for cost estimation to complete... +Resources: 1 of 1 estimated + $25.488/mo +$25.488 \ No newline at end of file diff --git a/internal/cloud/testdata/plan-cost-estimation/main.tf b/internal/cloud/testdata/plan-cost-estimation/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-cost-estimation/plan.log b/internal/cloud/testdata/plan-cost-estimation/plan.log new file mode 100644 index 000000000..fae287f45 --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/plan.log @@ -0,0 +1,20 @@ +Terraform v0.12.9 +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-long-line/main.tf b/internal/cloud/testdata/plan-long-line/main.tf new file mode 100644 index 000000000..0a8d623a9 --- /dev/null +++ b/internal/cloud/testdata/plan-long-line/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + long_line = "[{'_id':'5c5ab0ed7de45e993ffb9eeb','index':0,'guid':'e734d772-6b5a-4cb0-805c-91cd5e560e20','isActive':false,'balance':'$1,472.03','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Darlene','last':'Garza'},'company':'GEEKOSIS','email':'darlene.garza@geekosis.io','phone':'+1 (850) 506-3347','address':'165 Kiely Place, Como, New Mexico, 4335','about':'Officia ullamco et sunt magna voluptate culpa cupidatat ea tempor laboris cupidatat ea anim laboris. Minim enim quis enim esse laborum est veniam. Lorem excepteur elit Lorem cupidatat elit ea anim irure fugiat fugiat sunt mollit. Consectetur ad nulla dolor amet esse occaecat aliquip sit. Magna sit elit adipisicing ut reprehenderit anim exercitation sit quis ea pariatur Lorem magna dolore.','registered':'Wednesday, March 11, 2015 12:58 PM','latitude':'20.729127','longitude':'-127.343593','tags':['minim','in','deserunt','occaecat','fugiat'],'greeting':'Hello, Darlene! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda9117d15f1c1f112','index':1,'guid':'f0d1eed2-c6a9-4535-8800-d4bd53fe7eee','isActive':true,'balance':'$2,901.90','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Flora','last':'Short'},'company':'SIGNITY','email':'flora.short@signity.me','phone':'+1 (840) 520-2666','address':'636 Johnson Avenue, Gerber, Wisconsin, 9139','about':'Veniam dolore deserunt Lorem aliqua qui eiusmod. Amet tempor fugiat duis incididunt amet adipisicing. Id ea nisi veniam eiusmod.','registered':'Wednesday, May 2, 2018 5:59 AM','latitude':'-63.267612','longitude':'4.224102','tags':['veniam','incididunt','id','aliqua','reprehenderit'],'greeting':'Hello, Flora! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed83fd574d8041fa16','index':2,'guid':'29499a07-414a-436f-ba62-6634ca16bdcc','isActive':true,'balance':'$2,781.28','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Trevino','last':'Marks'},'company':'KEGULAR','email':'trevino.marks@kegular.com','phone':'+1 (843) 571-2269','address':'200 Alabama Avenue, Grenelefe, Florida, 7963','about':'Occaecat nisi exercitation Lorem mollit laborum magna adipisicing culpa dolor proident dolore. Non consequat ea amet et id mollit incididunt minim anim amet nostrud labore tempor. Proident eu sint commodo nisi consequat voluptate do fugiat proident. Laboris eiusmod veniam non et elit nulla nisi labore incididunt Lorem consequat consectetur voluptate.','registered':'Saturday, January 25, 2014 5:56 AM','latitude':'65.044005','longitude':'-127.454864','tags':['anim','duis','velit','pariatur','enim'],'greeting':'Hello, Trevino! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed784eb6e350ff0a07','index':3,'guid':'40ed47e2-1747-4665-ab59-cdb3630a7642','isActive':true,'balance':'$2,000.78','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Solis','last':'Mckinney'},'company':'QABOOS','email':'solis.mckinney@qaboos.org','phone':'+1 (924) 405-2560','address':'712 Herkimer Court, Klondike, Ohio, 8133','about':'Minim ad anim minim tempor mollit magna tempor et non commodo amet. Nisi cupidatat labore culpa consectetur exercitation laborum adipisicing fugiat officia adipisicing consequat non. Qui voluptate tempor laboris exercitation qui non adipisicing occaecat voluptate sunt do nostrud velit. Consequat tempor officia laboris tempor irure cupidatat aliquip voluptate nostrud velit ex nulla tempor laboris. Qui pariatur pariatur enim aliquip velit. Officia mollit ullamco laboris velit velit eiusmod enim amet incididunt consectetur sunt.','registered':'Wednesday, April 12, 2017 6:59 AM','latitude':'-25.055596','longitude':'-140.126525','tags':['ipsum','adipisicing','amet','nulla','dolore'],'greeting':'Hello, Solis! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed02ce1ea9a2155d51','index':4,'guid':'1b5fb7d3-3b9a-4382-81b5-9ab01a27e74b','isActive':true,'balance':'$1,373.67','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Janell','last':'Battle'},'company':'GEEKMOSIS','email':'janell.battle@geekmosis.net','phone':'+1 (810) 591-3014','address':'517 Onderdonk Avenue, Shrewsbury, District Of Columbia, 2335','about':'Reprehenderit ad proident do anim qui officia magna magna duis cillum esse minim est. Excepteur ipsum anim ad laboris. In occaecat dolore nulla ea Lorem tempor et culpa in sint. Officia eu eu incididunt sit amet. Culpa duis id reprehenderit ut anim sit sunt. Duis dolore proident velit incididunt adipisicing pariatur fugiat incididunt eiusmod eu veniam irure.','registered':'Thursday, February 8, 2018 1:44 AM','latitude':'-33.254864','longitude':'-154.145885','tags':['aute','deserunt','ipsum','eiusmod','laborum'],'greeting':'Hello, Janell! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edab58604bd7d3dd1c','index':5,'guid':'6354c035-af22-44c9-8be9-b2ea9decc24d','isActive':true,'balance':'$3,535.68','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Combs','last':'Kirby'},'company':'LUXURIA','email':'combs.kirby@luxuria.name','phone':'+1 (900) 498-3266','address':'377 Kingsland Avenue, Ruckersville, Maine, 9916','about':'Lorem duis ipsum pariatur aliquip sunt. Commodo esse laborum incididunt mollit quis est laboris ea ea quis fugiat. Enim elit ullamco velit et fugiat veniam irure deserunt aliqua ad irure veniam.','registered':'Tuesday, February 21, 2017 4:04 PM','latitude':'-70.20591','longitude':'162.546871','tags':['reprehenderit','est','enim','aute','ad'],'greeting':'Hello, Combs! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf7fafeffc6357c51','index':6,'guid':'02523e0b-cc90-4309-b6b2-f493dc6076f6','isActive':false,'balance':'$3,754.30','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Macias','last':'Calderon'},'company':'AMTAP','email':'macias.calderon@amtap.us','phone':'+1 (996) 569-3667','address':'305 Royce Street, Glidden, Iowa, 9248','about':'Exercitation nulla deserunt pariatur adipisicing. In commodo deserunt incididunt ut velit minim qui ut quis. Labore elit ullamco eiusmod voluptate in eu do est fugiat aute mollit deserunt. Eu duis proident velit fugiat velit ut. Ut non esse amet laborum nisi tempor in nulla.','registered':'Thursday, October 23, 2014 10:28 PM','latitude':'32.371629','longitude':'60.155135','tags':['commodo','elit','velit','excepteur','aliqua'],'greeting':'Hello, Macias! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0e8a6109e7fabf17','index':7,'guid':'675ff6b6-197b-4154-9775-813d661df822','isActive':false,'balance':'$2,850.62','picture':'http://placehold.it/32x32','age':37,'eyeColor':'green','name':{'first':'Stefanie','last':'Rivers'},'company':'RECRITUBE','email':'stefanie.rivers@recritube.biz','phone':'+1 (994) 591-3551','address':'995 Campus Road, Abrams, Virginia, 3251','about':'Esse aute non laborum Lorem nulla irure. Veniam elit aute ut et dolor non deserunt laboris tempor. Ipsum quis cupidatat laborum laboris voluptate esse duis eiusmod excepteur consectetur commodo ullamco qui occaecat. Culpa velit cillum occaecat minim nisi.','registered':'Thursday, June 9, 2016 3:40 PM','latitude':'-18.526825','longitude':'149.670782','tags':['occaecat','sunt','reprehenderit','ipsum','magna'],'greeting':'Hello, Stefanie! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf7d9bc2db4e476e3','index':8,'guid':'adaefc55-f6ea-4bd1-a147-0e31c3ce7a21','isActive':true,'balance':'$2,555.13','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Hillary','last':'Lancaster'},'company':'OLUCORE','email':'hillary.lancaster@olucore.ca','phone':'+1 (964) 474-3018','address':'232 Berriman Street, Kaka, Massachusetts, 6792','about':'Veniam ad laboris quis reprehenderit aliquip nisi sunt excepteur ea aute laborum excepteur incididunt. Nisi exercitation aliquip do culpa commodo ex officia ut enim mollit in deserunt in amet. Anim eu deserunt dolore non cupidatat ut enim incididunt aute dolore voluptate. Do cillum mollit laborum non incididunt occaecat aute voluptate nisi irure.','registered':'Thursday, June 4, 2015 9:45 PM','latitude':'88.075919','longitude':'-148.951368','tags':['reprehenderit','veniam','ad','aute','anim'],'greeting':'Hello, Hillary! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed7b7192ad6a0f267c','index':9,'guid':'0ca9b8ea-f671-474e-be26-4a49cae4838a','isActive':true,'balance':'$3,684.51','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Jill','last':'Conner'},'company':'EXOZENT','email':'jill.conner@exozent.info','phone':'+1 (887) 467-2168','address':'751 Thames Street, Juarez, American Samoa, 8386','about':'Enim voluptate et non est in magna laborum aliqua enim aliqua est non nostrud. Tempor est nulla ipsum consectetur esse nostrud est id. Consequat do voluptate cupidatat eu fugiat et fugiat velit id. Sint dolore ad qui tempor anim eu amet consectetur do elit aute adipisicing consequat ex.','registered':'Sunday, October 22, 2017 7:35 AM','latitude':'84.384911','longitude':'40.305648','tags':['tempor','sint','irure','et','ex'],'greeting':'Hello, Jill! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed713fe676575aa72b','index':10,'guid':'c28023cf-cc57-4c2e-8d91-dfbe6bafadcd','isActive':false,'balance':'$2,792.45','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Hurley','last':'George'},'company':'ZAJ','email':'hurley.george@zaj.tv','phone':'+1 (984) 547-3284','address':'727 Minna Street, Lacomb, Colorado, 2557','about':'Ex velit cupidatat veniam culpa. Eiusmod ut fugiat adipisicing incididunt consectetur exercitation Lorem exercitation ex. Incididunt anim aute incididunt fugiat cupidatat qui eu non reprehenderit. Eiusmod dolor nisi culpa excepteur ut velit minim dolor voluptate amet commodo culpa in.','registered':'Thursday, February 16, 2017 6:41 AM','latitude':'25.989949','longitude':'10.200053','tags':['minim','ut','sunt','consequat','ullamco'],'greeting':'Hello, Hurley! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1e56732746c70d8b','index':11,'guid':'e9766f13-766c-4450-b4d2-8b04580f60b7','isActive':true,'balance':'$3,874.26','picture':'http://placehold.it/32x32','age':35,'eyeColor':'green','name':{'first':'Leticia','last':'Pace'},'company':'HONOTRON','email':'leticia.pace@honotron.co.uk','phone':'+1 (974) 536-3322','address':'365 Goodwin Place, Savage, Nevada, 9191','about':'Nisi Lorem aliqua esse eiusmod magna. Ad minim incididunt proident ut Lorem cupidatat qui velit aliqua ullamco et ipsum in. Aliquip elit consectetur pariatur esse exercitation et officia quis. Occaecat tempor proident cillum anim ad commodo velit ut voluptate. Tempor et occaecat sit sint aliquip tempor nulla velit magna nisi proident exercitation Lorem id.','registered':'Saturday, August 4, 2018 5:05 AM','latitude':'70.620386','longitude':'-86.335813','tags':['occaecat','velit','labore','laboris','esse'],'greeting':'Hello, Leticia! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed941337fe42f47426','index':12,'guid':'6d390762-17ea-4b58-9a36-b0c9a8748a42','isActive':true,'balance':'$1,049.61','picture':'http://placehold.it/32x32','age':38,'eyeColor':'green','name':{'first':'Rose','last':'Humphrey'},'company':'MYOPIUM','email':'rose.humphrey@myopium.io','phone':'+1 (828) 426-3086','address':'389 Sapphire Street, Saticoy, Marshall Islands, 1423','about':'Aliquip enim excepteur adipisicing ex. Consequat aliqua consequat nostrud do occaecat deserunt excepteur sit et ipsum sunt dolor eu. Dolore laborum commodo excepteur tempor ad adipisicing proident excepteur magna non Lorem proident consequat aute. Fugiat minim consequat occaecat voluptate esse velit officia laboris nostrud nisi ut voluptate.','registered':'Monday, April 16, 2018 12:38 PM','latitude':'-47.083742','longitude':'109.022423','tags':['aute','non','sit','adipisicing','mollit'],'greeting':'Hello, Rose! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd0c02fc3fdc01a40','index':13,'guid':'07755618-6fdf-4b33-af50-364c18909227','isActive':true,'balance':'$1,823.61','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Judith','last':'Hale'},'company':'COLLAIRE','email':'judith.hale@collaire.me','phone':'+1 (922) 508-2843','address':'193 Coffey Street, Castleton, North Dakota, 3638','about':'Minim non ullamco ad anim nostrud dolore nostrud veniam consequat id eiusmod veniam laboris. Lorem irure esse mollit non velit aute id cupidatat est mollit occaecat magna excepteur. Adipisicing tempor nisi sit aliquip tempor pariatur tempor eu consectetur nulla amet nulla. Quis nisi nisi ea incididunt culpa et do. Esse officia eu pariatur velit sunt quis proident amet consectetur consequat. Nisi excepteur culpa nulla sit dolor deserunt excepteur dolor consequat elit cillum tempor Lorem.','registered':'Wednesday, August 24, 2016 12:29 AM','latitude':'-80.15514','longitude':'39.91007','tags':['consectetur','incididunt','aliquip','dolor','consequat'],'greeting':'Hello, Judith! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3e1e29caa4f728b','index':14,'guid':'2c6617a2-e7a9-4ff7-a8b9-e99554fe70fe','isActive':true,'balance':'$1,971.00','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Estes','last':'Sweet'},'company':'GEEKKO','email':'estes.sweet@geekko.com','phone':'+1 (866) 448-3032','address':'847 Cove Lane, Kula, Mississippi, 9178','about':'Veniam consectetur occaecat est excepteur consequat ipsum cillum sit consectetur. Ut cupidatat et reprehenderit dolore enim do cillum qui pariatur ad laborum incididunt esse. Fugiat sunt dolor veniam laboris ipsum deserunt proident reprehenderit laboris non nostrud. Magna excepteur sint magna laborum tempor sit exercitation ipsum labore est ullamco ullamco. Cillum voluptate cillum ea laborum Lorem. Excepteur sint ut nisi est esse non. Minim excepteur ullamco velit nisi ut in elit exercitation ut dolore.','registered':'Sunday, August 12, 2018 5:06 PM','latitude':'-9.57771','longitude':'-159.94577','tags':['culpa','dolor','velit','anim','pariatur'],'greeting':'Hello, Estes! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edbcf088c6fd593091','index':15,'guid':'2cc79958-1b40-4e2c-907a-433903fd3da9','isActive':false,'balance':'$3,751.53','picture':'http://placehold.it/32x32','age':34,'eyeColor':'brown','name':{'first':'Kemp','last':'Spence'},'company':'EXOBLUE','email':'kemp.spence@exoblue.org','phone':'+1 (864) 487-2992','address':'217 Clay Street, Monument, North Carolina, 1460','about':'Nostrud duis cillum sint non commodo dolor aute aliqua adipisicing ad nulla non excepteur proident. Fugiat labore elit tempor cillum veniam reprehenderit laboris consectetur dolore amet qui cupidatat. Amet aliqua elit anim et consequat commodo excepteur officia anim aliqua ea eu labore cillum. Et ex dolor duis dolore commodo veniam et nisi.','registered':'Monday, October 29, 2018 5:23 AM','latitude':'-70.304222','longitude':'83.582371','tags':['velit','duis','consequat','incididunt','duis'],'greeting':'Hello, Kemp! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed6400479feb3de505','index':16,'guid':'91ccae6d-a3ea-43cf-bb00-3f2729256cc9','isActive':false,'balance':'$2,477.79','picture':'http://placehold.it/32x32','age':40,'eyeColor':'blue','name':{'first':'Ronda','last':'Burris'},'company':'EQUITOX','email':'ronda.burris@equitox.net','phone':'+1 (817) 553-3228','address':'708 Lawton Street, Deputy, Wyoming, 8598','about':'Excepteur voluptate aliquip consequat cillum est duis sit cillum eu eiusmod et laborum ullamco. Et minim reprehenderit aute voluptate amet ullamco. Amet sit enim ad irure deserunt nostrud anim veniam consequat dolor commodo. Consequat do occaecat do exercitation ullamco dolor ut. Id laboris consequat est dolor dolore tempor ullamco anim do ut nulla deserunt labore. Mollit ex Lorem ullamco mollit.','registered':'Monday, April 23, 2018 5:27 PM','latitude':'-31.227208','longitude':'0.63785','tags':['ipsum','magna','consectetur','sit','irure'],'greeting':'Hello, Ronda! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddbeab2e53e04d563','index':17,'guid':'a86d4eb6-6bd8-48c2-a8fc-1c933c835852','isActive':false,'balance':'$3,709.03','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Rosario','last':'Dillard'},'company':'BARKARAMA','email':'rosario.dillard@barkarama.name','phone':'+1 (933) 525-3898','address':'730 Chauncey Street, Forbestown, South Carolina, 6894','about':'Est eu fugiat aliquip ea ad qui ad mollit ad tempor voluptate et incididunt reprehenderit. Incididunt fugiat commodo minim adipisicing culpa consectetur duis eu ut commodo consequat voluptate labore. Nostrud irure labore adipisicing irure quis magna consequat dolor Lorem sint enim. Sint excepteur eu dolore elit ut do mollit sunt enim est. Labore id nostrud sint Lorem esse nostrud.','registered':'Friday, December 25, 2015 8:59 PM','latitude':'37.440827','longitude':'44.580474','tags':['Lorem','sit','ipsum','ea','ut'],'greeting':'Hello, Rosario! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf8e9b9c031d04e8','index':18,'guid':'a96f997c-daf8-40d4-92e1-be07e2cf0f60','isActive':false,'balance':'$1,878.37','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Sondra','last':'Gonzales'},'company':'XUMONK','email':'sondra.gonzales@xumonk.us','phone':'+1 (838) 560-2255','address':'230 Cox Place, Geyserville, Georgia, 6805','about':'Laborum sunt voluptate ea laboris nostrud. Amet deserunt aliqua Lorem voluptate velit deserunt occaecat minim ullamco. Lorem occaecat sit labore adipisicing ad magna mollit labore ullamco proident. Ea velit do proident fugiat esse commodo ex nostrud eu mollit pariatur. Labore laborum qui voluptate quis proident reprehenderit tempor dolore duis deserunt esse aliqua aliquip. Non veniam enim pariatur cupidatat ipsum dolore est reprehenderit. Non exercitation adipisicing proident magna elit occaecat non magna.','registered':'Sunday, June 26, 2016 4:02 AM','latitude':'62.247742','longitude':'-44.90666','tags':['ea','aute','in','voluptate','magna'],'greeting':'Hello, Sondra! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed2c1bcd06781f677e','index':19,'guid':'6ac47a16-eed4-4460-92ee-e0dd33c1fbb5','isActive':false,'balance':'$3,730.64','picture':'http://placehold.it/32x32','age':20,'eyeColor':'brown','name':{'first':'Anastasia','last':'Vega'},'company':'FIREWAX','email':'anastasia.vega@firewax.biz','phone':'+1 (867) 493-3698','address':'803 Arlington Avenue, Rosburg, Northern Mariana Islands, 8769','about':'Sint ex nisi tempor sunt voluptate non et eiusmod irure. Aute reprehenderit dolor mollit aliqua Lorem voluptate occaecat. Sint laboris deserunt Lorem incididunt nulla cupidatat do.','registered':'Friday, March 18, 2016 12:02 PM','latitude':'-32.010216','longitude':'-87.874753','tags':['aliquip','mollit','mollit','ad','laborum'],'greeting':'Hello, Anastasia! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed727fd645854bbf43','index':20,'guid':'67bd8cdb-ce6b-455c-944c-a80e17c6fa75','isActive':true,'balance':'$2,868.06','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Lucinda','last':'Cox'},'company':'ENDIPINE','email':'lucinda.cox@endipine.ca','phone':'+1 (990) 428-3002','address':'412 Thatford Avenue, Lafferty, New Jersey, 5271','about':'Esse nulla sunt ut consequat aute mollit. Est occaecat sunt nisi irure id anim est commodo. Elit mollit amet dolore sunt adipisicing ea laborum quis ea reprehenderit non consequat dolore. Minim sunt occaecat quis aute commodo dolore quis commodo proident. Sunt sint duis ullamco sit ea esse Lorem. Consequat pariatur eiusmod laboris adipisicing labore in laboris adipisicing adipisicing consequat aute ea et.','registered':'Friday, May 1, 2015 10:16 PM','latitude':'-14.200957','longitude':'-82.211386','tags':['do','sit','qui','officia','aliquip'],'greeting':'Hello, Lucinda! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed5a97284eb2cbd3a8','index':21,'guid':'f9fc999d-515c-4fc4-b339-76300e1b4bf2','isActive':true,'balance':'$1,172.57','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Conrad','last':'Bradley'},'company':'FUELWORKS','email':'conrad.bradley@fuelworks.info','phone':'+1 (956) 561-3226','address':'685 Fenimore Street, Esmont, Maryland, 7523','about':'Labore reprehenderit anim nisi sunt do nisi in. Est anim cillum id minim exercitation ullamco voluptate ipsum eu. Elit culpa consequat reprehenderit laborum in eu. Laboris amet voluptate laboris qui voluptate duis minim reprehenderit. Commodo sunt irure dolore sunt occaecat velit nisi eu minim minim.','registered':'Wednesday, January 18, 2017 11:13 PM','latitude':'31.665993','longitude':'38.868968','tags':['excepteur','exercitation','est','nisi','mollit'],'greeting':'Hello, Conrad! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edc4eaf6f760c38218','index':22,'guid':'8794ef5f-da2f-46f0-a755-c18a16409fd5','isActive':false,'balance':'$3,594.73','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Marquez','last':'Vargas'},'company':'MALATHION','email':'marquez.vargas@malathion.tv','phone':'+1 (976) 438-3126','address':'296 Hall Street, National, Texas, 2067','about':'Proident cillum aute minim fugiat sunt aliqua non occaecat est duis id id tempor. Qui deserunt nisi amet pariatur proident eu laboris esse adipisicing magna. Anim anim mollit aute non magna nisi aute magna labore ullamco reprehenderit voluptate et ad. Proident adipisicing aute eiusmod nostrud nostrud deserunt culpa. Elit eu ullamco nisi aliqua dolor sint pariatur excepteur sit consectetur tempor. Consequat Lorem ullamco commodo veniam qui sint magna. Sit mollit ad aliquip est id eu officia id adipisicing duis ad.','registered':'Tuesday, November 17, 2015 6:16 PM','latitude':'-36.443667','longitude':'22.336776','tags':['aliquip','veniam','ipsum','Lorem','ex'],'greeting':'Hello, Marquez! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edd7c718518ee0466a','index':23,'guid':'ad8781a2-059e-4288-9879-309d53a99bf5','isActive':true,'balance':'$3,570.68','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Snider','last':'Frost'},'company':'ZILODYNE','email':'snider.frost@zilodyne.co.uk','phone':'+1 (913) 485-3275','address':'721 Lincoln Road, Richmond, Utah, 672','about':'Minim enim Lorem esse incididunt do reprehenderit velit laborum ullamco. In aute eiusmod esse aliqua et labore tempor sunt ex mollit veniam tempor. Nulla elit cillum qui ullamco dolore amet deserunt magna amet laborum.','registered':'Saturday, August 23, 2014 12:58 AM','latitude':'-88.682554','longitude':'74.063179','tags':['nulla','ea','sint','aliquip','duis'],'greeting':'Hello, Snider! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf026fece8e2c0970','index':24,'guid':'1b7d81e1-1dba-4322-bb1a-eaa6a24cccea','isActive':false,'balance':'$2,037.91','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Snyder','last':'Fletcher'},'company':'COMTEST','email':'snyder.fletcher@comtest.io','phone':'+1 (830) 538-3860','address':'221 Lewis Place, Zortman, Idaho, 572','about':'Elit anim enim esse dolore exercitation. Laboris esse sint adipisicing fugiat sint do occaecat ut voluptate sint nulla. Ad sint ut reprehenderit nostrud irure id consectetur officia velit consequat.','registered':'Sunday, January 1, 2017 1:13 AM','latitude':'-54.742604','longitude':'69.534932','tags':['exercitation','commodo','in','id','aliqua'],'greeting':'Hello, Snyder! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b9a7f83da6d2dfd','index':25,'guid':'0b2cc6b6-0044-4b1c-aa31-bd72963457a0','isActive':false,'balance':'$1,152.76','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Regina','last':'James'},'company':'TELPOD','email':'regina.james@telpod.me','phone':'+1 (989) 455-3228','address':'688 Essex Street, Clayville, Alabama, 2772','about':'Eiusmod elit culpa reprehenderit ea veniam. Officia irure culpa duis aute ut. Irure duis cillum officia ea pariatur velit ut dolor incididunt reprehenderit ex elit laborum. Est pariatur veniam ad irure. Labore velit sunt esse laboris aliqua velit deserunt deserunt sit. Elit eiusmod ad laboris aliquip minim irure excepteur enim quis. Quis incididunt adipisicing ut magna cupidatat sit amet culpa.','registered':'Tuesday, April 25, 2017 10:16 PM','latitude':'-75.088027','longitude':'47.209828','tags':['elit','nisi','est','voluptate','proident'],'greeting':'Hello, Regina! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed10884f32f779f2bf','index':26,'guid':'1f6fb522-0002-46ff-8dac-451247f28168','isActive':true,'balance':'$1,948.79','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Collins','last':'Mcpherson'},'company':'DIGIGEN','email':'collins.mcpherson@digigen.com','phone':'+1 (991) 519-2334','address':'317 Merit Court, Sanford, Michigan, 6468','about':'Magna qui culpa dolor officia labore mollit ex excepteur duis eiusmod. Ea cupidatat ex ipsum mollit do minim duis. Nisi eiusmod minim tempor id esse commodo sunt sunt ullamco ut do laborum ullamco magna. Aliquip laborum dolor officia officia eu nostrud velit minim est anim. Ex elit laborum sunt magna exercitation nisi cillum sunt aute qui ea ullamco. Cupidatat ea sunt aute dolor duis nisi Lorem ullamco eiusmod. Sit ea velit ad veniam aliqua ad elit cupidatat ut magna in.','registered':'Friday, June 10, 2016 4:38 PM','latitude':'25.513996','longitude':'14.911124','tags':['exercitation','non','sit','velit','officia'],'greeting':'Hello, Collins! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8a575110efb15c6c','index':27,'guid':'2a904c82-068b-4ded-9ae6-cfeb6d7e62c9','isActive':true,'balance':'$3,427.91','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Mckay','last':'Barrera'},'company':'COMVEYER','email':'mckay.barrera@comveyer.org','phone':'+1 (853) 470-2560','address':'907 Glenwood Road, Churchill, Oregon, 8583','about':'In voluptate esse dolore enim sint quis dolor do exercitation sint et labore nisi. Eiusmod tempor exercitation dolore elit sit velit sint et. Sit magna adipisicing eiusmod do anim velit deserunt laboris ad ea pariatur. Irure nisi anim mollit elit commodo nulla. Aute eiusmod sit nulla eiusmod. Eiusmod est officia commodo mollit laboris do deserunt eu do nisi amet. Proident ad duis eiusmod laboris Lorem ut culpa pariatur Lorem reprehenderit minim aliquip irure sunt.','registered':'Saturday, December 19, 2015 2:49 PM','latitude':'-55.243287','longitude':'138.035406','tags':['non','quis','laboris','enim','nisi'],'greeting':'Hello, Mckay! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edcd49ab6a73ff7f32','index':28,'guid':'5d3e0dae-3f58-437f-b12d-de24667a904d','isActive':true,'balance':'$3,270.52','picture':'http://placehold.it/32x32','age':35,'eyeColor':'blue','name':{'first':'Mabel','last':'Leonard'},'company':'QUADEEBO','email':'mabel.leonard@quadeebo.net','phone':'+1 (805) 432-2356','address':'965 Underhill Avenue, Falconaire, Minnesota, 4450','about':'Cupidatat amet sunt est ipsum occaecat sit fugiat excepteur Lorem Lorem ex ea ipsum. Ad incididunt est irure magna excepteur occaecat nostrud. Minim dolor id anim ipsum qui nostrud ullamco aute ex Lorem magna deserunt excepteur Lorem.','registered':'Saturday, March 28, 2015 5:55 AM','latitude':'27.388359','longitude':'156.408728','tags':['quis','velit','deserunt','dolore','sit'],'greeting':'Hello, Mabel! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edde16ac2dc2fbb6c1','index':29,'guid':'d50c2233-70fc-4748-8ebf-02d45ac2a446','isActive':false,'balance':'$3,100.70','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Pace','last':'Duke'},'company':'SEQUITUR','email':'pace.duke@sequitur.name','phone':'+1 (983) 568-3119','address':'895 Melrose Street, Reno, Connecticut, 6259','about':'Ex veniam aliquip exercitation mollit elit est minim veniam aliqua labore deserunt. Dolor sunt sint cillum Lorem nisi ea irure cupidatat. Velit ut culpa cupidatat consequat cillum. Sint voluptate quis laboris qui incididunt do elit Lorem qui ullamco ut eu pariatur occaecat.','registered':'Saturday, August 18, 2018 2:18 PM','latitude':'31.930443','longitude':'-129.494784','tags':['culpa','est','nostrud','quis','aliquip'],'greeting':'Hello, Pace! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb908d85642ba77e8','index':30,'guid':'3edb6e42-367a-403d-a511-eb78bcc11f60','isActive':true,'balance':'$1,912.07','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Cohen','last':'Morrison'},'company':'POWERNET','email':'cohen.morrison@powernet.us','phone':'+1 (888) 597-2141','address':'565 Troutman Street, Idledale, West Virginia, 3196','about':'Ullamco voluptate duis commodo amet occaecat consequat et occaecat dolore nulla eu. Do aliqua sunt deserunt occaecat laboris labore voluptate cupidatat ullamco exercitation aliquip elit voluptate anim. Occaecat deserunt in labore cillum aute deserunt ea excepteur laboris sunt. Officia irure sint incididunt labore sint ipsum ullamco ea elit. Fugiat nostrud sunt ut officia mollit proident sunt dolor fugiat esse tempor do.','registered':'Friday, January 1, 2016 5:42 AM','latitude':'-20.01215','longitude':'26.361552','tags':['consectetur','sunt','nulla','reprehenderit','dolore'],'greeting':'Hello, Cohen! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed91c77aa25a64a757','index':31,'guid':'8999a97b-0035-4f19-b555-91dd69aaa9b8','isActive':false,'balance':'$3,097.67','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Stout','last':'Valdez'},'company':'UPLINX','email':'stout.valdez@uplinx.biz','phone':'+1 (854) 480-3633','address':'880 Chestnut Avenue, Lowgap, Hawaii, 1537','about':'Cupidatat enim dolore non voluptate. Aliqua ut non Lorem in exercitation reprehenderit voluptate. Excepteur deserunt tempor laboris quis.','registered':'Wednesday, March 16, 2016 6:53 AM','latitude':'50.328393','longitude':'-25.990308','tags':['ea','fugiat','duis','consectetur','enim'],'greeting':'Hello, Stout! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0f52176c8c3e1bed','index':32,'guid':'743abcbd-1fab-4aed-8cb7-3c935eb64c74','isActive':false,'balance':'$1,118.54','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Ortega','last':'Joseph'},'company':'APEXIA','email':'ortega.joseph@apexia.ca','phone':'+1 (872) 596-3024','address':'304 Canda Avenue, Mulino, New York, 8721','about':'Ipsum elit id cupidatat minim nisi minim. Ea ex amet ea ipsum Lorem deserunt. Occaecat cupidatat magna cillum aliquip sint id quis amet nostrud officia enim laborum. Aliqua deserunt amet commodo laboris labore mollit est. Officia voluptate Lorem esse mollit aliquip laboris cupidatat minim et. Labore esse incididunt officia nostrud pariatur reprehenderit.','registered':'Tuesday, January 31, 2017 6:06 AM','latitude':'43.861714','longitude':'33.771783','tags':['ut','Lorem','esse','quis','fugiat'],'greeting':'Hello, Ortega! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed2c00cdd101b6cd52','index':33,'guid':'4f6f99cf-f692-4d03-b23a-26f2b27273bd','isActive':true,'balance':'$1,682.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Sampson','last':'Taylor'},'company':'GEOFORMA','email':'sampson.taylor@geoforma.info','phone':'+1 (911) 482-2993','address':'582 Kent Street, Umapine, Virgin Islands, 5300','about':'Voluptate laboris occaecat laboris tempor cillum quis cupidatat qui pariatur. Lorem minim commodo mollit adipisicing Lorem ut dolor consectetur ipsum. Sint sit voluptate labore aliqua ex labore velit. Ullamco tempor consectetur voluptate deserunt voluptate minim enim. Cillum commodo duis reprehenderit eu duis.','registered':'Thursday, November 9, 2017 11:24 PM','latitude':'24.949379','longitude':'155.034468','tags':['Lorem','cupidatat','elit','reprehenderit','commodo'],'greeting':'Hello, Sampson! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b7210ba0bc0d508','index':34,'guid':'73fd415f-f8cf-43e0-a86c-e725d000abd4','isActive':false,'balance':'$1,289.37','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Shari','last':'Melendez'},'company':'DIGIPRINT','email':'shari.melendez@digiprint.tv','phone':'+1 (914) 475-3995','address':'950 Wolf Place, Enetai, Alaska, 693','about':'Dolor incididunt et est commodo aliquip labore ad ullamco. Velit ex cillum nulla elit ex esse. Consectetur mollit fugiat cillum proident elit sunt non officia cillum ex laboris sint eu. Esse nulla eu officia in Lorem sint minim esse velit. Est Lorem ipsum enim aute. Elit minim eiusmod officia reprehenderit officia ut irure Lorem.','registered':'Wednesday, August 23, 2017 11:12 PM','latitude':'-70.347863','longitude':'94.812072','tags':['ea','ex','fugiat','duis','eu'],'greeting':'Hello, Shari! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed85ac364619d892ef','index':35,'guid':'c1905f34-14ff-4bd8-b683-02cac4d52623','isActive':false,'balance':'$2,538.50','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Santiago','last':'Joyner'},'company':'BRAINCLIP','email':'santiago.joyner@brainclip.co.uk','phone':'+1 (835) 405-2676','address':'554 Rose Street, Muir, Kentucky, 7752','about':'Quis culpa dolore fugiat magna culpa non deserunt consectetur elit. Id cupidatat occaecat duis irure ullamco elit in labore magna pariatur cillum est. Mollit dolore velit ipsum anim aliqua culpa sint. Occaecat aute anim ut sunt eu.','registered':'Thursday, January 18, 2018 4:49 PM','latitude':'57.057918','longitude':'-50.472596','tags':['ullamco','ullamco','sunt','voluptate','irure'],'greeting':'Hello, Santiago! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1763f56b1121fa88','index':36,'guid':'a7f50659-4ae3-4f3e-a9d8-087e05334b51','isActive':false,'balance':'$1,435.16','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Adeline','last':'Hoffman'},'company':'BITREX','email':'adeline.hoffman@bitrex.io','phone':'+1 (823) 488-3201','address':'221 Corbin Place, Edmund, Palau, 193','about':'Magna ullamco consectetur velit adipisicing cillum ea. Est qui incididunt est ullamco ex aute exercitation irure. Cupidatat consectetur proident qui fugiat do. Labore magna aliqua consectetur fugiat. Excepteur deserunt sit qui dolor fugiat aute sunt anim ipsum magna ea commodo qui. Minim eu adipisicing ut irure excepteur eiusmod aliqua. Voluptate nisi ad consequat qui.','registered':'Tuesday, June 14, 2016 9:26 AM','latitude':'-53.123355','longitude':'88.180776','tags':['non','est','commodo','ut','aliquip'],'greeting':'Hello, Adeline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed945d079f63e3185e','index':37,'guid':'1f4619e0-9289-4bea-a9db-a75f4cba1138','isActive':true,'balance':'$2,019.54','picture':'http://placehold.it/32x32','age':36,'eyeColor':'blue','name':{'first':'Porter','last':'Morse'},'company':'COMVOY','email':'porter.morse@comvoy.me','phone':'+1 (933) 562-3220','address':'416 India Street, Bourg, Rhode Island, 2266','about':'Et sint anim et sunt. Non mollit sunt cillum veniam sunt sint amet non mollit. Fugiat ea ullamco pariatur deserunt ex do minim irure irure.','registered':'Saturday, July 16, 2016 10:03 PM','latitude':'-81.782545','longitude':'69.783509','tags':['irure','consequat','veniam','nulla','velit'],'greeting':'Hello, Porter! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed411dd0f06c66bba6','index':38,'guid':'93c900f0-54c0-4c4c-b21d-d59d8d7c6177','isActive':true,'balance':'$3,764.84','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Fitzgerald','last':'Logan'},'company':'UTARIAN','email':'fitzgerald.logan@utarian.com','phone':'+1 (815) 461-2709','address':'498 Logan Street, Tonopah, Arkansas, 6652','about':'Quis Lorem sit est et dolor est esse in veniam. Mollit anim nostrud laboris consequat voluptate qui ad ipsum sint laborum exercitation quis ipsum. Incididunt cupidatat esse ea amet deserunt consequat eu proident duis adipisicing pariatur. Amet deserunt mollit aliquip mollit consequat sunt quis labore laboris quis. Magna cillum fugiat anim velit Lorem duis. Lorem duis amet veniam occaecat est excepteur ut ea velit esse non pariatur. Do veniam quis eu consequat ad duis incididunt minim dolore sit non minim adipisicing et.','registered':'Wednesday, August 9, 2017 9:20 PM','latitude':'24.480657','longitude':'-108.693421','tags':['dolore','ad','occaecat','quis','labore'],'greeting':'Hello, Fitzgerald! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb6f14559d8a7b28','index':39,'guid':'9434f48b-70a0-4161-8d06-c53bf8b9df94','isActive':true,'balance':'$3,713.47','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Mcconnell','last':'Nash'},'company':'TETAK','email':'mcconnell.nash@tetak.org','phone':'+1 (956) 477-3586','address':'853 Turnbull Avenue, Clarence, Missouri, 1599','about':'Culpa excepteur minim anim magna dolor dolore ad ex eu. In cupidatat cillum elit dolore in est minim dolore consectetur reprehenderit voluptate laborum. Deserunt id velit ad dolor mollit.','registered':'Saturday, November 10, 2018 9:27 AM','latitude':'1.691589','longitude':'143.704377','tags':['ut','deserunt','sit','cupidatat','ea'],'greeting':'Hello, Mcconnell! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed1a87ea0390733ffa','index':40,'guid':'ec8a55f7-7114-4787-b1ff-4e631731bc2c','isActive':true,'balance':'$2,200.71','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Kitty','last':'Meyers'},'company':'FIBEROX','email':'kitty.meyers@fiberox.net','phone':'+1 (864) 458-3826','address':'537 Georgia Avenue, Thermal, Illinois, 7930','about':'Non excepteur laboris Lorem magna adipisicing exercitation. Anim esse in pariatur minim ipsum qui voluptate irure. Pariatur Lorem pariatur esse commodo aute adipisicing anim commodo. Exercitation nostrud aliqua duis et amet amet tempor.','registered':'Tuesday, September 13, 2016 8:16 PM','latitude':'19.59506','longitude':'-57.814297','tags':['duis','ullamco','velit','sint','consequat'],'greeting':'Hello, Kitty! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed4dc76717bf1217b3','index':41,'guid':'40521cde-f835-4620-902b-af7abf185d8d','isActive':false,'balance':'$2,907.02','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Klein','last':'Goodwin'},'company':'PLASTO','email':'klein.goodwin@plasto.name','phone':'+1 (950) 563-3104','address':'764 Devoe Street, Lindcove, Oklahoma, 458','about':'Amet aliqua magna ea veniam non aliquip irure esse id ipsum cillum sint tempor dolor. Ullamco deserunt fugiat amet pariatur culpa nostrud commodo commodo. Ad occaecat magna adipisicing voluptate. Minim ad adipisicing cupidatat elit nostrud eu irure. Cupidatat occaecat aute magna consectetur dolore anim et. Ex voluptate velit exercitation laborum ad ullamco ad. Aliquip nulla ipsum dolore cillum qui nostrud eu adipisicing amet tempor do.','registered':'Tuesday, February 13, 2018 3:56 PM','latitude':'-27.168725','longitude':'-29.499285','tags':['minim','labore','do','deserunt','dolor'],'greeting':'Hello, Klein! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1ac77396b29aee9e','index':42,'guid':'7cfc03e3-30e9-4ae1-a1f5-f6c3223ca770','isActive':true,'balance':'$2,986.47','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Isabelle','last':'Bishop'},'company':'GEEKNET','email':'isabelle.bishop@geeknet.us','phone':'+1 (908) 418-2642','address':'729 Willmohr Street, Aguila, Montana, 7510','about':'In nulla commodo nostrud sint. Elit et occaecat et aliqua aliquip magna esse commodo duis Lorem dolor magna enim deserunt. Ipsum pariatur reprehenderit ipsum adipisicing mollit incididunt ut. Sunt in consequat ex ut minim non qui anim labore. Deserunt minim voluptate in nulla occaecat.','registered':'Monday, September 15, 2014 6:22 AM','latitude':'-81.686947','longitude':'38.409291','tags':['proident','est','aliqua','veniam','anim'],'greeting':'Hello, Isabelle! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3a070c9469a4893','index':43,'guid':'3dec76b4-0b55-4765-a2fd-b8dbd9c82f8f','isActive':true,'balance':'$2,501.24','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Josefina','last':'Turner'},'company':'COMSTAR','email':'josefina.turner@comstar.biz','phone':'+1 (908) 566-3029','address':'606 Schenck Place, Brutus, Vermont, 8681','about':'Enim consectetur pariatur sint dolor nostrud est deserunt nulla quis pariatur sit. Ad aute incididunt nisi excepteur duis est velit voluptate ullamco occaecat magna reprehenderit aliquip. Proident deserunt consectetur non et exercitation elit dolore enim aliqua incididunt anim amet. Ex esse sint commodo minim aliqua ut irure. Proident ex culpa voluptate fugiat nisi. Sint commodo laboris excepteur minim ipsum labore tempor quis magna.','registered':'Saturday, December 31, 2016 6:38 AM','latitude':'35.275088','longitude':'24.30485','tags':['minim','ut','irure','Lorem','veniam'],'greeting':'Hello, Josefina! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1aa7d74128ee3d0f','index':44,'guid':'10599279-c367-46c4-9f7a-744c2e4bf6c9','isActive':true,'balance':'$1,753.06','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Lily','last':'Haynes'},'company':'KIOSK','email':'lily.haynes@kiosk.ca','phone':'+1 (872) 451-2301','address':'509 Balfour Place, Grazierville, New Hampshire, 2750','about':'Nisi aliquip occaecat nostrud do sint qui nisi officia Lorem. Ad et et laboris nisi dolore aliqua eu. Aliqua veniam quis eu pariatur incididunt mollit id deserunt officia eiusmod. Consequat adipisicing do nisi voluptate eiusmod minim pariatur minim nisi nostrud culpa cupidatat. Irure consectetur id consequat adipisicing ullamco occaecat do. Ex proident ea quis nulla incididunt sunt excepteur incididunt. Aliquip minim nostrud non anim Lorem.','registered':'Tuesday, November 20, 2018 9:28 AM','latitude':'-12.677798','longitude':'114.506787','tags':['culpa','amet','elit','officia','irure'],'greeting':'Hello, Lily! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed74c76f2e84e201ce','index':45,'guid':'ec0a68d4-629e-46c9-9af7-f6ea867f02ba','isActive':true,'balance':'$1,477.93','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Shauna','last':'Pitts'},'company':'SPACEWAX','email':'shauna.pitts@spacewax.info','phone':'+1 (841) 406-2360','address':'348 Tabor Court, Westwood, Puerto Rico, 8297','about':'Aliquip irure officia magna ea magna mollit ea non amet deserunt. Veniam mollit labore culpa magna aliqua quis consequat est consectetur ea reprehenderit nostrud consequat aliqua. Mollit do ipsum mollit eiusmod.','registered':'Thursday, October 2, 2014 2:48 AM','latitude':'-55.17388','longitude':'-13.370494','tags':['anim','consectetur','cillum','veniam','duis'],'greeting':'Hello, Shauna! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed419e718484b16722','index':46,'guid':'b2d6101d-5646-43f4-8207-284494e5a990','isActive':false,'balance':'$2,006.96','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Lawrence','last':'Boyer'},'company':'SKYPLEX','email':'lawrence.boyer@skyplex.tv','phone':'+1 (953) 548-2618','address':'464 Pilling Street, Blandburg, Arizona, 5531','about':'Culpa sit minim pariatur mollit cupidatat sunt duis. Nisi ea proident veniam exercitation adipisicing Lorem aliquip amet dolor voluptate in nisi. Non commodo anim sunt est fugiat laborum nisi aliqua non Lorem exercitation dolor. Laboris dolore do minim ut eiusmod enim magna cillum laborum consectetur aliquip minim enim Lorem. Veniam ex veniam occaecat aliquip elit aliquip est eiusmod minim minim adipisicing.','registered':'Wednesday, July 30, 2014 2:17 AM','latitude':'-78.681255','longitude':'139.960626','tags':['consequat','Lorem','incididunt','dolor','esse'],'greeting':'Hello, Lawrence! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed08a9024998292c70','index':47,'guid':'277de142-ebeb-4828-906a-7fd8bc0a738a','isActive':true,'balance':'$1,273.19','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Sonya','last':'Stafford'},'company':'AQUACINE','email':'sonya.stafford@aquacine.co.uk','phone':'+1 (824) 581-3927','address':'641 Bowery Street, Hillsboro, Delaware, 7893','about':'Culpa labore ex reprehenderit mollit cupidatat dolore et ut quis in. Sint esse culpa enim culpa tempor exercitation veniam minim consectetur. Sunt est laboris minim quis incididunt exercitation laboris cupidatat fugiat ad. Deserunt ipsum do dolor cillum excepteur incididunt.','registered':'Thursday, March 26, 2015 1:10 PM','latitude':'-84.750592','longitude':'165.493533','tags':['minim','officia','dolore','ipsum','est'],'greeting':'Hello, Sonya! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5037f2c79ecde68','index':48,'guid':'2dc6532f-9a26-49aa-b444-8923896db89c','isActive':false,'balance':'$3,168.93','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Marguerite','last':'Stuart'},'company':'ACCUFARM','email':'marguerite.stuart@accufarm.io','phone':'+1 (848) 535-2253','address':'301 Menahan Street, Sunnyside, Nebraska, 4809','about':'Deserunt sint labore voluptate amet anim culpa nostrud adipisicing enim cupidatat ullamco exercitation fugiat est. Magna dolor aute incididunt ea ad adipisicing. Do cupidatat ut officia officia culpa sit do.','registered':'Thursday, May 8, 2014 1:25 PM','latitude':'21.82277','longitude':'-7.368347','tags':['labore','nulla','ullamco','irure','adipisicing'],'greeting':'Hello, Marguerite! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb26d315635818dae','index':49,'guid':'083a5eda-0a70-4f89-87f7-2cd386c0f22a','isActive':false,'balance':'$2,576.25','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Louella','last':'Holloway'},'company':'BEDDER','email':'louella.holloway@bedder.me','phone':'+1 (801) 425-3761','address':'545 Lafayette Avenue, Caledonia, Louisiana, 2816','about':'Qui exercitation occaecat dolore mollit. Fugiat cupidatat proident culpa fugiat quis. In cupidatat commodo elit ea enim occaecat esse exercitation nostrud occaecat veniam laboris fugiat. Nisi sunt reprehenderit aliqua reprehenderit tempor id dolore ullamco pariatur reprehenderit et eu ex pariatur.','registered':'Wednesday, November 5, 2014 1:10 AM','latitude':'36.385637','longitude':'77.949423','tags':['eu','irure','velit','non','aliquip'],'greeting':'Hello, Louella! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed77cd60a1abc1ecce','index':50,'guid':'2887c3c1-3eba-4237-a0db-1977eed94554','isActive':true,'balance':'$1,633.51','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Bates','last':'Carrillo'},'company':'ZOMBOID','email':'bates.carrillo@zomboid.com','phone':'+1 (934) 405-2006','address':'330 Howard Alley, Troy, Kansas, 4881','about':'Voluptate esse est ullamco anim tempor ea reprehenderit. Occaecat pariatur deserunt cillum laboris labore id exercitation esse ipsum ipsum ex aliquip. Sunt non elit est ea occaecat. Magna deserunt commodo aliqua ipsum est cillum dolor nisi. Ex duis est tempor tempor laboris do do quis id magna. Dolor do est elit eu laborum ullamco culpa consequat velit eiusmod tempor.','registered':'Saturday, May 28, 2016 3:56 AM','latitude':'83.310134','longitude':'-105.862836','tags':['est','commodo','ea','commodo','sunt'],'greeting':'Hello, Bates! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5ec0ec299b471fb5','index':51,'guid':'512b5e67-f785-492e-9d94-e43ef8b399b8','isActive':false,'balance':'$3,032.22','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Floyd','last':'Yang'},'company':'FRENEX','email':'floyd.yang@frenex.org','phone':'+1 (924) 566-3304','address':'418 Quay Street, Chumuckla, Guam, 7743','about':'Irure sit velit exercitation dolore est nisi incididunt ut quis consectetur incididunt est dolor. Aute nisi enim esse aliquip enim culpa commodo consectetur. Duis laborum magna ad duis ipsum aliqua eiusmod cillum. Consectetur et duis eiusmod irure ad est nisi incididunt eiusmod labore. Pariatur proident in Lorem adipisicing mollit proident excepteur nulla do nostrud mollit eiusmod. Duis ad dolore irure fugiat anim laboris ipsum et sit duis ipsum voluptate. Lorem non aute exercitation qui ullamco officia minim sint pariatur ut dolor.','registered':'Wednesday, January 18, 2017 2:01 AM','latitude':'45.888721','longitude':'-41.232793','tags':['elit','in','esse','ea','officia'],'greeting':'Hello, Floyd! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51e26ca89e5caf49','index':52,'guid':'4e0907f6-facc-46df-8952-73561a53fe33','isActive':true,'balance':'$3,767.41','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Gardner','last':'Carey'},'company':'KLUGGER','email':'gardner.carey@klugger.net','phone':'+1 (876) 481-3502','address':'131 Utica Avenue, Cannondale, Federated States Of Micronesia, 610','about':'Amet ad pariatur excepteur anim ex officia commodo proident aliqua occaecat consequat Lorem officia sit. Id minim velit nisi laboris nisi nulla incididunt eiusmod velit. Deserunt labore quis et tempor. Et labore exercitation laborum officia ullamco nostrud adipisicing laboris esse laborum aute anim elit. Sunt ad officia tempor esse et quis aliquip irure pariatur laborum id quis ex. Eu consequat nisi deserunt id eu proident ex minim aute nulla tempor ex.','registered':'Friday, February 21, 2014 6:42 AM','latitude':'-54.740231','longitude':'15.01484','tags':['commodo','laboris','occaecat','aliquip','adipisicing'],'greeting':'Hello, Gardner! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed52e3c9407105093a','index':53,'guid':'1d3b9e7a-1bc3-40ea-b808-1c33f0d48c70','isActive':true,'balance':'$1,113.30','picture':'http://placehold.it/32x32','age':26,'eyeColor':'blue','name':{'first':'Herman','last':'Rogers'},'company':'TALENDULA','email':'herman.rogers@talendula.name','phone':'+1 (818) 521-2005','address':'541 Norman Avenue, Winfred, Tennessee, 447','about':'Culpa ex laborum non ad ullamco officia. Nisi mollit mollit voluptate sit sint ullamco. Lorem exercitation nulla anim eiusmod deserunt magna sint. Officia sunt eiusmod aliqua reprehenderit sunt mollit sit cupidatat sint.','registered':'Wednesday, July 11, 2018 1:05 AM','latitude':'-20.708105','longitude':'-151.294563','tags':['exercitation','minim','officia','qui','enim'],'greeting':'Hello, Herman! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edfcb123d545b6edb4','index':54,'guid':'c0e0c669-4eed-43ee-bdd0-78fe6e9ca4d5','isActive':true,'balance':'$3,309.64','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Whitley','last':'Stark'},'company':'MUSAPHICS','email':'whitley.stark@musaphics.us','phone':'+1 (803) 476-2151','address':'548 Cobek Court, Chamizal, Indiana, 204','about':'Adipisicing veniam dolor ex sint sit id eu voluptate. Excepteur veniam proident exercitation id eu et sunt pariatur. Qui occaecat culpa aliqua nisi excepteur minim veniam. Est duis nulla laborum excepteur cillum pariatur sint incididunt. Velit commodo eu incididunt voluptate. Amet laboris laboris id adipisicing labore eiusmod consequat minim cillum et.','registered':'Thursday, March 27, 2014 9:10 AM','latitude':'71.219596','longitude':'51.012855','tags':['reprehenderit','mollit','laborum','voluptate','aliquip'],'greeting':'Hello, Whitley! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed81510dfc61602fcf','index':55,'guid':'7ec5c24d-f169-4399-a2a3-300c0f45e52e','isActive':false,'balance':'$3,721.04','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Gretchen','last':'Wade'},'company':'EWEVILLE','email':'gretchen.wade@eweville.biz','phone':'+1 (977) 598-3700','address':'721 Colonial Road, Brookfield, South Dakota, 3888','about':'Fugiat consequat sint ut ut et ullamco eiusmod deserunt pariatur. Veniam eiusmod esse fugiat mollit. Proident laboris minim qui do ipsum excepteur exercitation irure anim. Aliqua labore quis eu fugiat dolore ullamco velit Lorem voluptate ipsum nostrud eiusmod laborum proident.','registered':'Friday, October 12, 2018 10:59 AM','latitude':'41.937653','longitude':'63.378531','tags':['aute','cillum','ea','ex','aute'],'greeting':'Hello, Gretchen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf78f77d4a7d557bb','index':56,'guid':'8718ada7-6fd0-49ef-a405-29850503948b','isActive':false,'balance':'$3,341.33','picture':'http://placehold.it/32x32','age':32,'eyeColor':'blue','name':{'first':'Naomi','last':'Frye'},'company':'MAZUDA','email':'naomi.frye@mazuda.ca','phone':'+1 (825) 427-2255','address':'741 Coyle Street, Comptche, Pennsylvania, 8441','about':'Aliqua fugiat laborum quis ullamco cupidatat sit dolor nulla dolore. Do Lorem et ipsum culpa irure sit do dolor qui sit laboris aliqua. Ex consectetur irure in veniam reprehenderit amet do elit eiusmod est magna.','registered':'Thursday, January 9, 2014 7:18 AM','latitude':'41.078645','longitude':'-50.241966','tags':['do','aliquip','eiusmod','velit','id'],'greeting':'Hello, Naomi! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbf45db2e072a48b4','index':57,'guid':'c158ebf7-fb8b-4ea8-adbf-8c51c6486715','isActive':true,'balance':'$2,811.55','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Lamb','last':'Johns'},'company':'DOGTOWN','email':'lamb.johns@dogtown.info','phone':'+1 (946) 530-3057','address':'559 Malbone Street, Kennedyville, California, 2052','about':'Eiusmod dolor labore cillum ad veniam elit voluptate voluptate pariatur est cupidatat. Laboris ut qui in cillum sunt dolore ut enim. Minim nostrud ex qui quis reprehenderit magna ipsum cupidatat irure minim laboris veniam irure. Fugiat velit deserunt aliquip in esse proident excepteur labore reprehenderit excepteur sunt in cupidatat exercitation. Ex pariatur irure mollit tempor non magna ex.','registered':'Friday, April 21, 2017 1:51 AM','latitude':'-61.403599','longitude':'-93.447102','tags':['aliquip','tempor','sint','enim','ipsum'],'greeting':'Hello, Lamb! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb9c88190cb59cf2','index':58,'guid':'f0de5ac5-eb28-491b-81c5-76d447c9055e','isActive':true,'balance':'$1,611.99','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Lynette','last':'Cleveland'},'company':'ARTWORLDS','email':'lynette.cleveland@artworlds.tv','phone':'+1 (889) 596-3723','address':'439 Montauk Avenue, Felt, New Mexico, 9681','about':'Incididunt aliquip est aliquip est ullamco do consectetur dolor. Lorem mollit mollit dolor et ipsum ut qui veniam aute ea. Adipisicing reprehenderit culpa velit laborum adipisicing amet consectetur velit nisi. Ut qui proident ad cillum excepteur adipisicing quis labore. Duis velit culpa et excepteur eiusmod ex labore in nisi nostrud. Et ullamco minim excepteur ut enim reprehenderit consequat eiusmod laboris Lorem commodo exercitation qui laborum.','registered':'Wednesday, August 26, 2015 12:53 PM','latitude':'49.861336','longitude':'86.865926','tags':['reprehenderit','minim','in','minim','nostrud'],'greeting':'Hello, Lynette! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5b760ddde7295fa8','index':59,'guid':'f8180d3f-c5c0-48b2-966e-a0b2a80f8e84','isActive':true,'balance':'$3,376.75','picture':'http://placehold.it/32x32','age':32,'eyeColor':'green','name':{'first':'Obrien','last':'Page'},'company':'GLASSTEP','email':'obrien.page@glasstep.co.uk','phone':'+1 (902) 583-3086','address':'183 Ridgewood Avenue, Vicksburg, Wisconsin, 7430','about':'Aute excepteur cillum exercitation duis Lorem irure labore elit. Labore magna cupidatat velit consectetur minim do Lorem in excepteur commodo ea consequat ullamco laborum. Ut in id occaecat eu quis duis id ea deserunt veniam.','registered':'Wednesday, March 29, 2017 12:13 AM','latitude':'-40.156154','longitude':'72.76301','tags':['excepteur','non','anim','nulla','anim'],'greeting':'Hello, Obrien! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed52985d3d8901d653','index':60,'guid':'d2e14fa1-8c54-4bcb-8a58-eb2e6f8d0e45','isActive':true,'balance':'$1,659.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'brown','name':{'first':'Knowles','last':'Goodman'},'company':'CENTREE','email':'knowles.goodman@centree.io','phone':'+1 (862) 563-3692','address':'504 Lott Street, Allensworth, Florida, 7148','about':'Do aliquip voluptate aliqua nostrud. Eu dolore ex occaecat pariatur aute laborum aute nulla aute amet. Excepteur sit laboris ad non anim ut officia ut ad exercitation officia dolore laboris. Esse voluptate minim deserunt nostrud exercitation laborum voluptate exercitation id laborum fugiat proident cupidatat proident. Nulla nostrud est sint adipisicing incididunt exercitation dolor sit et elit tempor occaecat sint culpa. Pariatur occaecat laboris pariatur laboris ad pariatur in cillum fugiat est fugiat. Proident eu id irure excepteur esse aute cillum adipisicing.','registered':'Wednesday, October 15, 2014 6:17 PM','latitude':'-15.73863','longitude':'87.422009','tags':['consequat','sint','tempor','veniam','culpa'],'greeting':'Hello, Knowles! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda00b73bdb7ea54e9','index':61,'guid':'c8a064db-0ec6-4832-9820-7280a0333709','isActive':true,'balance':'$3,701.14','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Shepherd','last':'Todd'},'company':'ECRATIC','email':'shepherd.todd@ecratic.me','phone':'+1 (881) 444-3389','address':'450 Frank Court, Temperanceville, Ohio, 7006','about':'Voluptate cillum ad fugiat velit adipisicing sint consequat veniam Lorem reprehenderit. Cillum sit non deserunt consequat. Amet sunt pariatur non mollit ullamco proident sint dolore anim elit cupidatat anim do ullamco. Lorem Lorem incididunt ea elit consequat laboris enim duis quis Lorem id aute veniam consequat. Cillum veniam cillum sint qui Lorem fugiat culpa consequat. Est sint duis ut qui fugiat. Laborum pariatur velit et sunt mollit eiusmod excepteur culpa ex et officia.','registered':'Tuesday, October 10, 2017 2:01 AM','latitude':'82.951563','longitude':'-4.866954','tags':['eu','qui','proident','esse','ex'],'greeting':'Hello, Shepherd! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed0e51d1a7e2d9e559','index':62,'guid':'739c3d38-200d-4531-84d8-4e7c39ae5b8c','isActive':true,'balance':'$3,679.01','picture':'http://placehold.it/32x32','age':31,'eyeColor':'brown','name':{'first':'Rosalyn','last':'Heath'},'company':'ZAYA','email':'rosalyn.heath@zaya.com','phone':'+1 (865) 403-3520','address':'303 Henderson Walk, Hoehne, District Of Columbia, 4306','about':'Sint occaecat nulla mollit sint fugiat eu proident dolor labore consequat. Occaecat tempor excepteur do fugiat incididunt Lorem in ullamco dolore laborum. Cillum mollit aliquip excepteur aliquip sint sunt minim non irure irure. Cillum fugiat aliqua enim dolore. Nulla culpa culpa nostrud ad. Eiusmod culpa proident proident non est cupidatat eu sunt sit incididunt id nisi.','registered':'Wednesday, April 22, 2015 12:35 PM','latitude':'33.628504','longitude':'110.772802','tags':['consequat','ut','ex','labore','consectetur'],'greeting':'Hello, Rosalyn! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5274c01d353d0c5','index':63,'guid':'8815fe55-8af1-4708-a62a-d554dbd74a4a','isActive':true,'balance':'$2,126.01','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Queen','last':'Harper'},'company':'TRI@TRIBALOG','email':'queen.harper@tri@tribalog.org','phone':'+1 (903) 592-3145','address':'926 Heath Place, Wawona, Maine, 7340','about':'Laborum cupidatat commodo aliquip reprehenderit. Excepteur eu labore duis minim minim voluptate aute nostrud deserunt ut velit ullamco. Adipisicing nisi occaecat laborum proident. Id reprehenderit eiusmod cupidatat qui aute consequat amet enim commodo duis non ipsum. Amet ut aliqua magna qui proident mollit aute.','registered':'Saturday, April 9, 2016 5:12 AM','latitude':'51.814216','longitude':'177.348115','tags':['cillum','ut','dolor','do','nisi'],'greeting':'Hello, Queen! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed126298b6ce62ed56','index':64,'guid':'001c87fe-182f-450f-903b-2e29a9bb0322','isActive':true,'balance':'$3,578.29','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Pauline','last':'Mills'},'company':'CRUSTATIA','email':'pauline.mills@crustatia.net','phone':'+1 (984) 582-3899','address':'899 Revere Place, Welch, Iowa, 216','about':'Tempor eu exercitation ut id. Deserunt ex reprehenderit veniam nisi. Aute laborum veniam velit dolore ut deserunt Lorem sit esse quis dolor ex do nisi. In dolor tempor officia id. Velit nisi culpa nostrud laborum officia incididunt laborum velit non quis id exercitation exercitation. Anim elit ullamco in enim Lorem culpa aliqua Lorem.','registered':'Monday, June 2, 2014 2:03 PM','latitude':'56.427576','longitude':'172.183669','tags':['pariatur','pariatur','pariatur','fugiat','Lorem'],'greeting':'Hello, Pauline! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e332ad9e8a178d8','index':65,'guid':'5ad7292b-feef-4a7e-b485-142cadfbe8ea','isActive':false,'balance':'$3,916.54','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Garrett','last':'Richmond'},'company':'XYQAG','email':'garrett.richmond@xyqag.name','phone':'+1 (952) 584-3794','address':'233 Grove Street, Summerfield, Virginia, 4735','about':'Nostrud quis pariatur occaecat laborum laboris aliqua ut fugiat dolor. Commodo tempor excepteur enim nostrud Lorem. Aute elit nulla labore ad pariatur cupidatat Lorem qui cupidatat velit deserunt excepteur esse. Excepteur nulla et nostrud quis labore est veniam enim nisi laboris ut enim. Ea esse nulla anim excepteur reprehenderit deserunt voluptate minim qui labore adipisicing amet eu enim.','registered':'Wednesday, March 5, 2014 4:35 PM','latitude':'68.665041','longitude':'148.799524','tags':['irure','reprehenderit','minim','ea','do'],'greeting':'Hello, Garrett! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed541aa2ec47466ace','index':66,'guid':'9cda6f3c-c9ab-451c-bb19-2e4c8463d011','isActive':true,'balance':'$3,352.52','picture':'http://placehold.it/32x32','age':30,'eyeColor':'brown','name':{'first':'Cobb','last':'Whitley'},'company':'UNIA','email':'cobb.whitley@unia.us','phone':'+1 (888) 490-3342','address':'864 Belmont Avenue, Needmore, Massachusetts, 8286','about':'Nisi aliquip fugiat ipsum nisi ullamco minim pariatur labore. Sint labore anim do ad ad esse eu nostrud nulla commodo anim. Cillum anim enim duis cillum non do nisi aliquip veniam voluptate commodo aliqua laborum. Exercitation in do eu qui sint aliquip. Esse adipisicing deserunt deserunt qui anim aliqua occaecat et nostrud elit ea in anim cillum. Tempor mollit proident tempor sunt est sint laborum ullamco incididunt non. Velit aliqua sunt excepteur nisi qui eiusmod ipsum dolore aliquip velit ullamco ullamco.','registered':'Friday, May 23, 2014 7:11 PM','latitude':'-32.950581','longitude':'147.772494','tags':['mollit','adipisicing','irure','ad','minim'],'greeting':'Hello, Cobb! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8186c3d6f34c2be3','index':67,'guid':'fee98f6d-d68a-4189-8180-b6cb337e537e','isActive':false,'balance':'$1,698.42','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Brennan','last':'Tyler'},'company':'PODUNK','email':'brennan.tyler@podunk.biz','phone':'+1 (867) 498-2727','address':'599 Harkness Avenue, Gorst, American Samoa, 322','about':'Reprehenderit id sit qui id qui aute ea sit magna in qui proident. Excepteur ad nostrud do nostrud in incididunt voluptate adipisicing sint anim. Ullamco consequat minim nulla irure ex est irure reprehenderit deserunt voluptate dolore anim sunt. Occaecat dolore voluptate voluptate elit commodo nulla laborum ad do irure.','registered':'Friday, February 9, 2018 5:40 PM','latitude':'11.150893','longitude':'-85.298004','tags':['quis','minim','deserunt','cillum','laboris'],'greeting':'Hello, Brennan! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed075c9c4f7439818d','index':68,'guid':'1ef76b18-6b8d-4c3c-aca3-9fa2b43f0242','isActive':false,'balance':'$2,091.17','picture':'http://placehold.it/32x32','age':26,'eyeColor':'brown','name':{'first':'Neal','last':'Stephenson'},'company':'OTHERSIDE','email':'neal.stephenson@otherside.ca','phone':'+1 (820) 496-3344','address':'867 Wilson Street, Kidder, Colorado, 4599','about':'Do laboris enim proident in qui velit adipisicing magna anim. Amet proident non exercitation ipsum aliqua excepteur nostrud. Enim esse non sit in nostrud deserunt id laborum cillum deserunt consequat. Anim velit exercitation qui sit voluptate. Irure duis non veniam velit mollit exercitation id exercitation.','registered':'Thursday, November 13, 2014 11:00 PM','latitude':'54.809693','longitude':'1.877241','tags':['anim','duis','in','officia','sint'],'greeting':'Hello, Neal! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0eda0a2dc24db64b638','index':69,'guid':'194744fd-089b-40b6-a290-98a6ec30a415','isActive':false,'balance':'$3,191.67','picture':'http://placehold.it/32x32','age':24,'eyeColor':'brown','name':{'first':'Shields','last':'Hubbard'},'company':'MIRACULA','email':'shields.hubbard@miracula.info','phone':'+1 (885) 582-2001','address':'529 Eagle Street, Guilford, Nevada, 1460','about':'Eiusmod exercitation ut incididunt veniam commodo culpa ullamco mollit id adipisicing exercitation ad sint. Nostrud excepteur amet aliqua mollit incididunt laborum voluptate id anim. Nulla sint laboris dolor esse cupidatat laborum ex sint. Ex non sunt sit nulla.','registered':'Monday, February 13, 2017 6:22 AM','latitude':'-69.145209','longitude':'-40.69755','tags':['tempor','enim','qui','velit','elit'],'greeting':'Hello, Shields! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf939c130177e074d','index':70,'guid':'303b176c-7803-4ed2-a35f-3e3c831793ef','isActive':false,'balance':'$2,359.09','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Coleen','last':'Knight'},'company':'BLEEKO','email':'coleen.knight@bleeko.tv','phone':'+1 (867) 423-3146','address':'527 Broadway , Bonanza, Marshall Islands, 4988','about':'Laboris nulla pariatur laborum ad aute excepteur sunt pariatur exercitation. Do nostrud qui ipsum ullamco et sint do Lorem cillum ullamco do. Exercitation labore excepteur commodo incididunt eiusmod proident consectetur adipisicing nostrud aute voluptate laboris. Commodo anim proident eiusmod pariatur est ea laborum incididunt qui tempor reprehenderit ullamco id. Eiusmod commodo nisi consectetur ut qui quis aliqua sit minim nostrud sunt laborum eiusmod adipisicing.','registered':'Sunday, May 6, 2018 8:03 AM','latitude':'70.729041','longitude':'113.052761','tags':['Lorem','ullamco','nulla','ullamco','commodo'],'greeting':'Hello, Coleen! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edae8b1ce688b61223','index':71,'guid':'7d6f3b1a-c367-4068-9e8e-1717d513ece3','isActive':false,'balance':'$2,911.07','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Clark','last':'Ryan'},'company':'ECLIPSENT','email':'clark.ryan@eclipsent.co.uk','phone':'+1 (938) 562-2740','address':'500 Lewis Avenue, Rockbridge, North Dakota, 5133','about':'Adipisicing exercitation officia sit excepteur excepteur sunt sint amet. Aliqua ipsum sint laboris eiusmod esse culpa elit sunt. Dolore est consectetur est quis quis magna. Aliquip nostrud dolore ex pariatur. Anim nostrud duis exercitation ut magna magna culpa. Nisi irure id mollit labore non sit mollit occaecat Lorem est ipsum. Nulla est fugiat cillum nisi aliqua consectetur amet nulla nostrud esse.','registered':'Friday, July 24, 2015 9:28 AM','latitude':'-68.055815','longitude':'-50.926966','tags':['deserunt','ad','ad','ut','id'],'greeting':'Hello, Clark! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5d1e8df45d8ab4db','index':72,'guid':'ce85db37-7d04-4f4c-a4b0-78003533e5c6','isActive':false,'balance':'$1,127.43','picture':'http://placehold.it/32x32','age':21,'eyeColor':'green','name':{'first':'Dillon','last':'Hooper'},'company':'MEDESIGN','email':'dillon.hooper@medesign.io','phone':'+1 (929) 600-3797','address':'652 Mill Avenue, Elliston, Mississippi, 2958','about':'Dolore culpa qui exercitation nostrud do. Irure duis in ad ipsum aliqua aliquip nulla sit veniam officia quis occaecat est. Magna qui eiusmod pariatur aliquip minim commodo. Qui ex dolor excepteur consequat eiusmod occaecat. In officia ipsum do Lorem excepteur proident pariatur labore.','registered':'Monday, May 26, 2014 2:38 AM','latitude':'-36.032189','longitude':'86.865529','tags':['non','ut','ex','Lorem','quis'],'greeting':'Hello, Dillon! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb84814579c3121b3','index':73,'guid':'d7303901-5186-4595-a759-22306f67d0a3','isActive':true,'balance':'$2,326.59','picture':'http://placehold.it/32x32','age':33,'eyeColor':'green','name':{'first':'Moreno','last':'Hull'},'company':'ZEAM','email':'moreno.hull@zeam.me','phone':'+1 (984) 586-3738','address':'265 Pine Street, Talpa, North Carolina, 6041','about':'Fugiat exercitation est ullamco anim. Exercitation proident id sunt culpa Lorem amet. Consectetur anim consectetur pariatur consequat consectetur amet excepteur voluptate ea velit duis eiusmod proident. In sint laborum cupidatat ea amet ex. Reprehenderit amet sunt dolor ullamco est ex deserunt.','registered':'Wednesday, January 24, 2018 8:52 PM','latitude':'84.956857','longitude':'113.210051','tags':['est','excepteur','anim','Lorem','dolor'],'greeting':'Hello, Moreno! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda4eb9dcb92c82d06','index':74,'guid':'8ee28651-802e-4523-b676-c713f6e874b8','isActive':true,'balance':'$3,783.97','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Tracie','last':'Price'},'company':'ICOLOGY','email':'tracie.price@icology.com','phone':'+1 (897) 403-3768','address':'487 Sheffield Avenue, Vallonia, Wyoming, 276','about':'Voluptate laboris laborum aute ex sint voluptate officia proident. Sit esse nostrud cupidatat in veniam sit duis est. Do mollit elit exercitation aliqua id irure ex. Lorem reprehenderit do ullamco sint ea ad nisi ad ut.','registered':'Saturday, December 10, 2016 9:44 AM','latitude':'77.770464','longitude':'151.392903','tags':['incididunt','labore','aliquip','anim','minim'],'greeting':'Hello, Tracie! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed68ab1a55d1c35e6c','index':75,'guid':'deedd26a-8928-4064-9666-5c59ea8144b4','isActive':true,'balance':'$2,848.08','picture':'http://placehold.it/32x32','age':32,'eyeColor':'brown','name':{'first':'Montgomery','last':'Bruce'},'company':'CYTREK','email':'montgomery.bruce@cytrek.org','phone':'+1 (824) 414-2731','address':'397 Beach Place, Ellerslie, South Carolina, 967','about':'Mollit minim excepteur magna velit cillum excepteur exercitation anim id labore deserunt do. Fugiat ex et id ad. Duis excepteur laboris est nulla do id irure quis eiusmod do esse ut culpa in.','registered':'Tuesday, August 25, 2015 6:42 AM','latitude':'79.722631','longitude':'-7.516885','tags':['Lorem','sint','voluptate','proident','incididunt'],'greeting':'Hello, Montgomery! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd90e0abb1cc2b0aa','index':76,'guid':'a072159d-12db-4747-9c2a-e2486a53d043','isActive':false,'balance':'$2,723.54','picture':'http://placehold.it/32x32','age':40,'eyeColor':'green','name':{'first':'Zelma','last':'Salinas'},'company':'IMAGEFLOW','email':'zelma.salinas@imageflow.net','phone':'+1 (964) 555-3856','address':'584 Reeve Place, Nord, Georgia, 7473','about':'Aliqua proident excepteur duis cupidatat cillum amet esse esse consectetur ea. Officia sunt consequat nostrud minim enim dolore dolor duis cillum. Esse labore veniam sint laborum excepteur sint tempor do ad cupidatat aliquip laboris elit id. Velit reprehenderit ullamco velit ullamco adipisicing velit esse irure velit et.','registered':'Thursday, February 25, 2016 8:18 PM','latitude':'-32.880524','longitude':'115.180489','tags':['id','nulla','reprehenderit','consequat','reprehenderit'],'greeting':'Hello, Zelma! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed98d836c8da283bb2','index':77,'guid':'838bebad-cc20-44e9-9eb7-902a8ca25efb','isActive':false,'balance':'$3,488.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Shaw','last':'Parsons'},'company':'PEARLESEX','email':'shaw.parsons@pearlesex.name','phone':'+1 (912) 567-3580','address':'606 Ocean Avenue, Tyro, Northern Mariana Islands, 3367','about':'Laborum labore occaecat culpa pariatur nisi non adipisicing esse consectetur officia officia. Deserunt velit eu enim consectetur ut cillum aliqua occaecat dolor qui esse. Incididunt ad est ex eu culpa anim aliquip laborum. Aliqua consectetur velit exercitation magna minim nulla do ut excepteur enim aliquip et. Nostrud enim sunt amet amet proident aliqua velit dolore. Consectetur ipsum fugiat proident id est reprehenderit tempor irure commodo. Sit excepteur fugiat occaecat nulla Lorem et cillum.','registered':'Thursday, April 19, 2018 1:41 AM','latitude':'69.715573','longitude':'-118.481237','tags':['laboris','adipisicing','magna','voluptate','id'],'greeting':'Hello, Shaw! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1101734633c6ebba','index':78,'guid':'8fd0c52a-9d74-4984-a608-d612ecd8ddf0','isActive':true,'balance':'$3,820.02','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Jaime','last':'Beard'},'company':'IZZBY','email':'jaime.beard@izzby.us','phone':'+1 (820) 412-3806','address':'362 Hudson Avenue, Delco, New Jersey, 5684','about':'Ut cupidatat veniam nulla magna commodo sit duis veniam consectetur cupidatat elit quis tempor. Duis officia ullamco proident sunt non mollit excepteur. Nisi ex amet laboris proident duis reprehenderit et est aliqua mollit amet ad. Enim eu elit excepteur eu exercitation duis consequat culpa. Adipisicing reprehenderit duis Lorem reprehenderit dolor aliqua incididunt eiusmod consequat ad occaecat fugiat do laborum. Qui ad aliquip ex do sunt. Fugiat non ut fugiat eu.','registered':'Sunday, March 9, 2014 3:41 PM','latitude':'17.926318','longitude':'108.985996','tags':['ut','voluptate','veniam','non','commodo'],'greeting':'Hello, Jaime! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edcd125a89dcf18e0d','index':79,'guid':'eccaa4ca-0fa7-4b00-a1e3-fe7953403894','isActive':true,'balance':'$1,521.33','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Terra','last':'Sullivan'},'company':'ZANITY','email':'terra.sullivan@zanity.biz','phone':'+1 (995) 498-2714','address':'346 Congress Street, Tuttle, Maryland, 3152','about':'Incididunt enim veniam ut veniam quis dolore pariatur culpa ex. Cillum laboris dolor exercitation officia. Officia irure magna aliqua veniam officia ullamco culpa. Cillum enim velit ea sint sint officia labore ea adipisicing culpa laboris. Anim aute sint commodo culpa ex quis minim ut laborum.','registered':'Sunday, June 1, 2014 5:38 AM','latitude':'-4.655435','longitude':'5.851803','tags':['anim','non','anim','laborum','pariatur'],'greeting':'Hello, Terra! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed9b9fc3041a674c87','index':80,'guid':'9f95fa36-4e45-4c3f-9362-3d4d809bf57f','isActive':true,'balance':'$3,403.16','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Sharpe','last':'Berger'},'company':'ZILLAN','email':'sharpe.berger@zillan.ca','phone':'+1 (913) 498-3005','address':'277 Bragg Street, Faywood, Texas, 6487','about':'Dolor duis id aute ea veniam amet ullamco id. Culpa deserunt irure mollit tempor dolore veniam culpa officia culpa laborum eiusmod. Ullamco tempor qui aliqua cupidatat veniam cillum eu ut ex minim eu in. Quis exercitation anim eiusmod tempor esse mollit exercitation cillum ipsum reprehenderit. Sint voluptate ipsum officia sint magna nulla tempor eiusmod eiusmod veniam. Consectetur non ad veniam exercitation voluptate non nostrud.','registered':'Tuesday, June 27, 2017 12:58 AM','latitude':'-0.54085','longitude':'106.258693','tags':['proident','eiusmod','commodo','excepteur','pariatur'],'greeting':'Hello, Sharpe! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1a1866757bf675e0','index':81,'guid':'1b944a01-01d3-4846-94e3-630f4d0e51a3','isActive':true,'balance':'$2,038.61','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Blanchard','last':'Ewing'},'company':'CONJURICA','email':'blanchard.ewing@conjurica.info','phone':'+1 (859) 593-3212','address':'252 Beaver Street, Kiskimere, Utah, 3255','about':'Labore magna aute adipisicing ut dolor sit ea. Officia culpa aute occaecat sit ex ullamco aliquip ad sit culpa. Ex in enim dolore ex est sit. Do irure nulla magna sint aliquip in duis aute. Magna ullamco sit labore ea tempor voluptate.','registered':'Monday, May 4, 2015 10:50 AM','latitude':'76.207595','longitude':'0.672563','tags':['proident','pariatur','officia','in','culpa'],'greeting':'Hello, Blanchard! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed987d82f4e22d939c','index':82,'guid':'97a90aee-3cee-4678-819e-24fb94279dc1','isActive':false,'balance':'$1,201.55','picture':'http://placehold.it/32x32','age':28,'eyeColor':'blue','name':{'first':'Wells','last':'Solomon'},'company':'CORPULSE','email':'wells.solomon@corpulse.tv','phone':'+1 (840) 539-3349','address':'159 Radde Place, Linganore, Idaho, 230','about':'Consequat dolore mollit sit irure cupidatat commodo. Incididunt cillum reprehenderit ullamco sit proident cupidatat occaecat reprehenderit officia. Ad anim Lorem elit in officia minim proident nisi commodo eiusmod ea Lorem dolore voluptate. Dolor aliquip est commodo Lorem dolor ut aliquip ut. Sit anim officia dolore excepteur aute enim cillum.','registered':'Friday, January 6, 2017 1:59 PM','latitude':'70.020883','longitude':'14.503588','tags':['mollit','aute','officia','nostrud','laboris'],'greeting':'Hello, Wells! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf7a904ea0d0bc2a','index':83,'guid':'fe639a0c-7517-43e6-b0da-cd9ca5b9e267','isActive':false,'balance':'$3,664.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'blue','name':{'first':'Natalia','last':'Brown'},'company':'SYNTAC','email':'natalia.brown@syntac.co.uk','phone':'+1 (952) 595-3513','address':'332 Lenox Road, Springville, Alabama, 8406','about':'Nulla consequat officia commodo ea sunt irure anim velit aliquip aliquip. Labore ullamco occaecat proident voluptate cillum labore minim nostrud excepteur. Qui fugiat nostrud cillum fugiat ullamco id commodo aliqua voluptate mollit id id laboris. Cillum qui duis duis sit adipisicing elit ut aliqua eu. Anim nisi aliqua sit mollit.','registered':'Sunday, July 30, 2017 1:02 PM','latitude':'31.937613','longitude':'-9.957927','tags':['magna','adipisicing','exercitation','tempor','consectetur'],'greeting':'Hello, Natalia! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed8823fa385cad4aa3','index':84,'guid':'5cf280da-f5f0-4cc6-9063-e9d5863c8c89','isActive':false,'balance':'$1,624.17','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Greene','last':'Waller'},'company':'ISOTRACK','email':'greene.waller@isotrack.io','phone':'+1 (838) 406-3608','address':'362 Albemarle Road, Gardiner, Michigan, 2764','about':'Ut nisi sit sint nulla dolor magna. Culpa occaecat adipisicing veniam proident excepteur tempor quis ex. Fugiat tempor laborum dolor adipisicing irure anim cupidatat ut exercitation ex sit. Cupidatat exercitation commodo sunt ex irure fugiat eu esse do ullamco mollit dolore cupidatat. Cupidatat magna incididunt officia dolore esse voluptate deserunt in laborum dolor. Sit fugiat Lorem eu ullamco. Laboris veniam quis cillum tempor ex fugiat cillum cupidatat.','registered':'Sunday, June 10, 2018 10:32 PM','latitude':'0.256921','longitude':'-96.141941','tags':['magna','dolore','deserunt','aliquip','cillum'],'greeting':'Hello, Greene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda7c905c2d24c7d31','index':85,'guid':'aa30a9fb-8a16-48eb-8bb7-1307d1e1f191','isActive':false,'balance':'$1,974.04','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Carlene','last':'Hanson'},'company':'DIGIRANG','email':'carlene.hanson@digirang.me','phone':'+1 (981) 417-3209','address':'435 Clark Street, Choctaw, Oregon, 9888','about':'Amet labore esse cillum irure laborum consectetur occaecat non aliquip aliquip proident. Nisi magna nulla officia duis labore aute nulla laborum duis tempor minim. Velit elit reprehenderit nisi exercitation officia incididunt amet cupidatat excepteur proident consectetur.','registered':'Thursday, April 20, 2017 6:13 AM','latitude':'68.529086','longitude':'68.802409','tags':['pariatur','nulla','qui','amet','labore'],'greeting':'Hello, Carlene! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed6fbee12ce9e55dbf','index':86,'guid':'0fce89aa-3310-48df-862a-68bd3d776644','isActive':false,'balance':'$3,909.64','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Doris','last':'Collins'},'company':'ZIORE','email':'doris.collins@ziore.com','phone':'+1 (914) 405-2360','address':'301 Lorraine Street, Stouchsburg, Minnesota, 7476','about':'Nisi deserunt aliquip et deserunt ipsum ad consectetur est non ullamco. Dolore do ut voluptate do eiusmod. Culpa ad in eiusmod nisi cillum do. Officia magna cillum sint aliqua reprehenderit amet est ipsum. Eiusmod deserunt commodo proident consequat. Amet minim dolor consequat aliquip aliquip culpa non exercitation non.','registered':'Wednesday, February 25, 2015 9:15 PM','latitude':'-57.364906','longitude':'130.766587','tags':['nulla','deserunt','cillum','eiusmod','adipisicing'],'greeting':'Hello, Doris! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edede9402476c398c0','index':87,'guid':'60cf0aa6-bc6d-4305-8842-d27e6af1306f','isActive':false,'balance':'$2,817.53','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Cline','last':'Hayden'},'company':'ECRAZE','email':'cline.hayden@ecraze.org','phone':'+1 (965) 507-2138','address':'352 Rutland Road, Ebro, Connecticut, 1196','about':'Dolor eiusmod enim anim sit enim ea tempor. Tempor amet consectetur aliquip culpa do ex excepteur deserunt. Dolor commodo veniam culpa sint. Commodo consectetur pariatur irure nisi deserunt cillum est dolor ipsum ea.','registered':'Thursday, September 29, 2016 5:58 AM','latitude':'62.50713','longitude':'86.247286','tags':['enim','tempor','anim','veniam','proident'],'greeting':'Hello, Cline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edeb72f151994a551b','index':88,'guid':'dbb49c62-86b1-409f-b8b8-f609c709d2a8','isActive':false,'balance':'$3,122.56','picture':'http://placehold.it/32x32','age':39,'eyeColor':'green','name':{'first':'Janelle','last':'Rutledge'},'company':'TERRAGEN','email':'janelle.rutledge@terragen.net','phone':'+1 (914) 581-3749','address':'170 Falmouth Street, Alderpoint, West Virginia, 642','about':'Laboris proident cillum sunt qui ea sunt. Officia adipisicing exercitation dolore magna reprehenderit amet anim id. Laboris commodo sit irure irure. Excepteur est mollit fugiat incididunt consectetur veniam irure ea mollit. Cillum enim consequat sunt sunt nisi incididunt tempor enim.','registered':'Monday, February 16, 2015 5:46 AM','latitude':'-46.392023','longitude':'32.054562','tags':['eu','eu','nisi','labore','deserunt'],'greeting':'Hello, Janelle! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edc9c2604846ff9a0d','index':89,'guid':'c4d7a365-f1d3-4584-b78e-008394c219f7','isActive':true,'balance':'$1,807.19','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Abby','last':'Lopez'},'company':'GRAINSPOT','email':'abby.lopez@grainspot.name','phone':'+1 (917) 442-3955','address':'488 Kensington Walk, Winston, Hawaii, 9109','about':'Incididunt deserunt Lorem proident magna tempor enim quis duis eu ut adipisicing in. Ex mollit non irure aliqua officia. Fugiat id ipsum consequat irure id ullamco culpa quis nulla enim aliquip consequat et. Dolor ut anim velit irure consequat cillum eu. Aute occaecat laborum est aliqua.','registered':'Sunday, April 1, 2018 11:28 PM','latitude':'-10.177041','longitude':'-165.756718','tags':['est','laborum','culpa','non','quis'],'greeting':'Hello, Abby! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed03237438b158af9e','index':90,'guid':'36c4a19f-2d00-4e40-bd49-155fd2ce0a6c','isActive':false,'balance':'$2,757.86','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Whitney','last':'Sheppard'},'company':'ANACHO','email':'whitney.sheppard@anacho.us','phone':'+1 (922) 437-2383','address':'951 Beekman Place, Homeworth, New York, 6088','about':'Sint minim nisi minim non minim aliqua pariatur ullamco do sint qui labore. Aute elit reprehenderit ad do fugiat est amet. In incididunt tempor commodo cillum tempor est labore anim.','registered':'Tuesday, September 13, 2016 6:43 PM','latitude':'-49.732527','longitude':'-171.846715','tags':['exercitation','veniam','sunt','est','proident'],'greeting':'Hello, Whitney! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edb99dd3aa53d2cb7f','index':91,'guid':'17afd430-f37f-4d55-958c-72f35cdb5997','isActive':false,'balance':'$3,683.86','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Ilene','last':'Blackwell'},'company':'ENQUILITY','email':'ilene.blackwell@enquility.biz','phone':'+1 (817) 555-2616','address':'950 Varanda Place, Belgreen, Virgin Islands, 1765','about':'Id eiusmod deserunt eiusmod adipisicing adipisicing est enim pariatur esse duis. Qui velit duis irure magna consectetur dolore reprehenderit. Cillum dolore minim consectetur irure non qui velit cillum veniam adipisicing incididunt. Deserunt veniam excepteur veniam velit aliquip labore quis exercitation magna do non dolor. Aliquip occaecat minim adipisicing deserunt fugiat nulla occaecat proident irure consectetur eiusmod irure. Enim Lorem deserunt amet Lorem commodo eiusmod reprehenderit occaecat adipisicing dolor voluptate cillum.','registered':'Thursday, February 1, 2018 8:39 AM','latitude':'57.393644','longitude':'-3.704258','tags':['adipisicing','dolor','commodo','Lorem','Lorem'],'greeting':'Hello, Ilene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed353f4deb62c3342a','index':92,'guid':'9953e285-2095-4f1c-978b-9ece2a867e9d','isActive':false,'balance':'$1,202.44','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Dawson','last':'Herman'},'company':'BITENDREX','email':'dawson.herman@bitendrex.ca','phone':'+1 (843) 522-2655','address':'471 Channel Avenue, Denio, Alaska, 5040','about':'Nisi occaecat mollit reprehenderit nisi minim Lorem mollit. Ea proident irure cillum quis. Deserunt consectetur consectetur consequat quis enim minim ea ipsum proident nisi ad non aliquip. Veniam aute minim consequat irure voluptate aute amet excepteur exercitation cillum duis quis adipisicing nostrud.','registered':'Tuesday, December 8, 2015 5:40 PM','latitude':'-55.602721','longitude':'-26.683234','tags':['qui','dolor','deserunt','eiusmod','labore'],'greeting':'Hello, Dawson! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5464bc50a5310ad','index':93,'guid':'724b2434-4dbd-417d-aa07-6065715f434f','isActive':false,'balance':'$1,595.98','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Alice','last':'Christian'},'company':'ZENOLUX','email':'alice.christian@zenolux.info','phone':'+1 (954) 466-2650','address':'875 Gerritsen Avenue, Townsend, Kentucky, 6568','about':'Nulla labore occaecat ex culpa magna. Commodo occaecat et in consequat cillum laborum magna adipisicing excepteur. Do ut Lorem esse voluptate officia ea aliquip proident amet veniam minim nulla adipisicing. Enim consectetur incididunt laborum voluptate tempor deserunt non laboris. Aliquip deserunt aute irure dolore magna anim aliquip sint magna Lorem. Officia laboris nulla officia sint labore nisi. Do Lorem id in est esse adipisicing id fugiat enim esse laborum.','registered':'Wednesday, October 3, 2018 9:26 PM','latitude':'-88.790637','longitude':'138.817328','tags':['duis','ea','magna','ea','incididunt'],'greeting':'Hello, Alice! You have 8 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda01886247b6a4f3d','index':94,'guid':'17c9f4d3-7d72-44e3-8f7c-08d7de920f46','isActive':false,'balance':'$3,173.29','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Schwartz','last':'Mccormick'},'company':'EVIDENDS','email':'schwartz.mccormick@evidends.tv','phone':'+1 (924) 531-2802','address':'160 Midwood Street, Indio, Palau, 4241','about':'Anim reprehenderit et et adipisicing voluptate consequat elit. Sint Lorem laboris Lorem minim nostrud aute reprehenderit elit aute quis nulla. Officia aute eiusmod mollit cillum eu aliquip non enim ea occaecat quis fugiat occaecat officia. Eiusmod culpa exercitation dolor aliqua enim occaecat nisi cupidatat duis ex dolore id. Id consequat aliqua cupidatat ut. Sit nisi est sunt culpa ullamco excepteur sunt pariatur incididunt amet. Ut tempor duis velit eu ut id culpa aute anim occaecat labore.','registered':'Thursday, March 2, 2017 5:57 PM','latitude':'38.618587','longitude':'-165.142529','tags':['ad','reprehenderit','magna','elit','mollit'],'greeting':'Hello, Schwartz! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51be4df456ec2bc9','index':95,'guid':'44f68f65-959b-4ec2-bd2a-1f30035f76fc','isActive':false,'balance':'$3,242.24','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Bonita','last':'Stevens'},'company':'SLOFAST','email':'bonita.stevens@slofast.co.uk','phone':'+1 (886) 473-2105','address':'459 Bushwick Court, Kilbourne, Rhode Island, 9450','about':'Consequat reprehenderit qui reprehenderit nisi sit est in qui aliquip amet. Ex deserunt cupidatat amet cillum eiusmod irure anim in amet proident voluptate. Ad officia culpa in non incididunt do.','registered':'Saturday, August 22, 2015 5:23 AM','latitude':'60.013542','longitude':'58.242132','tags':['aute','adipisicing','in','cillum','officia'],'greeting':'Hello, Bonita! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed50a55e3587993f68','index':96,'guid':'652e434f-221e-4899-af12-38dca5c9621d','isActive':false,'balance':'$2,720.06','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Charmaine','last':'Jackson'},'company':'FLUM','email':'charmaine.jackson@flum.io','phone':'+1 (947) 573-2692','address':'788 Windsor Place, Highland, Arkansas, 8869','about':'Dolore reprehenderit irure excepteur eu reprehenderit sint Lorem ut amet in. Consequat anim elit sunt aliquip incididunt. Culpa consequat do exercitation dolor enim dolor sunt sit excepteur ad anim. Dolor aute elit velit mollit minim eu.','registered':'Wednesday, April 6, 2016 7:54 PM','latitude':'25.756553','longitude':'-5.482531','tags':['amet','sint','consequat','est','ex'],'greeting':'Hello, Charmaine! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed213621949bbdd5d3','index':97,'guid':'7d7d93d8-3e37-4b4a-9fa2-591fb7d153ce','isActive':true,'balance':'$1,370.63','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Petersen','last':'Cooley'},'company':'ROTODYNE','email':'petersen.cooley@rotodyne.me','phone':'+1 (929) 563-3339','address':'338 Pioneer Street, Carbonville, Missouri, 3030','about':'Cillum elit dolore labore aute. Cillum ea incididunt cupidatat consequat sint eu mollit. Excepteur commodo eiusmod ex Lorem enim velit minim.','registered':'Friday, December 8, 2017 5:53 AM','latitude':'-10.576254','longitude':'-111.176861','tags':['veniam','eu','eiusmod','dolore','voluptate'],'greeting':'Hello, Petersen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e938138d58ed453','index':98,'guid':'d6fea4a3-03f6-46ee-90b9-8ec51a585e29','isActive':true,'balance':'$1,216.54','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Rosanne','last':'Terry'},'company':'EXTREMO','email':'rosanne.terry@extremo.com','phone':'+1 (812) 496-2691','address':'368 Rockaway Avenue, Gloucester, Illinois, 7913','about':'Duis et nostrud duis quis minim eiusmod culpa do ea ad pariatur tempor. Velit veniam aliqua aliquip est enim ex et culpa dolor ullamco culpa officia. Eu id occaecat aute cillum aute sit aute laboris ipsum voluptate ex. Amet tempor minim tempor Lorem quis dolore. Pariatur consequat dolore nulla veniam dolor exercitation consequat nulla laboris incididunt do. Dolore do tempor deserunt exercitation incididunt officia incididunt ut do reprehenderit do eiusmod nulla.','registered':'Sunday, August 6, 2017 12:46 PM','latitude':'-43.257964','longitude':'-45.147686','tags':['et','incididunt','esse','commodo','ipsum'],'greeting':'Hello, Rosanne! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed632b1a1d65501d6b','index':99,'guid':'bf8c6ac1-ee18-48ee-ae94-ea515a53c951','isActive':true,'balance':'$2,905.58','picture':'http://placehold.it/32x32','age':21,'eyeColor':'blue','name':{'first':'Irene','last':'Castro'},'company':'POLARIA','email':'irene.castro@polaria.org','phone':'+1 (818) 417-3761','address':'901 Dupont Street, Sperryville, Oklahoma, 953','about':'Pariatur minim laboris aliqua dolor aliquip consequat ea do duis voluptate id Lorem. In reprehenderit et adipisicing anim elit incididunt velit in laborum laborum. Qui minim magna et amet sit do voluptate reprehenderit ea sit sint velit.','registered':'Tuesday, August 18, 2015 10:48 AM','latitude':'-7.004055','longitude':'116.052433','tags':['sit','proident','enim','ullamco','non'],'greeting':'Hello, Irene! You have 10 unread messages.','favoriteFruit':'apple'}]" + } +} diff --git a/internal/cloud/testdata/plan-long-line/plan.log b/internal/cloud/testdata/plan-long-line/plan.log new file mode 100644 index 000000000..f34ed170c --- /dev/null +++ b/internal/cloud/testdata/plan-long-line/plan.log @@ -0,0 +1,23 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + triggers.%: "1" + triggers.long_line: "[{'_id':'5c5ab0ed7de45e993ffb9eeb','index':0,'guid':'e734d772-6b5a-4cb0-805c-91cd5e560e20','isActive':false,'balance':'$1,472.03','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Darlene','last':'Garza'},'company':'GEEKOSIS','email':'darlene.garza@geekosis.io','phone':'+1 (850) 506-3347','address':'165 Kiely Place, Como, New Mexico, 4335','about':'Officia ullamco et sunt magna voluptate culpa cupidatat ea tempor laboris cupidatat ea anim laboris. Minim enim quis enim esse laborum est veniam. Lorem excepteur elit Lorem cupidatat elit ea anim irure fugiat fugiat sunt mollit. Consectetur ad nulla dolor amet esse occaecat aliquip sit. Magna sit elit adipisicing ut reprehenderit anim exercitation sit quis ea pariatur Lorem magna dolore.','registered':'Wednesday, March 11, 2015 12:58 PM','latitude':'20.729127','longitude':'-127.343593','tags':['minim','in','deserunt','occaecat','fugiat'],'greeting':'Hello, Darlene! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda9117d15f1c1f112','index':1,'guid':'f0d1eed2-c6a9-4535-8800-d4bd53fe7eee','isActive':true,'balance':'$2,901.90','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Flora','last':'Short'},'company':'SIGNITY','email':'flora.short@signity.me','phone':'+1 (840) 520-2666','address':'636 Johnson Avenue, Gerber, Wisconsin, 9139','about':'Veniam dolore deserunt Lorem aliqua qui eiusmod. Amet tempor fugiat duis incididunt amet adipisicing. Id ea nisi veniam eiusmod.','registered':'Wednesday, May 2, 2018 5:59 AM','latitude':'-63.267612','longitude':'4.224102','tags':['veniam','incididunt','id','aliqua','reprehenderit'],'greeting':'Hello, Flora! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed83fd574d8041fa16','index':2,'guid':'29499a07-414a-436f-ba62-6634ca16bdcc','isActive':true,'balance':'$2,781.28','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Trevino','last':'Marks'},'company':'KEGULAR','email':'trevino.marks@kegular.com','phone':'+1 (843) 571-2269','address':'200 Alabama Avenue, Grenelefe, Florida, 7963','about':'Occaecat nisi exercitation Lorem mollit laborum magna adipisicing culpa dolor proident dolore. Non consequat ea amet et id mollit incididunt minim anim amet nostrud labore tempor. Proident eu sint commodo nisi consequat voluptate do fugiat proident. Laboris eiusmod veniam non et elit nulla nisi labore incididunt Lorem consequat consectetur voluptate.','registered':'Saturday, January 25, 2014 5:56 AM','latitude':'65.044005','longitude':'-127.454864','tags':['anim','duis','velit','pariatur','enim'],'greeting':'Hello, Trevino! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed784eb6e350ff0a07','index':3,'guid':'40ed47e2-1747-4665-ab59-cdb3630a7642','isActive':true,'balance':'$2,000.78','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Solis','last':'Mckinney'},'company':'QABOOS','email':'solis.mckinney@qaboos.org','phone':'+1 (924) 405-2560','address':'712 Herkimer Court, Klondike, Ohio, 8133','about':'Minim ad anim minim tempor mollit magna tempor et non commodo amet. Nisi cupidatat labore culpa consectetur exercitation laborum adipisicing fugiat officia adipisicing consequat non. Qui voluptate tempor laboris exercitation qui non adipisicing occaecat voluptate sunt do nostrud velit. Consequat tempor officia laboris tempor irure cupidatat aliquip voluptate nostrud velit ex nulla tempor laboris. Qui pariatur pariatur enim aliquip velit. Officia mollit ullamco laboris velit velit eiusmod enim amet incididunt consectetur sunt.','registered':'Wednesday, April 12, 2017 6:59 AM','latitude':'-25.055596','longitude':'-140.126525','tags':['ipsum','adipisicing','amet','nulla','dolore'],'greeting':'Hello, Solis! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed02ce1ea9a2155d51','index':4,'guid':'1b5fb7d3-3b9a-4382-81b5-9ab01a27e74b','isActive':true,'balance':'$1,373.67','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Janell','last':'Battle'},'company':'GEEKMOSIS','email':'janell.battle@geekmosis.net','phone':'+1 (810) 591-3014','address':'517 Onderdonk Avenue, Shrewsbury, District Of Columbia, 2335','about':'Reprehenderit ad proident do anim qui officia magna magna duis cillum esse minim est. Excepteur ipsum anim ad laboris. In occaecat dolore nulla ea Lorem tempor et culpa in sint. Officia eu eu incididunt sit amet. Culpa duis id reprehenderit ut anim sit sunt. Duis dolore proident velit incididunt adipisicing pariatur fugiat incididunt eiusmod eu veniam irure.','registered':'Thursday, February 8, 2018 1:44 AM','latitude':'-33.254864','longitude':'-154.145885','tags':['aute','deserunt','ipsum','eiusmod','laborum'],'greeting':'Hello, Janell! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edab58604bd7d3dd1c','index':5,'guid':'6354c035-af22-44c9-8be9-b2ea9decc24d','isActive':true,'balance':'$3,535.68','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Combs','last':'Kirby'},'company':'LUXURIA','email':'combs.kirby@luxuria.name','phone':'+1 (900) 498-3266','address':'377 Kingsland Avenue, Ruckersville, Maine, 9916','about':'Lorem duis ipsum pariatur aliquip sunt. Commodo esse laborum incididunt mollit quis est laboris ea ea quis fugiat. Enim elit ullamco velit et fugiat veniam irure deserunt aliqua ad irure veniam.','registered':'Tuesday, February 21, 2017 4:04 PM','latitude':'-70.20591','longitude':'162.546871','tags':['reprehenderit','est','enim','aute','ad'],'greeting':'Hello, Combs! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf7fafeffc6357c51','index':6,'guid':'02523e0b-cc90-4309-b6b2-f493dc6076f6','isActive':false,'balance':'$3,754.30','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Macias','last':'Calderon'},'company':'AMTAP','email':'macias.calderon@amtap.us','phone':'+1 (996) 569-3667','address':'305 Royce Street, Glidden, Iowa, 9248','about':'Exercitation nulla deserunt pariatur adipisicing. In commodo deserunt incididunt ut velit minim qui ut quis. Labore elit ullamco eiusmod voluptate in eu do est fugiat aute mollit deserunt. Eu duis proident velit fugiat velit ut. Ut non esse amet laborum nisi tempor in nulla.','registered':'Thursday, October 23, 2014 10:28 PM','latitude':'32.371629','longitude':'60.155135','tags':['commodo','elit','velit','excepteur','aliqua'],'greeting':'Hello, Macias! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0e8a6109e7fabf17','index':7,'guid':'675ff6b6-197b-4154-9775-813d661df822','isActive':false,'balance':'$2,850.62','picture':'http://placehold.it/32x32','age':37,'eyeColor':'green','name':{'first':'Stefanie','last':'Rivers'},'company':'RECRITUBE','email':'stefanie.rivers@recritube.biz','phone':'+1 (994) 591-3551','address':'995 Campus Road, Abrams, Virginia, 3251','about':'Esse aute non laborum Lorem nulla irure. Veniam elit aute ut et dolor non deserunt laboris tempor. Ipsum quis cupidatat laborum laboris voluptate esse duis eiusmod excepteur consectetur commodo ullamco qui occaecat. Culpa velit cillum occaecat minim nisi.','registered':'Thursday, June 9, 2016 3:40 PM','latitude':'-18.526825','longitude':'149.670782','tags':['occaecat','sunt','reprehenderit','ipsum','magna'],'greeting':'Hello, Stefanie! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf7d9bc2db4e476e3','index':8,'guid':'adaefc55-f6ea-4bd1-a147-0e31c3ce7a21','isActive':true,'balance':'$2,555.13','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Hillary','last':'Lancaster'},'company':'OLUCORE','email':'hillary.lancaster@olucore.ca','phone':'+1 (964) 474-3018','address':'232 Berriman Street, Kaka, Massachusetts, 6792','about':'Veniam ad laboris quis reprehenderit aliquip nisi sunt excepteur ea aute laborum excepteur incididunt. Nisi exercitation aliquip do culpa commodo ex officia ut enim mollit in deserunt in amet. Anim eu deserunt dolore non cupidatat ut enim incididunt aute dolore voluptate. Do cillum mollit laborum non incididunt occaecat aute voluptate nisi irure.','registered':'Thursday, June 4, 2015 9:45 PM','latitude':'88.075919','longitude':'-148.951368','tags':['reprehenderit','veniam','ad','aute','anim'],'greeting':'Hello, Hillary! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed7b7192ad6a0f267c','index':9,'guid':'0ca9b8ea-f671-474e-be26-4a49cae4838a','isActive':true,'balance':'$3,684.51','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Jill','last':'Conner'},'company':'EXOZENT','email':'jill.conner@exozent.info','phone':'+1 (887) 467-2168','address':'751 Thames Street, Juarez, American Samoa, 8386','about':'Enim voluptate et non est in magna laborum aliqua enim aliqua est non nostrud. Tempor est nulla ipsum consectetur esse nostrud est id. Consequat do voluptate cupidatat eu fugiat et fugiat velit id. Sint dolore ad qui tempor anim eu amet consectetur do elit aute adipisicing consequat ex.','registered':'Sunday, October 22, 2017 7:35 AM','latitude':'84.384911','longitude':'40.305648','tags':['tempor','sint','irure','et','ex'],'greeting':'Hello, Jill! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed713fe676575aa72b','index':10,'guid':'c28023cf-cc57-4c2e-8d91-dfbe6bafadcd','isActive':false,'balance':'$2,792.45','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Hurley','last':'George'},'company':'ZAJ','email':'hurley.george@zaj.tv','phone':'+1 (984) 547-3284','address':'727 Minna Street, Lacomb, Colorado, 2557','about':'Ex velit cupidatat veniam culpa. Eiusmod ut fugiat adipisicing incididunt consectetur exercitation Lorem exercitation ex. Incididunt anim aute incididunt fugiat cupidatat qui eu non reprehenderit. Eiusmod dolor nisi culpa excepteur ut velit minim dolor voluptate amet commodo culpa in.','registered':'Thursday, February 16, 2017 6:41 AM','latitude':'25.989949','longitude':'10.200053','tags':['minim','ut','sunt','consequat','ullamco'],'greeting':'Hello, Hurley! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1e56732746c70d8b','index':11,'guid':'e9766f13-766c-4450-b4d2-8b04580f60b7','isActive':true,'balance':'$3,874.26','picture':'http://placehold.it/32x32','age':35,'eyeColor':'green','name':{'first':'Leticia','last':'Pace'},'company':'HONOTRON','email':'leticia.pace@honotron.co.uk','phone':'+1 (974) 536-3322','address':'365 Goodwin Place, Savage, Nevada, 9191','about':'Nisi Lorem aliqua esse eiusmod magna. Ad minim incididunt proident ut Lorem cupidatat qui velit aliqua ullamco et ipsum in. Aliquip elit consectetur pariatur esse exercitation et officia quis. Occaecat tempor proident cillum anim ad commodo velit ut voluptate. Tempor et occaecat sit sint aliquip tempor nulla velit magna nisi proident exercitation Lorem id.','registered':'Saturday, August 4, 2018 5:05 AM','latitude':'70.620386','longitude':'-86.335813','tags':['occaecat','velit','labore','laboris','esse'],'greeting':'Hello, Leticia! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed941337fe42f47426','index':12,'guid':'6d390762-17ea-4b58-9a36-b0c9a8748a42','isActive':true,'balance':'$1,049.61','picture':'http://placehold.it/32x32','age':38,'eyeColor':'green','name':{'first':'Rose','last':'Humphrey'},'company':'MYOPIUM','email':'rose.humphrey@myopium.io','phone':'+1 (828) 426-3086','address':'389 Sapphire Street, Saticoy, Marshall Islands, 1423','about':'Aliquip enim excepteur adipisicing ex. Consequat aliqua consequat nostrud do occaecat deserunt excepteur sit et ipsum sunt dolor eu. Dolore laborum commodo excepteur tempor ad adipisicing proident excepteur magna non Lorem proident consequat aute. Fugiat minim consequat occaecat voluptate esse velit officia laboris nostrud nisi ut voluptate.','registered':'Monday, April 16, 2018 12:38 PM','latitude':'-47.083742','longitude':'109.022423','tags':['aute','non','sit','adipisicing','mollit'],'greeting':'Hello, Rose! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd0c02fc3fdc01a40','index':13,'guid':'07755618-6fdf-4b33-af50-364c18909227','isActive':true,'balance':'$1,823.61','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Judith','last':'Hale'},'company':'COLLAIRE','email':'judith.hale@collaire.me','phone':'+1 (922) 508-2843','address':'193 Coffey Street, Castleton, North Dakota, 3638','about':'Minim non ullamco ad anim nostrud dolore nostrud veniam consequat id eiusmod veniam laboris. Lorem irure esse mollit non velit aute id cupidatat est mollit occaecat magna excepteur. Adipisicing tempor nisi sit aliquip tempor pariatur tempor eu consectetur nulla amet nulla. Quis nisi nisi ea incididunt culpa et do. Esse officia eu pariatur velit sunt quis proident amet consectetur consequat. Nisi excepteur culpa nulla sit dolor deserunt excepteur dolor consequat elit cillum tempor Lorem.','registered':'Wednesday, August 24, 2016 12:29 AM','latitude':'-80.15514','longitude':'39.91007','tags':['consectetur','incididunt','aliquip','dolor','consequat'],'greeting':'Hello, Judith! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3e1e29caa4f728b','index':14,'guid':'2c6617a2-e7a9-4ff7-a8b9-e99554fe70fe','isActive':true,'balance':'$1,971.00','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Estes','last':'Sweet'},'company':'GEEKKO','email':'estes.sweet@geekko.com','phone':'+1 (866) 448-3032','address':'847 Cove Lane, Kula, Mississippi, 9178','about':'Veniam consectetur occaecat est excepteur consequat ipsum cillum sit consectetur. Ut cupidatat et reprehenderit dolore enim do cillum qui pariatur ad laborum incididunt esse. Fugiat sunt dolor veniam laboris ipsum deserunt proident reprehenderit laboris non nostrud. Magna excepteur sint magna laborum tempor sit exercitation ipsum labore est ullamco ullamco. Cillum voluptate cillum ea laborum Lorem. Excepteur sint ut nisi est esse non. Minim excepteur ullamco velit nisi ut in elit exercitation ut dolore.','registered':'Sunday, August 12, 2018 5:06 PM','latitude':'-9.57771','longitude':'-159.94577','tags':['culpa','dolor','velit','anim','pariatur'],'greeting':'Hello, Estes! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edbcf088c6fd593091','index':15,'guid':'2cc79958-1b40-4e2c-907a-433903fd3da9','isActive':false,'balance':'$3,751.53','picture':'http://placehold.it/32x32','age':34,'eyeColor':'brown','name':{'first':'Kemp','last':'Spence'},'company':'EXOBLUE','email':'kemp.spence@exoblue.org','phone':'+1 (864) 487-2992','address':'217 Clay Street, Monument, North Carolina, 1460','about':'Nostrud duis cillum sint non commodo dolor aute aliqua adipisicing ad nulla non excepteur proident. Fugiat labore elit tempor cillum veniam reprehenderit laboris consectetur dolore amet qui cupidatat. Amet aliqua elit anim et consequat commodo excepteur officia anim aliqua ea eu labore cillum. Et ex dolor duis dolore commodo veniam et nisi.','registered':'Monday, October 29, 2018 5:23 AM','latitude':'-70.304222','longitude':'83.582371','tags':['velit','duis','consequat','incididunt','duis'],'greeting':'Hello, Kemp! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed6400479feb3de505','index':16,'guid':'91ccae6d-a3ea-43cf-bb00-3f2729256cc9','isActive':false,'balance':'$2,477.79','picture':'http://placehold.it/32x32','age':40,'eyeColor':'blue','name':{'first':'Ronda','last':'Burris'},'company':'EQUITOX','email':'ronda.burris@equitox.net','phone':'+1 (817) 553-3228','address':'708 Lawton Street, Deputy, Wyoming, 8598','about':'Excepteur voluptate aliquip consequat cillum est duis sit cillum eu eiusmod et laborum ullamco. Et minim reprehenderit aute voluptate amet ullamco. Amet sit enim ad irure deserunt nostrud anim veniam consequat dolor commodo. Consequat do occaecat do exercitation ullamco dolor ut. Id laboris consequat est dolor dolore tempor ullamco anim do ut nulla deserunt labore. Mollit ex Lorem ullamco mollit.','registered':'Monday, April 23, 2018 5:27 PM','latitude':'-31.227208','longitude':'0.63785','tags':['ipsum','magna','consectetur','sit','irure'],'greeting':'Hello, Ronda! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddbeab2e53e04d563','index':17,'guid':'a86d4eb6-6bd8-48c2-a8fc-1c933c835852','isActive':false,'balance':'$3,709.03','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Rosario','last':'Dillard'},'company':'BARKARAMA','email':'rosario.dillard@barkarama.name','phone':'+1 (933) 525-3898','address':'730 Chauncey Street, Forbestown, South Carolina, 6894','about':'Est eu fugiat aliquip ea ad qui ad mollit ad tempor voluptate et incididunt reprehenderit. Incididunt fugiat commodo minim adipisicing culpa consectetur duis eu ut commodo consequat voluptate labore. Nostrud irure labore adipisicing irure quis magna consequat dolor Lorem sint enim. Sint excepteur eu dolore elit ut do mollit sunt enim est. Labore id nostrud sint Lorem esse nostrud.','registered':'Friday, December 25, 2015 8:59 PM','latitude':'37.440827','longitude':'44.580474','tags':['Lorem','sit','ipsum','ea','ut'],'greeting':'Hello, Rosario! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf8e9b9c031d04e8','index':18,'guid':'a96f997c-daf8-40d4-92e1-be07e2cf0f60','isActive':false,'balance':'$1,878.37','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Sondra','last':'Gonzales'},'company':'XUMONK','email':'sondra.gonzales@xumonk.us','phone':'+1 (838) 560-2255','address':'230 Cox Place, Geyserville, Georgia, 6805','about':'Laborum sunt voluptate ea laboris nostrud. Amet deserunt aliqua Lorem voluptate velit deserunt occaecat minim ullamco. Lorem occaecat sit labore adipisicing ad magna mollit labore ullamco proident. Ea velit do proident fugiat esse commodo ex nostrud eu mollit pariatur. Labore laborum qui voluptate quis proident reprehenderit tempor dolore duis deserunt esse aliqua aliquip. Non veniam enim pariatur cupidatat ipsum dolore est reprehenderit. Non exercitation adipisicing proident magna elit occaecat non magna.','registered':'Sunday, June 26, 2016 4:02 AM','latitude':'62.247742','longitude':'-44.90666','tags':['ea','aute','in','voluptate','magna'],'greeting':'Hello, Sondra! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed2c1bcd06781f677e','index':19,'guid':'6ac47a16-eed4-4460-92ee-e0dd33c1fbb5','isActive':false,'balance':'$3,730.64','picture':'http://placehold.it/32x32','age':20,'eyeColor':'brown','name':{'first':'Anastasia','last':'Vega'},'company':'FIREWAX','email':'anastasia.vega@firewax.biz','phone':'+1 (867) 493-3698','address':'803 Arlington Avenue, Rosburg, Northern Mariana Islands, 8769','about':'Sint ex nisi tempor sunt voluptate non et eiusmod irure. Aute reprehenderit dolor mollit aliqua Lorem voluptate occaecat. Sint laboris deserunt Lorem incididunt nulla cupidatat do.','registered':'Friday, March 18, 2016 12:02 PM','latitude':'-32.010216','longitude':'-87.874753','tags':['aliquip','mollit','mollit','ad','laborum'],'greeting':'Hello, Anastasia! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed727fd645854bbf43','index':20,'guid':'67bd8cdb-ce6b-455c-944c-a80e17c6fa75','isActive':true,'balance':'$2,868.06','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Lucinda','last':'Cox'},'company':'ENDIPINE','email':'lucinda.cox@endipine.ca','phone':'+1 (990) 428-3002','address':'412 Thatford Avenue, Lafferty, New Jersey, 5271','about':'Esse nulla sunt ut consequat aute mollit. Est occaecat sunt nisi irure id anim est commodo. Elit mollit amet dolore sunt adipisicing ea laborum quis ea reprehenderit non consequat dolore. Minim sunt occaecat quis aute commodo dolore quis commodo proident. Sunt sint duis ullamco sit ea esse Lorem. Consequat pariatur eiusmod laboris adipisicing labore in laboris adipisicing adipisicing consequat aute ea et.','registered':'Friday, May 1, 2015 10:16 PM','latitude':'-14.200957','longitude':'-82.211386','tags':['do','sit','qui','officia','aliquip'],'greeting':'Hello, Lucinda! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed5a97284eb2cbd3a8','index':21,'guid':'f9fc999d-515c-4fc4-b339-76300e1b4bf2','isActive':true,'balance':'$1,172.57','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Conrad','last':'Bradley'},'company':'FUELWORKS','email':'conrad.bradley@fuelworks.info','phone':'+1 (956) 561-3226','address':'685 Fenimore Street, Esmont, Maryland, 7523','about':'Labore reprehenderit anim nisi sunt do nisi in. Est anim cillum id minim exercitation ullamco voluptate ipsum eu. Elit culpa consequat reprehenderit laborum in eu. Laboris amet voluptate laboris qui voluptate duis minim reprehenderit. Commodo sunt irure dolore sunt occaecat velit nisi eu minim minim.','registered':'Wednesday, January 18, 2017 11:13 PM','latitude':'31.665993','longitude':'38.868968','tags':['excepteur','exercitation','est','nisi','mollit'],'greeting':'Hello, Conrad! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edc4eaf6f760c38218','index':22,'guid':'8794ef5f-da2f-46f0-a755-c18a16409fd5','isActive':false,'balance':'$3,594.73','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Marquez','last':'Vargas'},'company':'MALATHION','email':'marquez.vargas@malathion.tv','phone':'+1 (976) 438-3126','address':'296 Hall Street, National, Texas, 2067','about':'Proident cillum aute minim fugiat sunt aliqua non occaecat est duis id id tempor. Qui deserunt nisi amet pariatur proident eu laboris esse adipisicing magna. Anim anim mollit aute non magna nisi aute magna labore ullamco reprehenderit voluptate et ad. Proident adipisicing aute eiusmod nostrud nostrud deserunt culpa. Elit eu ullamco nisi aliqua dolor sint pariatur excepteur sit consectetur tempor. Consequat Lorem ullamco commodo veniam qui sint magna. Sit mollit ad aliquip est id eu officia id adipisicing duis ad.','registered':'Tuesday, November 17, 2015 6:16 PM','latitude':'-36.443667','longitude':'22.336776','tags':['aliquip','veniam','ipsum','Lorem','ex'],'greeting':'Hello, Marquez! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edd7c718518ee0466a','index':23,'guid':'ad8781a2-059e-4288-9879-309d53a99bf5','isActive':true,'balance':'$3,570.68','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Snider','last':'Frost'},'company':'ZILODYNE','email':'snider.frost@zilodyne.co.uk','phone':'+1 (913) 485-3275','address':'721 Lincoln Road, Richmond, Utah, 672','about':'Minim enim Lorem esse incididunt do reprehenderit velit laborum ullamco. In aute eiusmod esse aliqua et labore tempor sunt ex mollit veniam tempor. Nulla elit cillum qui ullamco dolore amet deserunt magna amet laborum.','registered':'Saturday, August 23, 2014 12:58 AM','latitude':'-88.682554','longitude':'74.063179','tags':['nulla','ea','sint','aliquip','duis'],'greeting':'Hello, Snider! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf026fece8e2c0970','index':24,'guid':'1b7d81e1-1dba-4322-bb1a-eaa6a24cccea','isActive':false,'balance':'$2,037.91','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Snyder','last':'Fletcher'},'company':'COMTEST','email':'snyder.fletcher@comtest.io','phone':'+1 (830) 538-3860','address':'221 Lewis Place, Zortman, Idaho, 572','about':'Elit anim enim esse dolore exercitation. Laboris esse sint adipisicing fugiat sint do occaecat ut voluptate sint nulla. Ad sint ut reprehenderit nostrud irure id consectetur officia velit consequat.','registered':'Sunday, January 1, 2017 1:13 AM','latitude':'-54.742604','longitude':'69.534932','tags':['exercitation','commodo','in','id','aliqua'],'greeting':'Hello, Snyder! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b9a7f83da6d2dfd','index':25,'guid':'0b2cc6b6-0044-4b1c-aa31-bd72963457a0','isActive':false,'balance':'$1,152.76','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Regina','last':'James'},'company':'TELPOD','email':'regina.james@telpod.me','phone':'+1 (989) 455-3228','address':'688 Essex Street, Clayville, Alabama, 2772','about':'Eiusmod elit culpa reprehenderit ea veniam. Officia irure culpa duis aute ut. Irure duis cillum officia ea pariatur velit ut dolor incididunt reprehenderit ex elit laborum. Est pariatur veniam ad irure. Labore velit sunt esse laboris aliqua velit deserunt deserunt sit. Elit eiusmod ad laboris aliquip minim irure excepteur enim quis. Quis incididunt adipisicing ut magna cupidatat sit amet culpa.','registered':'Tuesday, April 25, 2017 10:16 PM','latitude':'-75.088027','longitude':'47.209828','tags':['elit','nisi','est','voluptate','proident'],'greeting':'Hello, Regina! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed10884f32f779f2bf','index':26,'guid':'1f6fb522-0002-46ff-8dac-451247f28168','isActive':true,'balance':'$1,948.79','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Collins','last':'Mcpherson'},'company':'DIGIGEN','email':'collins.mcpherson@digigen.com','phone':'+1 (991) 519-2334','address':'317 Merit Court, Sanford, Michigan, 6468','about':'Magna qui culpa dolor officia labore mollit ex excepteur duis eiusmod. Ea cupidatat ex ipsum mollit do minim duis. Nisi eiusmod minim tempor id esse commodo sunt sunt ullamco ut do laborum ullamco magna. Aliquip laborum dolor officia officia eu nostrud velit minim est anim. Ex elit laborum sunt magna exercitation nisi cillum sunt aute qui ea ullamco. Cupidatat ea sunt aute dolor duis nisi Lorem ullamco eiusmod. Sit ea velit ad veniam aliqua ad elit cupidatat ut magna in.','registered':'Friday, June 10, 2016 4:38 PM','latitude':'25.513996','longitude':'14.911124','tags':['exercitation','non','sit','velit','officia'],'greeting':'Hello, Collins! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8a575110efb15c6c','index':27,'guid':'2a904c82-068b-4ded-9ae6-cfeb6d7e62c9','isActive':true,'balance':'$3,427.91','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Mckay','last':'Barrera'},'company':'COMVEYER','email':'mckay.barrera@comveyer.org','phone':'+1 (853) 470-2560','address':'907 Glenwood Road, Churchill, Oregon, 8583','about':'In voluptate esse dolore enim sint quis dolor do exercitation sint et labore nisi. Eiusmod tempor exercitation dolore elit sit velit sint et. Sit magna adipisicing eiusmod do anim velit deserunt laboris ad ea pariatur. Irure nisi anim mollit elit commodo nulla. Aute eiusmod sit nulla eiusmod. Eiusmod est officia commodo mollit laboris do deserunt eu do nisi amet. Proident ad duis eiusmod laboris Lorem ut culpa pariatur Lorem reprehenderit minim aliquip irure sunt.','registered':'Saturday, December 19, 2015 2:49 PM','latitude':'-55.243287','longitude':'138.035406','tags':['non','quis','laboris','enim','nisi'],'greeting':'Hello, Mckay! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edcd49ab6a73ff7f32','index':28,'guid':'5d3e0dae-3f58-437f-b12d-de24667a904d','isActive':true,'balance':'$3,270.52','picture':'http://placehold.it/32x32','age':35,'eyeColor':'blue','name':{'first':'Mabel','last':'Leonard'},'company':'QUADEEBO','email':'mabel.leonard@quadeebo.net','phone':'+1 (805) 432-2356','address':'965 Underhill Avenue, Falconaire, Minnesota, 4450','about':'Cupidatat amet sunt est ipsum occaecat sit fugiat excepteur Lorem Lorem ex ea ipsum. Ad incididunt est irure magna excepteur occaecat nostrud. Minim dolor id anim ipsum qui nostrud ullamco aute ex Lorem magna deserunt excepteur Lorem.','registered':'Saturday, March 28, 2015 5:55 AM','latitude':'27.388359','longitude':'156.408728','tags':['quis','velit','deserunt','dolore','sit'],'greeting':'Hello, Mabel! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edde16ac2dc2fbb6c1','index':29,'guid':'d50c2233-70fc-4748-8ebf-02d45ac2a446','isActive':false,'balance':'$3,100.70','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Pace','last':'Duke'},'company':'SEQUITUR','email':'pace.duke@sequitur.name','phone':'+1 (983) 568-3119','address':'895 Melrose Street, Reno, Connecticut, 6259','about':'Ex veniam aliquip exercitation mollit elit est minim veniam aliqua labore deserunt. Dolor sunt sint cillum Lorem nisi ea irure cupidatat. Velit ut culpa cupidatat consequat cillum. Sint voluptate quis laboris qui incididunt do elit Lorem qui ullamco ut eu pariatur occaecat.','registered':'Saturday, August 18, 2018 2:18 PM','latitude':'31.930443','longitude':'-129.494784','tags':['culpa','est','nostrud','quis','aliquip'],'greeting':'Hello, Pace! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb908d85642ba77e8','index':30,'guid':'3edb6e42-367a-403d-a511-eb78bcc11f60','isActive':true,'balance':'$1,912.07','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Cohen','last':'Morrison'},'company':'POWERNET','email':'cohen.morrison@powernet.us','phone':'+1 (888) 597-2141','address':'565 Troutman Street, Idledale, West Virginia, 3196','about':'Ullamco voluptate duis commodo amet occaecat consequat et occaecat dolore nulla eu. Do aliqua sunt deserunt occaecat laboris labore voluptate cupidatat ullamco exercitation aliquip elit voluptate anim. Occaecat deserunt in labore cillum aute deserunt ea excepteur laboris sunt. Officia irure sint incididunt labore sint ipsum ullamco ea elit. Fugiat nostrud sunt ut officia mollit proident sunt dolor fugiat esse tempor do.','registered':'Friday, January 1, 2016 5:42 AM','latitude':'-20.01215','longitude':'26.361552','tags':['consectetur','sunt','nulla','reprehenderit','dolore'],'greeting':'Hello, Cohen! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed91c77aa25a64a757','index':31,'guid':'8999a97b-0035-4f19-b555-91dd69aaa9b8','isActive':false,'balance':'$3,097.67','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Stout','last':'Valdez'},'company':'UPLINX','email':'stout.valdez@uplinx.biz','phone':'+1 (854) 480-3633','address':'880 Chestnut Avenue, Lowgap, Hawaii, 1537','about':'Cupidatat enim dolore non voluptate. Aliqua ut non Lorem in exercitation reprehenderit voluptate. Excepteur deserunt tempor laboris quis.','registered':'Wednesday, March 16, 2016 6:53 AM','latitude':'50.328393','longitude':'-25.990308','tags':['ea','fugiat','duis','consectetur','enim'],'greeting':'Hello, Stout! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0f52176c8c3e1bed','index':32,'guid':'743abcbd-1fab-4aed-8cb7-3c935eb64c74','isActive':false,'balance':'$1,118.54','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Ortega','last':'Joseph'},'company':'APEXIA','email':'ortega.joseph@apexia.ca','phone':'+1 (872) 596-3024','address':'304 Canda Avenue, Mulino, New York, 8721','about':'Ipsum elit id cupidatat minim nisi minim. Ea ex amet ea ipsum Lorem deserunt. Occaecat cupidatat magna cillum aliquip sint id quis amet nostrud officia enim laborum. Aliqua deserunt amet commodo laboris labore mollit est. Officia voluptate Lorem esse mollit aliquip laboris cupidatat minim et. Labore esse incididunt officia nostrud pariatur reprehenderit.','registered':'Tuesday, January 31, 2017 6:06 AM','latitude':'43.861714','longitude':'33.771783','tags':['ut','Lorem','esse','quis','fugiat'],'greeting':'Hello, Ortega! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed2c00cdd101b6cd52','index':33,'guid':'4f6f99cf-f692-4d03-b23a-26f2b27273bd','isActive':true,'balance':'$1,682.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Sampson','last':'Taylor'},'company':'GEOFORMA','email':'sampson.taylor@geoforma.info','phone':'+1 (911) 482-2993','address':'582 Kent Street, Umapine, Virgin Islands, 5300','about':'Voluptate laboris occaecat laboris tempor cillum quis cupidatat qui pariatur. Lorem minim commodo mollit adipisicing Lorem ut dolor consectetur ipsum. Sint sit voluptate labore aliqua ex labore velit. Ullamco tempor consectetur voluptate deserunt voluptate minim enim. Cillum commodo duis reprehenderit eu duis.','registered':'Thursday, November 9, 2017 11:24 PM','latitude':'24.949379','longitude':'155.034468','tags':['Lorem','cupidatat','elit','reprehenderit','commodo'],'greeting':'Hello, Sampson! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b7210ba0bc0d508','index':34,'guid':'73fd415f-f8cf-43e0-a86c-e725d000abd4','isActive':false,'balance':'$1,289.37','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Shari','last':'Melendez'},'company':'DIGIPRINT','email':'shari.melendez@digiprint.tv','phone':'+1 (914) 475-3995','address':'950 Wolf Place, Enetai, Alaska, 693','about':'Dolor incididunt et est commodo aliquip labore ad ullamco. Velit ex cillum nulla elit ex esse. Consectetur mollit fugiat cillum proident elit sunt non officia cillum ex laboris sint eu. Esse nulla eu officia in Lorem sint minim esse velit. Est Lorem ipsum enim aute. Elit minim eiusmod officia reprehenderit officia ut irure Lorem.','registered':'Wednesday, August 23, 2017 11:12 PM','latitude':'-70.347863','longitude':'94.812072','tags':['ea','ex','fugiat','duis','eu'],'greeting':'Hello, Shari! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed85ac364619d892ef','index':35,'guid':'c1905f34-14ff-4bd8-b683-02cac4d52623','isActive':false,'balance':'$2,538.50','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Santiago','last':'Joyner'},'company':'BRAINCLIP','email':'santiago.joyner@brainclip.co.uk','phone':'+1 (835) 405-2676','address':'554 Rose Street, Muir, Kentucky, 7752','about':'Quis culpa dolore fugiat magna culpa non deserunt consectetur elit. Id cupidatat occaecat duis irure ullamco elit in labore magna pariatur cillum est. Mollit dolore velit ipsum anim aliqua culpa sint. Occaecat aute anim ut sunt eu.','registered':'Thursday, January 18, 2018 4:49 PM','latitude':'57.057918','longitude':'-50.472596','tags':['ullamco','ullamco','sunt','voluptate','irure'],'greeting':'Hello, Santiago! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1763f56b1121fa88','index':36,'guid':'a7f50659-4ae3-4f3e-a9d8-087e05334b51','isActive':false,'balance':'$1,435.16','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Adeline','last':'Hoffman'},'company':'BITREX','email':'adeline.hoffman@bitrex.io','phone':'+1 (823) 488-3201','address':'221 Corbin Place, Edmund, Palau, 193','about':'Magna ullamco consectetur velit adipisicing cillum ea. Est qui incididunt est ullamco ex aute exercitation irure. Cupidatat consectetur proident qui fugiat do. Labore magna aliqua consectetur fugiat. Excepteur deserunt sit qui dolor fugiat aute sunt anim ipsum magna ea commodo qui. Minim eu adipisicing ut irure excepteur eiusmod aliqua. Voluptate nisi ad consequat qui.','registered':'Tuesday, June 14, 2016 9:26 AM','latitude':'-53.123355','longitude':'88.180776','tags':['non','est','commodo','ut','aliquip'],'greeting':'Hello, Adeline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed945d079f63e3185e','index':37,'guid':'1f4619e0-9289-4bea-a9db-a75f4cba1138','isActive':true,'balance':'$2,019.54','picture':'http://placehold.it/32x32','age':36,'eyeColor':'blue','name':{'first':'Porter','last':'Morse'},'company':'COMVOY','email':'porter.morse@comvoy.me','phone':'+1 (933) 562-3220','address':'416 India Street, Bourg, Rhode Island, 2266','about':'Et sint anim et sunt. Non mollit sunt cillum veniam sunt sint amet non mollit. Fugiat ea ullamco pariatur deserunt ex do minim irure irure.','registered':'Saturday, July 16, 2016 10:03 PM','latitude':'-81.782545','longitude':'69.783509','tags':['irure','consequat','veniam','nulla','velit'],'greeting':'Hello, Porter! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed411dd0f06c66bba6','index':38,'guid':'93c900f0-54c0-4c4c-b21d-d59d8d7c6177','isActive':true,'balance':'$3,764.84','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Fitzgerald','last':'Logan'},'company':'UTARIAN','email':'fitzgerald.logan@utarian.com','phone':'+1 (815) 461-2709','address':'498 Logan Street, Tonopah, Arkansas, 6652','about':'Quis Lorem sit est et dolor est esse in veniam. Mollit anim nostrud laboris consequat voluptate qui ad ipsum sint laborum exercitation quis ipsum. Incididunt cupidatat esse ea amet deserunt consequat eu proident duis adipisicing pariatur. Amet deserunt mollit aliquip mollit consequat sunt quis labore laboris quis. Magna cillum fugiat anim velit Lorem duis. Lorem duis amet veniam occaecat est excepteur ut ea velit esse non pariatur. Do veniam quis eu consequat ad duis incididunt minim dolore sit non minim adipisicing et.','registered':'Wednesday, August 9, 2017 9:20 PM','latitude':'24.480657','longitude':'-108.693421','tags':['dolore','ad','occaecat','quis','labore'],'greeting':'Hello, Fitzgerald! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb6f14559d8a7b28','index':39,'guid':'9434f48b-70a0-4161-8d06-c53bf8b9df94','isActive':true,'balance':'$3,713.47','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Mcconnell','last':'Nash'},'company':'TETAK','email':'mcconnell.nash@tetak.org','phone':'+1 (956) 477-3586','address':'853 Turnbull Avenue, Clarence, Missouri, 1599','about':'Culpa excepteur minim anim magna dolor dolore ad ex eu. In cupidatat cillum elit dolore in est minim dolore consectetur reprehenderit voluptate laborum. Deserunt id velit ad dolor mollit.','registered':'Saturday, November 10, 2018 9:27 AM','latitude':'1.691589','longitude':'143.704377','tags':['ut','deserunt','sit','cupidatat','ea'],'greeting':'Hello, Mcconnell! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed1a87ea0390733ffa','index':40,'guid':'ec8a55f7-7114-4787-b1ff-4e631731bc2c','isActive':true,'balance':'$2,200.71','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Kitty','last':'Meyers'},'company':'FIBEROX','email':'kitty.meyers@fiberox.net','phone':'+1 (864) 458-3826','address':'537 Georgia Avenue, Thermal, Illinois, 7930','about':'Non excepteur laboris Lorem magna adipisicing exercitation. Anim esse in pariatur minim ipsum qui voluptate irure. Pariatur Lorem pariatur esse commodo aute adipisicing anim commodo. Exercitation nostrud aliqua duis et amet amet tempor.','registered':'Tuesday, September 13, 2016 8:16 PM','latitude':'19.59506','longitude':'-57.814297','tags':['duis','ullamco','velit','sint','consequat'],'greeting':'Hello, Kitty! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed4dc76717bf1217b3','index':41,'guid':'40521cde-f835-4620-902b-af7abf185d8d','isActive':false,'balance':'$2,907.02','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Klein','last':'Goodwin'},'company':'PLASTO','email':'klein.goodwin@plasto.name','phone':'+1 (950) 563-3104','address':'764 Devoe Street, Lindcove, Oklahoma, 458','about':'Amet aliqua magna ea veniam non aliquip irure esse id ipsum cillum sint tempor dolor. Ullamco deserunt fugiat amet pariatur culpa nostrud commodo commodo. Ad occaecat magna adipisicing voluptate. Minim ad adipisicing cupidatat elit nostrud eu irure. Cupidatat occaecat aute magna consectetur dolore anim et. Ex voluptate velit exercitation laborum ad ullamco ad. Aliquip nulla ipsum dolore cillum qui nostrud eu adipisicing amet tempor do.','registered':'Tuesday, February 13, 2018 3:56 PM','latitude':'-27.168725','longitude':'-29.499285','tags':['minim','labore','do','deserunt','dolor'],'greeting':'Hello, Klein! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1ac77396b29aee9e','index':42,'guid':'7cfc03e3-30e9-4ae1-a1f5-f6c3223ca770','isActive':true,'balance':'$2,986.47','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Isabelle','last':'Bishop'},'company':'GEEKNET','email':'isabelle.bishop@geeknet.us','phone':'+1 (908) 418-2642','address':'729 Willmohr Street, Aguila, Montana, 7510','about':'In nulla commodo nostrud sint. Elit et occaecat et aliqua aliquip magna esse commodo duis Lorem dolor magna enim deserunt. Ipsum pariatur reprehenderit ipsum adipisicing mollit incididunt ut. Sunt in consequat ex ut minim non qui anim labore. Deserunt minim voluptate in nulla occaecat.','registered':'Monday, September 15, 2014 6:22 AM','latitude':'-81.686947','longitude':'38.409291','tags':['proident','est','aliqua','veniam','anim'],'greeting':'Hello, Isabelle! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3a070c9469a4893','index':43,'guid':'3dec76b4-0b55-4765-a2fd-b8dbd9c82f8f','isActive':true,'balance':'$2,501.24','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Josefina','last':'Turner'},'company':'COMSTAR','email':'josefina.turner@comstar.biz','phone':'+1 (908) 566-3029','address':'606 Schenck Place, Brutus, Vermont, 8681','about':'Enim consectetur pariatur sint dolor nostrud est deserunt nulla quis pariatur sit. Ad aute incididunt nisi excepteur duis est velit voluptate ullamco occaecat magna reprehenderit aliquip. Proident deserunt consectetur non et exercitation elit dolore enim aliqua incididunt anim amet. Ex esse sint commodo minim aliqua ut irure. Proident ex culpa voluptate fugiat nisi. Sint commodo laboris excepteur minim ipsum labore tempor quis magna.','registered':'Saturday, December 31, 2016 6:38 AM','latitude':'35.275088','longitude':'24.30485','tags':['minim','ut','irure','Lorem','veniam'],'greeting':'Hello, Josefina! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1aa7d74128ee3d0f','index':44,'guid':'10599279-c367-46c4-9f7a-744c2e4bf6c9','isActive':true,'balance':'$1,753.06','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Lily','last':'Haynes'},'company':'KIOSK','email':'lily.haynes@kiosk.ca','phone':'+1 (872) 451-2301','address':'509 Balfour Place, Grazierville, New Hampshire, 2750','about':'Nisi aliquip occaecat nostrud do sint qui nisi officia Lorem. Ad et et laboris nisi dolore aliqua eu. Aliqua veniam quis eu pariatur incididunt mollit id deserunt officia eiusmod. Consequat adipisicing do nisi voluptate eiusmod minim pariatur minim nisi nostrud culpa cupidatat. Irure consectetur id consequat adipisicing ullamco occaecat do. Ex proident ea quis nulla incididunt sunt excepteur incididunt. Aliquip minim nostrud non anim Lorem.','registered':'Tuesday, November 20, 2018 9:28 AM','latitude':'-12.677798','longitude':'114.506787','tags':['culpa','amet','elit','officia','irure'],'greeting':'Hello, Lily! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed74c76f2e84e201ce','index':45,'guid':'ec0a68d4-629e-46c9-9af7-f6ea867f02ba','isActive':true,'balance':'$1,477.93','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Shauna','last':'Pitts'},'company':'SPACEWAX','email':'shauna.pitts@spacewax.info','phone':'+1 (841) 406-2360','address':'348 Tabor Court, Westwood, Puerto Rico, 8297','about':'Aliquip irure officia magna ea magna mollit ea non amet deserunt. Veniam mollit labore culpa magna aliqua quis consequat est consectetur ea reprehenderit nostrud consequat aliqua. Mollit do ipsum mollit eiusmod.','registered':'Thursday, October 2, 2014 2:48 AM','latitude':'-55.17388','longitude':'-13.370494','tags':['anim','consectetur','cillum','veniam','duis'],'greeting':'Hello, Shauna! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed419e718484b16722','index':46,'guid':'b2d6101d-5646-43f4-8207-284494e5a990','isActive':false,'balance':'$2,006.96','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Lawrence','last':'Boyer'},'company':'SKYPLEX','email':'lawrence.boyer@skyplex.tv','phone':'+1 (953) 548-2618','address':'464 Pilling Street, Blandburg, Arizona, 5531','about':'Culpa sit minim pariatur mollit cupidatat sunt duis. Nisi ea proident veniam exercitation adipisicing Lorem aliquip amet dolor voluptate in nisi. Non commodo anim sunt est fugiat laborum nisi aliqua non Lorem exercitation dolor. Laboris dolore do minim ut eiusmod enim magna cillum laborum consectetur aliquip minim enim Lorem. Veniam ex veniam occaecat aliquip elit aliquip est eiusmod minim minim adipisicing.','registered':'Wednesday, July 30, 2014 2:17 AM','latitude':'-78.681255','longitude':'139.960626','tags':['consequat','Lorem','incididunt','dolor','esse'],'greeting':'Hello, Lawrence! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed08a9024998292c70','index':47,'guid':'277de142-ebeb-4828-906a-7fd8bc0a738a','isActive':true,'balance':'$1,273.19','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Sonya','last':'Stafford'},'company':'AQUACINE','email':'sonya.stafford@aquacine.co.uk','phone':'+1 (824) 581-3927','address':'641 Bowery Street, Hillsboro, Delaware, 7893','about':'Culpa labore ex reprehenderit mollit cupidatat dolore et ut quis in. Sint esse culpa enim culpa tempor exercitation veniam minim consectetur. Sunt est laboris minim quis incididunt exercitation laboris cupidatat fugiat ad. Deserunt ipsum do dolor cillum excepteur incididunt.','registered':'Thursday, March 26, 2015 1:10 PM','latitude':'-84.750592','longitude':'165.493533','tags':['minim','officia','dolore','ipsum','est'],'greeting':'Hello, Sonya! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5037f2c79ecde68','index':48,'guid':'2dc6532f-9a26-49aa-b444-8923896db89c','isActive':false,'balance':'$3,168.93','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Marguerite','last':'Stuart'},'company':'ACCUFARM','email':'marguerite.stuart@accufarm.io','phone':'+1 (848) 535-2253','address':'301 Menahan Street, Sunnyside, Nebraska, 4809','about':'Deserunt sint labore voluptate amet anim culpa nostrud adipisicing enim cupidatat ullamco exercitation fugiat est. Magna dolor aute incididunt ea ad adipisicing. Do cupidatat ut officia officia culpa sit do.','registered':'Thursday, May 8, 2014 1:25 PM','latitude':'21.82277','longitude':'-7.368347','tags':['labore','nulla','ullamco','irure','adipisicing'],'greeting':'Hello, Marguerite! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb26d315635818dae','index':49,'guid':'083a5eda-0a70-4f89-87f7-2cd386c0f22a','isActive':false,'balance':'$2,576.25','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Louella','last':'Holloway'},'company':'BEDDER','email':'louella.holloway@bedder.me','phone':'+1 (801) 425-3761','address':'545 Lafayette Avenue, Caledonia, Louisiana, 2816','about':'Qui exercitation occaecat dolore mollit. Fugiat cupidatat proident culpa fugiat quis. In cupidatat commodo elit ea enim occaecat esse exercitation nostrud occaecat veniam laboris fugiat. Nisi sunt reprehenderit aliqua reprehenderit tempor id dolore ullamco pariatur reprehenderit et eu ex pariatur.','registered':'Wednesday, November 5, 2014 1:10 AM','latitude':'36.385637','longitude':'77.949423','tags':['eu','irure','velit','non','aliquip'],'greeting':'Hello, Louella! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed77cd60a1abc1ecce','index':50,'guid':'2887c3c1-3eba-4237-a0db-1977eed94554','isActive':true,'balance':'$1,633.51','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Bates','last':'Carrillo'},'company':'ZOMBOID','email':'bates.carrillo@zomboid.com','phone':'+1 (934) 405-2006','address':'330 Howard Alley, Troy, Kansas, 4881','about':'Voluptate esse est ullamco anim tempor ea reprehenderit. Occaecat pariatur deserunt cillum laboris labore id exercitation esse ipsum ipsum ex aliquip. Sunt non elit est ea occaecat. Magna deserunt commodo aliqua ipsum est cillum dolor nisi. Ex duis est tempor tempor laboris do do quis id magna. Dolor do est elit eu laborum ullamco culpa consequat velit eiusmod tempor.','registered':'Saturday, May 28, 2016 3:56 AM','latitude':'83.310134','longitude':'-105.862836','tags':['est','commodo','ea','commodo','sunt'],'greeting':'Hello, Bates! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5ec0ec299b471fb5','index':51,'guid':'512b5e67-f785-492e-9d94-e43ef8b399b8','isActive':false,'balance':'$3,032.22','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Floyd','last':'Yang'},'company':'FRENEX','email':'floyd.yang@frenex.org','phone':'+1 (924) 566-3304','address':'418 Quay Street, Chumuckla, Guam, 7743','about':'Irure sit velit exercitation dolore est nisi incididunt ut quis consectetur incididunt est dolor. Aute nisi enim esse aliquip enim culpa commodo consectetur. Duis laborum magna ad duis ipsum aliqua eiusmod cillum. Consectetur et duis eiusmod irure ad est nisi incididunt eiusmod labore. Pariatur proident in Lorem adipisicing mollit proident excepteur nulla do nostrud mollit eiusmod. Duis ad dolore irure fugiat anim laboris ipsum et sit duis ipsum voluptate. Lorem non aute exercitation qui ullamco officia minim sint pariatur ut dolor.','registered':'Wednesday, January 18, 2017 2:01 AM','latitude':'45.888721','longitude':'-41.232793','tags':['elit','in','esse','ea','officia'],'greeting':'Hello, Floyd! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51e26ca89e5caf49','index':52,'guid':'4e0907f6-facc-46df-8952-73561a53fe33','isActive':true,'balance':'$3,767.41','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Gardner','last':'Carey'},'company':'KLUGGER','email':'gardner.carey@klugger.net','phone':'+1 (876) 481-3502','address':'131 Utica Avenue, Cannondale, Federated States Of Micronesia, 610','about':'Amet ad pariatur excepteur anim ex officia commodo proident aliqua occaecat consequat Lorem officia sit. Id minim velit nisi laboris nisi nulla incididunt eiusmod velit. Deserunt labore quis et tempor. Et labore exercitation laborum officia ullamco nostrud adipisicing laboris esse laborum aute anim elit. Sunt ad officia tempor esse et quis aliquip irure pariatur laborum id quis ex. Eu consequat nisi deserunt id eu proident ex minim aute nulla tempor ex.','registered':'Friday, February 21, 2014 6:42 AM','latitude':'-54.740231','longitude':'15.01484','tags':['commodo','laboris','occaecat','aliquip','adipisicing'],'greeting':'Hello, Gardner! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed52e3c9407105093a','index':53,'guid':'1d3b9e7a-1bc3-40ea-b808-1c33f0d48c70','isActive':true,'balance':'$1,113.30','picture':'http://placehold.it/32x32','age':26,'eyeColor':'blue','name':{'first':'Herman','last':'Rogers'},'company':'TALENDULA','email':'herman.rogers@talendula.name','phone':'+1 (818) 521-2005','address':'541 Norman Avenue, Winfred, Tennessee, 447','about':'Culpa ex laborum non ad ullamco officia. Nisi mollit mollit voluptate sit sint ullamco. Lorem exercitation nulla anim eiusmod deserunt magna sint. Officia sunt eiusmod aliqua reprehenderit sunt mollit sit cupidatat sint.','registered':'Wednesday, July 11, 2018 1:05 AM','latitude':'-20.708105','longitude':'-151.294563','tags':['exercitation','minim','officia','qui','enim'],'greeting':'Hello, Herman! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edfcb123d545b6edb4','index':54,'guid':'c0e0c669-4eed-43ee-bdd0-78fe6e9ca4d5','isActive':true,'balance':'$3,309.64','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Whitley','last':'Stark'},'company':'MUSAPHICS','email':'whitley.stark@musaphics.us','phone':'+1 (803) 476-2151','address':'548 Cobek Court, Chamizal, Indiana, 204','about':'Adipisicing veniam dolor ex sint sit id eu voluptate. Excepteur veniam proident exercitation id eu et sunt pariatur. Qui occaecat culpa aliqua nisi excepteur minim veniam. Est duis nulla laborum excepteur cillum pariatur sint incididunt. Velit commodo eu incididunt voluptate. Amet laboris laboris id adipisicing labore eiusmod consequat minim cillum et.','registered':'Thursday, March 27, 2014 9:10 AM','latitude':'71.219596','longitude':'51.012855','tags':['reprehenderit','mollit','laborum','voluptate','aliquip'],'greeting':'Hello, Whitley! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed81510dfc61602fcf','index':55,'guid':'7ec5c24d-f169-4399-a2a3-300c0f45e52e','isActive':false,'balance':'$3,721.04','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Gretchen','last':'Wade'},'company':'EWEVILLE','email':'gretchen.wade@eweville.biz','phone':'+1 (977) 598-3700','address':'721 Colonial Road, Brookfield, South Dakota, 3888','about':'Fugiat consequat sint ut ut et ullamco eiusmod deserunt pariatur. Veniam eiusmod esse fugiat mollit. Proident laboris minim qui do ipsum excepteur exercitation irure anim. Aliqua labore quis eu fugiat dolore ullamco velit Lorem voluptate ipsum nostrud eiusmod laborum proident.','registered':'Friday, October 12, 2018 10:59 AM','latitude':'41.937653','longitude':'63.378531','tags':['aute','cillum','ea','ex','aute'],'greeting':'Hello, Gretchen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf78f77d4a7d557bb','index':56,'guid':'8718ada7-6fd0-49ef-a405-29850503948b','isActive':false,'balance':'$3,341.33','picture':'http://placehold.it/32x32','age':32,'eyeColor':'blue','name':{'first':'Naomi','last':'Frye'},'company':'MAZUDA','email':'naomi.frye@mazuda.ca','phone':'+1 (825) 427-2255','address':'741 Coyle Street, Comptche, Pennsylvania, 8441','about':'Aliqua fugiat laborum quis ullamco cupidatat sit dolor nulla dolore. Do Lorem et ipsum culpa irure sit do dolor qui sit laboris aliqua. Ex consectetur irure in veniam reprehenderit amet do elit eiusmod est magna.','registered':'Thursday, January 9, 2014 7:18 AM','latitude':'41.078645','longitude':'-50.241966','tags':['do','aliquip','eiusmod','velit','id'],'greeting':'Hello, Naomi! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbf45db2e072a48b4','index':57,'guid':'c158ebf7-fb8b-4ea8-adbf-8c51c6486715','isActive':true,'balance':'$2,811.55','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Lamb','last':'Johns'},'company':'DOGTOWN','email':'lamb.johns@dogtown.info','phone':'+1 (946) 530-3057','address':'559 Malbone Street, Kennedyville, California, 2052','about':'Eiusmod dolor labore cillum ad veniam elit voluptate voluptate pariatur est cupidatat. Laboris ut qui in cillum sunt dolore ut enim. Minim nostrud ex qui quis reprehenderit magna ipsum cupidatat irure minim laboris veniam irure. Fugiat velit deserunt aliquip in esse proident excepteur labore reprehenderit excepteur sunt in cupidatat exercitation. Ex pariatur irure mollit tempor non magna ex.','registered':'Friday, April 21, 2017 1:51 AM','latitude':'-61.403599','longitude':'-93.447102','tags':['aliquip','tempor','sint','enim','ipsum'],'greeting':'Hello, Lamb! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb9c88190cb59cf2','index':58,'guid':'f0de5ac5-eb28-491b-81c5-76d447c9055e','isActive':true,'balance':'$1,611.99','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Lynette','last':'Cleveland'},'company':'ARTWORLDS','email':'lynette.cleveland@artworlds.tv','phone':'+1 (889) 596-3723','address':'439 Montauk Avenue, Felt, New Mexico, 9681','about':'Incididunt aliquip est aliquip est ullamco do consectetur dolor. Lorem mollit mollit dolor et ipsum ut qui veniam aute ea. Adipisicing reprehenderit culpa velit laborum adipisicing amet consectetur velit nisi. Ut qui proident ad cillum excepteur adipisicing quis labore. Duis velit culpa et excepteur eiusmod ex labore in nisi nostrud. Et ullamco minim excepteur ut enim reprehenderit consequat eiusmod laboris Lorem commodo exercitation qui laborum.','registered':'Wednesday, August 26, 2015 12:53 PM','latitude':'49.861336','longitude':'86.865926','tags':['reprehenderit','minim','in','minim','nostrud'],'greeting':'Hello, Lynette! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5b760ddde7295fa8','index':59,'guid':'f8180d3f-c5c0-48b2-966e-a0b2a80f8e84','isActive':true,'balance':'$3,376.75','picture':'http://placehold.it/32x32','age':32,'eyeColor':'green','name':{'first':'Obrien','last':'Page'},'company':'GLASSTEP','email':'obrien.page@glasstep.co.uk','phone':'+1 (902) 583-3086','address':'183 Ridgewood Avenue, Vicksburg, Wisconsin, 7430','about':'Aute excepteur cillum exercitation duis Lorem irure labore elit. Labore magna cupidatat velit consectetur minim do Lorem in excepteur commodo ea consequat ullamco laborum. Ut in id occaecat eu quis duis id ea deserunt veniam.','registered':'Wednesday, March 29, 2017 12:13 AM','latitude':'-40.156154','longitude':'72.76301','tags':['excepteur','non','anim','nulla','anim'],'greeting':'Hello, Obrien! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed52985d3d8901d653','index':60,'guid':'d2e14fa1-8c54-4bcb-8a58-eb2e6f8d0e45','isActive':true,'balance':'$1,659.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'brown','name':{'first':'Knowles','last':'Goodman'},'company':'CENTREE','email':'knowles.goodman@centree.io','phone':'+1 (862) 563-3692','address':'504 Lott Street, Allensworth, Florida, 7148','about':'Do aliquip voluptate aliqua nostrud. Eu dolore ex occaecat pariatur aute laborum aute nulla aute amet. Excepteur sit laboris ad non anim ut officia ut ad exercitation officia dolore laboris. Esse voluptate minim deserunt nostrud exercitation laborum voluptate exercitation id laborum fugiat proident cupidatat proident. Nulla nostrud est sint adipisicing incididunt exercitation dolor sit et elit tempor occaecat sint culpa. Pariatur occaecat laboris pariatur laboris ad pariatur in cillum fugiat est fugiat. Proident eu id irure excepteur esse aute cillum adipisicing.','registered':'Wednesday, October 15, 2014 6:17 PM','latitude':'-15.73863','longitude':'87.422009','tags':['consequat','sint','tempor','veniam','culpa'],'greeting':'Hello, Knowles! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda00b73bdb7ea54e9','index':61,'guid':'c8a064db-0ec6-4832-9820-7280a0333709','isActive':true,'balance':'$3,701.14','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Shepherd','last':'Todd'},'company':'ECRATIC','email':'shepherd.todd@ecratic.me','phone':'+1 (881) 444-3389','address':'450 Frank Court, Temperanceville, Ohio, 7006','about':'Voluptate cillum ad fugiat velit adipisicing sint consequat veniam Lorem reprehenderit. Cillum sit non deserunt consequat. Amet sunt pariatur non mollit ullamco proident sint dolore anim elit cupidatat anim do ullamco. Lorem Lorem incididunt ea elit consequat laboris enim duis quis Lorem id aute veniam consequat. Cillum veniam cillum sint qui Lorem fugiat culpa consequat. Est sint duis ut qui fugiat. Laborum pariatur velit et sunt mollit eiusmod excepteur culpa ex et officia.','registered':'Tuesday, October 10, 2017 2:01 AM','latitude':'82.951563','longitude':'-4.866954','tags':['eu','qui','proident','esse','ex'],'greeting':'Hello, Shepherd! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed0e51d1a7e2d9e559','index':62,'guid':'739c3d38-200d-4531-84d8-4e7c39ae5b8c','isActive':true,'balance':'$3,679.01','picture':'http://placehold.it/32x32','age':31,'eyeColor':'brown','name':{'first':'Rosalyn','last':'Heath'},'company':'ZAYA','email':'rosalyn.heath@zaya.com','phone':'+1 (865) 403-3520','address':'303 Henderson Walk, Hoehne, District Of Columbia, 4306','about':'Sint occaecat nulla mollit sint fugiat eu proident dolor labore consequat. Occaecat tempor excepteur do fugiat incididunt Lorem in ullamco dolore laborum. Cillum mollit aliquip excepteur aliquip sint sunt minim non irure irure. Cillum fugiat aliqua enim dolore. Nulla culpa culpa nostrud ad. Eiusmod culpa proident proident non est cupidatat eu sunt sit incididunt id nisi.','registered':'Wednesday, April 22, 2015 12:35 PM','latitude':'33.628504','longitude':'110.772802','tags':['consequat','ut','ex','labore','consectetur'],'greeting':'Hello, Rosalyn! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5274c01d353d0c5','index':63,'guid':'8815fe55-8af1-4708-a62a-d554dbd74a4a','isActive':true,'balance':'$2,126.01','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Queen','last':'Harper'},'company':'TRI@TRIBALOG','email':'queen.harper@tri@tribalog.org','phone':'+1 (903) 592-3145','address':'926 Heath Place, Wawona, Maine, 7340','about':'Laborum cupidatat commodo aliquip reprehenderit. Excepteur eu labore duis minim minim voluptate aute nostrud deserunt ut velit ullamco. Adipisicing nisi occaecat laborum proident. Id reprehenderit eiusmod cupidatat qui aute consequat amet enim commodo duis non ipsum. Amet ut aliqua magna qui proident mollit aute.','registered':'Saturday, April 9, 2016 5:12 AM','latitude':'51.814216','longitude':'177.348115','tags':['cillum','ut','dolor','do','nisi'],'greeting':'Hello, Queen! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed126298b6ce62ed56','index':64,'guid':'001c87fe-182f-450f-903b-2e29a9bb0322','isActive':true,'balance':'$3,578.29','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Pauline','last':'Mills'},'company':'CRUSTATIA','email':'pauline.mills@crustatia.net','phone':'+1 (984) 582-3899','address':'899 Revere Place, Welch, Iowa, 216','about':'Tempor eu exercitation ut id. Deserunt ex reprehenderit veniam nisi. Aute laborum veniam velit dolore ut deserunt Lorem sit esse quis dolor ex do nisi. In dolor tempor officia id. Velit nisi culpa nostrud laborum officia incididunt laborum velit non quis id exercitation exercitation. Anim elit ullamco in enim Lorem culpa aliqua Lorem.','registered':'Monday, June 2, 2014 2:03 PM','latitude':'56.427576','longitude':'172.183669','tags':['pariatur','pariatur','pariatur','fugiat','Lorem'],'greeting':'Hello, Pauline! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e332ad9e8a178d8','index':65,'guid':'5ad7292b-feef-4a7e-b485-142cadfbe8ea','isActive':false,'balance':'$3,916.54','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Garrett','last':'Richmond'},'company':'XYQAG','email':'garrett.richmond@xyqag.name','phone':'+1 (952) 584-3794','address':'233 Grove Street, Summerfield, Virginia, 4735','about':'Nostrud quis pariatur occaecat laborum laboris aliqua ut fugiat dolor. Commodo tempor excepteur enim nostrud Lorem. Aute elit nulla labore ad pariatur cupidatat Lorem qui cupidatat velit deserunt excepteur esse. Excepteur nulla et nostrud quis labore est veniam enim nisi laboris ut enim. Ea esse nulla anim excepteur reprehenderit deserunt voluptate minim qui labore adipisicing amet eu enim.','registered':'Wednesday, March 5, 2014 4:35 PM','latitude':'68.665041','longitude':'148.799524','tags':['irure','reprehenderit','minim','ea','do'],'greeting':'Hello, Garrett! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed541aa2ec47466ace','index':66,'guid':'9cda6f3c-c9ab-451c-bb19-2e4c8463d011','isActive':true,'balance':'$3,352.52','picture':'http://placehold.it/32x32','age':30,'eyeColor':'brown','name':{'first':'Cobb','last':'Whitley'},'company':'UNIA','email':'cobb.whitley@unia.us','phone':'+1 (888) 490-3342','address':'864 Belmont Avenue, Needmore, Massachusetts, 8286','about':'Nisi aliquip fugiat ipsum nisi ullamco minim pariatur labore. Sint labore anim do ad ad esse eu nostrud nulla commodo anim. Cillum anim enim duis cillum non do nisi aliquip veniam voluptate commodo aliqua laborum. Exercitation in do eu qui sint aliquip. Esse adipisicing deserunt deserunt qui anim aliqua occaecat et nostrud elit ea in anim cillum. Tempor mollit proident tempor sunt est sint laborum ullamco incididunt non. Velit aliqua sunt excepteur nisi qui eiusmod ipsum dolore aliquip velit ullamco ullamco.','registered':'Friday, May 23, 2014 7:11 PM','latitude':'-32.950581','longitude':'147.772494','tags':['mollit','adipisicing','irure','ad','minim'],'greeting':'Hello, Cobb! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8186c3d6f34c2be3','index':67,'guid':'fee98f6d-d68a-4189-8180-b6cb337e537e','isActive':false,'balance':'$1,698.42','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Brennan','last':'Tyler'},'company':'PODUNK','email':'brennan.tyler@podunk.biz','phone':'+1 (867) 498-2727','address':'599 Harkness Avenue, Gorst, American Samoa, 322','about':'Reprehenderit id sit qui id qui aute ea sit magna in qui proident. Excepteur ad nostrud do nostrud in incididunt voluptate adipisicing sint anim. Ullamco consequat minim nulla irure ex est irure reprehenderit deserunt voluptate dolore anim sunt. Occaecat dolore voluptate voluptate elit commodo nulla laborum ad do irure.','registered':'Friday, February 9, 2018 5:40 PM','latitude':'11.150893','longitude':'-85.298004','tags':['quis','minim','deserunt','cillum','laboris'],'greeting':'Hello, Brennan! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed075c9c4f7439818d','index':68,'guid':'1ef76b18-6b8d-4c3c-aca3-9fa2b43f0242','isActive':false,'balance':'$2,091.17','picture':'http://placehold.it/32x32','age':26,'eyeColor':'brown','name':{'first':'Neal','last':'Stephenson'},'company':'OTHERSIDE','email':'neal.stephenson@otherside.ca','phone':'+1 (820) 496-3344','address':'867 Wilson Street, Kidder, Colorado, 4599','about':'Do laboris enim proident in qui velit adipisicing magna anim. Amet proident non exercitation ipsum aliqua excepteur nostrud. Enim esse non sit in nostrud deserunt id laborum cillum deserunt consequat. Anim velit exercitation qui sit voluptate. Irure duis non veniam velit mollit exercitation id exercitation.','registered':'Thursday, November 13, 2014 11:00 PM','latitude':'54.809693','longitude':'1.877241','tags':['anim','duis','in','officia','sint'],'greeting':'Hello, Neal! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0eda0a2dc24db64b638','index':69,'guid':'194744fd-089b-40b6-a290-98a6ec30a415','isActive':false,'balance':'$3,191.67','picture':'http://placehold.it/32x32','age':24,'eyeColor':'brown','name':{'first':'Shields','last':'Hubbard'},'company':'MIRACULA','email':'shields.hubbard@miracula.info','phone':'+1 (885) 582-2001','address':'529 Eagle Street, Guilford, Nevada, 1460','about':'Eiusmod exercitation ut incididunt veniam commodo culpa ullamco mollit id adipisicing exercitation ad sint. Nostrud excepteur amet aliqua mollit incididunt laborum voluptate id anim. Nulla sint laboris dolor esse cupidatat laborum ex sint. Ex non sunt sit nulla.','registered':'Monday, February 13, 2017 6:22 AM','latitude':'-69.145209','longitude':'-40.69755','tags':['tempor','enim','qui','velit','elit'],'greeting':'Hello, Shields! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf939c130177e074d','index':70,'guid':'303b176c-7803-4ed2-a35f-3e3c831793ef','isActive':false,'balance':'$2,359.09','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Coleen','last':'Knight'},'company':'BLEEKO','email':'coleen.knight@bleeko.tv','phone':'+1 (867) 423-3146','address':'527 Broadway , Bonanza, Marshall Islands, 4988','about':'Laboris nulla pariatur laborum ad aute excepteur sunt pariatur exercitation. Do nostrud qui ipsum ullamco et sint do Lorem cillum ullamco do. Exercitation labore excepteur commodo incididunt eiusmod proident consectetur adipisicing nostrud aute voluptate laboris. Commodo anim proident eiusmod pariatur est ea laborum incididunt qui tempor reprehenderit ullamco id. Eiusmod commodo nisi consectetur ut qui quis aliqua sit minim nostrud sunt laborum eiusmod adipisicing.','registered':'Sunday, May 6, 2018 8:03 AM','latitude':'70.729041','longitude':'113.052761','tags':['Lorem','ullamco','nulla','ullamco','commodo'],'greeting':'Hello, Coleen! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edae8b1ce688b61223','index':71,'guid':'7d6f3b1a-c367-4068-9e8e-1717d513ece3','isActive':false,'balance':'$2,911.07','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Clark','last':'Ryan'},'company':'ECLIPSENT','email':'clark.ryan@eclipsent.co.uk','phone':'+1 (938) 562-2740','address':'500 Lewis Avenue, Rockbridge, North Dakota, 5133','about':'Adipisicing exercitation officia sit excepteur excepteur sunt sint amet. Aliqua ipsum sint laboris eiusmod esse culpa elit sunt. Dolore est consectetur est quis quis magna. Aliquip nostrud dolore ex pariatur. Anim nostrud duis exercitation ut magna magna culpa. Nisi irure id mollit labore non sit mollit occaecat Lorem est ipsum. Nulla est fugiat cillum nisi aliqua consectetur amet nulla nostrud esse.','registered':'Friday, July 24, 2015 9:28 AM','latitude':'-68.055815','longitude':'-50.926966','tags':['deserunt','ad','ad','ut','id'],'greeting':'Hello, Clark! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5d1e8df45d8ab4db','index':72,'guid':'ce85db37-7d04-4f4c-a4b0-78003533e5c6','isActive':false,'balance':'$1,127.43','picture':'http://placehold.it/32x32','age':21,'eyeColor':'green','name':{'first':'Dillon','last':'Hooper'},'company':'MEDESIGN','email':'dillon.hooper@medesign.io','phone':'+1 (929) 600-3797','address':'652 Mill Avenue, Elliston, Mississippi, 2958','about':'Dolore culpa qui exercitation nostrud do. Irure duis in ad ipsum aliqua aliquip nulla sit veniam officia quis occaecat est. Magna qui eiusmod pariatur aliquip minim commodo. Qui ex dolor excepteur consequat eiusmod occaecat. In officia ipsum do Lorem excepteur proident pariatur labore.','registered':'Monday, May 26, 2014 2:38 AM','latitude':'-36.032189','longitude':'86.865529','tags':['non','ut','ex','Lorem','quis'],'greeting':'Hello, Dillon! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb84814579c3121b3','index':73,'guid':'d7303901-5186-4595-a759-22306f67d0a3','isActive':true,'balance':'$2,326.59','picture':'http://placehold.it/32x32','age':33,'eyeColor':'green','name':{'first':'Moreno','last':'Hull'},'company':'ZEAM','email':'moreno.hull@zeam.me','phone':'+1 (984) 586-3738','address':'265 Pine Street, Talpa, North Carolina, 6041','about':'Fugiat exercitation est ullamco anim. Exercitation proident id sunt culpa Lorem amet. Consectetur anim consectetur pariatur consequat consectetur amet excepteur voluptate ea velit duis eiusmod proident. In sint laborum cupidatat ea amet ex. Reprehenderit amet sunt dolor ullamco est ex deserunt.','registered':'Wednesday, January 24, 2018 8:52 PM','latitude':'84.956857','longitude':'113.210051','tags':['est','excepteur','anim','Lorem','dolor'],'greeting':'Hello, Moreno! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda4eb9dcb92c82d06','index':74,'guid':'8ee28651-802e-4523-b676-c713f6e874b8','isActive':true,'balance':'$3,783.97','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Tracie','last':'Price'},'company':'ICOLOGY','email':'tracie.price@icology.com','phone':'+1 (897) 403-3768','address':'487 Sheffield Avenue, Vallonia, Wyoming, 276','about':'Voluptate laboris laborum aute ex sint voluptate officia proident. Sit esse nostrud cupidatat in veniam sit duis est. Do mollit elit exercitation aliqua id irure ex. Lorem reprehenderit do ullamco sint ea ad nisi ad ut.','registered':'Saturday, December 10, 2016 9:44 AM','latitude':'77.770464','longitude':'151.392903','tags':['incididunt','labore','aliquip','anim','minim'],'greeting':'Hello, Tracie! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed68ab1a55d1c35e6c','index':75,'guid':'deedd26a-8928-4064-9666-5c59ea8144b4','isActive':true,'balance':'$2,848.08','picture':'http://placehold.it/32x32','age':32,'eyeColor':'brown','name':{'first':'Montgomery','last':'Bruce'},'company':'CYTREK','email':'montgomery.bruce@cytrek.org','phone':'+1 (824) 414-2731','address':'397 Beach Place, Ellerslie, South Carolina, 967','about':'Mollit minim excepteur magna velit cillum excepteur exercitation anim id labore deserunt do. Fugiat ex et id ad. Duis excepteur laboris est nulla do id irure quis eiusmod do esse ut culpa in.','registered':'Tuesday, August 25, 2015 6:42 AM','latitude':'79.722631','longitude':'-7.516885','tags':['Lorem','sint','voluptate','proident','incididunt'],'greeting':'Hello, Montgomery! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd90e0abb1cc2b0aa','index':76,'guid':'a072159d-12db-4747-9c2a-e2486a53d043','isActive':false,'balance':'$2,723.54','picture':'http://placehold.it/32x32','age':40,'eyeColor':'green','name':{'first':'Zelma','last':'Salinas'},'company':'IMAGEFLOW','email':'zelma.salinas@imageflow.net','phone':'+1 (964) 555-3856','address':'584 Reeve Place, Nord, Georgia, 7473','about':'Aliqua proident excepteur duis cupidatat cillum amet esse esse consectetur ea. Officia sunt consequat nostrud minim enim dolore dolor duis cillum. Esse labore veniam sint laborum excepteur sint tempor do ad cupidatat aliquip laboris elit id. Velit reprehenderit ullamco velit ullamco adipisicing velit esse irure velit et.','registered':'Thursday, February 25, 2016 8:18 PM','latitude':'-32.880524','longitude':'115.180489','tags':['id','nulla','reprehenderit','consequat','reprehenderit'],'greeting':'Hello, Zelma! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed98d836c8da283bb2','index':77,'guid':'838bebad-cc20-44e9-9eb7-902a8ca25efb','isActive':false,'balance':'$3,488.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Shaw','last':'Parsons'},'company':'PEARLESEX','email':'shaw.parsons@pearlesex.name','phone':'+1 (912) 567-3580','address':'606 Ocean Avenue, Tyro, Northern Mariana Islands, 3367','about':'Laborum labore occaecat culpa pariatur nisi non adipisicing esse consectetur officia officia. Deserunt velit eu enim consectetur ut cillum aliqua occaecat dolor qui esse. Incididunt ad est ex eu culpa anim aliquip laborum. Aliqua consectetur velit exercitation magna minim nulla do ut excepteur enim aliquip et. Nostrud enim sunt amet amet proident aliqua velit dolore. Consectetur ipsum fugiat proident id est reprehenderit tempor irure commodo. Sit excepteur fugiat occaecat nulla Lorem et cillum.','registered':'Thursday, April 19, 2018 1:41 AM','latitude':'69.715573','longitude':'-118.481237','tags':['laboris','adipisicing','magna','voluptate','id'],'greeting':'Hello, Shaw! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1101734633c6ebba','index':78,'guid':'8fd0c52a-9d74-4984-a608-d612ecd8ddf0','isActive':true,'balance':'$3,820.02','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Jaime','last':'Beard'},'company':'IZZBY','email':'jaime.beard@izzby.us','phone':'+1 (820) 412-3806','address':'362 Hudson Avenue, Delco, New Jersey, 5684','about':'Ut cupidatat veniam nulla magna commodo sit duis veniam consectetur cupidatat elit quis tempor. Duis officia ullamco proident sunt non mollit excepteur. Nisi ex amet laboris proident duis reprehenderit et est aliqua mollit amet ad. Enim eu elit excepteur eu exercitation duis consequat culpa. Adipisicing reprehenderit duis Lorem reprehenderit dolor aliqua incididunt eiusmod consequat ad occaecat fugiat do laborum. Qui ad aliquip ex do sunt. Fugiat non ut fugiat eu.','registered':'Sunday, March 9, 2014 3:41 PM','latitude':'17.926318','longitude':'108.985996','tags':['ut','voluptate','veniam','non','commodo'],'greeting':'Hello, Jaime! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edcd125a89dcf18e0d','index':79,'guid':'eccaa4ca-0fa7-4b00-a1e3-fe7953403894','isActive':true,'balance':'$1,521.33','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Terra','last':'Sullivan'},'company':'ZANITY','email':'terra.sullivan@zanity.biz','phone':'+1 (995) 498-2714','address':'346 Congress Street, Tuttle, Maryland, 3152','about':'Incididunt enim veniam ut veniam quis dolore pariatur culpa ex. Cillum laboris dolor exercitation officia. Officia irure magna aliqua veniam officia ullamco culpa. Cillum enim velit ea sint sint officia labore ea adipisicing culpa laboris. Anim aute sint commodo culpa ex quis minim ut laborum.','registered':'Sunday, June 1, 2014 5:38 AM','latitude':'-4.655435','longitude':'5.851803','tags':['anim','non','anim','laborum','pariatur'],'greeting':'Hello, Terra! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed9b9fc3041a674c87','index':80,'guid':'9f95fa36-4e45-4c3f-9362-3d4d809bf57f','isActive':true,'balance':'$3,403.16','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Sharpe','last':'Berger'},'company':'ZILLAN','email':'sharpe.berger@zillan.ca','phone':'+1 (913) 498-3005','address':'277 Bragg Street, Faywood, Texas, 6487','about':'Dolor duis id aute ea veniam amet ullamco id. Culpa deserunt irure mollit tempor dolore veniam culpa officia culpa laborum eiusmod. Ullamco tempor qui aliqua cupidatat veniam cillum eu ut ex minim eu in. Quis exercitation anim eiusmod tempor esse mollit exercitation cillum ipsum reprehenderit. Sint voluptate ipsum officia sint magna nulla tempor eiusmod eiusmod veniam. Consectetur non ad veniam exercitation voluptate non nostrud.','registered':'Tuesday, June 27, 2017 12:58 AM','latitude':'-0.54085','longitude':'106.258693','tags':['proident','eiusmod','commodo','excepteur','pariatur'],'greeting':'Hello, Sharpe! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1a1866757bf675e0','index':81,'guid':'1b944a01-01d3-4846-94e3-630f4d0e51a3','isActive':true,'balance':'$2,038.61','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Blanchard','last':'Ewing'},'company':'CONJURICA','email':'blanchard.ewing@conjurica.info','phone':'+1 (859) 593-3212','address':'252 Beaver Street, Kiskimere, Utah, 3255','about':'Labore magna aute adipisicing ut dolor sit ea. Officia culpa aute occaecat sit ex ullamco aliquip ad sit culpa. Ex in enim dolore ex est sit. Do irure nulla magna sint aliquip in duis aute. Magna ullamco sit labore ea tempor voluptate.','registered':'Monday, May 4, 2015 10:50 AM','latitude':'76.207595','longitude':'0.672563','tags':['proident','pariatur','officia','in','culpa'],'greeting':'Hello, Blanchard! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed987d82f4e22d939c','index':82,'guid':'97a90aee-3cee-4678-819e-24fb94279dc1','isActive':false,'balance':'$1,201.55','picture':'http://placehold.it/32x32','age':28,'eyeColor':'blue','name':{'first':'Wells','last':'Solomon'},'company':'CORPULSE','email':'wells.solomon@corpulse.tv','phone':'+1 (840) 539-3349','address':'159 Radde Place, Linganore, Idaho, 230','about':'Consequat dolore mollit sit irure cupidatat commodo. Incididunt cillum reprehenderit ullamco sit proident cupidatat occaecat reprehenderit officia. Ad anim Lorem elit in officia minim proident nisi commodo eiusmod ea Lorem dolore voluptate. Dolor aliquip est commodo Lorem dolor ut aliquip ut. Sit anim officia dolore excepteur aute enim cillum.','registered':'Friday, January 6, 2017 1:59 PM','latitude':'70.020883','longitude':'14.503588','tags':['mollit','aute','officia','nostrud','laboris'],'greeting':'Hello, Wells! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf7a904ea0d0bc2a','index':83,'guid':'fe639a0c-7517-43e6-b0da-cd9ca5b9e267','isActive':false,'balance':'$3,664.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'blue','name':{'first':'Natalia','last':'Brown'},'company':'SYNTAC','email':'natalia.brown@syntac.co.uk','phone':'+1 (952) 595-3513','address':'332 Lenox Road, Springville, Alabama, 8406','about':'Nulla consequat officia commodo ea sunt irure anim velit aliquip aliquip. Labore ullamco occaecat proident voluptate cillum labore minim nostrud excepteur. Qui fugiat nostrud cillum fugiat ullamco id commodo aliqua voluptate mollit id id laboris. Cillum qui duis duis sit adipisicing elit ut aliqua eu. Anim nisi aliqua sit mollit.','registered':'Sunday, July 30, 2017 1:02 PM','latitude':'31.937613','longitude':'-9.957927','tags':['magna','adipisicing','exercitation','tempor','consectetur'],'greeting':'Hello, Natalia! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed8823fa385cad4aa3','index':84,'guid':'5cf280da-f5f0-4cc6-9063-e9d5863c8c89','isActive':false,'balance':'$1,624.17','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Greene','last':'Waller'},'company':'ISOTRACK','email':'greene.waller@isotrack.io','phone':'+1 (838) 406-3608','address':'362 Albemarle Road, Gardiner, Michigan, 2764','about':'Ut nisi sit sint nulla dolor magna. Culpa occaecat adipisicing veniam proident excepteur tempor quis ex. Fugiat tempor laborum dolor adipisicing irure anim cupidatat ut exercitation ex sit. Cupidatat exercitation commodo sunt ex irure fugiat eu esse do ullamco mollit dolore cupidatat. Cupidatat magna incididunt officia dolore esse voluptate deserunt in laborum dolor. Sit fugiat Lorem eu ullamco. Laboris veniam quis cillum tempor ex fugiat cillum cupidatat.','registered':'Sunday, June 10, 2018 10:32 PM','latitude':'0.256921','longitude':'-96.141941','tags':['magna','dolore','deserunt','aliquip','cillum'],'greeting':'Hello, Greene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda7c905c2d24c7d31','index':85,'guid':'aa30a9fb-8a16-48eb-8bb7-1307d1e1f191','isActive':false,'balance':'$1,974.04','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Carlene','last':'Hanson'},'company':'DIGIRANG','email':'carlene.hanson@digirang.me','phone':'+1 (981) 417-3209','address':'435 Clark Street, Choctaw, Oregon, 9888','about':'Amet labore esse cillum irure laborum consectetur occaecat non aliquip aliquip proident. Nisi magna nulla officia duis labore aute nulla laborum duis tempor minim. Velit elit reprehenderit nisi exercitation officia incididunt amet cupidatat excepteur proident consectetur.','registered':'Thursday, April 20, 2017 6:13 AM','latitude':'68.529086','longitude':'68.802409','tags':['pariatur','nulla','qui','amet','labore'],'greeting':'Hello, Carlene! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed6fbee12ce9e55dbf','index':86,'guid':'0fce89aa-3310-48df-862a-68bd3d776644','isActive':false,'balance':'$3,909.64','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Doris','last':'Collins'},'company':'ZIORE','email':'doris.collins@ziore.com','phone':'+1 (914) 405-2360','address':'301 Lorraine Street, Stouchsburg, Minnesota, 7476','about':'Nisi deserunt aliquip et deserunt ipsum ad consectetur est non ullamco. Dolore do ut voluptate do eiusmod. Culpa ad in eiusmod nisi cillum do. Officia magna cillum sint aliqua reprehenderit amet est ipsum. Eiusmod deserunt commodo proident consequat. Amet minim dolor consequat aliquip aliquip culpa non exercitation non.','registered':'Wednesday, February 25, 2015 9:15 PM','latitude':'-57.364906','longitude':'130.766587','tags':['nulla','deserunt','cillum','eiusmod','adipisicing'],'greeting':'Hello, Doris! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edede9402476c398c0','index':87,'guid':'60cf0aa6-bc6d-4305-8842-d27e6af1306f','isActive':false,'balance':'$2,817.53','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Cline','last':'Hayden'},'company':'ECRAZE','email':'cline.hayden@ecraze.org','phone':'+1 (965) 507-2138','address':'352 Rutland Road, Ebro, Connecticut, 1196','about':'Dolor eiusmod enim anim sit enim ea tempor. Tempor amet consectetur aliquip culpa do ex excepteur deserunt. Dolor commodo veniam culpa sint. Commodo consectetur pariatur irure nisi deserunt cillum est dolor ipsum ea.','registered':'Thursday, September 29, 2016 5:58 AM','latitude':'62.50713','longitude':'86.247286','tags':['enim','tempor','anim','veniam','proident'],'greeting':'Hello, Cline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edeb72f151994a551b','index':88,'guid':'dbb49c62-86b1-409f-b8b8-f609c709d2a8','isActive':false,'balance':'$3,122.56','picture':'http://placehold.it/32x32','age':39,'eyeColor':'green','name':{'first':'Janelle','last':'Rutledge'},'company':'TERRAGEN','email':'janelle.rutledge@terragen.net','phone':'+1 (914) 581-3749','address':'170 Falmouth Street, Alderpoint, West Virginia, 642','about':'Laboris proident cillum sunt qui ea sunt. Officia adipisicing exercitation dolore magna reprehenderit amet anim id. Laboris commodo sit irure irure. Excepteur est mollit fugiat incididunt consectetur veniam irure ea mollit. Cillum enim consequat sunt sunt nisi incididunt tempor enim.','registered':'Monday, February 16, 2015 5:46 AM','latitude':'-46.392023','longitude':'32.054562','tags':['eu','eu','nisi','labore','deserunt'],'greeting':'Hello, Janelle! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edc9c2604846ff9a0d','index':89,'guid':'c4d7a365-f1d3-4584-b78e-008394c219f7','isActive':true,'balance':'$1,807.19','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Abby','last':'Lopez'},'company':'GRAINSPOT','email':'abby.lopez@grainspot.name','phone':'+1 (917) 442-3955','address':'488 Kensington Walk, Winston, Hawaii, 9109','about':'Incididunt deserunt Lorem proident magna tempor enim quis duis eu ut adipisicing in. Ex mollit non irure aliqua officia. Fugiat id ipsum consequat irure id ullamco culpa quis nulla enim aliquip consequat et. Dolor ut anim velit irure consequat cillum eu. Aute occaecat laborum est aliqua.','registered':'Sunday, April 1, 2018 11:28 PM','latitude':'-10.177041','longitude':'-165.756718','tags':['est','laborum','culpa','non','quis'],'greeting':'Hello, Abby! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed03237438b158af9e','index':90,'guid':'36c4a19f-2d00-4e40-bd49-155fd2ce0a6c','isActive':false,'balance':'$2,757.86','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Whitney','last':'Sheppard'},'company':'ANACHO','email':'whitney.sheppard@anacho.us','phone':'+1 (922) 437-2383','address':'951 Beekman Place, Homeworth, New York, 6088','about':'Sint minim nisi minim non minim aliqua pariatur ullamco do sint qui labore. Aute elit reprehenderit ad do fugiat est amet. In incididunt tempor commodo cillum tempor est labore anim.','registered':'Tuesday, September 13, 2016 6:43 PM','latitude':'-49.732527','longitude':'-171.846715','tags':['exercitation','veniam','sunt','est','proident'],'greeting':'Hello, Whitney! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edb99dd3aa53d2cb7f','index':91,'guid':'17afd430-f37f-4d55-958c-72f35cdb5997','isActive':false,'balance':'$3,683.86','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Ilene','last':'Blackwell'},'company':'ENQUILITY','email':'ilene.blackwell@enquility.biz','phone':'+1 (817) 555-2616','address':'950 Varanda Place, Belgreen, Virgin Islands, 1765','about':'Id eiusmod deserunt eiusmod adipisicing adipisicing est enim pariatur esse duis. Qui velit duis irure magna consectetur dolore reprehenderit. Cillum dolore minim consectetur irure non qui velit cillum veniam adipisicing incididunt. Deserunt veniam excepteur veniam velit aliquip labore quis exercitation magna do non dolor. Aliquip occaecat minim adipisicing deserunt fugiat nulla occaecat proident irure consectetur eiusmod irure. Enim Lorem deserunt amet Lorem commodo eiusmod reprehenderit occaecat adipisicing dolor voluptate cillum.','registered':'Thursday, February 1, 2018 8:39 AM','latitude':'57.393644','longitude':'-3.704258','tags':['adipisicing','dolor','commodo','Lorem','Lorem'],'greeting':'Hello, Ilene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed353f4deb62c3342a','index':92,'guid':'9953e285-2095-4f1c-978b-9ece2a867e9d','isActive':false,'balance':'$1,202.44','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Dawson','last':'Herman'},'company':'BITENDREX','email':'dawson.herman@bitendrex.ca','phone':'+1 (843) 522-2655','address':'471 Channel Avenue, Denio, Alaska, 5040','about':'Nisi occaecat mollit reprehenderit nisi minim Lorem mollit. Ea proident irure cillum quis. Deserunt consectetur consectetur consequat quis enim minim ea ipsum proident nisi ad non aliquip. Veniam aute minim consequat irure voluptate aute amet excepteur exercitation cillum duis quis adipisicing nostrud.','registered':'Tuesday, December 8, 2015 5:40 PM','latitude':'-55.602721','longitude':'-26.683234','tags':['qui','dolor','deserunt','eiusmod','labore'],'greeting':'Hello, Dawson! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5464bc50a5310ad','index':93,'guid':'724b2434-4dbd-417d-aa07-6065715f434f','isActive':false,'balance':'$1,595.98','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Alice','last':'Christian'},'company':'ZENOLUX','email':'alice.christian@zenolux.info','phone':'+1 (954) 466-2650','address':'875 Gerritsen Avenue, Townsend, Kentucky, 6568','about':'Nulla labore occaecat ex culpa magna. Commodo occaecat et in consequat cillum laborum magna adipisicing excepteur. Do ut Lorem esse voluptate officia ea aliquip proident amet veniam minim nulla adipisicing. Enim consectetur incididunt laborum voluptate tempor deserunt non laboris. Aliquip deserunt aute irure dolore magna anim aliquip sint magna Lorem. Officia laboris nulla officia sint labore nisi. Do Lorem id in est esse adipisicing id fugiat enim esse laborum.','registered':'Wednesday, October 3, 2018 9:26 PM','latitude':'-88.790637','longitude':'138.817328','tags':['duis','ea','magna','ea','incididunt'],'greeting':'Hello, Alice! You have 8 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda01886247b6a4f3d','index':94,'guid':'17c9f4d3-7d72-44e3-8f7c-08d7de920f46','isActive':false,'balance':'$3,173.29','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Schwartz','last':'Mccormick'},'company':'EVIDENDS','email':'schwartz.mccormick@evidends.tv','phone':'+1 (924) 531-2802','address':'160 Midwood Street, Indio, Palau, 4241','about':'Anim reprehenderit et et adipisicing voluptate consequat elit. Sint Lorem laboris Lorem minim nostrud aute reprehenderit elit aute quis nulla. Officia aute eiusmod mollit cillum eu aliquip non enim ea occaecat quis fugiat occaecat officia. Eiusmod culpa exercitation dolor aliqua enim occaecat nisi cupidatat duis ex dolore id. Id consequat aliqua cupidatat ut. Sit nisi est sunt culpa ullamco excepteur sunt pariatur incididunt amet. Ut tempor duis velit eu ut id culpa aute anim occaecat labore.','registered':'Thursday, March 2, 2017 5:57 PM','latitude':'38.618587','longitude':'-165.142529','tags':['ad','reprehenderit','magna','elit','mollit'],'greeting':'Hello, Schwartz! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51be4df456ec2bc9','index':95,'guid':'44f68f65-959b-4ec2-bd2a-1f30035f76fc','isActive':false,'balance':'$3,242.24','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Bonita','last':'Stevens'},'company':'SLOFAST','email':'bonita.stevens@slofast.co.uk','phone':'+1 (886) 473-2105','address':'459 Bushwick Court, Kilbourne, Rhode Island, 9450','about':'Consequat reprehenderit qui reprehenderit nisi sit est in qui aliquip amet. Ex deserunt cupidatat amet cillum eiusmod irure anim in amet proident voluptate. Ad officia culpa in non incididunt do.','registered':'Saturday, August 22, 2015 5:23 AM','latitude':'60.013542','longitude':'58.242132','tags':['aute','adipisicing','in','cillum','officia'],'greeting':'Hello, Bonita! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed50a55e3587993f68','index':96,'guid':'652e434f-221e-4899-af12-38dca5c9621d','isActive':false,'balance':'$2,720.06','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Charmaine','last':'Jackson'},'company':'FLUM','email':'charmaine.jackson@flum.io','phone':'+1 (947) 573-2692','address':'788 Windsor Place, Highland, Arkansas, 8869','about':'Dolore reprehenderit irure excepteur eu reprehenderit sint Lorem ut amet in. Consequat anim elit sunt aliquip incididunt. Culpa consequat do exercitation dolor enim dolor sunt sit excepteur ad anim. Dolor aute elit velit mollit minim eu.','registered':'Wednesday, April 6, 2016 7:54 PM','latitude':'25.756553','longitude':'-5.482531','tags':['amet','sint','consequat','est','ex'],'greeting':'Hello, Charmaine! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed213621949bbdd5d3','index':97,'guid':'7d7d93d8-3e37-4b4a-9fa2-591fb7d153ce','isActive':true,'balance':'$1,370.63','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Petersen','last':'Cooley'},'company':'ROTODYNE','email':'petersen.cooley@rotodyne.me','phone':'+1 (929) 563-3339','address':'338 Pioneer Street, Carbonville, Missouri, 3030','about':'Cillum elit dolore labore aute. Cillum ea incididunt cupidatat consequat sint eu mollit. Excepteur commodo eiusmod ex Lorem enim velit minim.','registered':'Friday, December 8, 2017 5:53 AM','latitude':'-10.576254','longitude':'-111.176861','tags':['veniam','eu','eiusmod','dolore','voluptate'],'greeting':'Hello, Petersen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e938138d58ed453','index':98,'guid':'d6fea4a3-03f6-46ee-90b9-8ec51a585e29','isActive':true,'balance':'$1,216.54','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Rosanne','last':'Terry'},'company':'EXTREMO','email':'rosanne.terry@extremo.com','phone':'+1 (812) 496-2691','address':'368 Rockaway Avenue, Gloucester, Illinois, 7913','about':'Duis et nostrud duis quis minim eiusmod culpa do ea ad pariatur tempor. Velit veniam aliqua aliquip est enim ex et culpa dolor ullamco culpa officia. Eu id occaecat aute cillum aute sit aute laboris ipsum voluptate ex. Amet tempor minim tempor Lorem quis dolore. Pariatur consequat dolore nulla veniam dolor exercitation consequat nulla laboris incididunt do. Dolore do tempor deserunt exercitation incididunt officia incididunt ut do reprehenderit do eiusmod nulla.','registered':'Sunday, August 6, 2017 12:46 PM','latitude':'-43.257964','longitude':'-45.147686','tags':['et','incididunt','esse','commodo','ipsum'],'greeting':'Hello, Rosanne! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed632b1a1d65501d6b','index':99,'guid':'bf8c6ac1-ee18-48ee-ae94-ea515a53c951','isActive':true,'balance':'$2,905.58','picture':'http://placehold.it/32x32','age':21,'eyeColor':'blue','name':{'first':'Irene','last':'Castro'},'company':'POLARIA','email':'irene.castro@polaria.org','phone':'+1 (818) 417-3761','address':'901 Dupont Street, Sperryville, Oklahoma, 953','about':'Pariatur minim laboris aliqua dolor aliquip consequat ea do duis voluptate id Lorem. In reprehenderit et adipisicing anim elit incididunt velit in laborum laborum. Qui minim magna et amet sit do voluptate reprehenderit ea sit sint velit.','registered':'Tuesday, August 18, 2015 10:48 AM','latitude':'-7.004055','longitude':'116.052433','tags':['sit','proident','enim','ullamco','non'],'greeting':'Hello, Irene! You have 10 unread messages.','favoriteFruit':'apple'}]" + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-no-changes/main.tf b/internal/cloud/testdata/plan-no-changes/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-no-changes/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-no-changes/plan.log b/internal/cloud/testdata/plan-no-changes/plan.log new file mode 100644 index 000000000..704168151 --- /dev/null +++ b/internal/cloud/testdata/plan-no-changes/plan.log @@ -0,0 +1,17 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. diff --git a/internal/cloud/testdata/plan-no-changes/policy.log b/internal/cloud/testdata/plan-no-changes/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/plan-no-changes/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-policy-hard-failed/main.tf b/internal/cloud/testdata/plan-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-policy-hard-failed/plan.log b/internal/cloud/testdata/plan-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-policy-hard-failed/policy.log b/internal/cloud/testdata/plan-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-policy-passed/main.tf b/internal/cloud/testdata/plan-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-policy-passed/plan.log b/internal/cloud/testdata/plan-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-policy-passed/policy.log b/internal/cloud/testdata/plan-policy-passed/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-passed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-policy-soft-failed/main.tf b/internal/cloud/testdata/plan-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-policy-soft-failed/plan.log b/internal/cloud/testdata/plan-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-policy-soft-failed/policy.log b/internal/cloud/testdata/plan-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-variables/main.tf b/internal/cloud/testdata/plan-variables/main.tf new file mode 100644 index 000000000..955e8b4c0 --- /dev/null +++ b/internal/cloud/testdata/plan-variables/main.tf @@ -0,0 +1,4 @@ +variable "foo" {} +variable "bar" {} + +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-variables/plan.log b/internal/cloud/testdata/plan-variables/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-variables/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-with-error/main.tf b/internal/cloud/testdata/plan-with-error/main.tf new file mode 100644 index 000000000..bc45f28f5 --- /dev/null +++ b/internal/cloud/testdata/plan-with-error/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + random = "${guid()}" + } +} diff --git a/internal/cloud/testdata/plan-with-error/plan.log b/internal/cloud/testdata/plan-with-error/plan.log new file mode 100644 index 000000000..4344a3722 --- /dev/null +++ b/internal/cloud/testdata/plan-with-error/plan.log @@ -0,0 +1,10 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... + +Error: null_resource.foo: 1 error(s) occurred: + +* null_resource.foo: 1:3: unknown function called: guid in: + +${guid()} diff --git a/internal/cloud/testdata/plan-with-working-directory/terraform/main.tf b/internal/cloud/testdata/plan-with-working-directory/terraform/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-with-working-directory/terraform/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-with-working-directory/terraform/plan.log b/internal/cloud/testdata/plan-with-working-directory/terraform/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-with-working-directory/terraform/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan/main.tf b/internal/cloud/testdata/plan/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan/plan.log b/internal/cloud/testdata/plan/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/refresh/main.tf b/internal/cloud/testdata/refresh/main.tf new file mode 100644 index 000000000..8d61d5f51 --- /dev/null +++ b/internal/cloud/testdata/refresh/main.tf @@ -0,0 +1,6 @@ +resource "random_pet" "always_new" { + keepers = { + uuid = uuid() # Force a new name each time + } + length = 3 +} diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go new file mode 100644 index 000000000..e7be4748c --- /dev/null +++ b/internal/cloud/testing.go @@ -0,0 +1,341 @@ +package cloud + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path" + "testing" + "time" + + tfe "github.com/hashicorp/go-tfe" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/auth" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/httpclient" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" + "github.com/zclconf/go-cty/cty" + + backendLocal "github.com/hashicorp/terraform/internal/backend/local" +) + +const ( + testCred = "test-auth-token" +) + +var ( + tfeHost = svchost.Hostname(defaultHostname) + credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ + tfeHost: {"token": testCred}, + }) + testBackendSingleWorkspaceName = "app-prod" +) + +// mockInput is a mock implementation of terraform.UIInput. +type mockInput struct { + answers map[string]string +} + +func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { + v, ok := m.answers[opts.Id] + if !ok { + return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) + } + if v == "wait-for-external-update" { + select { + case <-ctx.Done(): + case <-time.After(time.Minute): + } + } + delete(m.answers, opts.Id) + return v, nil +} + +func testInput(t *testing.T, answers map[string]string) *mockInput { + return &mockInput{answers: answers} +} + +func testBackendWithName(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal(testBackendSingleWorkspaceName), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }) + return testBackend(t, obj) +} + +func testBackendWithTags(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }) + return testBackend(t, obj) +} + +func testBackendNoOperations(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("no-operations"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal(testBackendSingleWorkspaceName), + "tags": cty.NullVal(cty.Set(cty.String)), + }), + }) + return testBackend(t, obj) +} + +func testRemoteClient(t *testing.T) remote.Client { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + raw, err := b.StateMgr(testBackendSingleWorkspaceName) + if err != nil { + t.Fatalf("error: %v", err) + } + + return raw.(*remote.State).Client +} + +func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { + s := testServer(t) + b := New(testDisco(s)) + + // Configure the backend so the client is created. + newObj, valDiags := b.PrepareConfig(obj) + if len(valDiags) != 0 { + t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings()) + } + obj = newObj + + confDiags := b.Configure(obj) + if len(confDiags) != 0 { + t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings()) + } + + // Get a new mock client. + mc := NewMockClient() + + // Replace the services we use with our mock services. + b.CLI = cli.NewMockUi() + b.client.Applies = mc.Applies + b.client.ConfigurationVersions = mc.ConfigurationVersions + b.client.CostEstimates = mc.CostEstimates + b.client.Organizations = mc.Organizations + b.client.Plans = mc.Plans + b.client.PolicyChecks = mc.PolicyChecks + b.client.Runs = mc.Runs + b.client.StateVersions = mc.StateVersions + b.client.Variables = mc.Variables + b.client.Workspaces = mc.Workspaces + + // Set local to a local test backend. + b.local = testLocalBackend(t, b) + + ctx := context.Background() + + // Create the organization. + _, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String(b.organization), + }) + if err != nil { + t.Fatalf("error: %v", err) + } + + // Create the default workspace if required. + if b.WorkspaceMapping.Name != "" { + _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.WorkspaceMapping.Name), + }) + if err != nil { + t.Fatalf("error: %v", err) + } + } + + return b, s.Close +} + +func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced { + b := backendLocal.NewWithBackend(cloud) + + // Add a test provider to the local backend. + p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{ + ResourceTypes: map[string]*configschema.Block{ + "null_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }) + p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("yes"), + })} + + return b +} + +// testServer returns a started *httptest.Server used for local testing with the default set of +// request handlers. +func testServer(t *testing.T) *httptest.Server { + return testServerWithHandlers(testDefaultRequestHandlers) +} + +// testServerWithHandlers returns a started *httptest.Server with the given set of request handlers +// overriding any default request handlers (testDefaultRequestHandlers). +func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server { + mux := http.NewServeMux() + for route, handler := range handlers { + mux.HandleFunc(route, handler) + } + for route, handler := range testDefaultRequestHandlers { + if handlers[route] == nil { + mux.HandleFunc(route, handler) + } + } + + return httptest.NewServer(mux) +} + +// testDefaultRequestHandlers is a map of request handlers intended to be used in a request +// multiplexer for a test server. A caller may use testServerWithHandlers to start a server with +// this base set of routes, and override a particular route for whatever edge case is being tested. +var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){ + // Respond to service discovery calls. + "/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{ + "tfe.v2": "/api/v2/", +}`) + }, + + // Respond to service version constraints calls. + "/v1/versions/": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fmt.Sprintf(`{ + "service": "%s", + "product": "terraform", + "minimum": "0.1.0", + "maximum": "10.0.0" +}`, path.Base(r.URL.Path))) + }, + + // Respond to pings to get the API version header. + "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.5") + }, + + // Respond to the initial query to read the hashicorp org entitlements. + "/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + io.WriteString(w, `{ + "data": { + "id": "org-GExadygjSbKP8hsY", + "type": "entitlement-sets", + "attributes": { + "operations": true, + "private-module-registry": true, + "sentinel": true, + "state-storage": true, + "teams": true, + "vcs-integrations": true + } + } +}`) + }, + + // Respond to the initial query to read the no-operations org entitlements. + "/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + io.WriteString(w, `{ + "data": { + "id": "org-ufxa3y8jSbKP8hsT", + "type": "entitlement-sets", + "attributes": { + "operations": false, + "private-module-registry": true, + "sentinel": true, + "state-storage": true, + "teams": true, + "vcs-integrations": true + } + } +}`) + }, + + // All tests that are assumed to pass will use the hashicorp organization, + // so for all other organization requests we will return a 404. + "/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + io.WriteString(w, `{ + "errors": [ + { + "status": "404", + "title": "not found" + } + ] +}`) + }, +} + +// testDisco returns a *disco.Disco mapping app.terraform.io and +// localhost to a local test server. +func testDisco(s *httptest.Server) *disco.Disco { + services := map[string]interface{}{ + "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), + } + d := disco.NewWithCredentialsSource(credsSrc) + d.SetUserAgent(httpclient.TerraformUserAgent(version.String())) + + d.ForceHostServices(svchost.Hostname(defaultHostname), services) + d.ForceHostServices(svchost.Hostname("localhost"), services) + return d +} + +type unparsedVariableValue struct { + value string + source terraform.ValueSourceType +} + +func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.StringVal(v.value), + SourceType: v.source, + }, tfdiags.Diagnostics{} +} + +// testVariable returns a backend.UnparsedVariableValue used for testing. +func testVariables(s terraform.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue { + vars := make(map[string]backend.UnparsedVariableValue, len(vs)) + for _, v := range vs { + vars[v] = &unparsedVariableValue{ + value: v, + source: s, + } + } + return vars +} diff --git a/internal/backend/remote/backend_mock.go b/internal/cloud/tfe_client_mock.go similarity index 70% rename from internal/backend/remote/backend_mock.go rename to internal/cloud/tfe_client_mock.go index abf150f7c..ecc3b9add 100644 --- a/internal/backend/remote/backend_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1,4 +1,4 @@ -package remote +package cloud import ( "bytes" @@ -16,26 +16,25 @@ import ( "time" tfe "github.com/hashicorp/go-tfe" - "github.com/hashicorp/terraform/internal/terraform" tfversion "github.com/hashicorp/terraform/version" "github.com/mitchellh/copystructure" ) -type mockClient struct { - Applies *mockApplies - ConfigurationVersions *mockConfigurationVersions - CostEstimates *mockCostEstimates - Organizations *mockOrganizations - Plans *mockPlans - PolicyChecks *mockPolicyChecks - Runs *mockRuns - StateVersions *mockStateVersions - Variables *mockVariables - Workspaces *mockWorkspaces +type MockClient struct { + Applies *MockApplies + ConfigurationVersions *MockConfigurationVersions + CostEstimates *MockCostEstimates + Organizations *MockOrganizations + Plans *MockPlans + PolicyChecks *MockPolicyChecks + Runs *MockRuns + StateVersions *MockStateVersions + Variables *MockVariables + Workspaces *MockWorkspaces } -func newMockClient() *mockClient { - c := &mockClient{} +func NewMockClient() *MockClient { + c := &MockClient{} c.Applies = newMockApplies(c) c.ConfigurationVersions = newMockConfigurationVersions(c) c.CostEstimates = newMockCostEstimates(c) @@ -49,14 +48,14 @@ func newMockClient() *mockClient { return c } -type mockApplies struct { - client *mockClient +type MockApplies struct { + client *MockClient applies map[string]*tfe.Apply logs map[string]string } -func newMockApplies(client *mockClient) *mockApplies { - return &mockApplies{ +func newMockApplies(client *MockClient) *MockApplies { + return &MockApplies{ client: client, applies: make(map[string]*tfe.Apply), logs: make(map[string]string), @@ -65,7 +64,7 @@ func newMockApplies(client *mockClient) *mockApplies { // create is a helper function to create a mock apply that uses the configured // working directory to find the logfile. -func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { +func (m *MockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { c, ok := m.client.ConfigurationVersions.configVersions[cvID] if !ok { return nil, tfe.ErrResourceNotFound @@ -75,7 +74,7 @@ func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { return nil, nil } - id := generateID("apply-") + id := GenerateID("apply-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) a := &tfe.Apply{ @@ -103,7 +102,7 @@ func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { return a, nil } -func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { +func (m *MockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { a, ok := m.applies[applyID] if !ok { return nil, tfe.ErrResourceNotFound @@ -115,7 +114,7 @@ func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, err return a, nil } -func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { +func (m *MockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { a, err := m.Read(ctx, applyID) if err != nil { return nil, err @@ -152,15 +151,15 @@ func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, erro }, nil } -type mockConfigurationVersions struct { - client *mockClient +type MockConfigurationVersions struct { + client *MockClient configVersions map[string]*tfe.ConfigurationVersion uploadPaths map[string]string uploadURLs map[string]*tfe.ConfigurationVersion } -func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions { - return &mockConfigurationVersions{ +func newMockConfigurationVersions(client *MockClient) *MockConfigurationVersions { + return &MockConfigurationVersions{ client: client, configVersions: make(map[string]*tfe.ConfigurationVersion), uploadPaths: make(map[string]string), @@ -168,7 +167,7 @@ func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions } } -func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { +func (m *MockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { cvl := &tfe.ConfigurationVersionList{} for _, cv := range m.configVersions { cvl.Items = append(cvl.Items, cv) @@ -185,8 +184,8 @@ func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string return cvl, nil } -func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { - id := generateID("cv-") +func (m *MockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { + id := GenerateID("cv-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) cv := &tfe.ConfigurationVersion{ @@ -201,7 +200,7 @@ func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID stri return cv, nil } -func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { +func (m *MockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { cv, ok := m.configVersions[cvID] if !ok { return nil, tfe.ErrResourceNotFound @@ -209,7 +208,15 @@ func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe return cv, nil } -func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string) error { +func (m *MockConfigurationVersions) ReadWithOptions(ctx context.Context, cvID string, options *tfe.ConfigurationVersionReadOptions) (*tfe.ConfigurationVersion, error) { + cv, ok := m.configVersions[cvID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return cv, nil +} + +func (m *MockConfigurationVersions) Upload(ctx context.Context, url, path string) error { cv, ok := m.uploadURLs[url] if !ok { return errors.New("404 not found") @@ -219,24 +226,24 @@ func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string return nil } -type mockCostEstimates struct { - client *mockClient - estimations map[string]*tfe.CostEstimate +type MockCostEstimates struct { + client *MockClient + Estimations map[string]*tfe.CostEstimate logs map[string]string } -func newMockCostEstimates(client *mockClient) *mockCostEstimates { - return &mockCostEstimates{ +func newMockCostEstimates(client *MockClient) *MockCostEstimates { + return &MockCostEstimates{ client: client, - estimations: make(map[string]*tfe.CostEstimate), + Estimations: make(map[string]*tfe.CostEstimate), logs: make(map[string]string), } } // create is a helper function to create a mock cost estimation that uses the // configured working directory to find the logfile. -func (m *mockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, error) { - id := generateID("ce-") +func (m *MockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, error) { + id := GenerateID("ce-") ce := &tfe.CostEstimate{ ID: id, @@ -263,21 +270,21 @@ func (m *mockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, } m.logs[ce.ID] = logfile - m.estimations[ce.ID] = ce + m.Estimations[ce.ID] = ce return ce, nil } -func (m *mockCostEstimates) Read(ctx context.Context, costEstimateID string) (*tfe.CostEstimate, error) { - ce, ok := m.estimations[costEstimateID] +func (m *MockCostEstimates) Read(ctx context.Context, costEstimateID string) (*tfe.CostEstimate, error) { + ce, ok := m.Estimations[costEstimateID] if !ok { return nil, tfe.ErrResourceNotFound } return ce, nil } -func (m *mockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io.Reader, error) { - ce, ok := m.estimations[costEstimateID] +func (m *MockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io.Reader, error) { + ce, ok := m.Estimations[costEstimateID] if !ok { return nil, tfe.ErrResourceNotFound } @@ -301,39 +308,19 @@ func (m *mockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io return bytes.NewBuffer(logs), nil } -// mockInput is a mock implementation of terraform.UIInput. -type mockInput struct { - answers map[string]string -} - -func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { - v, ok := m.answers[opts.Id] - if !ok { - return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) - } - if v == "wait-for-external-update" { - select { - case <-ctx.Done(): - case <-time.After(time.Minute): - } - } - delete(m.answers, opts.Id) - return v, nil -} - -type mockOrganizations struct { - client *mockClient +type MockOrganizations struct { + client *MockClient organizations map[string]*tfe.Organization } -func newMockOrganizations(client *mockClient) *mockOrganizations { - return &mockOrganizations{ +func newMockOrganizations(client *MockClient) *MockOrganizations { + return &MockOrganizations{ client: client, organizations: make(map[string]*tfe.Organization), } } -func (m *mockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { +func (m *MockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { orgl := &tfe.OrganizationList{} for _, org := range m.organizations { orgl.Items = append(orgl.Items, org) @@ -376,13 +363,13 @@ func (m *mockLogReader) read(l []byte) (int, error) { return m.logs.Read(l) } -func (m *mockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { +func (m *MockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { org := &tfe.Organization{Name: *options.Name} m.organizations[org.Name] = org return org, nil } -func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { +func (m *MockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { org, ok := m.organizations[name] if !ok { return nil, tfe.ErrResourceNotFound @@ -390,7 +377,7 @@ func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organiz return org, nil } -func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { +func (m *MockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { org, ok := m.organizations[name] if !ok { return nil, tfe.ErrResourceNotFound @@ -400,14 +387,14 @@ func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe } -func (m *mockOrganizations) Delete(ctx context.Context, name string) error { +func (m *MockOrganizations) Delete(ctx context.Context, name string) error { delete(m.organizations, name) return nil } -func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { +func (m *MockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { var pending, running int - for _, r := range m.client.Runs.runs { + for _, r := range m.client.Runs.Runs { if r.Status == tfe.RunPending { pending++ continue @@ -417,7 +404,7 @@ func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Cap return &tfe.Capacity{Pending: pending, Running: running}, nil } -func (m *mockOrganizations) Entitlements(ctx context.Context, name string) (*tfe.Entitlements, error) { +func (m *MockOrganizations) Entitlements(ctx context.Context, name string) (*tfe.Entitlements, error) { return &tfe.Entitlements{ Operations: true, PrivateModuleRegistry: true, @@ -428,10 +415,10 @@ func (m *mockOrganizations) Entitlements(ctx context.Context, name string) (*tfe }, nil } -func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { +func (m *MockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { rq := &tfe.RunQueue{} - for _, r := range m.client.Runs.runs { + for _, r := range m.client.Runs.Runs { rq.Items = append(rq.Items, r) } @@ -446,15 +433,15 @@ func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options t return rq, nil } -type mockPlans struct { - client *mockClient +type MockPlans struct { + client *MockClient logs map[string]string planOutputs map[string]string plans map[string]*tfe.Plan } -func newMockPlans(client *mockClient) *mockPlans { - return &mockPlans{ +func newMockPlans(client *MockClient) *MockPlans { + return &MockPlans{ client: client, logs: make(map[string]string), planOutputs: make(map[string]string), @@ -464,8 +451,8 @@ func newMockPlans(client *mockClient) *mockPlans { // create is a helper function to create a mock plan that uses the configured // working directory to find the logfile. -func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { - id := generateID("plan-") +func (m *MockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { + id := GenerateID("plan-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) p := &tfe.Plan{ @@ -489,7 +476,7 @@ func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { return p, nil } -func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { +func (m *MockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { p, ok := m.plans[planID] if !ok { return nil, tfe.ErrResourceNotFound @@ -501,7 +488,7 @@ func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) return p, nil } -func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { +func (m *MockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { p, err := m.Read(ctx, planID) if err != nil { return nil, err @@ -538,7 +525,7 @@ func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) }, nil } -func (m *mockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, error) { +func (m *MockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, error) { planOutput, ok := m.planOutputs[planID] if !ok { return nil, tfe.ErrResourceNotFound @@ -547,14 +534,14 @@ func (m *mockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, erro return []byte(planOutput), nil } -type mockPolicyChecks struct { - client *mockClient +type MockPolicyChecks struct { + client *MockClient checks map[string]*tfe.PolicyCheck logs map[string]string } -func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { - return &mockPolicyChecks{ +func newMockPolicyChecks(client *MockClient) *MockPolicyChecks { + return &MockPolicyChecks{ client: client, checks: make(map[string]*tfe.PolicyCheck), logs: make(map[string]string), @@ -563,8 +550,8 @@ func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { // create is a helper function to create a mock policy check that uses the // configured working directory to find the logfile. -func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { - id := generateID("pc-") +func (m *MockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { + id := GenerateID("pc-") pc := &tfe.PolicyCheck{ ID: id, @@ -595,8 +582,8 @@ func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, e return pc, nil } -func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { - _, ok := m.client.Runs.runs[runID] +func (m *MockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { + _, ok := m.client.Runs.Runs[runID] if !ok { return nil, tfe.ErrResourceNotFound } @@ -617,7 +604,7 @@ func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.P return pcl, nil } -func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { +func (m *MockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { pc, ok := m.checks[policyCheckID] if !ok { return nil, tfe.ErrResourceNotFound @@ -657,7 +644,7 @@ func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe return pc, nil } -func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { +func (m *MockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { pc, ok := m.checks[policyCheckID] if !ok { return nil, tfe.ErrResourceNotFound @@ -666,7 +653,7 @@ func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) ( return pc, nil } -func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { +func (m *MockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { pc, ok := m.checks[policyCheckID] if !ok { return nil, tfe.ErrResourceNotFound @@ -706,28 +693,28 @@ func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.R return bytes.NewBuffer(logs), nil } -type mockRuns struct { +type MockRuns struct { sync.Mutex - client *mockClient - runs map[string]*tfe.Run + client *MockClient + Runs map[string]*tfe.Run workspaces map[string][]*tfe.Run - // If modifyNewRun is non-nil, the create method will call it just before + // If ModifyNewRun is non-nil, the create method will call it just before // saving a new run in the runs map, so that a calling test can mimic // side-effects that a real server might apply in certain situations. - modifyNewRun func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) + ModifyNewRun func(client *MockClient, options tfe.RunCreateOptions, run *tfe.Run) } -func newMockRuns(client *mockClient) *mockRuns { - return &mockRuns{ +func newMockRuns(client *MockClient) *MockRuns { + return &MockRuns{ client: client, - runs: make(map[string]*tfe.Run), + Runs: make(map[string]*tfe.Run), workspaces: make(map[string][]*tfe.Run), } } -func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { +func (m *MockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { m.Lock() defer m.Unlock() @@ -756,7 +743,7 @@ func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.Run return rl, nil } -func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { +func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { m.Lock() defer m.Unlock() @@ -781,7 +768,7 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t } r := &tfe.Run{ - ID: generateID("run-"), + ID: GenerateID("run-"), Actions: &tfe.RunActions{IsCancelable: true}, Apply: a, CostEstimate: ce, @@ -821,33 +808,33 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t w.CurrentRun = r } - if m.modifyNewRun != nil { + if m.ModifyNewRun != nil { // caller-provided callback may modify the run in-place to mimic // side-effects that a real server might take in some situations. - m.modifyNewRun(m.client, options, r) + m.ModifyNewRun(m.client, options, r) } - m.runs[r.ID] = r + m.Runs[r.ID] = r m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r) return r, nil } -func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { +func (m *MockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { return m.ReadWithOptions(ctx, runID, nil) } -func (m *mockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) { +func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) { m.Lock() defer m.Unlock() - r, ok := m.runs[runID] + r, ok := m.Runs[runID] if !ok { return nil, tfe.ErrResourceNotFound } pending := false - for _, r := range m.runs { + for _, r := range m.Runs { if r.ID != runID && r.Status == tfe.RunPending { pending = true break @@ -885,11 +872,11 @@ func (m *mockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run return rc.(*tfe.Run), nil } -func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { +func (m *MockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { m.Lock() defer m.Unlock() - r, ok := m.runs[runID] + r, ok := m.Runs[runID] if !ok { return tfe.ErrResourceNotFound } @@ -902,19 +889,19 @@ func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApply return nil } -func (m *mockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { +func (m *MockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { panic("not implemented") } -func (m *mockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { +func (m *MockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { panic("not implemented") } -func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { +func (m *MockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { m.Lock() defer m.Unlock() - r, ok := m.runs[runID] + r, ok := m.Runs[runID] if !ok { return tfe.ErrResourceNotFound } @@ -923,15 +910,15 @@ func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDis return nil } -type mockStateVersions struct { - client *mockClient +type MockStateVersions struct { + client *MockClient states map[string][]byte stateVersions map[string]*tfe.StateVersion workspaces map[string][]string } -func newMockStateVersions(client *mockClient) *mockStateVersions { - return &mockStateVersions{ +func newMockStateVersions(client *MockClient) *MockStateVersions { + return &MockStateVersions{ client: client, states: make(map[string][]byte), stateVersions: make(map[string]*tfe.StateVersion), @@ -939,7 +926,7 @@ func newMockStateVersions(client *mockClient) *mockStateVersions { } } -func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { +func (m *MockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { svl := &tfe.StateVersionList{} for _, sv := range m.stateVersions { svl.Items = append(svl.Items, sv) @@ -956,8 +943,8 @@ func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionLi return svl, nil } -func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { - id := generateID("sv-") +func (m *MockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { + id := GenerateID("sv-") runID := os.Getenv("TFE_RUN_ID") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) @@ -983,11 +970,11 @@ func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, opti return sv, nil } -func (m *mockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { +func (m *MockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { return m.ReadWithOptions(ctx, svID, nil) } -func (m *mockStateVersions) ReadWithOptions(ctx context.Context, svID string, options *tfe.StateVersionReadOptions) (*tfe.StateVersion, error) { +func (m *MockStateVersions) ReadWithOptions(ctx context.Context, svID string, options *tfe.StateVersionReadOptions) (*tfe.StateVersion, error) { sv, ok := m.stateVersions[svID] if !ok { return nil, tfe.ErrResourceNotFound @@ -995,11 +982,11 @@ func (m *mockStateVersions) ReadWithOptions(ctx context.Context, svID string, op return sv, nil } -func (m *mockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { +func (m *MockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { return m.CurrentWithOptions(ctx, workspaceID, nil) } -func (m *mockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID string, options *tfe.StateVersionCurrentOptions) (*tfe.StateVersion, error) { +func (m *MockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID string, options *tfe.StateVersionCurrentOptions) (*tfe.StateVersion, error) { w, ok := m.client.Workspaces.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1018,7 +1005,7 @@ func (m *mockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID return sv, nil } -func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { +func (m *MockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { state, ok := m.states[url] if !ok { return nil, tfe.ErrResourceNotFound @@ -1026,28 +1013,32 @@ func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, e return state, nil } -type mockVariables struct { - client *mockClient +func (m *MockStateVersions) Outputs(ctx context.Context, svID string, options tfe.StateVersionOutputsListOptions) ([]*tfe.StateVersionOutput, error) { + panic("not implemented") +} + +type MockVariables struct { + client *MockClient workspaces map[string]*tfe.VariableList } -var _ tfe.Variables = (*mockVariables)(nil) +var _ tfe.Variables = (*MockVariables)(nil) -func newMockVariables(client *mockClient) *mockVariables { - return &mockVariables{ +func newMockVariables(client *MockClient) *MockVariables { + return &MockVariables{ client: client, workspaces: make(map[string]*tfe.VariableList), } } -func (m *mockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { +func (m *MockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { vl := m.workspaces[workspaceID] return vl, nil } -func (m *mockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { +func (m *MockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { v := &tfe.Variable{ - ID: generateID("var-"), + ID: GenerateID("var-"), Key: *options.Key, Category: *options.Category, } @@ -1073,47 +1064,63 @@ func (m *mockVariables) Create(ctx context.Context, workspaceID string, options return v, nil } -func (m *mockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { +func (m *MockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { panic("not implemented") } -func (m *mockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { +func (m *MockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { panic("not implemented") } -func (m *mockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { +func (m *MockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { panic("not implemented") } -type mockWorkspaces struct { - client *mockClient +type MockWorkspaces struct { + client *MockClient workspaceIDs map[string]*tfe.Workspace workspaceNames map[string]*tfe.Workspace } -func newMockWorkspaces(client *mockClient) *mockWorkspaces { - return &mockWorkspaces{ +func newMockWorkspaces(client *MockClient) *MockWorkspaces { + return &MockWorkspaces{ client: client, workspaceIDs: make(map[string]*tfe.Workspace), workspaceNames: make(map[string]*tfe.Workspace), } } -func (m *mockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { - dummyWorkspaces := 10 +func (m *MockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { wl := &tfe.WorkspaceList{} - // Get the prefix from the search options. - prefix := "" + // Get all the workspaces that match the Search value + searchValue := "" if options.Search != nil { - prefix = *options.Search + searchValue = *options.Search } - // Get all the workspaces that match the prefix. var ws []*tfe.Workspace + var tags []string + + if options.Tags != nil { + tags = strings.Split(*options.Tags, ",") + } for _, w := range m.workspaceIDs { - if strings.HasPrefix(w.Name, prefix) { - ws = append(ws, w) + wTags := make(map[string]struct{}) + for _, wTag := range w.Tags { + wTags[wTag.Name] = struct{}{} + } + + if strings.Contains(w.Name, searchValue) { + tagsSatisfied := true + for _, tag := range tags { + if _, ok := wTags[tag]; !ok { + tagsSatisfied = false + } + } + if tagsSatisfied { + ws = append(ws, w) + } } } @@ -1125,45 +1132,44 @@ func (m *mockWorkspaces) List(ctx context.Context, organization string, options return wl, nil } - // Return dummy workspaces for the first page to test pagination. - if options.PageNumber <= 1 { - for i := 0; i < dummyWorkspaces; i++ { - wl.Items = append(wl.Items, &tfe.Workspace{ - ID: generateID("ws-"), - Name: fmt.Sprintf("dummy-workspace-%d", i), - }) - } + numPages := (len(ws) / 20) + 1 + currentPage := 1 + if options.PageNumber != 0 { + currentPage = options.PageNumber + } + previousPage := currentPage - 1 + nextPage := currentPage + 1 - wl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 2, - TotalPages: 2, - TotalCount: len(wl.Items) + len(ws), + for i := ((currentPage - 1) * 20); i < ((currentPage-1)*20)+20; i++ { + if i > (len(ws) - 1) { + break } - - return wl, nil + wl.Items = append(wl.Items, ws[i]) } - // Return the actual workspaces that matched as the second page. - wl.Items = ws wl.Pagination = &tfe.Pagination{ - CurrentPage: 2, - PreviousPage: 1, - TotalPages: 2, - TotalCount: len(wl.Items) + dummyWorkspaces, + CurrentPage: currentPage, + NextPage: nextPage, + PreviousPage: previousPage, + TotalPages: numPages, + TotalCount: len(wl.Items), } return wl, nil } -func (m *mockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { + // for TestCloud_setUnavailableTerraformVersion + if *options.Name == "unavailable-terraform-version" && options.TerraformVersion != nil { + return nil, fmt.Errorf("requested Terraform version not available in this TFC instance") + } if strings.HasSuffix(*options.Name, "no-operations") { options.Operations = tfe.Bool(false) } else if options.Operations == nil { options.Operations = tfe.Bool(true) } w := &tfe.Workspace{ - ID: generateID("ws-"), + ID: GenerateID("ws-"), Name: *options.Name, Operations: *options.Operations, Permissions: &tfe.WorkspacePermissions{ @@ -1182,13 +1188,19 @@ func (m *mockWorkspaces) Create(ctx context.Context, organization string, option } else { w.TerraformVersion = tfversion.String() } + var tags []*tfe.Tag + for _, tag := range options.Tags { + tags = append(tags, tag) + w.TagNames = append(w.TagNames, tag.Name) + } + w.Tags = tags m.workspaceIDs[w.ID] = w m.workspaceNames[w.Name] = w return w, nil } -func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { - // custom error for TestRemote_plan500 in backend_plan_test.go +func (m *MockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { + // custom error for TestCloud_plan500 in backend_plan_test.go if workspace == "network-error" { return nil, errors.New("I'm a little teacup") } @@ -1200,7 +1212,7 @@ func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace strin return w, nil } -func (m *mockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1208,12 +1220,59 @@ func (m *mockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe return w, nil } -func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) ReadWithOptions(ctx context.Context, organization string, workspace string, options *tfe.WorkspaceReadOptions) (*tfe.Workspace, error) { + panic("not implemented") +} + +func (m *MockWorkspaces) ReadByIDWithOptions(ctx context.Context, workspaceID string, options *tfe.WorkspaceReadOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return w, nil +} + +func (m *MockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { w, ok := m.workspaceNames[workspace] if !ok { return nil, tfe.ErrResourceNotFound } + err := updateMockWorkspaceAttributes(w, options) + if err != nil { + return nil, err + } + + delete(m.workspaceNames, workspace) + m.workspaceNames[w.Name] = w + + return w, nil +} + +func (m *MockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + originalName := w.Name + err := updateMockWorkspaceAttributes(w, options) + if err != nil { + return nil, err + } + + delete(m.workspaceNames, originalName) + m.workspaceNames[w.Name] = w + + return w, nil +} + +func updateMockWorkspaceAttributes(w *tfe.Workspace, options tfe.WorkspaceUpdateOptions) error { + // for TestCloud_setUnavailableTerraformVersion + if w.Name == "unavailable-terraform-version" && options.TerraformVersion != nil { + return fmt.Errorf("requested Terraform version not available in this TFC instance") + } + if options.Operations != nil { w.Operations = *options.Operations } @@ -1226,36 +1285,10 @@ func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace str if options.WorkingDirectory != nil { w.WorkingDirectory = *options.WorkingDirectory } - - delete(m.workspaceNames, workspace) - m.workspaceNames[w.Name] = w - - return w, nil + return nil } -func (m *mockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if options.Name != nil { - w.Name = *options.Name - } - if options.TerraformVersion != nil { - w.TerraformVersion = *options.TerraformVersion - } - if options.WorkingDirectory != nil { - w.WorkingDirectory = *options.WorkingDirectory - } - - delete(m.workspaceNames, w.Name) - m.workspaceNames[w.Name] = w - - return w, nil -} - -func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { +func (m *MockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { if w, ok := m.workspaceNames[workspace]; ok { delete(m.workspaceIDs, w.ID) } @@ -1263,7 +1296,7 @@ func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace str return nil } -func (m *mockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { +func (m *MockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { if w, ok := m.workspaceIDs[workspaceID]; ok { delete(m.workspaceIDs, w.Name) } @@ -1271,7 +1304,7 @@ func (m *mockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) err return nil } -func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { w, ok := m.workspaceNames[workspace] if !ok { return nil, tfe.ErrResourceNotFound @@ -1280,7 +1313,7 @@ func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, return w, nil } -func (m *mockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1289,7 +1322,7 @@ func (m *mockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceI return w, nil } -func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1301,7 +1334,7 @@ func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options t return w, nil } -func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1313,7 +1346,7 @@ func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.W return w, nil } -func (m *mockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1325,37 +1358,49 @@ func (m *mockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (* return w, nil } -func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { panic("not implemented") } -func (m *mockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { panic("not implemented") } -func (m *mockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { +func (m *MockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string, options *tfe.RemoteStateConsumersListOptions) (*tfe.WorkspaceList, error) { panic("not implemented") } -func (m *mockWorkspaces) AddRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceAddRemoteStateConsumersOptions) error { +func (m *MockWorkspaces) AddRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceAddRemoteStateConsumersOptions) error { panic("not implemented") } -func (m *mockWorkspaces) RemoveRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveRemoteStateConsumersOptions) error { +func (m *MockWorkspaces) RemoveRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveRemoteStateConsumersOptions) error { panic("not implemented") } -func (m *mockWorkspaces) UpdateRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateRemoteStateConsumersOptions) error { +func (m *MockWorkspaces) UpdateRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateRemoteStateConsumersOptions) error { panic("not implemented") } -func (m *mockWorkspaces) Readme(ctx context.Context, workspaceID string) (io.Reader, error) { +func (m *MockWorkspaces) Readme(ctx context.Context, workspaceID string) (io.Reader, error) { + panic("not implemented") +} + +func (m *MockWorkspaces) Tags(ctx context.Context, workspaceID string, options tfe.WorkspaceTagListOptions) (*tfe.TagList, error) { + panic("not implemented") +} + +func (m *MockWorkspaces) AddTags(ctx context.Context, workspaceID string, options tfe.WorkspaceAddTagsOptions) error { + panic("not implemented") +} + +func (m *MockWorkspaces) RemoveTags(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveTagsOptions) error { panic("not implemented") } const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" -func generateID(s string) string { +func GenerateID(s string) string { b := make([]byte, 16) for i := range b { b[i] = alphanumeric[rand.Intn(len(alphanumeric))] diff --git a/internal/command/apply.go b/internal/command/apply.go index 9d481c06b..7405bddf0 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -29,8 +29,9 @@ func (c *ApplyCommand) Run(rawArgs []string) int { common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) - // Propagate -no-color for the remote backend's legacy use of Ui. This - // should be removed when the remote backend is migrated to views. + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. c.Meta.color = !common.NoColor c.Meta.Color = c.Meta.color diff --git a/internal/command/console.go b/internal/command/console.go index 319598868..a29007128 100644 --- a/internal/command/console.go +++ b/internal/command/console.go @@ -72,7 +72,7 @@ func (c *ConsoleCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Build the operation opReq := c.Operation(b) diff --git a/internal/command/graph.go b/internal/command/graph.go index 87880a855..4fe742804 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -88,7 +88,7 @@ func (c *GraphCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Build the operation opReq := c.Operation(b) diff --git a/internal/command/import.go b/internal/command/import.go index 7fc61a2f0..a576a29e4 100644 --- a/internal/command/import.go +++ b/internal/command/import.go @@ -204,7 +204,7 @@ func (c *ImportCommand) Run(args []string) int { opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, opReq.Workspace) + remoteVersionDiags := c.remoteVersionCheck(b, opReq.Workspace) diags = diags.Append(remoteVersionDiags) c.showDiagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/init.go b/internal/command/init.go index 8f8c5b829..39a902c96 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/getproviders" @@ -209,8 +210,20 @@ func (c *InitCommand) Run(args []string) int { } var back backend.Backend - if flagBackend { + switch { + case config.Module.CloudConfig != nil: + be, backendOutput, backendDiags := c.initCloud(config.Module) + diags = diags.Append(backendDiags) + if backendDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + if backendOutput { + header = true + } + back = be + case flagBackend: be, backendOutput, backendDiags := c.initBackend(config.Module, flagConfigExtra) diags = diags.Append(backendDiags) if backendDiags.HasErrors() { @@ -221,7 +234,7 @@ func (c *InitCommand) Run(args []string) int { header = true } back = be - } else { + default: // load the previously-stored backend config be, backendDiags := c.Meta.backendFromState() diags = diags.Append(backendDiags) @@ -251,7 +264,7 @@ func (c *InitCommand) Run(args []string) int { // on a previous run) we'll use the current state as a potential source // of provider dependencies. if back != nil { - c.ignoreRemoteBackendVersionConflict(back) + c.ignoreRemoteVersionConflict(back) workspace, err := c.Workspace() if err != nil { c.Ui.Error(fmt.Sprintf("Error selecting workspace: %s", err)) @@ -292,12 +305,23 @@ func (c *InitCommand) Run(args []string) int { // by errors then we'll output them here so that the success message is // still the final thing shown. c.showDiagnostics(diags) - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitSuccess))) + _, cloud := back.(*cloud.Cloud) + output := outputInitSuccess + if cloud { + output = outputInitSuccessCloud + } + + c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) + if !c.RunningInAutomation { // If we're not running in an automation wrapper, give the user // some more detailed next steps that are appropriate for interactive // shell usage. - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitSuccessCLI))) + output = outputInitSuccessCLI + if cloud { + output = outputInitSuccessCLICloud + } + c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) } return 0 } @@ -337,6 +361,21 @@ func (c *InitCommand) getModules(path string, earlyRoot *tfconfig.Module, upgrad return true, diags } +func (c *InitCommand) initCloud(root *configs.Module) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { + c.Ui.Output(c.Colorize().Color("\n[reset][bold]Initializing Terraform Cloud...")) + + backendConfig := root.CloudConfig.ToBackendConfig() + + opts := &BackendOpts{ + Config: &backendConfig, + Init: true, + } + + back, backDiags := c.Backend(opts) + diags = diags.Append(backDiags) + return back, true, diags +} + func (c *InitCommand) initBackend(root *configs.Module, extraConfig rawFlags) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { c.Ui.Output(c.Colorize().Color("\n[reset][bold]Initializing the backend...")) @@ -344,6 +383,16 @@ func (c *InitCommand) initBackend(root *configs.Module, extraConfig rawFlags) (b var backendConfigOverride hcl.Body if root.Backend != nil { backendType := root.Backend.Type + if backendType == "cloud" { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported backend type", + Detail: fmt.Sprintf("There is no explicit backend type named %q. To configure Terraform Cloud, declare a 'cloud' block instead.", backendType), + Subject: &root.Backend.TypeRange, + }) + return nil, true, diags + } + bf := backendInit.Backend(backendType) if bf == nil { diags = diags.Append(&hcl.Diagnostic{ @@ -1050,6 +1099,10 @@ const outputInitSuccess = ` [reset][bold][green]Terraform has been successfully initialized![reset][green] ` +const outputInitSuccessCloud = ` +[reset][bold][green]Terraform Cloud has been successfully initialized![reset][green] +` + const outputInitSuccessCLI = `[reset][green] You may now begin working with Terraform. Try running "terraform plan" to see any changes that are required for your infrastructure. All Terraform commands @@ -1060,6 +1113,14 @@ rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. ` +const outputInitSuccessCLICloud = `[reset][green] +You may now begin working with Terraform Cloud. Try running "terraform plan" to +see any changes that are required for your infrastructure. + +If you ever set or change modules or Terraform Settings, run "terraform init" +again to reinitialize your working directory. +` + // providerProtocolTooOld is a message sent to the CLI UI if the provider's // supported protocol versions are too old for the user's version of terraform, // but a newer version of the provider is compatible. diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 042e7cfff..4167964a9 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/terraform/internal/backend" - remoteBackend "github.com/hashicorp/terraform/internal/backend/remote" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -55,6 +55,13 @@ type BackendOpts struct { ForceLocal bool } +// BackendWithRemoteTerraformVersion is a shared interface between the 'remote' and 'cloud' backends +// for simplified type checking when calling functions common to those particular backends. +type BackendWithRemoteTerraformVersion interface { + IgnoreVersionConflict() + VerifyWorkspaceTerraformVersion(workspace string) tfdiags.Diagnostics +} + // Backend initializes and returns the backend for this CLI session. // // The backend is used to perform the actual Terraform operations. This @@ -589,12 +596,21 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di case c != nil && s.Backend.Empty(): log.Printf("[TRACE] Meta.Backend: moving from default local state only to %q backend", c.Type) if !opts.Init { - initReason := fmt.Sprintf("Initial configuration of the requested backend %q", c.Type) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Backend initialization required, please run \"terraform init\"", - fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), - )) + if c.Type == "cloud" { + initReason := "Initial configuration of Terraform Cloud" + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInitCloud), initReason), + )) + } else { + initReason := fmt.Sprintf("Initial configuration of the requested backend %q", c.Type) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Backend initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), + )) + } return nil, diags } @@ -619,22 +635,39 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di } log.Printf("[TRACE] Meta.Backend: backend configuration has changed (from type %q to type %q)", s.Backend.Type, c.Type) - initReason := fmt.Sprintf("Backend configuration changed for %q", c.Type) - if s.Backend.Type != c.Type { + initReason := "" + switch { + case c.Type == "cloud": + initReason = fmt.Sprintf("Backend configuration changed from %q to Terraform Cloud", s.Backend.Type) + case s.Backend.Type != c.Type: initReason = fmt.Sprintf("Backend configuration changed from %q to %q", s.Backend.Type, c.Type) + default: + initReason = fmt.Sprintf("Backend configuration changed for %q", c.Type) } if !opts.Init { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Backend initialization required, please run \"terraform init\"", - fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), - )) + if c.Type == "cloud" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInitCloud), initReason), + )) + } else { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Backend initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), + )) + } return nil, diags } if !m.migrateState { - diags = diags.Append(migrateOrReconfigDiag) + if c.Type == "cloud" { + diags = diags.Append(migrateOrReconfigDiagCloud) + } else { + diags = diags.Append(migrateOrReconfigDiag) + } return nil, diags } @@ -746,7 +779,11 @@ func (m *Meta) backend_c_r_S(c *configs.Backend, cHash int, sMgr *clistate.Local // Get the backend type for output backendType := s.Backend.Type - m.Ui.Output(fmt.Sprintf(strings.TrimSpace(outputBackendMigrateLocal), s.Backend.Type)) + if s.Backend.Type == "cloud" { + m.Ui.Output(strings.TrimSpace(outputBackendMigrateLocalFromCloud)) + } else { + m.Ui.Output(fmt.Sprintf(strings.TrimSpace(outputBackendMigrateLocal), s.Backend.Type)) + } // Grab a purely local backend to get the local state if it exists localB, diags := m.Backend(&BackendOpts{ForceLocal: true, Init: true}) @@ -915,9 +952,12 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *clistate.Local return nil, diags } - // By now the backend is successfully configured. - m.Ui.Output(m.Colorize().Color(fmt.Sprintf( - "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + // By now the backend is successfully configured. If using Terraform Cloud, the success + // message is handled as part of the final init message + if _, ok := b.(*cloud.Cloud); !ok { + m.Ui.Output(m.Colorize().Color(fmt.Sprintf( + "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + } return b, diags } @@ -942,7 +982,11 @@ func (m *Meta) backend_C_r_S_changed(c *configs.Backend, cHash int, sMgr *clista // no need to confuse the user if the backend types are the same if s.Backend.Type != c.Type { - m.Ui.Output(strings.TrimSpace(fmt.Sprintf(outputBackendMigrateChange, s.Backend.Type, c.Type))) + output := fmt.Sprintf(outputBackendMigrateChange, s.Backend.Type, c.Type) + if c.Type == "cloud" { + output = fmt.Sprintf(outputBackendMigrateChangeCloud, s.Backend.Type) + } + m.Ui.Output(strings.TrimSpace(output)) } // Grab the existing backend @@ -1001,8 +1045,12 @@ func (m *Meta) backend_C_r_S_changed(c *configs.Backend, cHash int, sMgr *clista } if output { - m.Ui.Output(m.Colorize().Color(fmt.Sprintf( - "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + // By now the backend is successfully configured. If using Terraform Cloud, the success + // message is handled as part of the final init message + if _, ok := b.(*cloud.Cloud); !ok { + m.Ui.Output(m.Colorize().Color(fmt.Sprintf( + "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + } } return b, diags @@ -1168,32 +1216,32 @@ func (m *Meta) backendInitFromConfig(c *configs.Backend) (backend.Backend, cty.V return b, configVal, diags } -// Helper method to ignore remote backend version conflicts. Only call this +// Helper method to ignore remote/cloud backend version conflicts. Only call this // for commands which cannot accidentally upgrade remote state files. -func (m *Meta) ignoreRemoteBackendVersionConflict(b backend.Backend) { - if rb, ok := b.(*remoteBackend.Remote); ok { - rb.IgnoreVersionConflict() +func (m *Meta) ignoreRemoteVersionConflict(b backend.Backend) { + if back, ok := b.(BackendWithRemoteTerraformVersion); ok { + back.IgnoreVersionConflict() } } // Helper method to check the local Terraform version against the configured // version in the remote workspace, returning diagnostics if they conflict. -func (m *Meta) remoteBackendVersionCheck(b backend.Backend, workspace string) tfdiags.Diagnostics { +func (m *Meta) remoteVersionCheck(b backend.Backend, workspace string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics - if rb, ok := b.(*remoteBackend.Remote); ok { + if back, ok := b.(BackendWithRemoteTerraformVersion); ok { // Allow user override based on command-line flag if m.ignoreRemoteVersion { - rb.IgnoreVersionConflict() + back.IgnoreVersionConflict() } // If the override is set, this check will return a warning instead of // an error - versionDiags := rb.VerifyWorkspaceTerraformVersion(workspace) + versionDiags := back.VerifyWorkspaceTerraformVersion(workspace) diags = diags.Append(versionDiags) // If there are no errors resulting from this check, we do not need to // check again if !diags.HasErrors() { - rb.IgnoreVersionConflict() + back.IgnoreVersionConflict() } } @@ -1283,6 +1331,19 @@ hasn't changed and try again. At this point, no changes to your existing configuration or state have been made. ` +const errBackendInitCloud = ` +Reason: %s + +Changes to the Terraform Cloud configuration block require reinitialization. +This allows Terraform to set up the new configuration, copy existing state, etc. +Please run "terraform init" with either the "-reconfigure" or "-migrate-state" +flags to use the current configuration. + +If the change reason above is incorrect, please verify your configuration +hasn't changed and try again. At this point, no changes to your existing +configuration or state have been made. +` + const errBackendWriteSaved = ` Error saving the backend configuration: %s @@ -1296,9 +1357,16 @@ const outputBackendMigrateChange = ` Terraform detected that the backend type changed from %q to %q. ` +const outputBackendMigrateChangeCloud = ` +Terraform detected that the backend type changed from %q to Terraform Cloud. +` + const outputBackendMigrateLocal = ` Terraform has detected you're unconfiguring your previously set %q backend. ` +const outputBackendMigrateLocalFromCloud = ` +Terraform has detected you're unconfiguring Terraform Cloud. +` const outputBackendReconfigure = ` [reset][bold]Backend configuration changed![reset] @@ -1322,3 +1390,10 @@ var migrateOrReconfigDiag = tfdiags.Sourceless( "A change in the backend configuration has been detected, which may require migrating existing state.\n\n"+ "If you wish to attempt automatic migration of the state, use \"terraform init -migrate-state\".\n"+ `If you wish to store the current configuration with no changes to the state, use "terraform init -reconfigure".`) + +var migrateOrReconfigDiagCloud = tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud configuration changed", + "A change in the Terraform Cloud configuration has been detected, which may require migrating existing state.\n\n"+ + "If you wish to attempt automatic migration of the state, use \"terraform init -migrate-state\".\n"+ + `If you wish to store the current configuration with no changes to the state, use "terraform init -reconfigure".`) diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 15206bf8a..449c8303f 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -43,28 +44,21 @@ type backendMigrateOpts struct { // // This will attempt to lock both states for the migration. func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { - log.Printf("[TRACE] backendMigrateState: need to migrate from %q to %q backend config", opts.SourceType, opts.DestinationType) + log.Printf("[INFO] backendMigrateState: need to migrate from %q to %q backend config", opts.SourceType, opts.DestinationType) // We need to check what the named state status is. If we're converting // from multi-state to single-state for example, we need to handle that. - var sourceSingleState, destinationSingleState bool - sourceWorkspaces, err := opts.Source.Workspaces() - if err == backend.ErrWorkspacesNotSupported { - sourceSingleState = true - err = nil - } - if err != nil { - return fmt.Errorf(strings.TrimSpace( - errMigrateLoadStates), opts.SourceType, err) - } + var sourceSingleState, destinationSingleState, sourceTFC, destinationTFC bool - destinationWorkspaces, err := opts.Destination.Workspaces() - if err == backend.ErrWorkspacesNotSupported { - destinationSingleState = true - err = nil - } + _, sourceTFC = opts.Source.(*cloud.Cloud) + _, destinationTFC = opts.Destination.(*cloud.Cloud) + + sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType) if err != nil { - return fmt.Errorf(strings.TrimSpace( - errMigrateLoadStates), opts.DestinationType, err) + return err + } + destinationWorkspaces, destinationSingleState, err := retrieveWorkspaces(opts.Destination, opts.SourceType) + if err != nil { + return err } // Set up defaults @@ -75,25 +69,27 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // Disregard remote Terraform version for the state source backend. If it's a // Terraform Cloud remote backend, we don't care about the remote version, // as we are migrating away and will not break a remote workspace. - m.ignoreRemoteBackendVersionConflict(opts.Source) + m.ignoreRemoteVersionConflict(opts.Source) // Disregard remote Terraform version if instructed to do so via CLI flag. if m.ignoreRemoteVersion { - m.ignoreRemoteBackendVersionConflict(opts.Destination) + m.ignoreRemoteVersionConflict(opts.Destination) } else { // Check the remote Terraform version for the state destination backend. If // it's a Terraform Cloud remote backend, we want to ensure that we don't // break the workspace by uploading an incompatible state file. for _, workspace := range destinationWorkspaces { - diags := m.remoteBackendVersionCheck(opts.Destination, workspace) + diags := m.remoteVersionCheck(opts.Destination, workspace) if diags.HasErrors() { return diags.Err() } } // If there are no specified destination workspaces, perform a remote // backend version check with the default workspace. - if len(destinationWorkspaces) == 0 { - diags := m.remoteBackendVersionCheck(opts.Destination, backend.DefaultStateName) + // Ensure that we are not dealing with Terraform Cloud migrations, as it + // does not support the default name. + if len(destinationWorkspaces) == 0 && !destinationTFC { + diags := m.remoteVersionCheck(opts.Destination, backend.DefaultStateName) if diags.HasErrors() { return diags.Err() } @@ -103,6 +99,9 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // Determine migration behavior based on whether the source/destination // supports multi-state. switch { + case sourceTFC || destinationTFC: + return m.backendMigrateTFC(opts) + // Single-state to single-state. This is the easiest case: we just // copy the default state directly. case sourceSingleState && destinationSingleState: @@ -157,7 +156,7 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // Multi-state to multi-state. func (m *Meta) backendMigrateState_S_S(opts *backendMigrateOpts) error { - log.Print("[TRACE] backendMigrateState: migrating all named workspaces") + log.Print("[INFO] backendMigrateState: migrating all named workspaces") migrate := opts.force if !migrate { @@ -212,9 +211,9 @@ func (m *Meta) backendMigrateState_S_S(opts *backendMigrateOpts) error { // Multi-state to single state. func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { - log.Printf("[TRACE] backendMigrateState: destination backend type %q does not support named workspaces", opts.DestinationType) + log.Printf("[INFO] backendMigrateState: destination backend type %q does not support named workspaces", opts.DestinationType) - currentEnv, err := m.Workspace() + currentWorkspace, err := m.Workspace() if err != nil { return err } @@ -231,7 +230,7 @@ func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { opts.DestinationType), Description: fmt.Sprintf( strings.TrimSpace(inputBackendMigrateMultiToSingle), - opts.SourceType, opts.DestinationType, currentEnv), + opts.SourceType, opts.DestinationType, currentWorkspace), }) if err != nil { return fmt.Errorf( @@ -244,7 +243,7 @@ func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { } // Copy the default state - opts.sourceWorkspace = currentEnv + opts.sourceWorkspace = currentWorkspace // now switch back to the default env so we can acccess the new backend m.SetWorkspace(backend.DefaultStateName) @@ -254,7 +253,7 @@ func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { // Single state to single state, assumed default state name. func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { - log.Printf("[TRACE] backendMigrateState: migrating %q workspace to %q workspace", opts.sourceWorkspace, opts.destinationWorkspace) + log.Printf("[INFO] backendMigrateState: single-to-single migrating %q workspace to %q workspace", opts.sourceWorkspace, opts.destinationWorkspace) sourceState, err := opts.Source.StateMgr(opts.sourceWorkspace) if err != nil { @@ -278,16 +277,9 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { // for a new name and migrate the default state to the given named state. destinationState, err = func() (statemgr.Full, error) { log.Print("[TRACE] backendMigrateState: destination doesn't support a default workspace, so we must prompt for a new name") - name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ - Id: "new-state-name", - Query: fmt.Sprintf( - "[reset][bold][yellow]The %q backend configuration only allows "+ - "named workspaces![reset]", - opts.DestinationType), - Description: strings.TrimSpace(inputBackendNewWorkspaceName), - }) + name, err := m.promptNewWorkspaceName(opts.DestinationType) if err != nil { - return nil, fmt.Errorf("Error asking for new state name: %s", err) + return nil, err } // Update the name of the destination state. @@ -444,12 +436,21 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { } func (m *Meta) backendMigrateEmptyConfirm(source, destination statemgr.Full, opts *backendMigrateOpts) (bool, error) { - inputOpts := &terraform.InputOpts{ - Id: "backend-migrate-copy-to-empty", - Query: "Do you want to copy existing state to the new backend?", - Description: fmt.Sprintf( - strings.TrimSpace(inputBackendMigrateEmpty), - opts.SourceType, opts.DestinationType), + var inputOpts *terraform.InputOpts + if opts.DestinationType == "cloud" { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-copy-to-empty-cloud", + Query: "Do you want to copy existing state to Terraform Cloud?", + Description: fmt.Sprintf(strings.TrimSpace(inputBackendMigrateEmptyCloud), opts.SourceType), + } + } else { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-copy-to-empty", + Query: "Do you want to copy existing state to the new backend?", + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendMigrateEmpty), + opts.SourceType, opts.DestinationType), + } } return m.confirm(inputOpts) @@ -485,18 +486,266 @@ func (m *Meta) backendMigrateNonEmptyConfirm( } // Ask for confirmation - inputOpts := &terraform.InputOpts{ - Id: "backend-migrate-to-backend", - Query: "Do you want to copy existing state to the new backend?", - Description: fmt.Sprintf( - strings.TrimSpace(inputBackendMigrateNonEmpty), - opts.SourceType, opts.DestinationType, sourcePath, destinationPath), + var inputOpts *terraform.InputOpts + if opts.DestinationType == "cloud" { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-to-tfc", + Query: "Do you want to copy existing state to Terraform Cloud?", + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendMigrateNonEmptyCloud), + opts.SourceType, sourcePath, destinationPath), + } + } else { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-to-backend", + Query: "Do you want to copy existing state to the new backend?", + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendMigrateNonEmpty), + opts.SourceType, opts.DestinationType, sourcePath, destinationPath), + } } // Confirm with the user that the copy should occur return m.confirm(inputOpts) } +func retrieveWorkspaces(back backend.Backend, sourceType string) ([]string, bool, error) { + var singleState bool + var err error + workspaces, err := back.Workspaces() + if err == backend.ErrWorkspacesNotSupported { + singleState = true + err = nil + } + if err != nil { + return nil, singleState, fmt.Errorf(strings.TrimSpace( + errMigrateLoadStates), sourceType, err) + } + + return workspaces, singleState, err +} + +func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { + _, sourceTFC := opts.Source.(*cloud.Cloud) + cloudBackendDestination, destinationTFC := opts.Destination.(*cloud.Cloud) + + sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType) + if err != nil { + return err + } + //to be used below, not yet implamented + // destinationWorkspaces, destinationSingleState + _, _, err = retrieveWorkspaces(opts.Destination, opts.SourceType) + if err != nil { + return err + } + + // from TFC to non-TFC backend + if sourceTFC && !destinationTFC { + // From Terraform Cloud to another backend. This is not yet implemented, and + // we recommend people to use the TFC API. + return fmt.Errorf(strings.TrimSpace(errTFCMigrateNotYetImplemented)) + } + + // Everything below, by the above two conditionals, now assumes that the + // destination is always Terraform Cloud (TFC). + + sourceSingle := sourceSingleState || (len(sourceWorkspaces) == 1) + if sourceSingle { + if cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy { + // If we know the name via WorkspaceNameStrategy, then set the + // destinationWorkspace to the new Name and skip the user prompt. Here the + // destinationWorkspace is not set to `default` thereby we will create it + // in TFC if it does not exist. + opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + } + + currentWorkspace, err := m.Workspace() + if err != nil { + return err + } + opts.sourceWorkspace = currentWorkspace + + log.Printf("[INFO] backendMigrateTFC: single-to-single migration from source %s to destination %q", opts.sourceWorkspace, opts.destinationWorkspace) + // Run normal single-to-single state migration + // This will handle both situations where the new cloud backend + // configuration is using a workspace.name strategy or workspace.tags + // strategy. + return m.backendMigrateState_s_s(opts) + } + + destinationTagsStrategy := cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceTagsStrategy + destinationNameStrategy := cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy + + multiSource := !sourceSingleState && len(sourceWorkspaces) > 1 + if multiSource && destinationNameStrategy { + currentWorkspace, err := m.Workspace() + if err != nil { + return err + } + + opts.sourceWorkspace = currentWorkspace + opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + if err := m.promptMultiToSingleCloudMigration(opts); err != nil { + return err + } + + log.Printf("[INFO] backendMigrateTFC: multi-to-single migration from source %s to destination %q", opts.sourceWorkspace, opts.destinationWorkspace) + + return m.backendMigrateState_s_s(opts) + } + + // Multiple sources, and using tags strategy. So migrate every source + // workspace over to new one, prompt for workspace name pattern (*), + // and start migrating, and create tags for each workspace. + if multiSource && destinationTagsStrategy { + log.Printf("[INFO] backendMigrateTFC: multi-to-multi migration from source workspaces %q", sourceWorkspaces) + return m.backendMigrateState_S_TFC(opts, sourceWorkspaces) + } + + // TODO(omar): after the check for sourceSingle is done, everything following + // it has to be multi. So rework the code to not need to check for multi, adn + // return m.backendMigrateState_S_TFC here. + return nil +} + +// migrates a multi-state backend to Terraform Cloud +func (m *Meta) backendMigrateState_S_TFC(opts *backendMigrateOpts, sourceWorkspaces []string) error { + log.Print("[TRACE] backendMigrateState: migrating all named workspaces") + + // This map is used later when doing the migration per source/destination. + // If a source has 'default', then we ask what the new name should be. + // And further down when we actually run state migration for each + // sourc/destination workspce, we use this new name (where source is 'default') + // and set as destinationWorkspace. + defaultNewName := map[string]string{} + for i := 0; i < len(sourceWorkspaces); i++ { + if sourceWorkspaces[i] == backend.DefaultStateName { + newName, err := m.promptNewWorkspaceName(opts.DestinationType) + if err != nil { + return err + } + defaultNewName[sourceWorkspaces[i]] = newName + } + } + pattern, err := m.promptMultiStateMigrationPattern(opts.SourceType) + if err != nil { + return err + } + + // Go through each and migrate + for _, name := range sourceWorkspaces { + + // Copy the same names + opts.sourceWorkspace = name + if newName, ok := defaultNewName[name]; ok { + // this has to be done before setting destinationWorkspace + name = newName + } + opts.destinationWorkspace = strings.Replace(pattern, "*", name, -1) + + // Force it, we confirmed above + opts.force = true + + // Perform the migration + log.Printf("[INFO] backendMigrateTFC: multi-to-multi migration, source workspace %q to destination workspace %q", opts.sourceWorkspace, opts.destinationWorkspace) + if err := m.backendMigrateState_s_s(opts); err != nil { + return fmt.Errorf(strings.TrimSpace( + errMigrateMulti), name, opts.SourceType, opts.DestinationType, err) + } + } + + // After migrating multiple workspaces, we want to ensure that a workspace is + // set or we prompt the user to set a workspace. + err = m.selectWorkspace(opts.Destination) + if err != nil { + return err + } + + return nil +} + +// Multi-state to single state. +func (m *Meta) promptMultiToSingleCloudMigration(opts *backendMigrateOpts) error { + migrate := opts.force + if !migrate { + var err error + // Ask the user if they want to migrate their existing remote state + migrate, err = m.confirm(&terraform.InputOpts{ + Id: "backend-migrate-multistate-to-single", + Query: "Do you want to copy only your current workspace?", + Description: fmt.Sprintf( + strings.TrimSpace(tfcInputBackendMigrateMultiToSingle), + opts.SourceType, opts.destinationWorkspace), + }) + if err != nil { + return fmt.Errorf("Error asking for state migration action: %s", err) + } + } + + if !migrate { + return fmt.Errorf("Migration aborted by user.") + } + + return nil +} + +func (m *Meta) promptNewWorkspaceName(destinationType string) (string, error) { + message := fmt.Sprintf("[reset][bold][yellow]The %q backend configuration only allows "+ + "named workspaces![reset]", destinationType) + if destinationType == "cloud" { + message = `[reset][bold][yellow]Terraform Cloud requires all workspaces to be given an explicit name.[reset]` + } + name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "new-state-name", + Query: message, + Description: strings.TrimSpace(inputBackendNewWorkspaceName), + }) + if err != nil { + return "", fmt.Errorf("Error asking for new state name: %s", err) + } + + return name, nil +} + +func (m *Meta) promptMultiStateMigrationPattern(sourceType string) (string, error) { + renameWorkspaces, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "backend-migrate-multistate-to-tfc", + Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "Would you like to rename your workspaces?"), + Description: fmt.Sprintf(strings.TrimSpace(tfcInputBackendMigrateMultiToMulti), sourceType), + }) + if err != nil { + return "", fmt.Errorf("Error asking for state migration action: %s", err) + } + if renameWorkspaces != "2" && renameWorkspaces != "1" { + return "", fmt.Errorf("Please select 1 or 2 as part of this option.") + } + if renameWorkspaces == "2" { + // this means they did not want to rename their workspaces, and we are + // returning a generic '*' that means use the same workspace name during + // migration. + return "*", nil + } + + pattern, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "backend-migrate-multistate-to-tfc-pattern", + Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "What pattern would you like to add to all your workspaces?"), + Description: strings.TrimSpace(tfcInputBackendMigrateMultiToMultiPattern), + }) + if err != nil { + return "", fmt.Errorf("Error asking for state migration action: %s", err) + } + if !strings.Contains(pattern, "*") { + return "", fmt.Errorf("The pattern must have an '*'") + } + + if count := strings.Count(pattern, "*"); count > 1 { + return "", fmt.Errorf("The pattern '*' cannot be used more than once.") + } + + return pattern, nil +} + const errMigrateLoadStates = ` Error inspecting states in the %q backend: %s @@ -541,6 +790,46 @@ The state in the previous backend remains intact and unmodified. Please resolve the error above and try again. ` +const errTFCMigrateNotYetImplemented = ` +Migrating state from Terraform Cloud to another backend is not yet implemented. + +Please use the API to do this: https://www.terraform.io/docs/cloud/api/state-versions.html +` + +const tfcInputBackendMigrateMultiToMultiPattern = ` +If you choose to NOT rename your workspaces, just input "*". + +The asterisk "*" represents your workspace name. Here are a few examples +if a workspace was named 'prod': +* input: 'app-*'; output: 'app-prod' +* input: '*-app', output: 'prod-app' +* input: 'app-*-service', output: 'app-prod-service' +* input: '*'; output: 'prod' +` + +const tfcInputBackendMigrateMultiToMulti = ` +When migrating existing workspaces from the backend %[1]q to Terraform Cloud, would you like to +rename your workspaces? + +Unlike typical Terraform workspaces representing an environment associated with a particular +configuration (e.g. production, staging, development), Terraform Cloud workspaces are named uniquely +across all configurations used within an organization. A typical strategy to start with is +-- (e.g. networking-prod-us-east, networking-staging-us-east). + +For more information on workspace naming, see https://www.terraform.io/docs/cloud/workspaces/naming.html + +1. Yes, rename workspaces according to a pattern. +2. No, I would not like to rename my workspaces. Migrate them as currently named. +` + +const tfcInputBackendMigrateMultiToSingle = ` +The previous backend %[1]q has multiple workspaces, but Terraform Cloud has been +configured to use a single workspace (%[2]q). By continuing, you will only +migrate your current workspace. If you wish to migrate all workspaces from the +previous backend, use the 'tags' strategy in your workspace configuration block +instead. +` + const inputBackendMigrateEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. No existing state was found in the newly @@ -548,6 +837,12 @@ configured %[2]q backend. Do you want to copy this state to the new %[2]q backend? Enter "yes" to copy and "no" to start with an empty state. ` +const inputBackendMigrateEmptyCloud = ` +Pre-existing state was found while migrating the previous %q backend to Terraform Cloud. +No existing state was found in Terraform Cloud. Do you want to copy this state to Terraform Cloud? +Enter "yes" to copy and "no" to start with an empty state. +` + const inputBackendMigrateNonEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. An existing non-empty state already exists in @@ -562,6 +857,19 @@ Enter "yes" to copy and "no" to start with the existing state in the newly configured %[2]q backend. ` +const inputBackendMigrateNonEmptyCloud = ` +Pre-existing state was found while migrating the previous %q backend to +Terraform Cloud. An existing non-empty state already exists in Terraform Cloud. +The two states have been saved to temporary files that will be removed after +responding to this query. + +Previous (type %[1]q): %[2]s +New (Terraform Cloud): %[3]s + +Do you want to overwrite the state in Terraform Cloud with the previous state? +Enter "yes" to copy and "no" to start with the existing state in Terraform Cloud. +` + const inputBackendMigrateMultiToSingle = ` The existing %[1]q backend supports workspaces and you currently are using more than one. The newly configured %[2]q backend doesn't support @@ -587,7 +895,7 @@ If you answer "yes", Terraform will migrate all states. If you answer const inputBackendNewWorkspaceName = ` Please provide a new workspace name (e.g. dev, test) that will be used -to migrate the existing default workspace. +to migrate the existing default workspace. ` const inputBackendSelectWorkspace = ` diff --git a/internal/command/meta_backend_migrate_test.go b/internal/command/meta_backend_migrate_test.go new file mode 100644 index 000000000..d45cbdc0d --- /dev/null +++ b/internal/command/meta_backend_migrate_test.go @@ -0,0 +1,62 @@ +package command + +import ( + "fmt" + "testing" +) + +func TestBackendMigrate_promptMultiStatePattern(t *testing.T) { + // Setup the meta + + cases := map[string]struct { + renamePrompt string + patternPrompt string + expectedErr string + }{ + "valid pattern": { + renamePrompt: "1", + patternPrompt: "hello-*", + expectedErr: "", + }, + "invalid pattern, only one asterisk allowed": { + renamePrompt: "1", + patternPrompt: "hello-*-world-*", + expectedErr: "The pattern '*' cannot be used more than once.", + }, + "invalid pattern, missing asterisk": { + renamePrompt: "1", + patternPrompt: "hello-world", + expectedErr: "The pattern must have an '*'", + }, + "invalid rename": { + renamePrompt: "3", + expectedErr: "Please select 1 or 2 as part of this option.", + }, + "no rename": { + renamePrompt: "2", + }, + } + for name, tc := range cases { + fmt.Println("Test: ", name) + m := testMetaBackend(t, nil) + input := map[string]string{} + cleanup := testInputMap(t, input) + if tc.renamePrompt != "" { + input["backend-migrate-multistate-to-tfc"] = tc.renamePrompt + } + if tc.patternPrompt != "" { + input["backend-migrate-multistate-to-tfc-pattern"] = tc.patternPrompt + } + + sourceType := "cloud" + _, err := m.promptMultiStateMigrationPattern(sourceType) + if tc.expectedErr == "" && err != nil { + t.Fatalf("expected error to be nil, but was %s", err.Error()) + } + if tc.expectedErr != "" && tc.expectedErr != err.Error() { + t.Fatalf("expected error to eq %s but got %s", tc.expectedErr, err.Error()) + } + + cleanup() + } +} diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 439df6b91..6913db594 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -136,6 +136,12 @@ func (m *Meta) loadBackendConfig(rootDir string) (*configs.Backend, tfdiags.Diag if diags.HasErrors() { return nil, diags } + + if mod.CloudConfig != nil { + backendConfig := mod.CloudConfig.ToBackendConfig() + return &backendConfig, nil + } + return mod.Backend, nil } diff --git a/internal/command/output.go b/internal/command/output.go index 3594fe33d..0f23a6109 100644 --- a/internal/command/output.go +++ b/internal/command/output.go @@ -67,7 +67,7 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) env, err := c.Workspace() if err != nil { diff --git a/internal/command/plan.go b/internal/command/plan.go index ec90d8371..a5ff74ac1 100644 --- a/internal/command/plan.go +++ b/internal/command/plan.go @@ -21,8 +21,9 @@ func (c *PlanCommand) Run(rawArgs []string) int { common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) - // Propagate -no-color for the remote backend's legacy use of Ui. This - // should be removed when the remote backend is migrated to views. + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. c.Meta.color = !common.NoColor c.Meta.Color = c.Meta.color diff --git a/internal/command/providers.go b/internal/command/providers.go index 31fd79594..5bc0d4e6c 100644 --- a/internal/command/providers.go +++ b/internal/command/providers.go @@ -83,7 +83,7 @@ func (c *ProvidersCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Get the state env, err := c.Workspace() diff --git a/internal/command/providers_schema.go b/internal/command/providers_schema.go index 372564f12..b4d61ec76 100644 --- a/internal/command/providers_schema.go +++ b/internal/command/providers_schema.go @@ -68,7 +68,7 @@ func (c *ProvidersSchemaCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // we expect that the config dir is the cwd cwd, err := os.Getwd() diff --git a/internal/command/refresh.go b/internal/command/refresh.go index 1bb5d3933..18891b414 100644 --- a/internal/command/refresh.go +++ b/internal/command/refresh.go @@ -17,10 +17,18 @@ type RefreshCommand struct { } func (c *RefreshCommand) Run(rawArgs []string) int { + var diags tfdiags.Diagnostics + // Parse and apply global view arguments common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. + c.Meta.color = !common.NoColor + c.Meta.Color = c.Meta.color + // Parse and validate flags args, diags := arguments.ParseRefresh(rawArgs) diff --git a/internal/command/show.go b/internal/command/show.go index 9886768ca..6ae66beeb 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -70,7 +70,7 @@ func (c *ShowCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // the show command expects the config dir to always be the cwd cwd, err := os.Getwd() diff --git a/internal/command/state_list.go b/internal/command/state_list.go index ebd318bc8..54358b28d 100644 --- a/internal/command/state_list.go +++ b/internal/command/state_list.go @@ -41,7 +41,7 @@ func (c *StateListCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Get the state env, err := c.Workspace() diff --git a/internal/command/state_meta.go b/internal/command/state_meta.go index fa04245a6..17959f5ff 100644 --- a/internal/command/state_meta.go +++ b/internal/command/state_meta.go @@ -43,7 +43,7 @@ func (c *StateMeta) State() (statemgr.Full, error) { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) c.showDiagnostics(remoteVersionDiags) if remoteVersionDiags.HasErrors() { return nil, fmt.Errorf("Error checking remote Terraform version") diff --git a/internal/command/state_pull.go b/internal/command/state_pull.go index 0616df2d4..8ce16ff57 100644 --- a/internal/command/state_pull.go +++ b/internal/command/state_pull.go @@ -31,7 +31,7 @@ func (c *StatePullCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Get the state manager for the current workspace env, err := c.Workspace() diff --git a/internal/command/state_push.go b/internal/command/state_push.go index 117611e94..eb0ea1679 100644 --- a/internal/command/state_push.go +++ b/internal/command/state_push.go @@ -80,7 +80,7 @@ func (c *StatePushCommand) Run(args []string) int { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) c.showDiagnostics(remoteVersionDiags) if remoteVersionDiags.HasErrors() { return 1 diff --git a/internal/command/state_show.go b/internal/command/state_show.go index e95eca70f..7ee86624d 100644 --- a/internal/command/state_show.go +++ b/internal/command/state_show.go @@ -54,7 +54,7 @@ func (c *StateShowCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Check if the address can be parsed addr, addrDiags := addrs.ParseAbsResourceInstanceStr(args[0]) diff --git a/internal/command/taint.go b/internal/command/taint.go index 46e92d6d6..f2fbbb1ef 100644 --- a/internal/command/taint.go +++ b/internal/command/taint.go @@ -102,7 +102,7 @@ func (c *TaintCommand) Run(args []string) int { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) diags = diags.Append(remoteVersionDiags) c.showDiagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/untaint.go b/internal/command/untaint.go index 98f203560..ba290a8a4 100644 --- a/internal/command/untaint.go +++ b/internal/command/untaint.go @@ -67,7 +67,7 @@ func (c *UntaintCommand) Run(args []string) int { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) diags = diags.Append(remoteVersionDiags) c.showDiagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/workspace_delete.go b/internal/command/workspace_delete.go index 5c826d908..654aac581 100644 --- a/internal/command/workspace_delete.go +++ b/internal/command/workspace_delete.go @@ -68,7 +68,7 @@ func (c *WorkspaceDeleteCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) workspaces, err := b.Workspaces() if err != nil { diff --git a/internal/command/workspace_list.go b/internal/command/workspace_list.go index aac6bc97d..7b43bc346 100644 --- a/internal/command/workspace_list.go +++ b/internal/command/workspace_list.go @@ -52,7 +52,7 @@ func (c *WorkspaceListCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) states, err := b.Workspaces() if err != nil { diff --git a/internal/command/workspace_new.go b/internal/command/workspace_new.go index 1d7b2898c..41e657bef 100644 --- a/internal/command/workspace_new.go +++ b/internal/command/workspace_new.go @@ -83,7 +83,7 @@ func (c *WorkspaceNewCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) workspaces, err := b.Workspaces() if err != nil { diff --git a/internal/command/workspace_select.go b/internal/command/workspace_select.go index 645a9c2bc..1f98ec55e 100644 --- a/internal/command/workspace_select.go +++ b/internal/command/workspace_select.go @@ -68,7 +68,7 @@ func (c *WorkspaceSelectCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) name := args[0] if !validWorkspaceName(name) { diff --git a/internal/configs/cloud.go b/internal/configs/cloud.go new file mode 100644 index 000000000..1ed6482e1 --- /dev/null +++ b/internal/configs/cloud.go @@ -0,0 +1,27 @@ +package configs + +import ( + "github.com/hashicorp/hcl/v2" +) + +// Cloud represents a "cloud" block inside a "terraform" block in a module +// or file. +type CloudConfig struct { + Config hcl.Body + + DeclRange hcl.Range +} + +func decodeCloudBlock(block *hcl.Block) (*CloudConfig, hcl.Diagnostics) { + return &CloudConfig{ + Config: block.Body, + DeclRange: block.DefRange, + }, nil +} + +func (c *CloudConfig) ToBackendConfig() Backend { + return Backend{ + Type: "cloud", + Config: c.Config, + } +} diff --git a/internal/configs/module.go b/internal/configs/module.go index 18676de9d..c2088b9fd 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -29,6 +29,7 @@ type Module struct { ActiveExperiments experiments.Set Backend *Backend + CloudConfig *CloudConfig ProviderConfigs map[string]*Provider ProviderRequirements *RequiredProviders ProviderLocalNames map[addrs.Provider]string @@ -63,6 +64,7 @@ type File struct { ActiveExperiments experiments.Set Backends []*Backend + CloudConfigs []*CloudConfig ProviderConfigs []*Provider ProviderMetas []*ProviderMeta RequiredProviders []*RequiredProviders @@ -190,6 +192,29 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics { m.Backend = b } + for _, c := range file.CloudConfigs { + if m.CloudConfig != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate Terraform Cloud configurations", + Detail: fmt.Sprintf("A module may have only one 'cloud' block configuring Terraform Cloud. Terraform Cloud was previously configured at %s.", m.CloudConfig.DeclRange), + Subject: &c.DeclRange, + }) + continue + } + + m.CloudConfig = c + } + + if m.Backend != nil && m.CloudConfig != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Both a backend and Terraform Cloud configuration are present", + Detail: fmt.Sprintf("A module may declare either one 'cloud' block configuring Terraform Cloud OR one 'backend' block configuring a state backend. Terraform Cloud is configured at %s; a backend is configured at %s. Remove the backend block to configure Terraform Cloud.", m.CloudConfig.DeclRange, m.Backend.DeclRange), + Subject: &m.Backend.DeclRange, + }) + } + for _, pc := range file.ProviderConfigs { key := pc.moduleUniqueKey() if existing, exists := m.ProviderConfigs[key]; exists { @@ -354,6 +379,7 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { if len(file.Backends) != 0 { switch len(file.Backends) { case 1: + m.CloudConfig = nil // A backend block is mutually exclusive with a cloud one, and overwrites any cloud config m.Backend = file.Backends[0] default: // An override file with multiple backends is still invalid, even @@ -367,6 +393,23 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { } } + if len(file.CloudConfigs) != 0 { + switch len(file.CloudConfigs) { + case 1: + m.Backend = nil // A cloud block is mutually exclusive with a backend one, and overwrites any backend + m.CloudConfig = file.CloudConfigs[0] + default: + // An override file with multiple cloud blocks is still invalid, even + // though it can override cloud/backend blocks from _other_ files. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate Terraform Cloud configurations", + Detail: fmt.Sprintf("A module may have only one 'cloud' block configuring Terraform Cloud. Terraform Cloud was previously configured at %s.", file.CloudConfigs[0].DeclRange), + Subject: &file.CloudConfigs[1].DeclRange, + }) + } + } + for _, pc := range file.ProviderConfigs { key := pc.moduleUniqueKey() existing, exists := m.ProviderConfigs[key] diff --git a/internal/configs/module_test.go b/internal/configs/module_test.go index 394eaff1c..3eea93d37 100644 --- a/internal/configs/module_test.go +++ b/internal/configs/module_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/hashicorp/terraform/internal/addrs" + "github.com/zclconf/go-cty/cty" ) // TestNewModule_provider_fqns exercises module.gatherProviderLocalNames() @@ -309,3 +310,105 @@ func TestImpliedProviderForUnqualifiedType(t *testing.T) { } } } + +func TestModule_backend_override(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-backend") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + gotType := mod.Backend.Type + wantType := "bar" + + if gotType != wantType { + t.Errorf("wrong result for backend type: got %#v, want %#v\n", gotType, wantType) + } + + attrs, _ := mod.Backend.Config.JustAttributes() + + gotAttr, diags := attrs["path"].Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + wantAttr := cty.StringVal("CHANGED/relative/path/to/terraform.tfstate") + + if !gotAttr.RawEquals(wantAttr) { + t.Errorf("wrong result for backend 'path': got %#v, want %#v\n", gotAttr, wantAttr) + } +} + +// Unlike most other overrides, backend blocks do not require a base configuration in a primary +// configuration file, as an omitted backend there implies the local backend. +func TestModule_backend_override_no_base(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-backend-no-base") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if mod.Backend == nil { + t.Errorf("expected module Backend not to be nil") + } +} + +func TestModule_cloud_override_backend(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-backend-with-cloud") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if mod.Backend != nil { + t.Errorf("expected module Backend to be nil") + } + + if mod.CloudConfig == nil { + t.Errorf("expected module CloudConfig not to be nil") + } +} + +// Unlike most other overrides, cloud blocks do not require a base configuration in a primary +// configuration file, as an omitted backend there implies the local backend and cloud blocks +// override backends. +func TestModule_cloud_override_no_base(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-cloud-no-base") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if mod.CloudConfig == nil { + t.Errorf("expected module CloudConfig not to be nil") + } +} + +func TestModule_cloud_override(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-cloud") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + attrs, _ := mod.CloudConfig.Config.JustAttributes() + + gotAttr, diags := attrs["organization"].Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + wantAttr := cty.StringVal("CHANGED") + + if !gotAttr.RawEquals(wantAttr) { + t.Errorf("wrong result for Cloud 'organization': got %#v, want %#v\n", gotAttr, wantAttr) + } + + // The override should have completely replaced the cloud block in the primary file, no merging + if attrs["should_not_be_present_with_override"] != nil { + t.Errorf("expected 'should_not_be_present_with_override' attribute to be nil") + } +} + +func TestModule_cloud_duplicate_overrides(t *testing.T) { + _, diags := testModuleFromDir("testdata/invalid-modules/override-cloud-duplicates") + want := `Duplicate Terraform Cloud configurations` + if got := diags.Error(); !strings.Contains(got, want) { + t.Fatalf("expected module error to contain %q\nerror was:\n%s", want, got) + } +} diff --git a/internal/configs/parser_config.go b/internal/configs/parser_config.go index 4be14501d..caebb8911 100644 --- a/internal/configs/parser_config.go +++ b/internal/configs/parser_config.go @@ -72,6 +72,13 @@ func (p *Parser) loadConfigFile(path string, override bool) (*File, hcl.Diagnost file.Backends = append(file.Backends, backendCfg) } + case "cloud": + cloudCfg, cfgDiags := decodeCloudBlock(innerBlock) + diags = append(diags, cfgDiags...) + if cloudCfg != nil { + file.CloudConfigs = append(file.CloudConfigs, cloudCfg) + } + case "required_providers": reqs, reqsDiags := decodeRequiredProvidersBlock(innerBlock) diags = append(diags, reqsDiags...) @@ -261,6 +268,9 @@ var terraformBlockSchema = &hcl.BodySchema{ Type: "backend", LabelNames: []string{"type"}, }, + { + Type: "cloud", + }, { Type: "required_providers", }, diff --git a/internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf new file mode 100644 index 000000000..2de9a58dd --- /dev/null +++ b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf @@ -0,0 +1,14 @@ +terraform { + cloud { + organization = "foo" + should_not_be_present_with_override = true + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf new file mode 100644 index 000000000..17ef01150 --- /dev/null +++ b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf @@ -0,0 +1,11 @@ +terraform { + cloud { + organization = "foo" + } +} + +terraform { + cloud { + organization = "bar" + } +} diff --git a/internal/configs/testdata/nested-cloud-warning/child/child.tf b/internal/configs/testdata/nested-cloud-warning/child/child.tf new file mode 100644 index 000000000..540b92170 --- /dev/null +++ b/internal/configs/testdata/nested-cloud-warning/child/child.tf @@ -0,0 +1,6 @@ +terraform { + # Only the root module can declare a Cloud configuration. Terraform should emit a warning + # about this child module Cloud declaration. + cloud { + } +} diff --git a/internal/configs/testdata/nested-cloud-warning/root.tf b/internal/configs/testdata/nested-cloud-warning/root.tf new file mode 100644 index 000000000..1f95749fa --- /dev/null +++ b/internal/configs/testdata/nested-cloud-warning/root.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} diff --git a/internal/configs/testdata/valid-files/cloud.tf b/internal/configs/testdata/valid-files/cloud.tf new file mode 100644 index 000000000..91985fcad --- /dev/null +++ b/internal/configs/testdata/valid-files/cloud.tf @@ -0,0 +1,10 @@ + +terraform { + cloud { + foo = "bar" + + baz { + bar = "foo" + } + } +} diff --git a/internal/configs/testdata/valid-modules/override-backend-no-base/main.tf b/internal/configs/testdata/valid-modules/override-backend-no-base/main.tf new file mode 100644 index 000000000..7bb1380e6 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-no-base/main.tf @@ -0,0 +1,7 @@ +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-backend-no-base/override.tf b/internal/configs/testdata/valid-modules/override-backend-no-base/override.tf new file mode 100644 index 000000000..d57fade63 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-no-base/override.tf @@ -0,0 +1,5 @@ +terraform { + backend "bar" { + path = "CHANGED/relative/path/to/terraform.tfstate" + } +} diff --git a/internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf b/internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf new file mode 100644 index 000000000..56fb72f32 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf @@ -0,0 +1,13 @@ +terraform { + backend "foo" { + path = "relative/path/to/terraform.tfstate" + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf b/internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf new file mode 100644 index 000000000..51ae925fb --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf @@ -0,0 +1,5 @@ +terraform { + cloud { + organization = "foo" + } +} diff --git a/internal/configs/testdata/valid-modules/override-backend/main.tf b/internal/configs/testdata/valid-modules/override-backend/main.tf new file mode 100644 index 000000000..56fb72f32 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend/main.tf @@ -0,0 +1,13 @@ +terraform { + backend "foo" { + path = "relative/path/to/terraform.tfstate" + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-backend/override.tf b/internal/configs/testdata/valid-modules/override-backend/override.tf new file mode 100644 index 000000000..d57fade63 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend/override.tf @@ -0,0 +1,5 @@ +terraform { + backend "bar" { + path = "CHANGED/relative/path/to/terraform.tfstate" + } +} diff --git a/internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf b/internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf new file mode 100644 index 000000000..7bb1380e6 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf @@ -0,0 +1,7 @@ +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf b/internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf new file mode 100644 index 000000000..51ae925fb --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf @@ -0,0 +1,5 @@ +terraform { + cloud { + organization = "foo" + } +} diff --git a/internal/configs/testdata/valid-modules/override-cloud/main.tf b/internal/configs/testdata/valid-modules/override-cloud/main.tf new file mode 100644 index 000000000..2de9a58dd --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud/main.tf @@ -0,0 +1,14 @@ +terraform { + cloud { + organization = "foo" + should_not_be_present_with_override = true + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-cloud/override.tf b/internal/configs/testdata/valid-modules/override-cloud/override.tf new file mode 100644 index 000000000..a4a7752ca --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud/override.tf @@ -0,0 +1,5 @@ +terraform { + cloud { + organization = "CHANGED" + } +} diff --git a/internal/plugin/grpc_provider_test.go b/internal/plugin/grpc_provider_test.go index a78c6d318..fc6216cc0 100644 --- a/internal/plugin/grpc_provider_test.go +++ b/internal/plugin/grpc_provider_test.go @@ -228,7 +228,8 @@ func TestGRPCProvider_Configure(t *testing.T) { } func TestGRPCProvider_Stop(t *testing.T) { - client := mockProviderClient(t) + ctrl := gomock.NewController(t) + client := mockproto.NewMockProviderClient(ctrl) p := &GRPCProvider{ client: client, } diff --git a/internal/plugin/grpc_provisioner_test.go b/internal/plugin/grpc_provisioner_test.go index ab775cdbd..848c9460f 100644 --- a/internal/plugin/grpc_provisioner_test.go +++ b/internal/plugin/grpc_provisioner_test.go @@ -129,7 +129,8 @@ func (r *provisionRecorder) Output(s string) { } func TestGRPCProvisioner_Stop(t *testing.T) { - client := mockProvisionerClient(t) + ctrl := gomock.NewController(t) + client := mockproto.NewMockProvisionerClient(ctrl) p := &GRPCProvisioner{ client: client, } diff --git a/internal/plugin/mock_proto/mock.go b/internal/plugin/mock_proto/mock.go index 5f4a69d72..054fe1cd8 100644 --- a/internal/plugin/mock_proto/mock.go +++ b/internal/plugin/mock_proto/mock.go @@ -6,37 +6,38 @@ package mock_tfplugin5 import ( context "context" + reflect "reflect" + gomock "github.com/golang/mock/gomock" tfplugin5 "github.com/hashicorp/terraform/internal/tfplugin5" grpc "google.golang.org/grpc" metadata "google.golang.org/grpc/metadata" - reflect "reflect" ) -// MockProviderClient is a mock of ProviderClient interface +// MockProviderClient is a mock of ProviderClient interface. type MockProviderClient struct { ctrl *gomock.Controller recorder *MockProviderClientMockRecorder } -// MockProviderClientMockRecorder is the mock recorder for MockProviderClient +// MockProviderClientMockRecorder is the mock recorder for MockProviderClient. type MockProviderClientMockRecorder struct { mock *MockProviderClient } -// NewMockProviderClient creates a new mock instance +// NewMockProviderClient creates a new mock instance. func NewMockProviderClient(ctrl *gomock.Controller) *MockProviderClient { mock := &MockProviderClient{ctrl: ctrl} mock.recorder = &MockProviderClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProviderClient) EXPECT() *MockProviderClientMockRecorder { return m.recorder } -// ApplyResourceChange mocks base method +// ApplyResourceChange mocks base method. func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfplugin5.ApplyResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin5.ApplyResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -49,14 +50,14 @@ func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ApplyResourceChange indicates an expected call of ApplyResourceChange +// ApplyResourceChange indicates an expected call of ApplyResourceChange. func (mr *MockProviderClientMockRecorder) ApplyResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyResourceChange", reflect.TypeOf((*MockProviderClient)(nil).ApplyResourceChange), varargs...) } -// Configure mocks base method +// Configure mocks base method. func (m *MockProviderClient) Configure(arg0 context.Context, arg1 *tfplugin5.Configure_Request, arg2 ...grpc.CallOption) (*tfplugin5.Configure_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -69,14 +70,14 @@ func (m *MockProviderClient) Configure(arg0 context.Context, arg1 *tfplugin5.Con return ret0, ret1 } -// Configure indicates an expected call of Configure +// Configure indicates an expected call of Configure. func (mr *MockProviderClientMockRecorder) Configure(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Configure", reflect.TypeOf((*MockProviderClient)(nil).Configure), varargs...) } -// GetSchema mocks base method +// GetSchema mocks base method. func (m *MockProviderClient) GetSchema(arg0 context.Context, arg1 *tfplugin5.GetProviderSchema_Request, arg2 ...grpc.CallOption) (*tfplugin5.GetProviderSchema_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -89,14 +90,14 @@ func (m *MockProviderClient) GetSchema(arg0 context.Context, arg1 *tfplugin5.Get return ret0, ret1 } -// GetSchema indicates an expected call of GetSchema +// GetSchema indicates an expected call of GetSchema. func (mr *MockProviderClientMockRecorder) GetSchema(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSchema", reflect.TypeOf((*MockProviderClient)(nil).GetSchema), varargs...) } -// ImportResourceState mocks base method +// ImportResourceState mocks base method. func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfplugin5.ImportResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin5.ImportResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -109,14 +110,14 @@ func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ImportResourceState indicates an expected call of ImportResourceState +// ImportResourceState indicates an expected call of ImportResourceState. func (mr *MockProviderClientMockRecorder) ImportResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportResourceState", reflect.TypeOf((*MockProviderClient)(nil).ImportResourceState), varargs...) } -// PlanResourceChange mocks base method +// PlanResourceChange mocks base method. func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfplugin5.PlanResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin5.PlanResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -129,14 +130,14 @@ func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfpl return ret0, ret1 } -// PlanResourceChange indicates an expected call of PlanResourceChange +// PlanResourceChange indicates an expected call of PlanResourceChange. func (mr *MockProviderClientMockRecorder) PlanResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlanResourceChange", reflect.TypeOf((*MockProviderClient)(nil).PlanResourceChange), varargs...) } -// PrepareProviderConfig mocks base method +// PrepareProviderConfig mocks base method. func (m *MockProviderClient) PrepareProviderConfig(arg0 context.Context, arg1 *tfplugin5.PrepareProviderConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.PrepareProviderConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -149,14 +150,14 @@ func (m *MockProviderClient) PrepareProviderConfig(arg0 context.Context, arg1 *t return ret0, ret1 } -// PrepareProviderConfig indicates an expected call of PrepareProviderConfig +// PrepareProviderConfig indicates an expected call of PrepareProviderConfig. func (mr *MockProviderClientMockRecorder) PrepareProviderConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrepareProviderConfig", reflect.TypeOf((*MockProviderClient)(nil).PrepareProviderConfig), varargs...) } -// ReadDataSource mocks base method +// ReadDataSource mocks base method. func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin5.ReadDataSource_Request, arg2 ...grpc.CallOption) (*tfplugin5.ReadDataSource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -169,14 +170,14 @@ func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin return ret0, ret1 } -// ReadDataSource indicates an expected call of ReadDataSource +// ReadDataSource indicates an expected call of ReadDataSource. func (mr *MockProviderClientMockRecorder) ReadDataSource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadDataSource", reflect.TypeOf((*MockProviderClient)(nil).ReadDataSource), varargs...) } -// ReadResource mocks base method +// ReadResource mocks base method. func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin5.ReadResource_Request, arg2 ...grpc.CallOption) (*tfplugin5.ReadResource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -189,14 +190,14 @@ func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin5. return ret0, ret1 } -// ReadResource indicates an expected call of ReadResource +// ReadResource indicates an expected call of ReadResource. func (mr *MockProviderClientMockRecorder) ReadResource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadResource", reflect.TypeOf((*MockProviderClient)(nil).ReadResource), varargs...) } -// Stop mocks base method +// Stop mocks base method. func (m *MockProviderClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_Request, arg2 ...grpc.CallOption) (*tfplugin5.Stop_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -209,14 +210,14 @@ func (m *MockProviderClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_Req return ret0, ret1 } -// Stop indicates an expected call of Stop +// Stop indicates an expected call of Stop. func (mr *MockProviderClientMockRecorder) Stop(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockProviderClient)(nil).Stop), varargs...) } -// UpgradeResourceState mocks base method +// UpgradeResourceState mocks base method. func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tfplugin5.UpgradeResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin5.UpgradeResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -229,14 +230,14 @@ func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tf return ret0, ret1 } -// UpgradeResourceState indicates an expected call of UpgradeResourceState +// UpgradeResourceState indicates an expected call of UpgradeResourceState. func (mr *MockProviderClientMockRecorder) UpgradeResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpgradeResourceState", reflect.TypeOf((*MockProviderClient)(nil).UpgradeResourceState), varargs...) } -// ValidateDataSourceConfig mocks base method +// ValidateDataSourceConfig mocks base method. func (m *MockProviderClient) ValidateDataSourceConfig(arg0 context.Context, arg1 *tfplugin5.ValidateDataSourceConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.ValidateDataSourceConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -249,14 +250,14 @@ func (m *MockProviderClient) ValidateDataSourceConfig(arg0 context.Context, arg1 return ret0, ret1 } -// ValidateDataSourceConfig indicates an expected call of ValidateDataSourceConfig +// ValidateDataSourceConfig indicates an expected call of ValidateDataSourceConfig. func (mr *MockProviderClientMockRecorder) ValidateDataSourceConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateDataSourceConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateDataSourceConfig), varargs...) } -// ValidateResourceTypeConfig mocks base method +// ValidateResourceTypeConfig mocks base method. func (m *MockProviderClient) ValidateResourceTypeConfig(arg0 context.Context, arg1 *tfplugin5.ValidateResourceTypeConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.ValidateResourceTypeConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -269,37 +270,37 @@ func (m *MockProviderClient) ValidateResourceTypeConfig(arg0 context.Context, ar return ret0, ret1 } -// ValidateResourceTypeConfig indicates an expected call of ValidateResourceTypeConfig +// ValidateResourceTypeConfig indicates an expected call of ValidateResourceTypeConfig. func (mr *MockProviderClientMockRecorder) ValidateResourceTypeConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateResourceTypeConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateResourceTypeConfig), varargs...) } -// MockProvisionerClient is a mock of ProvisionerClient interface +// MockProvisionerClient is a mock of ProvisionerClient interface. type MockProvisionerClient struct { ctrl *gomock.Controller recorder *MockProvisionerClientMockRecorder } -// MockProvisionerClientMockRecorder is the mock recorder for MockProvisionerClient +// MockProvisionerClientMockRecorder is the mock recorder for MockProvisionerClient. type MockProvisionerClientMockRecorder struct { mock *MockProvisionerClient } -// NewMockProvisionerClient creates a new mock instance +// NewMockProvisionerClient creates a new mock instance. func NewMockProvisionerClient(ctrl *gomock.Controller) *MockProvisionerClient { mock := &MockProvisionerClient{ctrl: ctrl} mock.recorder = &MockProvisionerClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvisionerClient) EXPECT() *MockProvisionerClientMockRecorder { return m.recorder } -// GetSchema mocks base method +// GetSchema mocks base method. func (m *MockProvisionerClient) GetSchema(arg0 context.Context, arg1 *tfplugin5.GetProvisionerSchema_Request, arg2 ...grpc.CallOption) (*tfplugin5.GetProvisionerSchema_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -312,14 +313,14 @@ func (m *MockProvisionerClient) GetSchema(arg0 context.Context, arg1 *tfplugin5. return ret0, ret1 } -// GetSchema indicates an expected call of GetSchema +// GetSchema indicates an expected call of GetSchema. func (mr *MockProvisionerClientMockRecorder) GetSchema(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSchema", reflect.TypeOf((*MockProvisionerClient)(nil).GetSchema), varargs...) } -// ProvisionResource mocks base method +// ProvisionResource mocks base method. func (m *MockProvisionerClient) ProvisionResource(arg0 context.Context, arg1 *tfplugin5.ProvisionResource_Request, arg2 ...grpc.CallOption) (tfplugin5.Provisioner_ProvisionResourceClient, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -332,14 +333,14 @@ func (m *MockProvisionerClient) ProvisionResource(arg0 context.Context, arg1 *tf return ret0, ret1 } -// ProvisionResource indicates an expected call of ProvisionResource +// ProvisionResource indicates an expected call of ProvisionResource. func (mr *MockProvisionerClientMockRecorder) ProvisionResource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProvisionResource", reflect.TypeOf((*MockProvisionerClient)(nil).ProvisionResource), varargs...) } -// Stop mocks base method +// Stop mocks base method. func (m *MockProvisionerClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_Request, arg2 ...grpc.CallOption) (*tfplugin5.Stop_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -352,14 +353,14 @@ func (m *MockProvisionerClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_ return ret0, ret1 } -// Stop indicates an expected call of Stop +// Stop indicates an expected call of Stop. func (mr *MockProvisionerClientMockRecorder) Stop(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockProvisionerClient)(nil).Stop), varargs...) } -// ValidateProvisionerConfig mocks base method +// ValidateProvisionerConfig mocks base method. func (m *MockProvisionerClient) ValidateProvisionerConfig(arg0 context.Context, arg1 *tfplugin5.ValidateProvisionerConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.ValidateProvisionerConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -372,37 +373,37 @@ func (m *MockProvisionerClient) ValidateProvisionerConfig(arg0 context.Context, return ret0, ret1 } -// ValidateProvisionerConfig indicates an expected call of ValidateProvisionerConfig +// ValidateProvisionerConfig indicates an expected call of ValidateProvisionerConfig. func (mr *MockProvisionerClientMockRecorder) ValidateProvisionerConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateProvisionerConfig", reflect.TypeOf((*MockProvisionerClient)(nil).ValidateProvisionerConfig), varargs...) } -// MockProvisioner_ProvisionResourceClient is a mock of Provisioner_ProvisionResourceClient interface +// MockProvisioner_ProvisionResourceClient is a mock of Provisioner_ProvisionResourceClient interface. type MockProvisioner_ProvisionResourceClient struct { ctrl *gomock.Controller recorder *MockProvisioner_ProvisionResourceClientMockRecorder } -// MockProvisioner_ProvisionResourceClientMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceClient +// MockProvisioner_ProvisionResourceClientMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceClient. type MockProvisioner_ProvisionResourceClientMockRecorder struct { mock *MockProvisioner_ProvisionResourceClient } -// NewMockProvisioner_ProvisionResourceClient creates a new mock instance +// NewMockProvisioner_ProvisionResourceClient creates a new mock instance. func NewMockProvisioner_ProvisionResourceClient(ctrl *gomock.Controller) *MockProvisioner_ProvisionResourceClient { mock := &MockProvisioner_ProvisionResourceClient{ctrl: ctrl} mock.recorder = &MockProvisioner_ProvisionResourceClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvisioner_ProvisionResourceClient) EXPECT() *MockProvisioner_ProvisionResourceClientMockRecorder { return m.recorder } -// CloseSend mocks base method +// CloseSend mocks base method. func (m *MockProvisioner_ProvisionResourceClient) CloseSend() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CloseSend") @@ -410,13 +411,13 @@ func (m *MockProvisioner_ProvisionResourceClient) CloseSend() error { return ret0 } -// CloseSend indicates an expected call of CloseSend +// CloseSend indicates an expected call of CloseSend. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) CloseSend() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseSend", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).CloseSend)) } -// Context mocks base method +// Context mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Context() context.Context { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Context") @@ -424,13 +425,13 @@ func (m *MockProvisioner_ProvisionResourceClient) Context() context.Context { return ret0 } -// Context indicates an expected call of Context +// Context indicates an expected call of Context. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Context() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Context)) } -// Header mocks base method +// Header mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Header() (metadata.MD, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Header") @@ -439,13 +440,13 @@ func (m *MockProvisioner_ProvisionResourceClient) Header() (metadata.MD, error) return ret0, ret1 } -// Header indicates an expected call of Header +// Header indicates an expected call of Header. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Header() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Header)) } -// Recv mocks base method +// Recv mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Recv() (*tfplugin5.ProvisionResource_Response, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Recv") @@ -454,13 +455,13 @@ func (m *MockProvisioner_ProvisionResourceClient) Recv() (*tfplugin5.ProvisionRe return ret0, ret1 } -// Recv indicates an expected call of Recv +// Recv indicates an expected call of Recv. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Recv() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recv", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Recv)) } -// RecvMsg mocks base method +// RecvMsg mocks base method. func (m *MockProvisioner_ProvisionResourceClient) RecvMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RecvMsg", arg0) @@ -468,13 +469,13 @@ func (m *MockProvisioner_ProvisionResourceClient) RecvMsg(arg0 interface{}) erro return ret0 } -// RecvMsg indicates an expected call of RecvMsg +// RecvMsg indicates an expected call of RecvMsg. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) RecvMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).RecvMsg), arg0) } -// SendMsg mocks base method +// SendMsg mocks base method. func (m *MockProvisioner_ProvisionResourceClient) SendMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMsg", arg0) @@ -482,13 +483,13 @@ func (m *MockProvisioner_ProvisionResourceClient) SendMsg(arg0 interface{}) erro return ret0 } -// SendMsg indicates an expected call of SendMsg +// SendMsg indicates an expected call of SendMsg. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) SendMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).SendMsg), arg0) } -// Trailer mocks base method +// Trailer mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Trailer() metadata.MD { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Trailer") @@ -496,36 +497,36 @@ func (m *MockProvisioner_ProvisionResourceClient) Trailer() metadata.MD { return ret0 } -// Trailer indicates an expected call of Trailer +// Trailer indicates an expected call of Trailer. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Trailer() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trailer", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Trailer)) } -// MockProvisioner_ProvisionResourceServer is a mock of Provisioner_ProvisionResourceServer interface +// MockProvisioner_ProvisionResourceServer is a mock of Provisioner_ProvisionResourceServer interface. type MockProvisioner_ProvisionResourceServer struct { ctrl *gomock.Controller recorder *MockProvisioner_ProvisionResourceServerMockRecorder } -// MockProvisioner_ProvisionResourceServerMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceServer +// MockProvisioner_ProvisionResourceServerMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceServer. type MockProvisioner_ProvisionResourceServerMockRecorder struct { mock *MockProvisioner_ProvisionResourceServer } -// NewMockProvisioner_ProvisionResourceServer creates a new mock instance +// NewMockProvisioner_ProvisionResourceServer creates a new mock instance. func NewMockProvisioner_ProvisionResourceServer(ctrl *gomock.Controller) *MockProvisioner_ProvisionResourceServer { mock := &MockProvisioner_ProvisionResourceServer{ctrl: ctrl} mock.recorder = &MockProvisioner_ProvisionResourceServerMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvisioner_ProvisionResourceServer) EXPECT() *MockProvisioner_ProvisionResourceServerMockRecorder { return m.recorder } -// Context mocks base method +// Context mocks base method. func (m *MockProvisioner_ProvisionResourceServer) Context() context.Context { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Context") @@ -533,13 +534,13 @@ func (m *MockProvisioner_ProvisionResourceServer) Context() context.Context { return ret0 } -// Context indicates an expected call of Context +// Context indicates an expected call of Context. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) Context() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).Context)) } -// RecvMsg mocks base method +// RecvMsg mocks base method. func (m *MockProvisioner_ProvisionResourceServer) RecvMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RecvMsg", arg0) @@ -547,13 +548,13 @@ func (m *MockProvisioner_ProvisionResourceServer) RecvMsg(arg0 interface{}) erro return ret0 } -// RecvMsg indicates an expected call of RecvMsg +// RecvMsg indicates an expected call of RecvMsg. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) RecvMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).RecvMsg), arg0) } -// Send mocks base method +// Send mocks base method. func (m *MockProvisioner_ProvisionResourceServer) Send(arg0 *tfplugin5.ProvisionResource_Response) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Send", arg0) @@ -561,13 +562,13 @@ func (m *MockProvisioner_ProvisionResourceServer) Send(arg0 *tfplugin5.Provision return ret0 } -// Send indicates an expected call of Send +// Send indicates an expected call of Send. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) Send(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).Send), arg0) } -// SendHeader mocks base method +// SendHeader mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SendHeader(arg0 metadata.MD) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendHeader", arg0) @@ -575,13 +576,13 @@ func (m *MockProvisioner_ProvisionResourceServer) SendHeader(arg0 metadata.MD) e return ret0 } -// SendHeader indicates an expected call of SendHeader +// SendHeader indicates an expected call of SendHeader. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SendHeader(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendHeader", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SendHeader), arg0) } -// SendMsg mocks base method +// SendMsg mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SendMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMsg", arg0) @@ -589,13 +590,13 @@ func (m *MockProvisioner_ProvisionResourceServer) SendMsg(arg0 interface{}) erro return ret0 } -// SendMsg indicates an expected call of SendMsg +// SendMsg indicates an expected call of SendMsg. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SendMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SendMsg), arg0) } -// SetHeader mocks base method +// SetHeader mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SetHeader(arg0 metadata.MD) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetHeader", arg0) @@ -603,19 +604,19 @@ func (m *MockProvisioner_ProvisionResourceServer) SetHeader(arg0 metadata.MD) er return ret0 } -// SetHeader indicates an expected call of SetHeader +// SetHeader indicates an expected call of SetHeader. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SetHeader(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetHeader", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SetHeader), arg0) } -// SetTrailer mocks base method +// SetTrailer mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SetTrailer(arg0 metadata.MD) { m.ctrl.T.Helper() m.ctrl.Call(m, "SetTrailer", arg0) } -// SetTrailer indicates an expected call of SetTrailer +// SetTrailer indicates an expected call of SetTrailer. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SetTrailer(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTrailer", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SetTrailer), arg0) diff --git a/internal/plugin6/grpc_provider_test.go b/internal/plugin6/grpc_provider_test.go index ee58f5779..cca0b5820 100644 --- a/internal/plugin6/grpc_provider_test.go +++ b/internal/plugin6/grpc_provider_test.go @@ -235,7 +235,8 @@ func TestGRPCProvider_Configure(t *testing.T) { } func TestGRPCProvider_Stop(t *testing.T) { - client := mockProviderClient(t) + ctrl := gomock.NewController(t) + client := mockproto.NewMockProviderClient(ctrl) p := &GRPCProvider{ client: client, } diff --git a/internal/plugin6/mock_proto/mock.go b/internal/plugin6/mock_proto/mock.go index 506fd7bc6..448008ef7 100644 --- a/internal/plugin6/mock_proto/mock.go +++ b/internal/plugin6/mock_proto/mock.go @@ -6,36 +6,37 @@ package mock_tfplugin6 import ( context "context" + reflect "reflect" + gomock "github.com/golang/mock/gomock" tfplugin6 "github.com/hashicorp/terraform/internal/tfplugin6" grpc "google.golang.org/grpc" - reflect "reflect" ) -// MockProviderClient is a mock of ProviderClient interface +// MockProviderClient is a mock of ProviderClient interface. type MockProviderClient struct { ctrl *gomock.Controller recorder *MockProviderClientMockRecorder } -// MockProviderClientMockRecorder is the mock recorder for MockProviderClient +// MockProviderClientMockRecorder is the mock recorder for MockProviderClient. type MockProviderClientMockRecorder struct { mock *MockProviderClient } -// NewMockProviderClient creates a new mock instance +// NewMockProviderClient creates a new mock instance. func NewMockProviderClient(ctrl *gomock.Controller) *MockProviderClient { mock := &MockProviderClient{ctrl: ctrl} mock.recorder = &MockProviderClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProviderClient) EXPECT() *MockProviderClientMockRecorder { return m.recorder } -// ApplyResourceChange mocks base method +// ApplyResourceChange mocks base method. func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfplugin6.ApplyResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin6.ApplyResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -48,14 +49,14 @@ func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ApplyResourceChange indicates an expected call of ApplyResourceChange +// ApplyResourceChange indicates an expected call of ApplyResourceChange. func (mr *MockProviderClientMockRecorder) ApplyResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyResourceChange", reflect.TypeOf((*MockProviderClient)(nil).ApplyResourceChange), varargs...) } -// ConfigureProvider mocks base method +// ConfigureProvider mocks base method. func (m *MockProviderClient) ConfigureProvider(arg0 context.Context, arg1 *tfplugin6.ConfigureProvider_Request, arg2 ...grpc.CallOption) (*tfplugin6.ConfigureProvider_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -68,14 +69,14 @@ func (m *MockProviderClient) ConfigureProvider(arg0 context.Context, arg1 *tfplu return ret0, ret1 } -// ConfigureProvider indicates an expected call of ConfigureProvider +// ConfigureProvider indicates an expected call of ConfigureProvider. func (mr *MockProviderClientMockRecorder) ConfigureProvider(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigureProvider", reflect.TypeOf((*MockProviderClient)(nil).ConfigureProvider), varargs...) } -// GetProviderSchema mocks base method +// GetProviderSchema mocks base method. func (m *MockProviderClient) GetProviderSchema(arg0 context.Context, arg1 *tfplugin6.GetProviderSchema_Request, arg2 ...grpc.CallOption) (*tfplugin6.GetProviderSchema_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -88,14 +89,14 @@ func (m *MockProviderClient) GetProviderSchema(arg0 context.Context, arg1 *tfplu return ret0, ret1 } -// GetProviderSchema indicates an expected call of GetProviderSchema +// GetProviderSchema indicates an expected call of GetProviderSchema. func (mr *MockProviderClientMockRecorder) GetProviderSchema(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProviderSchema", reflect.TypeOf((*MockProviderClient)(nil).GetProviderSchema), varargs...) } -// ImportResourceState mocks base method +// ImportResourceState mocks base method. func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfplugin6.ImportResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin6.ImportResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -108,14 +109,14 @@ func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ImportResourceState indicates an expected call of ImportResourceState +// ImportResourceState indicates an expected call of ImportResourceState. func (mr *MockProviderClientMockRecorder) ImportResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportResourceState", reflect.TypeOf((*MockProviderClient)(nil).ImportResourceState), varargs...) } -// PlanResourceChange mocks base method +// PlanResourceChange mocks base method. func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfplugin6.PlanResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin6.PlanResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -128,14 +129,14 @@ func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfpl return ret0, ret1 } -// PlanResourceChange indicates an expected call of PlanResourceChange +// PlanResourceChange indicates an expected call of PlanResourceChange. func (mr *MockProviderClientMockRecorder) PlanResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlanResourceChange", reflect.TypeOf((*MockProviderClient)(nil).PlanResourceChange), varargs...) } -// ReadDataSource mocks base method +// ReadDataSource mocks base method. func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin6.ReadDataSource_Request, arg2 ...grpc.CallOption) (*tfplugin6.ReadDataSource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -148,14 +149,14 @@ func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin return ret0, ret1 } -// ReadDataSource indicates an expected call of ReadDataSource +// ReadDataSource indicates an expected call of ReadDataSource. func (mr *MockProviderClientMockRecorder) ReadDataSource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadDataSource", reflect.TypeOf((*MockProviderClient)(nil).ReadDataSource), varargs...) } -// ReadResource mocks base method +// ReadResource mocks base method. func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin6.ReadResource_Request, arg2 ...grpc.CallOption) (*tfplugin6.ReadResource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -168,14 +169,14 @@ func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin6. return ret0, ret1 } -// ReadResource indicates an expected call of ReadResource +// ReadResource indicates an expected call of ReadResource. func (mr *MockProviderClientMockRecorder) ReadResource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadResource", reflect.TypeOf((*MockProviderClient)(nil).ReadResource), varargs...) } -// StopProvider mocks base method +// StopProvider mocks base method. func (m *MockProviderClient) StopProvider(arg0 context.Context, arg1 *tfplugin6.StopProvider_Request, arg2 ...grpc.CallOption) (*tfplugin6.StopProvider_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -188,14 +189,14 @@ func (m *MockProviderClient) StopProvider(arg0 context.Context, arg1 *tfplugin6. return ret0, ret1 } -// StopProvider indicates an expected call of StopProvider +// StopProvider indicates an expected call of StopProvider. func (mr *MockProviderClientMockRecorder) StopProvider(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopProvider", reflect.TypeOf((*MockProviderClient)(nil).StopProvider), varargs...) } -// UpgradeResourceState mocks base method +// UpgradeResourceState mocks base method. func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tfplugin6.UpgradeResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin6.UpgradeResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -208,14 +209,14 @@ func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tf return ret0, ret1 } -// UpgradeResourceState indicates an expected call of UpgradeResourceState +// UpgradeResourceState indicates an expected call of UpgradeResourceState. func (mr *MockProviderClientMockRecorder) UpgradeResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpgradeResourceState", reflect.TypeOf((*MockProviderClient)(nil).UpgradeResourceState), varargs...) } -// ValidateDataResourceConfig mocks base method +// ValidateDataResourceConfig mocks base method. func (m *MockProviderClient) ValidateDataResourceConfig(arg0 context.Context, arg1 *tfplugin6.ValidateDataResourceConfig_Request, arg2 ...grpc.CallOption) (*tfplugin6.ValidateDataResourceConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -228,14 +229,14 @@ func (m *MockProviderClient) ValidateDataResourceConfig(arg0 context.Context, ar return ret0, ret1 } -// ValidateDataResourceConfig indicates an expected call of ValidateDataResourceConfig +// ValidateDataResourceConfig indicates an expected call of ValidateDataResourceConfig. func (mr *MockProviderClientMockRecorder) ValidateDataResourceConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateDataResourceConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateDataResourceConfig), varargs...) } -// ValidateProviderConfig mocks base method +// ValidateProviderConfig mocks base method. func (m *MockProviderClient) ValidateProviderConfig(arg0 context.Context, arg1 *tfplugin6.ValidateProviderConfig_Request, arg2 ...grpc.CallOption) (*tfplugin6.ValidateProviderConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -248,14 +249,14 @@ func (m *MockProviderClient) ValidateProviderConfig(arg0 context.Context, arg1 * return ret0, ret1 } -// ValidateProviderConfig indicates an expected call of ValidateProviderConfig +// ValidateProviderConfig indicates an expected call of ValidateProviderConfig. func (mr *MockProviderClientMockRecorder) ValidateProviderConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateProviderConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateProviderConfig), varargs...) } -// ValidateResourceConfig mocks base method +// ValidateResourceConfig mocks base method. func (m *MockProviderClient) ValidateResourceConfig(arg0 context.Context, arg1 *tfplugin6.ValidateResourceConfig_Request, arg2 ...grpc.CallOption) (*tfplugin6.ValidateResourceConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -268,7 +269,7 @@ func (m *MockProviderClient) ValidateResourceConfig(arg0 context.Context, arg1 * return ret0, ret1 } -// ValidateResourceConfig indicates an expected call of ValidateResourceConfig +// ValidateResourceConfig indicates an expected call of ValidateResourceConfig. func (mr *MockProviderClientMockRecorder) ValidateResourceConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...)