diff --git a/go.mod b/go.mod index 06f46e2..806d061 100644 --- a/go.mod +++ b/go.mod @@ -8,30 +8,60 @@ require ( connectrpc.com/connect v1.18.1 github.com/hashicorp/terraform-plugin-framework v1.15.1 github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 - github.com/hashicorp/terraform-plugin-log v0.9.0 + github.com/hashicorp/terraform-plugin-go v0.29.0 + github.com/hashicorp/terraform-plugin-log v0.10.0 + github.com/hashicorp/terraform-plugin-testing v1.14.1 google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 + google.golang.org/grpc v1.75.1 google.golang.org/protobuf v1.36.9 ) require ( + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/agext/levenshtein v1.2.2 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.5.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-plugin-go v0.28.0 // indirect - github.com/hashicorp/terraform-registry-address v0.2.5 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/hc-install v0.9.2 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.24.0 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect - github.com/hashicorp/yamux v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/oklog/run v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/grpc v1.74.2 // indirect ) diff --git a/go.sum b/go.sum index a923ea7..fdbfa5d 100644 --- a/go.sum +++ b/go.sum @@ -1,45 +1,118 @@ connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= -github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= -github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= +github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= +github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= -github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= +github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.24.0 h1:mL0xlk9H5g2bn0pPF6JQZk5YlByqSqrO5VoaNtAf8OE= +github.com/hashicorp/terraform-exec v0.24.0/go.mod h1:lluc/rDYfAhYdslLJQg3J0oDqo88oGQAdHR+wDqFvo4= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/terraform-plugin-framework v1.15.1 h1:2mKDkwb8rlx/tvJTlIcpw0ykcmvdWv+4gY3SIgk8Pq8= github.com/hashicorp/terraform-plugin-framework v1.15.1/go.mod h1:hxrNI/GY32KPISpWqlCoTLM9JZsGH3CyYlir09bD/fI= github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 h1:OQnlOt98ua//rCw+QhBbSqfW3QbwtVrcdWeQN5gI3Hw= github.com/hashicorp/terraform-plugin-framework-validators v0.18.0/go.mod h1:lZvZvagw5hsJwuY7mAY6KUz45/U6fiDR0CzQAwWD0CA= -github.com/hashicorp/terraform-plugin-go v0.28.0 h1:zJmu2UDwhVN0J+J20RE5huiF3XXlTYVIleaevHZgKPA= -github.com/hashicorp/terraform-plugin-go v0.28.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= -github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= -github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= -github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= +github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU= +github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 h1:mlAq/OrMlg04IuJT7NpefI1wwtdpWudnEmjuQs04t/4= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1/go.mod h1:GQhpKVvvuwzD79e8/NZ+xzj+ZpWovdPAe8nfV/skwNU= +github.com/hashicorp/terraform-plugin-testing v1.14.1 h1:CHVPv1goCEGwPZyZluub3ZDsbcMpDFH6rsE0UWry+5Y= +github.com/hashicorp/terraform-plugin-testing v1.14.1/go.mod h1:1qfWkecyYe1Do2EEOK/5/WnTyvC8wQucUkkhiGLg5nk= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= -github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= -github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= -github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -49,53 +122,126 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/acceptance_test.go b/internal/provider/acceptance_test.go new file mode 100644 index 0000000..d0e57e3 --- /dev/null +++ b/internal/provider/acceptance_test.go @@ -0,0 +1,57 @@ +package provider + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + apiv1connect "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1/apiv1connect" +) + +// --------------------------------------------------------------------------- +// Acceptance-test harness: wires the real provider (via ProtoV6ProviderFactories) +// against the same in-memory fake backend the unit tests use, so +// resource.TestCase can drive genuine `terraform import` / `terraform plan` / +// `terraform apply` cycles without hitting a real DevZero API. +// --------------------------------------------------------------------------- + +const ( + testAccTeamID = "team-1" + testAccToken = "test-token" +) + +// testAccHarness starts a fake backend and returns the provider factories +// terraform-plugin-testing needs, plus the backend for direct assertions +// against what was actually persisted (independent of what Terraform reports). +func testAccHarness(t *testing.T) (map[string]func() (tfprotov6.ProviderServer, error), *fakeBackend) { + t.Helper() + + fake := newFakeBackend(testAccTeamID) + mux := http.NewServeMux() + mux.Handle(apiv1connect.NewK8SRecommendationServiceHandler(fake)) + mux.Handle(apiv1connect.NewK8SServiceHandler(fake)) + mux.Handle(apiv1connect.NewClusterMutationServiceHandler(fake)) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + factories := map[string]func() (tfprotov6.ProviderServer, error){ + "devzero": providerserver.NewProtocol6WithError(&DevzeroProvider{version: "test", defaultURL: srv.URL}), + } + return factories, fake +} + +// testAccProviderConfig renders the provider block every test config needs; +// the URL is baked into the provider instance itself (see testAccHarness), so +// only credentials the fake backend expects are supplied here. +func testAccProviderConfig() string { + return fmt.Sprintf(` +provider "devzero" { + team_id = %q + token = %q +} +`, testAccTeamID, testAccToken) +} diff --git a/internal/provider/cluster_resource.go b/internal/provider/cluster_resource.go index 3060cb9..c85657e 100644 --- a/internal/provider/cluster_resource.go +++ b/internal/provider/cluster_resource.go @@ -20,7 +20,6 @@ import ( var _ resource.Resource = &ClusterResource{} var _ resource.ResourceWithConfigure = &ClusterResource{} var _ resource.ResourceWithImportState = &ClusterResource{} -var _ resource.ResourceWithModifyPlan = &ClusterResource{} func NewClusterResource() resource.Resource { return &ClusterResource{} @@ -91,33 +90,6 @@ func (r *ClusterResource) Configure(ctx context.Context, req resource.ConfigureR r.client = client } -func (r *ClusterResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { - // If the resource is being created, skip forcing a rotation during plan - if req.State.Raw.IsNull() { - return - } - - var data ClusterResourceModel - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // If the prior token is empty, mark the planned token as unknown so that - // Terraform plans an apply which will rotate the token during Update. - if data.Token.IsNull() || data.Token.ValueString() == "" { - // Only attempt to set if plan is available - if !req.Plan.Raw.IsNull() { - // Set token to unknown in plan - err := resp.Plan.SetAttribute(ctx, path.Root("token"), types.StringUnknown()) - if err != nil { - resp.Diagnostics.AddError("Plan Error", fmt.Sprintf("Unable to mark token unknown in plan: %s", err)) - return - } - } - } -} - func (r *ClusterResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data ClusterResourceModel @@ -227,23 +199,9 @@ func (r *ClusterResource) Update(ctx context.Context, req resource.UpdateRequest } data.Name = types.StringValue(updatedName) - // If prior token was empty, rotate it now and persist the new token in state - if data.Token.IsNull() || data.Token.IsUnknown() || data.Token.ValueString() == "" { - resetReq := &apiv1.ResetClusterTokenRequest{ - TeamId: r.client.TeamId, - ClusterId: data.Id.ValueString(), - } - resetResp, err := r.client.ClusterMutationClient.ResetClusterToken(ctx, connect.NewRequest(resetReq)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to reset cluster token, got error: %s", err)) - return - } - if resetResp.Msg.Token == "" { - resp.Diagnostics.AddError("Client Error", "Cluster token reset returned empty token") - return - } - data.Token = types.StringValue(resetResp.Msg.Token) - } + // token is write-once: the API only ever returns it from CreateCluster, + // never from GetCluster, so it's simply carried forward from state here + // (real value if set at create, null if the resource was imported). // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) diff --git a/internal/provider/cluster_resource_import_test.go b/internal/provider/cluster_resource_import_test.go new file mode 100644 index 0000000..a242b95 --- /dev/null +++ b/internal/provider/cluster_resource_import_test.go @@ -0,0 +1,117 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + apiv1 "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1" +) + +// TestAccClusterResource_ImportRoundTrip proves full import fidelity for +// devzero_cluster: import hydrates every readable attribute, the plan right +// after import is clean, and editing+reapplying an imported resource updates +// the real resource. +// +// The cluster is seeded directly against the fake backend (not via a +// Terraform apply) and imported as the test's first step. This is +// deliberate: a Terraform-driven Create never exercises Read at all (it +// writes state straight from the API response), so an import test built on +// top of an in-suite apply would still pass even if Read silently dropped +// fields. Importing a backend object Terraform never created is the only +// way to actually exercise "Read from scratch" the way `terraform import` +// really uses it, and it matches terraform-plugin-testing's own documented +// pattern for import-as-first-step (see ImportStatePersist's doc comment). +func TestAccClusterResource_ImportRoundTrip(t *testing.T) { + factories, fake := testAccHarness(t) + + seeded, err := fake.CreateCluster(context.Background(), connect.NewRequest(&apiv1.CreateClusterRequest{ + TeamId: testAccTeamID, + ClusterName: "acc-cluster", + })) + if err != nil { + t.Fatalf("seeding cluster: %s", err) + } + clusterID := seeded.Msg.Cluster.Id + + importCfg := testAccProviderConfig() + ` +resource "devzero_cluster" "test" { + name = "acc-cluster" +} +` + renamedCfg := testAccProviderConfig() + ` +resource "devzero_cluster" "test" { + name = "acc-cluster-renamed" +} +` + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: importCfg, + ResourceName: "devzero_cluster.test", + ImportState: true, + ImportStateId: clusterID, + // Without this, the imported state is discarded at the end + // of the step and step 2 below would silently check a plan + // against no state at all rather than the real post-import + // state. + ImportStatePersist: true, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected 1 imported instance, got %d", len(states)) + } + if got := states[0].Attributes["name"]; got != "acc-cluster" { + return fmt.Errorf("imported name = %q, want %q", got, "acc-cluster") + } + if got := states[0].Attributes["id"]; got != clusterID { + return fmt.Errorf("imported id = %q, want %q", got, clusterID) + } + return nil + }, + }, + { + // Same config as the import step, applied fresh right + // after: this must produce zero changes, proving + // `terraform plan` right after `terraform import` is clean. + Config: importCfg, + PlanOnly: true, + }, + { + Config: renamedCfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("devzero_cluster.test", "name", "acc-cluster-renamed"), + testAccCheckClusterNameInBackend(fake, "devzero_cluster.test", "acc-cluster-renamed"), + ), + }, + }, + }) +} + +// testAccCheckClusterNameInBackend asserts the fake backend's own record was +// actually updated, independent of what Terraform's state reports. +func testAccCheckClusterNameInBackend(fake *fakeBackend, resourceName, wantName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource %s not found in state", resourceName) + } + id := rs.Primary.ID + + fake.mu.Lock() + defer fake.mu.Unlock() + c, ok := fake.clusters[id] + if !ok { + return fmt.Errorf("cluster %s not found in fake backend", id) + } + if c.CustomName != wantName { + return fmt.Errorf("fake backend cluster %s has name %q, want %q", id, c.CustomName, wantName) + } + return nil + } +} diff --git a/internal/provider/node_policy.go b/internal/provider/node_policy.go index 49508ab..759628d 100644 --- a/internal/provider/node_policy.go +++ b/internal/provider/node_policy.go @@ -226,6 +226,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "weight": schema.Int32Attribute{ Description: "Priority weight for this node policy", @@ -233,6 +234,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: int32default.StaticInt32(10), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, // Instance selector fields with LabelSelector "instance_categories": labelSelectorAttribute("Instance categories selector (e.g., D for Azure, m for AWS)"), @@ -316,22 +318,25 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Attributes: map[string]schema.Attribute{ "respect_zonal_shift": schema.BoolAttribute{ - Description: "Master opt-in. When false the other fields are ignored", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Master opt-in. When false the other fields are ignored", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "evict_impacted_nodes": schema.BoolAttribute{ - Description: "Also terminate existing nodes in the impacted zone (respects PDBs)", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Also terminate existing nodes in the impacted zone (respects PDBs)", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "allow_zone_fallback": schema.BoolAttribute{ - Description: "Expand a single-zone policy to other zones when its zone is impacted", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Expand a single-zone policy to other zones when its zone is impacted", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, }, }, @@ -352,6 +357,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString("15m"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "consolidation_policy": schema.StringAttribute{ Description: "Consolidation policy (WhenEmpty, WhenEmptyOrUnderutilized)", @@ -359,6 +365,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString("WhenEmptyOrUnderutilized"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "expire_after": schema.StringAttribute{ Description: "Duration after which nodes expire", @@ -366,18 +373,21 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString("720h"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "ttl_seconds_after_empty": schema.Int32Attribute{ - Description: "Seconds to wait before terminating empty nodes", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(0), + Description: "Seconds to wait before terminating empty nodes", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(0), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "termination_grace_period_seconds": schema.Int32Attribute{ - Description: "Grace period for node termination", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(0), + Description: "Grace period for node termination", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(0), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "budgets": schema.ListNestedAttribute{ Description: "Disruption budgets", @@ -432,22 +442,25 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ "limits_tip": tooltipAttribute("Tooltip for limits"), // Karpenter naming "master_override_role_name": schema.StringAttribute{ - Description: "Master override role name for Karpenter", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(""), + Description: "Master override role name for Karpenter", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "node_pool_name": schema.StringAttribute{ - Description: "Node pool name", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(""), + Description: "Node pool name", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "node_class_name": schema.StringAttribute{ - Description: "Node class name", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(""), + Description: "Node class name", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, // AWS provider configuration "aws": schema.SingleNestedAttribute{ @@ -599,16 +612,18 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, }, "detailed_monitoring": schema.BoolAttribute{ - Description: "Enable detailed CloudWatch monitoring", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Enable detailed CloudWatch monitoring", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "associate_public_ip_address": schema.BoolAttribute{ - Description: "Associate public IP address with instances", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Associate public IP address with instances", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "metadata_options": schema.SingleNestedAttribute{ Description: "EC2 instance metadata service (IMDS) options", @@ -622,6 +637,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString("enabled"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "http_protocol_ipv6": schema.StringAttribute{ Description: "Enable or disable IPv6 endpoint", @@ -629,6 +645,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString("disabled"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "http_put_response_hop_limit": schema.Int64Attribute{ Description: "Desired HTTP PUT response hop limit for instance metadata requests", @@ -636,6 +653,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: int64default.StaticInt64(2), + PlanModifiers: []planmodifier.Int64{preserveInt64StateOverDefault()}, }, "http_tokens": schema.StringAttribute{ Description: "Whether or not the metadata service requires session tokens (IMDSv2)", @@ -643,6 +661,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, Computed: true, Default: stringdefault.StaticString("required"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, }, }, @@ -834,16 +853,18 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "nodepool_yaml": schema.StringAttribute{ - Description: "Raw NodePool YAML", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(""), + Description: "Raw NodePool YAML", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "nodeclass_yaml": schema.StringAttribute{ - Description: "Raw NodeClass YAML", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(""), + Description: "Raw NodeClass YAML", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, }, }, @@ -2568,7 +2589,8 @@ func isAzureSpecEmpty(spec *apiv1.AzureNodeClassSpec) bool { spec.FipsMode == nil && spec.Tags == nil && spec.Kubelet == nil && - spec.MaxPods == nil + spec.MaxPods == nil && + spec.ImageVersion == nil } func azureNodeClassFromProto(spec *apiv1.AzureNodeClassSpec) *AzureNodeClass { diff --git a/internal/provider/node_policy_import_test.go b/internal/provider/node_policy_import_test.go new file mode 100644 index 0000000..a23075a --- /dev/null +++ b/internal/provider/node_policy_import_test.go @@ -0,0 +1,122 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + apiv1 "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1" +) + +// TestAccNodePolicyResource_ImportRoundTrip proves full import fidelity for +// devzero_node_policy: the policy is seeded directly against the fake +// backend (never via a Terraform apply), so importing it is the only way +// Terraform ever learns about it — exactly the "resource created by any +// means, Terraform must still be able to import it cleanly" scenario. It +// deliberately sets an azure.image_version-only nested block (the exact +// shape that used to be dropped by the isAzureSpecEmpty bug) to prove that +// fix holds through a real import. +func TestAccNodePolicyResource_ImportRoundTrip(t *testing.T) { + factories, fake := testAccHarness(t) + + imageVersion := "AzureLinux-202401.01.0" + seeded, err := fake.CreateNodePolicies(context.Background(), connect.NewRequest(&apiv1.CreateNodePoliciesRequest{ + TeamId: testAccTeamID, + Policies: []*apiv1.NodePolicy{ + { + Name: "acc-node-policy", + Description: "seeded outside terraform", + Azure: &apiv1.AzureNodeClassSpec{ + ImageVersion: &imageVersion, + }, + }, + }, + })) + if err != nil { + t.Fatalf("seeding node policy: %s", err) + } + policyID := seeded.Msg.Policies[0].Id + + importCfg := testAccProviderConfig() + ` +resource "devzero_node_policy" "test" { + name = "acc-node-policy" + description = "seeded outside terraform" + azure = { + image_version = "AzureLinux-202401.01.0" + } +} +` + renamedCfg := testAccProviderConfig() + ` +resource "devzero_node_policy" "test" { + name = "acc-node-policy-renamed" + description = "seeded outside terraform" + azure = { + image_version = "AzureLinux-202401.01.0" + } +} +` + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: importCfg, + ResourceName: "devzero_node_policy.test", + ImportState: true, + ImportStateId: policyID, + ImportStatePersist: true, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected 1 imported instance, got %d", len(states)) + } + attrs := states[0].Attributes + if got := attrs["name"]; got != "acc-node-policy" { + return fmt.Errorf("imported name = %q, want %q", got, "acc-node-policy") + } + if got := attrs["azure.image_version"]; got != imageVersion { + return fmt.Errorf("imported azure.image_version = %q, want %q", got, imageVersion) + } + return nil + }, + }, + { + // Same config as import, applied fresh right after: must + // produce zero changes. + Config: importCfg, + PlanOnly: true, + }, + { + Config: renamedCfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("devzero_node_policy.test", "name", "acc-node-policy-renamed"), + testAccCheckNodePolicyNameInBackend(fake, "devzero_node_policy.test", "acc-node-policy-renamed"), + ), + }, + }, + }) +} + +func testAccCheckNodePolicyNameInBackend(fake *fakeBackend, resourceName, wantName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource %s not found in state", resourceName) + } + id := rs.Primary.ID + + fake.mu.Lock() + defer fake.mu.Unlock() + p, ok := fake.nodePol[id] + if !ok { + return fmt.Errorf("node policy %s not found in fake backend", id) + } + if p.Name != wantName { + return fmt.Errorf("fake backend node policy %s has name %q, want %q", id, p.Name, wantName) + } + return nil + } +} diff --git a/internal/provider/node_policy_target.go b/internal/provider/node_policy_target.go index acf6150..5f8fde2 100644 --- a/internal/provider/node_policy_target.go +++ b/internal/provider/node_policy_target.go @@ -78,6 +78,7 @@ func (r *NodePolicyTargetResource) Schema(ctx context.Context, req resource.Sche Optional: true, Computed: true, Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "enabled": schema.BoolAttribute{ Description: "Whether this target is active", @@ -85,6 +86,7 @@ func (r *NodePolicyTargetResource) Schema(ctx context.Context, req resource.Sche Optional: true, Computed: true, Default: booldefault.StaticBool(true), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "cluster_ids": schema.ListAttribute{ Description: "Cluster ID to apply the node policy to (at most one)", diff --git a/internal/provider/preserve_computed_default.go b/internal/provider/preserve_computed_default.go new file mode 100644 index 0000000..b481f67 --- /dev/null +++ b/internal/provider/preserve_computed_default.go @@ -0,0 +1,125 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// The modifiers in this file exist because terraform-plugin-framework's +// schema.Default is unconditionally re-applied on every plan whenever an +// attribute is null in config (see the framework's internal +// fwschemadata.TransformDefaults) — it never checks whether a real, known +// value already exists in state. For any Optional+Computed attribute that +// also declares a Default, omitting the attribute from config resets it to +// the default on every single plan, clobbering the real backend value. +// +// This is invisible for resources this provider itself created (Create +// always re-applies the same default, so state and backend never diverge), +// but it silently destroys real data for any resource whose actual value +// differs from the static default — which includes every resource +// `terraform import` picks up that this exact Terraform config didn't +// create. Pairing Default with one of these modifiers restores the +// intended behavior: Default seeds a sane value at Create time (no prior +// state yet), and after that the real state value always wins over the +// default when config doesn't set the attribute explicitly. +const preserveStateOverDefaultDescription = "Once set (including by import), this attribute's real value is preserved across plans instead of reverting to its schema default when omitted from config." + +func preserveStringStateOverDefault() planmodifier.String { return preserveStringOverDefault{} } +func preserveBoolStateOverDefault() planmodifier.Bool { return preserveBoolOverDefault{} } +func preserveInt32StateOverDefault() planmodifier.Int32 { return preserveInt32OverDefault{} } +func preserveInt64StateOverDefault() planmodifier.Int64 { return preserveInt64OverDefault{} } +func preserveFloat32StateOverDefault() planmodifier.Float32 { + return preserveFloat32OverDefault{} +} +func preserveListStateOverDefault() planmodifier.List { return preserveListOverDefault{} } + +type preserveStringOverDefault struct{} + +func (preserveStringOverDefault) Description(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveStringOverDefault) MarkdownDescription(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveStringOverDefault) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if req.State.Raw.IsNull() || !req.ConfigValue.IsNull() { + return + } + resp.PlanValue = req.StateValue +} + +type preserveBoolOverDefault struct{} + +func (preserveBoolOverDefault) Description(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveBoolOverDefault) MarkdownDescription(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveBoolOverDefault) PlanModifyBool(_ context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + if req.State.Raw.IsNull() || !req.ConfigValue.IsNull() { + return + } + resp.PlanValue = req.StateValue +} + +type preserveInt32OverDefault struct{} + +func (preserveInt32OverDefault) Description(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveInt32OverDefault) MarkdownDescription(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveInt32OverDefault) PlanModifyInt32(_ context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + if req.State.Raw.IsNull() || !req.ConfigValue.IsNull() { + return + } + resp.PlanValue = req.StateValue +} + +type preserveInt64OverDefault struct{} + +func (preserveInt64OverDefault) Description(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveInt64OverDefault) MarkdownDescription(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveInt64OverDefault) PlanModifyInt64(_ context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + if req.State.Raw.IsNull() || !req.ConfigValue.IsNull() { + return + } + resp.PlanValue = req.StateValue +} + +type preserveFloat32OverDefault struct{} + +func (preserveFloat32OverDefault) Description(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveFloat32OverDefault) MarkdownDescription(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveFloat32OverDefault) PlanModifyFloat32(_ context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + if req.State.Raw.IsNull() || !req.ConfigValue.IsNull() { + return + } + resp.PlanValue = req.StateValue +} + +type preserveListOverDefault struct{} + +func (preserveListOverDefault) Description(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveListOverDefault) MarkdownDescription(context.Context) string { + return preserveStateOverDefaultDescription +} +func (preserveListOverDefault) PlanModifyList(_ context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + if req.State.Raw.IsNull() || !req.ConfigValue.IsNull() { + return + } + resp.PlanValue = req.StateValue +} diff --git a/internal/provider/workload_policy.go b/internal/provider/workload_policy.go index 7299b84..70e49fa 100644 --- a/internal/provider/workload_policy.go +++ b/internal/provider/workload_policy.go @@ -138,6 +138,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(defaultEnabled), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "min_request": schema.Int64Attribute{ Description: "Lower bound for container resource requests", @@ -155,6 +156,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: float32default.StaticFloat32(0.05), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "limits_adjustment_enabled": schema.BoolAttribute{ Description: "Allow recommender to adjust container limits as well as requests", @@ -162,6 +164,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "target_percentile": schema.Float32Attribute{ Description: "Target percentile for resource sizing (0.0-1.0)", @@ -169,6 +172,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: float32default.StaticFloat32(0.8), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "max_scale_up_percent": schema.Float32Attribute{ Description: "Maximum percent to scale up in one step", @@ -184,12 +188,14 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: float32default.StaticFloat32(2.0), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "min_data_points": schema.Int32Attribute{ - Description: "Minimum data points required for VPA decisions", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(15), + Description: "Minimum data points required for VPA decisions", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(15), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "adjust_req_even_if_not_set": schema.BoolAttribute{ Description: "Recommend requests even when the workload has no existing requests set", @@ -197,6 +203,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "limits_removal_enabled": schema.BoolAttribute{ Description: "Actively remove resource limits from workloads", @@ -204,6 +211,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "request_use_rss": schema.BoolAttribute{ Description: "Memory only: size the request from RSS instead of working set", @@ -242,6 +250,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "action_triggers": schema.ListAttribute{ Description: "When to apply this policy", @@ -256,7 +265,8 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema []attr.Value{types.StringValue("on_schedule")}, ), ), - ElementType: types.StringType, + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, + ElementType: types.StringType, Validators: []validator.List{ listvalidator.SizeAtLeast(1), listvalidator.NoNullValues(), @@ -270,6 +280,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: stringdefault.StaticString("*/15 * * * *"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "detection_triggers": schema.ListAttribute{ Description: "Events that trigger application of this policy", @@ -286,6 +297,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema []attr.Value{types.StringValue("pod_creation"), types.StringValue("pod_update")}, ), ), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, Validators: []validator.List{ listvalidator.SizeAtLeast(1), listvalidator.NoNullValues(), @@ -299,6 +311,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: int32default.StaticInt32(86400), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "startup_period_seconds": schema.Int64Attribute{ Description: "Ignore early-life metrics for this duration", @@ -330,10 +343,11 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Attributes: map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ - Description: "Enable or disable horizontal scaling", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Enable or disable horizontal scaling", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "min_replicas": schema.Int32Attribute{ Description: "Lower bound on replicas", @@ -344,25 +358,28 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, }, "target_utilization": schema.Float32Attribute{ - Description: "Target utilization for primary metric (0.0-1.0)", - Optional: true, - Computed: true, - Default: float32default.StaticFloat32(0.8), + Description: "Target utilization for primary metric (0.0-1.0)", + Optional: true, + Computed: true, + Default: float32default.StaticFloat32(0.8), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "primary_metric": schema.StringAttribute{ - Description: "Primary metric to use for HPA decisions", - Optional: true, - Computed: true, - Default: stringdefault.StaticString("cpu"), + Description: "Primary metric to use for HPA decisions", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("cpu"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, Validators: []validator.String{ stringvalidator.OneOf("cpu", "memory", "gpu", "network", "network_ingress", "network_egress"), }, }, "min_data_points": schema.Int32Attribute{ - Description: "Minimum data points required for HPA decisions", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(15), + Description: "Minimum data points required for HPA decisions", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(15), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "max_replica_change_percent": schema.Float32Attribute{ Description: "Maximum percent replica change in one step", @@ -391,10 +408,11 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema }, }, "live_migration_enabled": schema.BoolAttribute{ - Description: "Allow live migration when applying recommendations", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Allow live migration when applying recommendations", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "scheduler_plugins": schema.ListAttribute{ Description: "Kubernetes scheduler plugins to activate", @@ -407,6 +425,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema []attr.Value{}, ), ), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, Validators: []validator.List{ listvalidator.NoNullValues(), listvalidator.UniqueValues(), @@ -418,48 +437,56 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: stringdefault.StaticString("*/15 * * * *"), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "min_change_percent": schema.Float32Attribute{ - Description: "Global minimum change threshold for applying recommendations", - Optional: true, - Computed: true, - Default: float32default.StaticFloat32(0.1), + Description: "Global minimum change threshold for applying recommendations", + Optional: true, + Computed: true, + Default: float32default.StaticFloat32(0.1), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "min_data_points": schema.Int32Attribute{ - Description: "Global minimum data points required for recommendations", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(15), + Description: "Global minimum data points required for recommendations", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(15), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "stability_cv_max": schema.Float32Attribute{ - Description: "Maximum coefficient of variation to consider stable", - Optional: true, - Computed: true, - Default: float32default.StaticFloat32(0.3), + Description: "Maximum coefficient of variation to consider stable", + Optional: true, + Computed: true, + Default: float32default.StaticFloat32(0.3), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "hysteresis_vs_target": schema.Float32Attribute{ - Description: "Hysteresis threshold vs target for HPA coordination", - Optional: true, - Computed: true, - Default: float32default.StaticFloat32(0.5), + Description: "Hysteresis threshold vs target for HPA coordination", + Optional: true, + Computed: true, + Default: float32default.StaticFloat32(0.5), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "drift_delta_percent": schema.Float32Attribute{ - Description: "Percentage drift from baseline that triggers VPA refresh", - Optional: true, - Computed: true, - Default: float32default.StaticFloat32(0.5), + Description: "Percentage drift from baseline that triggers VPA refresh", + Optional: true, + Computed: true, + Default: float32default.StaticFloat32(0.5), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "min_vpa_window_data_points": schema.Int32Attribute{ - Description: "Minimum data points in VPA analysis window", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(15), + Description: "Minimum data points in VPA analysis window", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(15), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "cooldown_minutes": schema.Int32Attribute{ - Description: "Minutes to wait between applying recommendations", - Optional: true, - Computed: true, - Default: int32default.StaticInt32(30), + Description: "Minutes to wait between applying recommendations", + Optional: true, + Computed: true, + Default: int32default.StaticInt32(30), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "enable_pmax_protection": schema.BoolAttribute{ Description: "Raise requests to cover observed peak usage when the peak/recommendation ratio exceeds pmax_ratio_threshold", @@ -467,6 +494,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "pmax_ratio_threshold": schema.Float32Attribute{ Description: "Peak-to-recommendation ratio above which pmax protection activates", @@ -474,6 +502,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: float32default.StaticFloat32(3.0), + PlanModifiers: []planmodifier.Float32{preserveFloat32StateOverDefault()}, }, "enable_in_place_vertical_scaling": schema.BoolAttribute{ Description: "Apply vertical recommendations in place (no pod restart) where possible", @@ -481,6 +510,7 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "allow_in_place_memory_limit_decrease": schema.BoolAttribute{ Description: "Allow in-place memory limit decreases (requires enable_in_place_vertical_scaling)", @@ -488,12 +518,14 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "pdb_enabled": schema.BoolAttribute{ - Description: "Respect PodDisruptionBudgets when applying recommendations", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Respect PodDisruptionBudgets when applying recommendations", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "cpu_floor_percent": schema.Int64Attribute{ Description: "Floor for CPU requests as a percent of the initial request (1-100)", @@ -536,10 +568,11 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Validators: []validator.Int64{int64validator.Between(1, 1000)}, }, "jvm_heap_optimization_enabled": schema.BoolAttribute{ - Description: "Enable JVM heap sizing recommendations", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Enable JVM heap sizing recommendations", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "jvm_heap_target_percentile": schema.Float32Attribute{ Description: "Target percentile for JVM heap sizing (0.0-1.0)", @@ -566,10 +599,11 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Optional: true, }, "jvm_prefer_container_support": schema.BoolAttribute{ - Description: "Prefer container-aware JVM flags (UseContainerSupport) over explicit -Xmx", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Prefer container-aware JVM flags (UseContainerSupport) over explicit -Xmx", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "jvm_cpu_startup_floor_millicores": schema.Int64Attribute{ Description: "CPU floor during JVM startup, in millicores", diff --git a/internal/provider/workload_policy_target.go b/internal/provider/workload_policy_target.go index fc83071..447f793 100644 --- a/internal/provider/workload_policy_target.go +++ b/internal/provider/workload_policy_target.go @@ -164,6 +164,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. Optional: true, Computed: true, Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{preserveStringStateOverDefault()}, }, "priority": schema.Int32Attribute{ Description: "Evaluation priority among multiple targets", @@ -171,6 +172,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. Optional: true, Computed: true, Default: int32default.StaticInt32(0), + PlanModifiers: []planmodifier.Int32{preserveInt32StateOverDefault()}, }, "enabled": schema.BoolAttribute{ Description: "Enable or disable this target", @@ -178,6 +180,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. Optional: true, Computed: true, Default: booldefault.StaticBool(true), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "namespace_selector": schema.SingleNestedAttribute{ Description: "Select namespaces by labels", @@ -198,6 +201,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. ElementType: types.StringType, Computed: true, Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, }, "name_pattern": schema.SingleNestedAttribute{ Description: "Regex to match workload names", @@ -218,6 +222,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. ElementType: types.StringType, Computed: true, Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, }, "workload_names_not_in": schema.ListAttribute{ Description: "Explicit list of workload names to exclude", @@ -226,6 +231,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. ElementType: types.StringType, Computed: true, Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, }, "kind_filter_not_in": schema.ListAttribute{ Description: "Kubernetes kinds to exclude from matching", @@ -234,6 +240,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. ElementType: types.StringType, Computed: true, Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, }, "annotation_selector": schema.SingleNestedAttribute{ Description: "Select workloads by annotations", @@ -248,6 +255,7 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. ElementType: types.StringType, Computed: true, Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, DeprecationMessage: "node_group_names is deprecated by the DevZero API and no longer evaluated.", }, "cluster_ids": schema.ListAttribute{ diff --git a/internal/provider/workload_rule.go b/internal/provider/workload_rule.go index f51282b..cece31b 100644 --- a/internal/provider/workload_rule.go +++ b/internal/provider/workload_rule.go @@ -15,6 +15,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -204,10 +209,11 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe resourceRuleConfigAttributes := func() map[string]schema.Attribute { return map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ - Description: "Enable this resource axis rule", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Enable this resource axis rule", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "min_request": schema.Int64Attribute{ Description: "Minimum resource request (millicores for CPU, bytes for memory/GPU)", @@ -222,10 +228,11 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Optional: true, }, "limits_adjustment_enabled": schema.BoolAttribute{ - Description: "Whether to also adjust resource limits alongside requests", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Whether to also adjust resource limits alongside requests", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "target_percentile": schema.Float32Attribute{ Description: "Percentile of usage data used as the recommendation target (0-1)", @@ -240,10 +247,11 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Optional: true, }, "limits_removal_enabled": schema.BoolAttribute{ - Description: "Actively remove limits from workloads", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Actively remove limits from workloads", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "initial_request": schema.Int64Attribute{ Description: "Baseline request the floor/ceiling percents are computed against", @@ -287,10 +295,11 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe containerResourceConfigAttributes := func() map[string]schema.Attribute { return map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ - Description: "Enable this resource axis rule", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Enable this resource axis rule", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "min_request": schema.Int64Attribute{ Description: "Minimum resource request", @@ -305,20 +314,22 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Optional: true, }, "limits_adjustment_enabled": schema.BoolAttribute{ - Description: "Whether to also adjust resource limits alongside requests", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Whether to also adjust resource limits alongside requests", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "target_percentile": schema.Float32Attribute{ Description: "Percentile of usage data used as the recommendation target (0-1)", Optional: true, }, "limits_removal_enabled": schema.BoolAttribute{ - Description: "Actively remove limits from workloads", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Actively remove limits from workloads", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "request_use_rss": schema.BoolAttribute{ Description: "Memory only: size the request from RSS instead of working set", @@ -362,35 +373,45 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Required: true, }, "auto_generate": schema.BoolAttribute{ - Description: "When true the engine generates all rule fields automatically; manual field overrides are ignored", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "When true the engine generates all rule fields automatically; manual field overrides are ignored", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "cpu_rule": schema.SingleNestedAttribute{ - Description: "CPU vertical scaling rule configuration", - Optional: true, - Attributes: resourceRuleConfigAttributes(), + Description: "CPU vertical scaling rule configuration", + Optional: true, + Computed: true, + Attributes: resourceRuleConfigAttributes(), + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, }, "memory_rule": schema.SingleNestedAttribute{ - Description: "Memory vertical scaling rule configuration", - Optional: true, - Attributes: resourceRuleConfigAttributes(), + Description: "Memory vertical scaling rule configuration", + Optional: true, + Computed: true, + Attributes: resourceRuleConfigAttributes(), + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, }, "gpu_rule": schema.SingleNestedAttribute{ - Description: "GPU vertical scaling rule configuration", - Optional: true, - Attributes: resourceRuleConfigAttributes(), + Description: "GPU vertical scaling rule configuration", + Optional: true, + Computed: true, + Attributes: resourceRuleConfigAttributes(), + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, }, "hpa_rule": schema.SingleNestedAttribute{ - Description: "Horizontal (replica) scaling rule configuration", - Optional: true, + Description: "Horizontal (replica) scaling rule configuration", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, Attributes: map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ - Description: "Enable horizontal (replica) scaling", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Enable horizontal (replica) scaling", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "min_replicas": schema.Int32Attribute{ Description: "Minimum number of replicas", @@ -490,14 +511,17 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "emergency_response": schema.SingleNestedAttribute{ - Description: "Emergency response configuration for OOM and CPU throttle events", - Optional: true, + Description: "Emergency response configuration for OOM and CPU throttle events", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, Attributes: map[string]schema.Attribute{ "oom_enabled": schema.BoolAttribute{ - Description: "React to OOM kills by increasing memory", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "React to OOM kills by increasing memory", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "oom_memory_multiplier": schema.Float32Attribute{ Description: "Multiplier applied to memory on OOM", @@ -514,10 +538,11 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Computed: true, }, "cpu_throttling_enabled": schema.BoolAttribute{ - Description: "React to CPU throttling by increasing CPU request", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "React to CPU throttling by increasing CPU request", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "cpu_throttling_threshold": schema.Float32Attribute{ Description: "Throttle ratio threshold that triggers a reaction (0-1)", @@ -532,7 +557,12 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe "action_triggers": schema.ListAttribute{ Description: "When to apply recommendations. Valid values: 'on_detection', 'on_schedule'", Optional: true, + Computed: true, ElementType: types.StringType, + Default: listdefault.StaticValue( + types.ListValueMust(types.StringType, []attr.Value{}), + ), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, Validators: []validator.List{ listvalidator.NoNullValues(), listvalidator.UniqueValues(), @@ -540,21 +570,32 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "startup_period_seconds": schema.Int64Attribute{ - Description: "Seconds after workload start to exclude from usage data", - Optional: true, + Description: "Seconds after workload start to exclude from usage data", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}, }, "cron_schedule": schema.StringAttribute{ - Description: "Cron expression for scheduled application (5-field UTC)", - Optional: true, + Description: "Cron expression for scheduled application (5-field UTC)", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, }, "cooldown_minutes": schema.Int32Attribute{ - Description: "Minimum minutes between consecutive recommendation applications", - Optional: true, + Description: "Minimum minutes between consecutive recommendation applications", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int32{int32planmodifier.UseStateForUnknown()}, }, "detection_triggers": schema.ListAttribute{ Description: "Events that trigger a recommendation. Valid values: 'pod_creation', 'pod_update'", Optional: true, + Computed: true, ElementType: types.StringType, + Default: listdefault.StaticValue( + types.ListValueMust(types.StringType, []attr.Value{}), + ), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, Validators: []validator.List{ listvalidator.NoNullValues(), listvalidator.UniqueValues(), @@ -564,27 +605,36 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe "scheduler_plugins": schema.ListAttribute{ Description: "Kubernetes scheduler plugins to activate", Optional: true, + Computed: true, ElementType: types.StringType, + Default: listdefault.StaticValue( + types.ListValueMust(types.StringType, []attr.Value{}), + ), + PlanModifiers: []planmodifier.List{preserveListStateOverDefault()}, Validators: []validator.List{ listvalidator.NoNullValues(), listvalidator.UniqueValues(), }, }, "defragmentation_schedule": schema.StringAttribute{ - Description: "Cron expression for node defragmentation", - Optional: true, + Description: "Cron expression for node defragmentation", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, }, "live_migration_enabled": schema.BoolAttribute{ - Description: "Allow live pod migration when applying recommendations", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Allow live pod migration when applying recommendations", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "use_in_place_vertical_scaling": schema.BoolAttribute{ - Description: "Use in-place pod vertical scaling instead of pod restarts", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), + Description: "Use in-place pod vertical scaling instead of pod restarts", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "disabled": schema.BoolAttribute{ Description: "Create the rule in a disabled state", @@ -592,6 +642,7 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{preserveBoolStateOverDefault()}, }, "lookback_period_seconds": schema.Int32Attribute{ Description: "Per-rule override of the metrics lookback window (seconds)", @@ -600,8 +651,10 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Validators: []validator.Int32{int32validator.Between(3600, 2592000)}, }, "containers": schema.ListNestedAttribute{ - Description: "Per-container resource rule configurations. When empty, workload-level rules apply to all containers.", - Optional: true, + Description: "Per-container resource rule configurations. When empty, workload-level rules apply to all containers.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()}, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "container_name": schema.StringAttribute{ @@ -670,9 +723,7 @@ func (r *WorkloadRuleResource) Create(ctx context.Context, req resource.CreateRe return } - plan := data data.fromProto(upsertResp.Msg.Rule) - data.preserveNullsFrom(&plan) tflog.Trace(ctx, "created a workload rule resource") @@ -704,9 +755,7 @@ func (r *WorkloadRuleResource) Read(ctx context.Context, req resource.ReadReques return } - prior := data data.fromProto(getRuleResp.Msg.Rule) - data.preserveNullsFrom(&prior) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -758,9 +807,7 @@ func (r *WorkloadRuleResource) Update(ctx context.Context, req resource.UpdateRe } } - plan := data data.fromProto(upsertResp.Msg.Rule) - data.preserveNullsFrom(&plan) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -893,48 +940,6 @@ func (m *WorkloadRuleResourceModel) toProto(ctx context.Context, diags *diag.Dia return req } -func (m *WorkloadRuleResourceModel) preserveNullsFrom(plan *WorkloadRuleResourceModel) { - if plan.CpuRule == nil { - m.CpuRule = nil - } - if plan.MemoryRule == nil { - m.MemoryRule = nil - } - if plan.GpuRule == nil { - m.GpuRule = nil - } - if plan.HpaRule == nil { - m.HpaRule = nil - } - if plan.EmergencyResponse == nil { - m.EmergencyResponse = nil - } - if plan.ActionTriggers.IsNull() { - m.ActionTriggers = types.ListNull(types.StringType) - } - if plan.DetectionTriggers.IsNull() { - m.DetectionTriggers = types.ListNull(types.StringType) - } - if plan.SchedulerPlugins.IsNull() { - m.SchedulerPlugins = types.ListNull(types.StringType) - } - if plan.CooldownMinutes.IsNull() { - m.CooldownMinutes = types.Int32Null() - } - if plan.StartupPeriodSeconds.IsNull() { - m.StartupPeriodSeconds = types.Int64Null() - } - if plan.CronSchedule.IsNull() { - m.CronSchedule = types.StringNull() - } - if plan.DefragmentationSchedule.IsNull() { - m.DefragmentationSchedule = types.StringNull() - } - if plan.Containers == nil { - m.Containers = nil - } -} - func (m *WorkloadRuleResourceModel) fromProto(r *apiv1.WorkloadRule) { m.Id = types.StringValue(r.RuleId) m.ClusterId = types.StringValue(r.ClusterId) diff --git a/internal/provider/workload_rule_import_read_test.go b/internal/provider/workload_rule_import_read_test.go new file mode 100644 index 0000000..8941b86 --- /dev/null +++ b/internal/provider/workload_rule_import_read_test.go @@ -0,0 +1,98 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + apiv1 "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1" +) + +// TestWorkloadRuleRead_ImportHydratesTriggerLists proves Read hydrates +// action_triggers/detection_triggers/scheduler_plugins from the real backend +// record right after import. +// +// Read's preserveNullsFrom step nulls these three fields whenever the PRIOR +// state had them null, to keep ordinary refreshes free of perpetual diffs +// for these plain-Optional (non-Computed) attributes. But right after +// `terraform import`, the prior state is import-shaped: only `id` is set and +// everything else is null — not because config said so, but because there +// is no config yet. preserveNullsFrom can't tell the difference, so it +// unconditionally wipes real backend data on import: a full-fidelity import +// bug. +func TestWorkloadRuleRead_ImportHydratesTriggerLists(t *testing.T) { + ctx := context.Background() + cs, fake := newFakeClientSet(t) + r := &WorkloadRuleResource{client: cs} + + mutate := func(m *WorkloadRuleResourceModel) { + m.ClusterId = types.StringValue("cluster-1") + m.Namespace = types.StringValue("prod") + m.Kind = types.StringValue("Deployment") + m.Name = types.StringValue("api") + m.Disabled = types.BoolValue(false) + cpu := &ResourceRuleConfigModel{} + hydrateNested(t, r, "cpu_rule", cpu) + cpu.Enabled = types.BoolValue(true) + cpu.MinRequest = types.Int64Value(10) + m.CpuRule = cpu + } + createResp := resource.CreateResponse{State: emptyState(t, r)} + r.Create(ctx, resource.CreateRequest{Plan: buildPlan[WorkloadRuleResourceModel](t, r, mutate)}, &createResp) + mustNoDiags(t, "Create", createResp.Diagnostics) + + var created WorkloadRuleResourceModel + mustNoDiags(t, "State.Get", createResp.State.Get(ctx, &created)) + id := created.Id.ValueString() + + // Give the backend record real trigger data, simulating a rule whose + // triggers were set outside of this Terraform config (e.g. imported + // from a rule the dashboard or another tool configured). + fake.mu.Lock() + fake.rules[id].ActionTriggers = []apiv1.ActionTrigger{ + apiv1.ActionTrigger_ACTION_TRIGGER_ON_SCHEDULE, + apiv1.ActionTrigger_ACTION_TRIGGER_ON_DETECTION, + } + fake.rules[id].DetectionTriggers = []apiv1.WorkloadDetectionTrigger{ + apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_CREATION, + } + fake.rules[id].SchedulerPlugins = []string{"my-scheduler-plugin"} + fake.mu.Unlock() + + // Build an import-shaped prior state: only `id` populated, matching + // what resource.ImportStatePassthroughID produces before Read runs. + importState := emptyState(t, r) + mustNoDiags(t, "SetAttribute(id)", importState.SetAttribute(ctx, path.Root("id"), id)) + + readResp := resource.ReadResponse{State: importState} + r.Read(ctx, resource.ReadRequest{State: importState}, &readResp) + mustNoDiags(t, "Read", readResp.Diagnostics) + + var got WorkloadRuleResourceModel + mustNoDiags(t, "State.Get", readResp.State.Get(ctx, &got)) + + if got.ActionTriggers.IsNull() { + t.Fatal("action_triggers: expected hydrated list from import, got null") + } + if got.DetectionTriggers.IsNull() { + t.Fatal("detection_triggers: expected hydrated list from import, got null") + } + if got.SchedulerPlugins.IsNull() { + t.Fatal("scheduler_plugins: expected hydrated list from import, got null") + } + + var gotActionTriggers []string + mustNoDiags(t, "ActionTriggers.ElementsAs", got.ActionTriggers.ElementsAs(ctx, &gotActionTriggers, false)) + if len(gotActionTriggers) != 2 { + t.Fatalf("action_triggers = %v, want 2 elements", gotActionTriggers) + } + + var gotSchedulerPlugins []string + mustNoDiags(t, "SchedulerPlugins.ElementsAs", got.SchedulerPlugins.ElementsAs(ctx, &gotSchedulerPlugins, false)) + if len(gotSchedulerPlugins) != 1 || gotSchedulerPlugins[0] != "my-scheduler-plugin" { + t.Fatalf("scheduler_plugins = %v, want [my-scheduler-plugin]", gotSchedulerPlugins) + } +} diff --git a/internal/provider/workload_rule_import_test.go b/internal/provider/workload_rule_import_test.go new file mode 100644 index 0000000..7cd70f6 --- /dev/null +++ b/internal/provider/workload_rule_import_test.go @@ -0,0 +1,152 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + apiv1 "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1" +) + +// TestAccWorkloadRuleResource_ImportRoundTrip proves full import fidelity for +// devzero_workload_rule, including the trigger-list attributes +// (action_triggers/detection_triggers/scheduler_plugins). Those three are +// Optional+Computed with an empty-list Default, so they carry the exact +// systemic bug fixed by the preserve*StateOverDefault plan modifiers: with a +// non-empty real value and config omitting the attribute, a plan without the +// fix would revert them to `[]` on every apply. The rule is seeded directly +// against the fake backend (never via a Terraform apply), simulating a rule +// created by any means other than this Terraform config. +func TestAccWorkloadRuleResource_ImportRoundTrip(t *testing.T) { + factories, fake := testAccHarness(t) + + ruleID := fake.id("wr") + fake.mu.Lock() + fake.rules[ruleID] = &apiv1.WorkloadRule{ + RuleId: ruleID, + ClusterId: "cluster-1", + Namespace: "prod", + Kind: "Deployment", + Name: "api", + CpuRule: &apiv1.ResourceRuleConfig{ + Enabled: true, + MinRequest: int64Ptr(10), + }, + ActionTriggers: []apiv1.ActionTrigger{ + apiv1.ActionTrigger_ACTION_TRIGGER_ON_SCHEDULE, + apiv1.ActionTrigger_ACTION_TRIGGER_ON_DETECTION, + }, + DetectionTriggers: []apiv1.WorkloadDetectionTrigger{ + apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_CREATION, + }, + SchedulerPlugins: []string{"my-scheduler-plugin"}, + } + fake.mu.Unlock() + + importCfg := testAccProviderConfig() + ` +resource "devzero_workload_rule" "test" { + cluster_id = "cluster-1" + namespace = "prod" + kind = "Deployment" + name = "api" + + action_triggers = ["on_schedule", "on_detection"] + detection_triggers = ["pod_creation"] + scheduler_plugins = ["my-scheduler-plugin"] + + cpu_rule = { + enabled = true + min_request = 10 + } +} +` + updatedCfg := testAccProviderConfig() + ` +resource "devzero_workload_rule" "test" { + cluster_id = "cluster-1" + namespace = "prod" + kind = "Deployment" + name = "api" + + action_triggers = ["on_schedule", "on_detection"] + detection_triggers = ["pod_creation"] + scheduler_plugins = ["my-scheduler-plugin"] + + cpu_rule = { + enabled = true + min_request = 25 + } +} +` + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: importCfg, + ResourceName: "devzero_workload_rule.test", + ImportState: true, + ImportStateId: ruleID, + ImportStatePersist: true, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected 1 imported instance, got %d", len(states)) + } + attrs := states[0].Attributes + if got := attrs["scheduler_plugins.0"]; got != "my-scheduler-plugin" { + return fmt.Errorf("imported scheduler_plugins.0 = %q, want %q", got, "my-scheduler-plugin") + } + if got := attrs["action_triggers.#"]; got != "2" { + return fmt.Errorf("imported action_triggers.# = %q, want 2", got) + } + return nil + }, + }, + { + // Same config as import, applied fresh right after: must + // produce zero changes — proves the trigger lists (and + // cpu_rule.min_request, which also carries a Default) don't + // get clobbered back to their schema defaults. + Config: importCfg, + PlanOnly: true, + }, + { + // Edit-and-reapply proof: mutate a genuinely mutable + // attribute (min_request), not the workload identity + // (cluster_id/namespace/kind/name) the backend upserts on. + Config: updatedCfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("devzero_workload_rule.test", "cpu_rule.min_request", "25"), + testAccCheckWorkloadRuleMinRequestInBackend(fake, "devzero_workload_rule.test", 25), + ), + }, + }, + }) +} + +func int64Ptr(v int64) *int64 { return &v } + +func testAccCheckWorkloadRuleMinRequestInBackend(fake *fakeBackend, resourceName string, want int64) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource %s not found in state", resourceName) + } + id := rs.Primary.ID + + fake.mu.Lock() + defer fake.mu.Unlock() + r, ok := fake.rules[id] + if !ok { + return fmt.Errorf("workload rule %s not found in fake backend", id) + } + if r.CpuRule == nil || r.CpuRule.MinRequest == nil { + return fmt.Errorf("fake backend workload rule %s has no cpu_rule.min_request", id) + } + if got := *r.CpuRule.MinRequest; got != want { + return fmt.Errorf("fake backend workload rule %s has cpu_rule.min_request %d, want %d", id, got, want) + } + return nil + } +} diff --git a/internal/provider/workload_rule_test.go b/internal/provider/workload_rule_test.go index 80254cc..5c5bffc 100644 --- a/internal/provider/workload_rule_test.go +++ b/internal/provider/workload_rule_test.go @@ -1146,23 +1146,21 @@ func TestWorkloadRuleResourceModel(t *testing.T) { } }) - // ---------- preserveNullsFrom ---------- - - t.Run("PreserveNullsFrom_RestoresNullOptionals", func(t *testing.T) { - // Simulate plan: only hpa_rule set, everything else null/nil - plan := WorkloadRuleResourceModel{ - CpuRule: nil, - MemoryRule: nil, - GpuRule: nil, - EmergencyResponse: nil, - ActionTriggers: types.ListNull(types.StringType), - DetectionTriggers: types.ListNull(types.StringType), - SchedulerPlugins: types.ListNull(types.StringType), - CooldownMinutes: types.Int32Null(), - StartupPeriodSeconds: types.Int64Null(), - } - - // Simulate API response filling in server-side defaults + // ---------- fromProto hydration (cpu_rule/memory_rule/gpu_rule/hpa_rule/ + // emergency_response/cooldown_minutes/startup_period_seconds/ + // cron_schedule/defragmentation_schedule/containers are all + // Optional+Computed with no Default: fromProto must always mirror the + // real backend value, regardless of what a prior plan or state held. + // These fields used to be gated by a preserveNullsFrom() step that + // nulled them out whenever a *reference* model (the plan on + // Create/Update, the prior state on Read) had them null — which + // silently dropped real backend data on the very first Read after + // `terraform import`, since an import-shaped prior state has + // everything but `id` null. Making the fields Computed lets + // Terraform's own Optional+Computed carry-forward semantics keep + // later plans stable instead. ---------- + + t.Run("FromProto_HydratesRealBackendValuesEvenWhenPreviouslyUnset", func(t *testing.T) { cooldown := int32(15) startupPeriod := int64(300) apiRule := &apiv1.WorkloadRule{ @@ -1191,121 +1189,33 @@ func TestWorkloadRuleResourceModel(t *testing.T) { var data WorkloadRuleResourceModel data.fromProto(apiRule) - data.preserveNullsFrom(&plan) - // All fields that were null in the plan must remain null/nil after preserveNullsFrom - if data.CpuRule != nil { - t.Error("Expected CpuRule to be nil after preserveNullsFrom") - } - if data.MemoryRule != nil { - t.Error("Expected MemoryRule to be nil after preserveNullsFrom") - } - if data.GpuRule != nil { - t.Error("Expected GpuRule to be nil after preserveNullsFrom") - } - if data.EmergencyResponse != nil { - t.Error("Expected EmergencyResponse to be nil after preserveNullsFrom") - } - if !data.ActionTriggers.IsNull() { - t.Error("Expected ActionTriggers to be null after preserveNullsFrom") - } - if !data.DetectionTriggers.IsNull() { - t.Error("Expected DetectionTriggers to be null after preserveNullsFrom") - } - if !data.SchedulerPlugins.IsNull() { - t.Error("Expected SchedulerPlugins to be null after preserveNullsFrom") - } - if !data.CooldownMinutes.IsNull() { - t.Error("Expected CooldownMinutes to be null after preserveNullsFrom") - } - if !data.StartupPeriodSeconds.IsNull() { - t.Error("Expected StartupPeriodSeconds to be null after preserveNullsFrom") + if data.CpuRule == nil { + t.Error("Expected CpuRule to be hydrated from the real API value") } - }) - - t.Run("PreserveNullsFrom_KeepsSetFields", func(t *testing.T) { - // Plan has all optional fields explicitly set - plan := WorkloadRuleResourceModel{ - CpuRule: &ResourceRuleConfigModel{ - Enabled: types.BoolValue(true), - MinRequest: types.Int64Value(100), - MaxRequest: types.Int64Null(), - LimitMultiplier: types.Float32Null(), - LimitsAdjustmentEnabled: types.BoolValue(false), - TargetPercentile: types.Float32Null(), - MaxScaleUpPercent: types.Float32Null(), - MaxScaleDownPercent: types.Float32Null(), - LimitsRemovalEnabled: types.BoolValue(false), - }, - EmergencyResponse: &EmergencyResponseModel{ - OomEnabled: types.BoolValue(true), - OomMemoryMultiplier: types.Float32Value(1.5), - OomMaxReactions: types.Int32Value(5), - OomCooldownSeconds: types.Int32Value(10), - CpuThrottlingEnabled: types.BoolValue(true), - CpuThrottlingThreshold: types.Float32Value(0.2), - CpuThrottlingMultiplier: types.Float32Value(1.25), - }, - ActionTriggers: types.ListValueMust(types.StringType, []attr.Value{ - types.StringValue("on_detection"), - }), - DetectionTriggers: types.ListValueMust(types.StringType, []attr.Value{}), - SchedulerPlugins: types.ListValueMust(types.StringType, []attr.Value{}), - CooldownMinutes: types.Int32Value(30), - StartupPeriodSeconds: types.Int64Value(120), + if data.MemoryRule == nil { + t.Error("Expected MemoryRule to be hydrated from the real API value") } - - cooldown := int32(30) - startupPeriod := int64(120) - apiRule := &apiv1.WorkloadRule{ - RuleId: "rule-full", - ClusterId: "cluster-1", - Namespace: "default", - Kind: "Deployment", - Name: "my-app", - CurrentSource: "manual", - CooldownMinutes: &cooldown, - StartupPeriodSeconds: &startupPeriod, - CpuRule: &apiv1.ResourceRuleConfig{Enabled: true}, - EmergencyResponse: &apiv1.EmergencyResponseConfig{ - OomEnabled: true, - OomMemoryMultiplier: 1.5, - OomMaxReactions: 5, - OomCooldownSeconds: 10, - CpuThrottlingEnabled: true, - }, - ActionTriggers: []apiv1.ActionTrigger{ - apiv1.ActionTrigger_ACTION_TRIGGER_ON_DETECTION, - }, - DetectionTriggers: []apiv1.WorkloadDetectionTrigger{}, - SchedulerPlugins: []string{}, - } - - var data WorkloadRuleResourceModel - data.fromProto(apiRule) - data.preserveNullsFrom(&plan) - - // Fields set in the plan must NOT be wiped - if data.CpuRule == nil { - t.Error("Expected CpuRule to remain non-nil") + if data.GpuRule == nil { + t.Error("Expected GpuRule to be hydrated from the real API value") } if data.EmergencyResponse == nil { - t.Error("Expected EmergencyResponse to remain non-nil") + t.Error("Expected EmergencyResponse to be hydrated from the real API value") } if data.ActionTriggers.IsNull() { - t.Error("Expected ActionTriggers to remain non-null") + t.Error("Expected ActionTriggers to be hydrated (non-null)") } - if data.CooldownMinutes.IsNull() { - t.Error("Expected CooldownMinutes to remain non-null") + if data.DetectionTriggers.IsNull() { + t.Error("Expected DetectionTriggers to be hydrated (non-null)") } - if data.CooldownMinutes.ValueInt32() != 30 { - t.Errorf("Expected CooldownMinutes=30, got %d", data.CooldownMinutes.ValueInt32()) + if data.SchedulerPlugins.IsNull() { + t.Error("Expected SchedulerPlugins to be hydrated (non-null)") } - if data.StartupPeriodSeconds.IsNull() { - t.Error("Expected StartupPeriodSeconds to remain non-null") + if data.CooldownMinutes.IsNull() || data.CooldownMinutes.ValueInt32() != 15 { + t.Errorf("Expected CooldownMinutes=15, got %v", data.CooldownMinutes) } - if data.StartupPeriodSeconds.ValueInt64() != 120 { - t.Errorf("Expected StartupPeriodSeconds=120, got %d", data.StartupPeriodSeconds.ValueInt64()) + if data.StartupPeriodSeconds.IsNull() || data.StartupPeriodSeconds.ValueInt64() != 300 { + t.Errorf("Expected StartupPeriodSeconds=300, got %v", data.StartupPeriodSeconds) } })