Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attribute plan modification #102

Merged
merged 12 commits into from
Sep 1, 2021
Merged

Attribute plan modification #102

merged 12 commits into from
Sep 1, 2021

Conversation

kmoe
Copy link
Member

@kmoe kmoe commented Aug 16, 2021

Closes #34

Design doc: #64

Needs #107

This PR implements schema.Attribute.PlanModifiers and the RequiresReplace and RequiresReplaceIf helpers, as described in the design doc.

Some more design points that arose during implementation:

  1. When multiple AttributePlanModifiers are specified, should the req.AttributePlan value supplied to each be the resp.AttributePlan value from the previous modifier? This seems the intuitive behaviour (see chained_modifiers test for example).
  2. If an error diagnostic is returned at some point during attribute plan modification, should RequiresReplace and the planned state still be populated in the server response? This PR assumes so, and that it is Core's responsibility to decide what to do in this case. This also makes it easier to write deterministic tests, as there is otherwise a race condition between early return on certain errors, and RequiresReplace being populated.

@kmoe kmoe force-pushed the katy-plan-customisation-attr branch from ab60b8c to a1d7d0f Compare August 17, 2021 15:11
@kmoe kmoe marked this pull request as ready for review August 17, 2021 16:28
@kmoe kmoe force-pushed the katy-plan-customisation-attr branch from a1d7d0f to b44c515 Compare August 17, 2021 16:28
@kmoe kmoe requested a review from a team August 17, 2021 16:28
@kmoe kmoe added this to the v0.3.0 milestone Aug 17, 2021
@paddycarver
Copy link
Contributor

When multiple AttributePlanModifiers are specified, should the req.AttributePlan value supplied to each be the resp.AttributePlan value from the previous modifier? This seems the intuitive behaviour (see chained_modifiers test for example).

This matches my expectations. I'm not sure how we'd let them all operate on the request; what if two or more modified the same field in different ways? Which result would we use?

If an error diagnostic is returned at some point during attribute plan modification, should RequiresReplace and the planned state still be populated in the server response? This PR assumes so, and that it is Core's responsibility to decide what to do in this case. This also makes it easier to write deterministic tests, as there is otherwise a race condition between early return on certain errors, and RequiresReplace being populated.

This also matches my expectations, and my mental model of how plans works tells me it doesn't matter. Unlike applies, plans shouldn't get persisted in the face of an error, I believe, so if an error diagnostic is being returned, I think the plan is just going to get discarded, anyways. So this seems like reasonable behavior that won't have any external negative impacts, as far as I know.

@kmoe
Copy link
Member Author

kmoe commented Aug 18, 2021

what if two or more modified the same field in different ways? Which result would we use?

Attribute.PlanModifiers is a slice of AttributePlanModifiers, executed in order. Each one can modify only the planned value for that attribute. Given this I think it's clear that if several plan modifiers on one attribute modify its value, the last modified value should be used.

Some complexity comes with nested attributes - currently it's possible to define PlanModifiers at several levels of the nested attribute hierarchy, e.g.:

Schema{
	Attributes: map[string]Attribute{
		"disks": {
			Optional: true,
			Computed: true,
			PlanModifiers: ..., // modifies "disks"
			Attributes: ListNestedAttributes(map[string]Attribute{
				"name": {
					Required: true,
					Type:     types.StringType,
					PlanModifiers: ..., // modifies "disks[0].name" etc
				},
				"size_gb": {
					Required: true,
					Type:     types.NumberType,
				},
				"boot": {
					Required: true,
					Type:     types.BoolType,
				},
			}, ListNestedAttributesOptions{}),
		},
	},
}

Currently, the top-level PlanModifiers are run first, followed by any modifiers on nested attributes.

This at least needs to be noted in the GoDoc and a test case added, so I'll do that.

If we like we could also prohibit PlanModifiers on Attributes without a Type, but I don't see a need to do this as long as the order is unambiguous and clearly documented.

Copy link
Contributor

@paddycarver paddycarver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments, mostly about some testing stuff and about recursion.

tfsdk/attribute.go Show resolved Hide resolved
tfsdk/attribute.go Show resolved Hide resolved
tfsdk/schema.go Outdated Show resolved Hide resolved
tfsdk/serve_provider_test.go Outdated Show resolved Hide resolved
tfsdk/attribute.go Show resolved Hide resolved
tfsdk/serve_test.go Show resolved Hide resolved
@@ -1940,7 +2269,9 @@ func TestServerPlanResourceChange(t *testing.T) {
t.Errorf("Expected planned private to be %q, got %q", tc.expectedPlannedPrivate, got.PlannedPrivate)
return
}
if diff := cmp.Diff(got.RequiresReplace, tc.expectedRequiresReplace, cmpopts.EquateEmpty()); diff != "" {
if diff := cmp.Diff(got.RequiresReplace, tc.expectedRequiresReplace, cmpopts.EquateEmpty(), cmpopts.SortSlices(func(x, y *tftypes.AttributePath) bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, rather than new modifiers on cmp.Diff, does implementing an Equal method allow us to make this work without the cmpopts?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that would work. We could do one of the following:

  1. Define a type RequiresReplacePaths []*tftypes.AttributePath in the framework with an Equal method that ignores ordering
  2. Do something similar but in plugin-go/tftypes itself.

However, I think it might be better just to have the framework server sort them so the response is deterministic, rather than ignoring the change of order during testing. Added 583f552 and a8991d7 for this purpose.

tfsdk/serve_test.go Outdated Show resolved Hide resolved
@paddycarver
Copy link
Contributor

Also, some merge conflicts to resolve, I believe.

@paddycarver
Copy link
Contributor

Attribute.PlanModifiers is a slice of AttributePlanModifiers, executed in order. Each one can modify only the planned value for that attribute. Given this I think it's clear that if several plan modifiers on one attribute modify its value, the last modified value should be used.

Yeah, sorry, I think we're getting at the same thing here. I was trying to say I don't know how it would work if we didn't take that approach.

Some complexity comes with nested attributes - currently it's possible to define PlanModifiers at several levels of the nested attribute hierarchy, e.g.:

[snip]

Currently, the top-level PlanModifiers are run first, followed by any modifiers on nested attributes.

This at least needs to be noted in the GoDoc and a test case added, so I'll do that.

If we like we could also prohibit PlanModifiers on Attributes without a Type, but I don't see a need to do this as long as the order is unambiguous and clearly documented.

Yeah, I'm fine with just "top-level goes first, nested ones follow", that seems like a really reasonable approach and I think the complexity is worth the extra power.

@appilon
Copy link

appilon commented Aug 18, 2021

Would this feature allow a provider to specify defaults for optional fields?

@kmoe
Copy link
Member Author

kmoe commented Aug 20, 2021

@appilon Yes, thanks for raising this. Adding a test case to show this working as expected.

A helper func like this makes sense I think:

Schema{
	Attributes: map[string]Attribute{
		"size": {
			Optional: true,
			PlanModifiers: []AttributePlanModifier{AttributeDefault(val)},
		},
	},
}

where we have either func AttributeDefault(v interface{}) AttributePlanModifier or func AttributeDefault(v attr.Value) AttributePlanModifier. Similar helper makes sense for a DefaultFunc equivalent.

Will follow up with a further PR for any such helpers. Also considering putting RequiresReplace and any other helpers in their own package so they are namespaced.

@kmoe
Copy link
Member Author

kmoe commented Aug 24, 2021

Implementation of full recursion of nested attributes works, but the tests are currently running into #112 as tftypes.WalkAttributePath (called in Plan.terraformValueAtPath()) cannot handle a nil tftypes.Object. I suspect this is a plugin-go fix and will write it up there.

@tckb
Copy link

tckb commented Aug 24, 2021

+1 waiting for this PR merged - I have the same issue as mentioned in #112

@kmoe kmoe force-pushed the katy-plan-customisation-attr branch 2 times, most recently from 8763850 to dc0765b Compare August 25, 2021 13:30
@kmoe
Copy link
Member Author

kmoe commented Aug 25, 2021

In fact I think this doesn't require a plugin-go change (see comment on #112).

Changes since review:

  • fixed error when a SingleNestedAttributes attribute has a nil plan value
  • fixed recursion, so attribute plan modifiers run for arbitrarily nested attributes
  • added sorting and deduplication to resp.RequiresReplace slice so it is entirely deterministic
  • added test case for setting default attribute values
  • added early return after first plan modifier that errors, instead of continuing with possibly invalid plan values

Ready for review.

@kmoe kmoe requested review from a team and paddycarver August 25, 2021 13:37
@paddycarver paddycarver modified the milestone: v0.3.0 Aug 30, 2021
Copy link
Contributor

@paddycarver paddycarver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work! This looks great to me.

// RequiresReplace returns an AttributePlanModifier specifying the attribute as
// requiring replacement. This behaviour is identical to the ForceNew behaviour
// in terraform-plugin-sdk.
func RequiresReplace() AttributePlanModifier {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: if we're going to have a constructor, I feel like the underlying type could be unexported to limit our compatibility surface. If we're going to export the underlying type, I don't know that we need a constructor. 🤔

(This should not block merge, we can iterate later.)

tfsdk/plan.go Outdated Show resolved Hide resolved
Comment on lines +244 to +249
setAttrDiags := resp.Plan.SetAttribute(ctx, attrPath, nestedAttrResp.AttributePlan)
resp.Diagnostics = append(resp.Diagnostics, setAttrDiags...)
if diagnostics.DiagsHasErrors(setAttrDiags) {
return
}
resp.Diagnostics = nestedAttrResp.Diagnostics
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this last line expected? I think its just extraneous since they should point to the same address, but it could be confusing as it looks like it might be removing SetAttribute diagnostics.

@github-actions
Copy link

github-actions bot commented Oct 2, 2021

I'm going to lock this pull request because it has been closed for 30 days ⏳. This helps our maintainers find and focus on the active contributions.
If you have found a problem that seems related to this change, please open a new issue and complete the issue template so we can capture all the details necessary to investigate further.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Oct 2, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support customizing plans
5 participants