Skip to content

Commit 2d4f4b0

Browse files
authored
[TF-23089] implement write only values for tfe_policy_set_parameter (#1641)
* feat: add a write-only value to tfe_policy_set_parameter * docs: update changelog * docs: update docs
1 parent 5e391d5 commit 2d4f4b0

File tree

6 files changed

+342
-22
lines changed

6 files changed

+342
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ FEATURES:
1515

1616
ENHANCEMENTS:
1717

18-
* resource/tfe_variable: Add `value_wo` write-only attribute ([#1639](https://github.com/hashicorp/terraform-provider-tfe/pull/1639))
18+
* resource/tfe_variable: Add `value_wo` write-only attribute, by @uturunku ([#1639](https://github.com/hashicorp/terraform-provider-tfe/pull/1639))
1919

20-
* resource/tfe_test_variable: Add `value_wo` write-only attribute ([#1639](https://github.com/hashicorp/terraform-provider-tfe/pull/1639))
20+
* resource/tfe_test_variable: Add `value_wo` write-only attribute, by @uturunku ([#1639](https://github.com/hashicorp/terraform-provider-tfe/pull/1639))
21+
22+
* resource/tfe_policy_set_parameter: Add `value_wo` write-only attribute, by @ctrombley ([#1641](https://github.com/hashicorp/terraform-provider-tfe/pull/1641))
2123

2224
## v.0.64.0
2325

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package helpers
5+
6+
import (
7+
"context"
8+
"crypto/sha256"
9+
"encoding/hex"
10+
"encoding/json"
11+
"fmt"
12+
13+
"github.com/hashicorp/terraform-plugin-framework/diag"
14+
"github.com/hashicorp/terraform-plugin-framework/types"
15+
)
16+
17+
func NewWriteOnlyValueStore(private PrivateState, attributeName string) *WriteOnlyValueStore {
18+
return &WriteOnlyValueStore{
19+
private: private,
20+
attributeName: attributeName,
21+
}
22+
}
23+
24+
type WriteOnlyValueStore struct {
25+
private PrivateState
26+
attributeName string
27+
}
28+
29+
type PrivateState interface {
30+
SetKey(ctx context.Context, key string, value []byte) diag.Diagnostics
31+
GetKey(ctx context.Context, key string) ([]byte, diag.Diagnostics)
32+
}
33+
34+
// MatchesPriorValue determines if the given string value matches the prior
35+
// value in state by comparing the hased values of each.
36+
func (w *WriteOnlyValueStore) MatchesPriorValue(ctx context.Context, configValue types.String) (bool, diag.Diagnostics) {
37+
serializedPriorValue, diags := w.private.GetKey(ctx, w.attributeName)
38+
var hashedPriorValue string
39+
err := json.Unmarshal(serializedPriorValue, &hashedPriorValue)
40+
if err != nil {
41+
diags.AddError(fmt.Sprintf("failed to unmarshal prior value for `%s`", w.attributeName), err.Error())
42+
}
43+
44+
hashedValue := generateSHA256Hash(configValue.ValueString())
45+
return hashedPriorValue == hashedValue, diags
46+
}
47+
48+
// PriorValueExists determines if a hashed prior value exists in state.
49+
func (w *WriteOnlyValueStore) PriorValueExists(ctx context.Context) (bool, diag.Diagnostics) {
50+
serializedPriorValue, diags := w.private.GetKey(ctx, w.attributeName)
51+
return len(serializedPriorValue) != 0, diags
52+
}
53+
54+
// SetPriorValue stores the hashed value of the given string value in state.
55+
func (w *WriteOnlyValueStore) SetPriorValue(ctx context.Context, configValue types.String) diag.Diagnostics {
56+
// If not write-only, then remove the hashed value from private state.
57+
// Setting a key with an empty byte slice is interpreted by the framework as a request to remove the key from the ProviderData map.
58+
if configValue.IsNull() {
59+
return w.private.SetKey(ctx, w.attributeName, []byte(""))
60+
}
61+
62+
// Store the hashed value of the string in private state.
63+
hashedValue := generateSHA256Hash(configValue.ValueString())
64+
return w.private.SetKey(ctx, w.attributeName, fmt.Appendf(nil, `"%s"`, hashedValue))
65+
}
66+
67+
func generateSHA256Hash(data string) string {
68+
hasher := sha256.New()
69+
hasher.Write([]byte(data))
70+
return hex.EncodeToString(hasher.Sum(nil))
71+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package planmodifiers
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
"github.com/hashicorp/terraform-plugin-framework/path"
11+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
12+
"github.com/hashicorp/terraform-plugin-framework/types"
13+
"github.com/hashicorp/terraform-plugin-log/tflog"
14+
"github.com/hashicorp/terraform-provider-tfe/internal/provider/helpers"
15+
)
16+
17+
var _ planmodifier.String = &replaceForWriteOnlyStringValue{}
18+
19+
func NewReplaceForWriteOnlyStringValue(attributeWriteOnly string) planmodifier.String {
20+
return &replaceForWriteOnlyStringValue{
21+
attributeWriteOnly: attributeWriteOnly,
22+
}
23+
}
24+
25+
// replaceForWriteOnlyStringValue is a plan modifier that will cause a resource
26+
// to be replaced if the value of a write-only attribute has changed.
27+
//
28+
// For this to work, the write-only attribute must be added to private state
29+
// using WriteOnlyValueStore.SetPriorValue() after creating or updating the value.
30+
type replaceForWriteOnlyStringValue struct {
31+
attributeWriteOnly string
32+
}
33+
34+
func (p *replaceForWriteOnlyStringValue) Description(ctx context.Context) string {
35+
return "The resource will be replaced when the value of `%s` has changed"
36+
}
37+
38+
func (p *replaceForWriteOnlyStringValue) MarkdownDescription(ctx context.Context) string {
39+
return p.Description(ctx)
40+
}
41+
42+
func (p *replaceForWriteOnlyStringValue) PlanModifyString(ctx context.Context, request planmodifier.StringRequest, response *planmodifier.StringResponse) {
43+
// This plan modifier can be used to trigger a resource replacement when the
44+
// value of a write-only attribute has changed.
45+
//
46+
// Write-only argument values cannot produce a Terraform plan difference on
47+
// their own. The prior state value for a write-only argument will always be
48+
// null, and the planned state value will also be null.
49+
//
50+
// The one exception to this case is if the write-only argument is added to
51+
// requires_replace during Plan Modification, in that case, the write-only
52+
// argument will always cause a diff/trigger a resource recreation.
53+
var writeOnlyValue types.String
54+
diags := request.Config.GetAttribute(ctx, path.Root(p.attributeWriteOnly), &writeOnlyValue)
55+
response.Diagnostics.Append(diags...)
56+
if response.Diagnostics.HasError() {
57+
return
58+
}
59+
60+
writeOnlyValueExists := !writeOnlyValue.IsNull()
61+
writeOnlyValueStore := helpers.NewWriteOnlyValueStore(request.Private, p.attributeWriteOnly)
62+
priorWriteOnlyValueExists, diags := writeOnlyValueStore.PriorValueExists(ctx)
63+
response.Diagnostics.Append(diags...)
64+
if response.Diagnostics.HasError() {
65+
return
66+
}
67+
68+
if !writeOnlyValueExists && priorWriteOnlyValueExists {
69+
tflog.Debug(ctx, fmt.Sprintf("Replacing resource because the write-only `%s` attribute has been removed", p.attributeWriteOnly))
70+
response.RequiresReplace = true
71+
return
72+
}
73+
74+
if !writeOnlyValueExists {
75+
return
76+
}
77+
78+
// Now are dealing with a write-only attribute that has a value set.
79+
if !priorWriteOnlyValueExists {
80+
tflog.Debug(ctx, fmt.Sprintf("Replacing resource because the write-only `%s` attribute has been newly added to a resource in state", p.attributeWriteOnly))
81+
response.RequiresReplace = true
82+
return
83+
}
84+
85+
matches, diags := writeOnlyValueStore.MatchesPriorValue(ctx, writeOnlyValue)
86+
response.Diagnostics.Append(diags...)
87+
88+
if !matches {
89+
tflog.Debug(ctx, fmt.Sprintf("Replacing resource because the value of the write-only `%s` attribute has changed", p.attributeWriteOnly))
90+
response.RequiresReplace = true
91+
}
92+
}

internal/provider/resource_tfe_policy_set_parameter.go

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"context"
88
"errors"
99
"fmt"
10-
"log"
1110
"regexp"
1211
"strings"
1312

@@ -23,6 +22,9 @@ import (
2322
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
2423
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
2524
"github.com/hashicorp/terraform-plugin-framework/types"
25+
"github.com/hashicorp/terraform-plugin-log/tflog"
26+
"github.com/hashicorp/terraform-provider-tfe/internal/provider/helpers"
27+
"github.com/hashicorp/terraform-provider-tfe/internal/provider/planmodifiers"
2628
)
2729

2830
var (
@@ -43,11 +45,12 @@ type modelTFEPolicySetParameter struct {
4345
ID types.String `tfsdk:"id"`
4446
Key types.String `tfsdk:"key"`
4547
Value types.String `tfsdk:"value"`
48+
ValueWO types.String `tfsdk:"value_wo"`
4649
Sensitive types.Bool `tfsdk:"sensitive"`
4750
PolicySetID types.String `tfsdk:"policy_set_id"`
4851
}
4952

50-
func modelFromTFEPolicySetParameter(v *tfe.PolicySetParameter, lastValue types.String) modelTFEPolicySetParameter {
53+
func modelFromTFEPolicySetParameter(v *tfe.PolicySetParameter, lastValue types.String, isWriteOnly bool) modelTFEPolicySetParameter {
5154
p := modelTFEPolicySetParameter{
5255
ID: types.StringValue(v.ID),
5356
Key: types.StringValue(v.Key),
@@ -62,6 +65,11 @@ func modelFromTFEPolicySetParameter(v *tfe.PolicySetParameter, lastValue types.S
6265
p.Value = lastValue
6366
}
6467

68+
// If the variable is write-only, clear the value.
69+
if isWriteOnly {
70+
p.Value = types.StringValue("")
71+
}
72+
6573
return p
6674
}
6775

@@ -126,6 +134,22 @@ func (r *resourceTFEPolicySetParameter) Schema(ctx context.Context, req resource
126134
Computed: true,
127135
Default: stringdefault.StaticString(""),
128136
Sensitive: true,
137+
Validators: []validator.String{
138+
stringvalidator.ConflictsWith(path.MatchRoot("value_wo")),
139+
},
140+
},
141+
142+
"value_wo": schema.StringAttribute{
143+
Optional: true,
144+
WriteOnly: true,
145+
Sensitive: true,
146+
Description: "Value of the parameter in write-only mode",
147+
Validators: []validator.String{
148+
stringvalidator.ConflictsWith(path.MatchRoot("value")),
149+
},
150+
PlanModifiers: []planmodifier.String{
151+
planmodifiers.NewReplaceForWriteOnlyStringValue("value_wo"),
152+
},
129153
},
130154

131155
"sensitive": schema.BoolAttribute{
@@ -168,30 +192,46 @@ func (r *resourceTFEPolicySetParameter) Schema(ctx context.Context, req resource
168192
}
169193

170194
func (r *resourceTFEPolicySetParameter) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
171-
// Read the Terraform plan into the model
172-
var plan modelTFEPolicySetParameter
195+
// Read the Terraform plan and config into the model
196+
var plan, config modelTFEPolicySetParameter
173197
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
198+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
174199
if resp.Diagnostics.HasError() {
175200
return
176201
}
177202

178203
// Create an options struct
179204
options := tfe.PolicySetParameterCreateOptions{
180205
Key: plan.Key.ValueStringPointer(),
181-
Value: plan.Value.ValueStringPointer(),
182206
Category: tfe.Category(tfe.CategoryPolicySet),
183207
Sensitive: plan.Sensitive.ValueBoolPointer(),
184208
}
185209

210+
// Set Value from `value_wo` if set, otherwise use the normal value
211+
isWriteOnly := !config.ValueWO.IsNull()
212+
if isWriteOnly {
213+
options.Value = config.ValueWO.ValueStringPointer()
214+
} else {
215+
options.Value = plan.Value.ValueStringPointer()
216+
}
217+
186218
// Create the policy set parameter
187-
log.Printf("[DEBUG] Create %s parameter: %s", tfe.CategoryPolicySet, plan.Key.ValueString())
219+
tflog.Debug(ctx, fmt.Sprintf("Create %s parameter: %s", tfe.CategoryPolicySet, plan.Key.ValueString()))
188220
p, err := r.config.Client.PolicySetParameters.Create(ctx, plan.PolicySetID.ValueString(), options)
189221
if err != nil {
190222
resp.Diagnostics.AddError(fmt.Sprintf("Error creating %s parameter %s", tfe.CategoryPolicySet, plan.Key), err.Error())
191223
return
192224
}
193225

194-
result := modelFromTFEPolicySetParameter(p, plan.Value)
226+
// Store the hashed write-only value in the private state
227+
store := r.writeOnlyValueStore(resp.Private)
228+
resp.Diagnostics.Append(store.SetPriorValue(ctx, config.ValueWO)...)
229+
if resp.Diagnostics.HasError() {
230+
return
231+
}
232+
233+
// Update state
234+
result := modelFromTFEPolicySetParameter(p, plan.Value, isWriteOnly)
195235
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
196236
}
197237

@@ -211,33 +251,36 @@ func (r *resourceTFEPolicySetParameter) Read(ctx context.Context, req resource.R
211251
}
212252

213253
// Read the policy set parameter
214-
log.Printf("[DEBUG] Read parameter: %s", state.ID)
254+
tflog.Debug(ctx, fmt.Sprintf("Read parameter: %s", state.ID))
215255
p, err := r.config.Client.PolicySetParameters.Read(ctx, state.PolicySetID.ValueString(), state.ID.ValueString())
216256
if err != nil {
217257
if errors.Is(err, tfe.ErrResourceNotFound) {
218-
log.Printf("[DEBUG] Parameter %s no longer exists", state.ID)
258+
tflog.Debug(ctx, fmt.Sprintf("Parameter %s no longer exists", state.ID))
219259
resp.State.RemoveResource(ctx)
220260
}
221261

222262
resp.Diagnostics.AddError(fmt.Sprintf("Error reading %s parameter %s", tfe.CategoryPolicySet, state.ID), err.Error())
223263
return
224264
}
225265

226-
result := modelFromTFEPolicySetParameter(p, state.Value)
266+
// Check if the parameter is write-only
267+
isWriteOnly, diags := r.writeOnlyValueStore(resp.Private).PriorValueExists(ctx)
268+
resp.Diagnostics.Append(diags...)
269+
if diags.HasError() {
270+
return
271+
}
272+
273+
result := modelFromTFEPolicySetParameter(p, state.Value, isWriteOnly)
227274
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
228275
}
229276

230277
// Update implements resource.Resource
231278
func (r *resourceTFEPolicySetParameter) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
232-
// Read the Terraform plan into the model
233-
var plan modelTFEPolicySetParameter
279+
// Read the Terraform plan, state, and config into the model
280+
var plan, state, config modelTFEPolicySetParameter
234281
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
235-
if resp.Diagnostics.HasError() {
236-
return
237-
}
238-
// Read the Terraform state into the model
239-
var state modelTFEPolicySetParameter
240282
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
283+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
241284
if resp.Diagnostics.HasError() {
242285
return
243286
}
@@ -256,13 +299,21 @@ func (r *resourceTFEPolicySetParameter) Update(ctx context.Context, req resource
256299
}
257300

258301
// Update the policy set parameter
259-
log.Printf("[DEBUG] Update parameter: %s", plan.ID.ValueString())
302+
tflog.Debug(ctx, fmt.Sprintf("Update parameter: %s", plan.ID.ValueString()))
260303
p, err := r.config.Client.PolicySetParameters.Update(ctx, plan.PolicySetID.ValueString(), plan.ID.ValueString(), options)
261304
if err != nil {
262305
resp.Diagnostics.AddError(fmt.Sprintf("Error updating parameter %s", plan.ID), err.Error())
263306
}
264307

265-
result := modelFromTFEPolicySetParameter(p, plan.Value)
308+
// Store the hashed write-only value in the private state
309+
store := r.writeOnlyValueStore(resp.Private)
310+
resp.Diagnostics.Append(store.SetPriorValue(ctx, config.ValueWO)...)
311+
if resp.Diagnostics.HasError() {
312+
return
313+
}
314+
315+
// Update state
316+
result := modelFromTFEPolicySetParameter(p, plan.Value, !config.ValueWO.IsNull())
266317
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
267318
}
268319

@@ -283,7 +334,7 @@ func (r *resourceTFEPolicySetParameter) Delete(ctx context.Context, req resource
283334
}
284335

285336
// Delete the policy set parameter
286-
log.Printf("[DEBUG] Delete parameter: %s", state.ID)
337+
tflog.Debug(ctx, fmt.Sprintf("Delete parameter: %s", state.ID))
287338
err = r.config.Client.PolicySetParameters.Delete(ctx, state.PolicySetID.ValueString(), state.ID.ValueString())
288339
if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) {
289340
resp.Diagnostics.AddError(
@@ -313,3 +364,7 @@ func (r *resourceTFEPolicySetParameter) ImportState(ctx context.Context, req res
313364
diags := resp.State.Set(ctx, &data)
314365
resp.Diagnostics.Append(diags...)
315366
}
367+
368+
func (r *resourceTFEPolicySetParameter) writeOnlyValueStore(private helpers.PrivateState) *helpers.WriteOnlyValueStore {
369+
return helpers.NewWriteOnlyValueStore(private, "value_wo")
370+
}

0 commit comments

Comments
 (0)