Skip to content

Commit 2d10330

Browse files
committed
expand following review
1 parent 4606023 commit 2d10330

File tree

1 file changed

+201
-36
lines changed

1 file changed

+201
-36
lines changed

docs/design/plan-modification.md

Lines changed: 201 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ The Terraform [resource instance change lifecycle](https://github.com/hashicorp/
55
1. `PlanResourceChange`
66
1. `ApplyResourceChange`
77

8-
The plugin framework handles requests and responses for these RPCs, allowing providers to hook in to `ValidateResourceTypeConfig` via validation helpers (https://github.com/hashicorp/terraform-plugin-framework/issues/17), and to `ApplyResourceChange` via resource CRUD functions. This design document concerns the ways we allow provider developers to hook into the `PlanResourceChange` RPC, giving providers control over the plan rendered to users via the Terraform CLI.
8+
The plugin framework handles requests and responses for these RPCs, allowing providers to hook in to `ValidateResourceTypeConfig` via validation helpers (https://github.com/hashicorp/terraform-plugin-framework/issues/17), and to `ApplyResourceChange` via resource CRUD functions. This design document concerns the ways we allow provider developers to hook into the `PlanResourceChange` RPC, giving providers control over the plan rendered to users via the Terraform CLI, and which Terraform commits to apply.
99

1010
## The `PlanResourceChange` RPC
1111

@@ -37,15 +37,15 @@ The `PlanResourceChange` RPC response also contains a list of attribute paths: `
3737

3838
In allowing providers to control the `PlanResourceChange` response, i.e. "modify the plan", the plugin framework therefore enables providers not only to modify the diff that will be displayed to the user (and ultimately applied), but also to branch into a destroy-and-create lifecycle phase, triggering other RPCs.
3939

40-
Plan modification has two distinct use cases for providers:
40+
Plan modification currently has two distinct use cases for providers:
4141
- Modifying plan values, and
4242
- Forcing resource replacement.
4343

4444
This design document therefore distinguishes "ModifyPlan" from "RequiresReplace" behaviour, the former being a superset of the latter.
4545

46-
## History: `ForceNew` and `CustomizeDiff`
46+
## History: `ForceNew`, `DiffSuppressFunc`, and `CustomizeDiff`
4747

48-
In `helper/schema`, there are two ways, both somewhat indirect, that a provider can customise the plan.
48+
In `helper/schema`, there are three ways, all somewhat indirect, that a provider can customise the plan.
4949

5050
### `ForceNew`
5151

@@ -61,10 +61,24 @@ After receiving the `PlanResourceChange` request, the SDK determines whether any
6161

6262
The SDK also executes logic at resource validation time (`InternalValidate`) to enforce the condition that if a resource does not have an Update function, all non-Computed attributes must have ForceNew set; and that if all fields are ForceNew or Computed without Optional, Update must _not_ be defined.
6363

64+
### `DiffSuppressFunc`
65+
66+
```go
67+
type SchemaDiffSuppressFunc func(k, old, new string, d *ResourceData) bool
68+
```
69+
70+
Providers can use another schema behaviour, `DiffSuppressFunc`, to control whether a detected diff on a schema field should be considered valid. If this function returns true, any diff in the element values is ignored. This is commonly used to ignore differences in string capitalisation, or logically equivalent JSON values.
71+
6472
### `CustomizeDiff`
6573

74+
```go
75+
type CustomizeDiffFunc func(context.Context, *ResourceDiff, interface{}) error
76+
```
77+
6678
Rather than exposing Terraform plans to provider developers, `helper/schema` has as a first-class concept the _resource diff_. Providers can optionally define a `CustomizeDiff` method on the `Resource` struct, which resembles a CRUD method, except that instead of `ResourceData` the function is supplied a `*ResourceDiff`, and is called during several points in the resource lifecycle, and must therefore be "resilient to support all scenarios".
6779

80+
Unlike `DiffSuppressFunc`, `CustomizeDiff` is supplied the `meta` parameter, so API calls can be made.
81+
6882
A large proportion of the examples of `CustomizeDiff` in large cloud provider code involves conditionally setting `ForceNew` behaviour on an attribute, most often:
6983
- If certain conditions hold on the value of the attribute (e.g. if the bandwidth of an instance is reduced)
7084
- If certain conditions hold on the values of the attribute and other attributes.
@@ -77,7 +91,7 @@ The legacy SDK provides a set of reusable and composable helper functions in its
7791

7892
## Solution options
7993

80-
### `tfsdk.Resource.ModifyPlan()`
94+
### 1. `tfsdk.Resource.ModifyPlan()`
8195

8296
An extension to the `tfsdk.Resource` interface could add an optional `ModifyPlan()` function to resource implementations:
8397

@@ -128,10 +142,6 @@ type ModifyResourcePlanResponse struct {
128142
// generated.
129143
Diagnostics []*tfprotov6.Diagnostic
130144
}
131-
132-
func (r ModifyResourcePlanResponse) AppendRequiresReplace(attrPath *tftypes.AttributePath) {
133-
r.RequiresReplace = append(r.RequiresReplace, attrPath)
134-
}
135145
```
136146

137147
The only field unique to the `ModifyPlan` request or response types is `RequiresReplace` (whose name is copied directly from the protocol, but which could very well be called `ForceNewAttributes` or similar).
@@ -155,7 +165,7 @@ func (r myFileResource) ModifyPlan(ctx context.Context, req ModifyResourcePlanRe
155165

156166
// force resource recreation if the new favourite number is larger than the old
157167
if plan.FavoriteNumber > state.FavoriteNumber {
158-
resp.AppendRequiresReplace(tftypes.NewAttributePath.WithAttributeName("favorite_number"))
168+
resp.RequiresReplace = append(resp.RequiresReplace, tftypes.NewAttributePath.WithAttributeName("favorite_number"))
159169
}
160170
}
161171
```
@@ -168,7 +178,7 @@ A `ModifyPlan` method on a Resource is as unit testable as any CRUD method, and
168178

169179
The main tradeoff here is verbosity. The actual work done by the function is the selection of attribute path(s) whose old and new values should be compared, the comparison condition, and the selection of attribute path(s) to mark as RequiresReplace. In the `FavoriteNumber` example above in particular, a less verbose option is illustrated below with the use of `schema.Attribute.ModifyPlanFunc`. For complex cases of plan modification involving multiple attributes, reading config, or making API calls, the `Resource.ModifyPlan` method has an appropriate amount of verbosity. We anticipate that most use cases for plan modification will not be this complex.
170180

171-
### `schema.Attribute.RequiresReplace`
181+
### 2. `schema.Attribute.RequiresReplace`
172182

173183
Like `helper/schema`, we could add a `ForceNew bool`, here called `RequiresReplace` to match the protocol, to the framework's `schema.Attribute` struct, enabling provider developers to take advantage of this simple schema behaviour with one line of code.
174184

@@ -182,7 +192,7 @@ A cursory survey of existing provider code finds `ForceNew` very widely used, an
182192

183193
One possible disadvantage of this approach is that it is atomic with respect to compatibility - unlike provider-defined plan modification functions like those described below, the framework must deprecate the field or undergo a breaking change in order to modify the behaviour of `schema.Attribute.RequiresReplace`.
184194

185-
### `schema.Attribute.RequiresReplaceIf`
195+
### 3. `schema.Attribute.RequiresReplaceIf`
186196

187197
An field on the `schema.Attribute` struct could add an optional `RequiresReplaceIf` function to schema attributes:
188198

@@ -193,7 +203,7 @@ type Attribute struct {
193203
RequiresReplaceIf RequiresReplaceIfFunc
194204
}
195205

196-
type RequiresReplaceIfFunc func(context.Context, old, new attr.Value) bool
206+
type RequiresReplaceIfFunc func(context.Context, state, config attr.Value) bool
197207
```
198208

199209
Provider code:
@@ -208,12 +218,12 @@ func (f fileResourceType) GetSchema(_ context.Context) (schema.Schema, []*tfprot
208218
"favorite_number": {
209219
Type: types.NumberType,
210220
Required: true,
211-
RequiresReplaceIf: func(ctx context.Context, old, new attr.Value) bool {
212-
oldVal := old.(types.Number)
213-
newVal := new.(types.Number)
221+
RequiresReplaceIf: func(ctx context.Context, state, config attr.Value) bool {
222+
stateVal := state.(types.Number)
223+
configVal := config.(types.Number)
214224

215-
if !oldVal.Unknown && !oldVal.Null && !newVal.Unknown && !newVal.Null {
216-
if newVal.Value.Cmp(oldVal.Value) > 0 {
225+
if !stateVal.Unknown && !stateVal.Null && !configVal.Unknown && !configVal.Null {
226+
if configVal.Value.Cmp(stateVal.Value) > 0 {
217227
return true
218228
}
219229
}
@@ -239,24 +249,10 @@ Compatibility may be an issue if a common use case emerges for RequiresReplace c
239249
```go
240250
type RequiresReplaceIfFunc (context.Context, ModifyPlanRequest) bool
241251
```
242-
By doing this, however, we would lose the benefits of the `old, new` parameters in reducing verbosity - the provider code would have to repeat the attribute path in order to retrieve the values from `req.Plan` and `req.State`.
252+
By doing this, however, we would lose the benefits of the `state, config` parameters in reducing verbosity - the provider code would have to repeat the attribute path in order to retrieve the values from `req.Plan` and `req.State`.
243253

244-
### `attr.TypeWithRequiresReplace`
245254

246-
A `ModifyPlan()` or `RequiresReplaceIf()` function could be added to an extension of the `attr.Type` interface:
247-
248-
```go
249-
type TypeWithRequiresReplace interface {
250-
Type
251-
252-
RequiresReplaceIf(context.Context, old, new Value) bool
253-
```
254-
255-
This would allow bundling reusable RequiresReplace behaviour up with a custom type's validation and other behaviours.
256-
257-
Without knowing how custom types will be used by provider developers, this option seems premature, and makes less sense than bundling validation functions with custom types.
258-
259-
### Composition and other helpers
255+
### 3a. Composition and other helpers
260256

261257
#### `All()`
262258

@@ -268,7 +264,7 @@ func All(funcs ...RequiresReplaceFunc) bool {}
268264

269265
#### `Sequence()`
270266

271-
Similarly to `helper/schema`, the `Sequence` composition helper runs all `RequiresReplaceFunc`s in sequence, stopping at the first that returns false.
267+
Similarly to `helper/schema`, the `Sequence` composition helper runs all `RequiresReplaceFunc`s in sequence, stopping at the first that returns true.
272268

273269
```go
274270
func Sequence(funcs ...RequiresReplaceFunc) bool {}
@@ -293,6 +289,175 @@ func RequiresReplaceIfSet(f RequiresReplaceFunc) RequiresReplaceFunc {
293289

294290
Note that this would require the addition of `IsNull()` and `IsUnknown()` functions to the `attr.Value` interface, since there is at present no way to determine whether a generic `attr.Value` is null or unknown.
295291

292+
### 4. `schema.Attribute.PlanModifier`
293+
294+
Extending the abstraction of `RequiresReplaceIf` one level higher, we can add a `PlanModifiers` field on the `schema.Attribute` struct, with the following framework code:
295+
296+
```go
297+
type AttributePlanModifier interface {
298+
Description(context.Context) string
299+
MarkdownDescription(context.Context) string
300+
301+
Modify(context.Context, ModifyAttributePlanRequest, *ModifyAttributePlanResponse)
302+
}
303+
304+
type AttributePlanModifiers []AttributePlanModifier
305+
306+
type Attribute struct {
307+
// ...
308+
PlanModifiers AttributePlanModifiers
309+
}
310+
311+
type ModifyAttributePlanRequest struct {
312+
// Config is the configuration the user supplied for the attribute.
313+
Config attr.Value
314+
315+
// State is the current state of the attribute.
316+
State attr.Value
317+
318+
// Plan is the planned new state for the attribute.
319+
Plan attr.Value
320+
321+
// ProviderMeta is metadata from the provider_meta block of the module.
322+
ProviderMeta Config
323+
}
324+
325+
type ModifyAttributePlanResponse struct {
326+
// Plan is the planned new state for the attribute.
327+
Plan attr.Value
328+
329+
// RequiresReplace indicates whether a change in the attribute
330+
// requires replacement of the whole resource.
331+
RequiresReplace bool
332+
333+
// Diagnostics report errors or warnings related to determining the
334+
// planned state of the requested resource. Returning an empty slice
335+
// indicates a successful validation with no warnings or errors
336+
// generated.
337+
Diagnostics []*tfprotov6.Diagnostic
338+
}
339+
```
340+
341+
This approach directly offers documentation hooks, so that plan modification behaviours can be documented alongside their definition and included in generated schema docs.
342+
343+
In this case, `RequiresReplace` and `RequiresReplaceIf` can be implemented as `AttributePlanModifier`s, e.g.:
344+
345+
346+
```go
347+
func RequiresReplace() AttributePlanModifier {
348+
return RequiresReplaceModifier{}
349+
}
350+
351+
type RequiresReplaceModifier struct{}
352+
353+
func (r RequiresReplace) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) {
354+
resp.RequiresReplace = true
355+
}
356+
357+
func (r RequiresReplace) Description(ctx context.Context) string {
358+
// ...
359+
}
360+
361+
func (r RequiresReplace) MarkdownDescription(ctx context.Context) string {
362+
// ...
363+
}
364+
```
365+
366+
```go
367+
func RequiresReplaceIf(f RequiresReplaceIfFunc, description markdownDescription string) AttributePlanModifier {
368+
return RequiresReplaceIfModifier{
369+
f: f,
370+
description: description,
371+
markdownDescription: markdownDescription
372+
}
373+
}
374+
375+
type RequiresReplaceIfFunc func(context.Context, state, config attr.Value) bool
376+
377+
type RequiresReplaceIfModifier struct {
378+
f RequiresReplaceIfFunc
379+
description string
380+
markdownDescription string
381+
}
382+
383+
func (r RequiresReplaceIfModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) {
384+
resp.RequiresReplace = r.f(ctx, req.State, req.Config)
385+
}
386+
387+
func (r RequiresReplaceIfModifier) Description(ctx context.Context) string {
388+
return r.description
389+
}
390+
391+
func (r RequiresReplaceIfModifier) MarkdownDescription(ctx context.Context) string {
392+
return r.markdownDescription
393+
}
394+
```
395+
396+
Provider code:
397+
398+
```go
399+
type fileResourceType struct{}
400+
401+
// GetSchema returns the schema for this resource.
402+
func (f fileResourceType) GetSchema(_ context.Context) (schema.Schema, []*tfprotov6.Diagnostic) {
403+
return schema.Schema{
404+
Attributes: map[string]schema.Attribute{
405+
"favorite_number": {
406+
Type: types.NumberType,
407+
Required: true,
408+
PlanModifiers: schema.AttributePlanModifiers{
409+
RequiresReplaceIf(func(ctx context.Context, state, config attr.Value) bool {
410+
stateVal := state.(types.Number)
411+
configVal := config.(types.Number)
412+
413+
if !stateVal.Unknown && !stateVal.Null && !configVal.Unknown && !configVal.Null {
414+
if configVal.Value.Cmp(stateVal.Value) > 0 {
415+
return true
416+
}
417+
}
418+
return false
419+
}),
420+
CustomModifier,
421+
// ...
422+
},
423+
},
424+
},
425+
}, nil
426+
}
427+
```
428+
429+
Here, `CustomModifier` is a user-defined `AttributePlanModifier`.
430+
431+
The `AttriburePlanModifier`s in the slice of `PlanModifiers` are executed in order. Note that unlike the `customdiff.All` and `customdiff.Sequence` composition helpers in SDKv2, there is no choice to be made here between executing all helpers, and stopping at the first that "returns true", since the function could be setting the `resp.RequiresReplace` bool _or_ modifying the plan.
432+
433+
The fields in the `ModifyAttributePlanRequest` and `ModifyAttributePlanResponse` struct are not the same as those in `ModifyResourcePlanRequest` and `ModifyResourcePlanResponse` from option 1. In particular, it is not possible to change the planned value of _other_ attributes inside an attribute's `PlanModifier`. If it were, it would be possible for two or more attributes to have `PlanModifier`s modifying each other's planned values, with no clear indication of the order in which those operations would be performed.
434+
435+
Framework documentation should make it clear that `schema.Attribute.PlanModifier` is scoped to a single attribute with no access to other attribute values or API requests. If either of these is needed, provider developers should use `tfsdk.Resource.ModifyPlan()`.
436+
437+
#### Tradeoffs
438+
439+
Similarly to option 3, the plan modifier functions are easily unit testable, and the `PlanModifier` field easily discoverable on the `schema.Attribute` struct. The code is reasonably Go-native.
440+
441+
This solution aims to improve on the compatibility of option 3 by ensuring that no further fields need be added to `schema.Attribute` in future for the purposes of plan modification.
442+
443+
If we were to change the fields of `ModifyResourcePlanRequest` to allow access to the full plan, state, and config (so that the result of the modify plan function could depend on the planned value of another attribute, for example), it would come at the cost of verbosity in the user-defined `RequiresIfFunc`, since the user must now retrieve the attribute values from the full `Config` and `State` rather than having them supplied as `attr.Value`s in the function arguments.
444+
445+
### 5. `attr.TypeWithModifyPlan`
446+
447+
A `ModifyPlan()` or `RequiresReplaceIf()` function could be added to an extension of the `attr.Type` interface:
448+
449+
```go
450+
type TypeWithModifyPlan interface {
451+
Type
452+
453+
ModifyPlan(context.Context, ModifyAttributeTypePlanRequest, *ModifyAttributeTypePlanResponse) bool
454+
```
455+
456+
This would allow bundling reusable `ModifyPlan` behaviour up with a custom type's validation and other behaviours. This could be useful, for example, in a custom timestamp type to squash semantically meanignless diffs, so provider developers do not have to specify the attribute plan modifier wherever the attribute appears in a schema.
457+
458+
Without knowing how custom types will be used by provider developers, this option seems premature, and makes less sense than bundling validation functions with custom types.
459+
460+
296461
## Recommendations
297462
298-
We recommend implementing `schema.Attribute.RequiresReplace`, `schema.Attribute.RequiresReplaceIf`, and the `ResourceWithModifyPlan` interface. Composition and other helpers can be implemented as required.
463+
We recommend implementing `schema.Attribute.PlanModifier`, and the `ResourceWithModifyPlan` interface. Composition, `attr.TypeWithModifyPlan`, and other helpers can be implemented as required.

0 commit comments

Comments
 (0)