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

Adding plan checks for ExpectKnownValue, ExpectKnownOutputValue, and ExpectKnownOutputValueAtPath #248

Merged
merged 39 commits into from
Jan 15, 2024

Conversation

bendbennett
Copy link
Contributor

@bendbennett bendbennett commented Dec 18, 2023

Closes: #243

Further Considerations

  • There are two related PRs open on terraform-json, and terraform-exec which, if they proceed, would allow for checking of numerical values in the plan using json.Number. The current PR will be amended if the PRs on terraform-json, and terraform-exec are merged and released.
  • There has been some discussion of adding assertions for plancheck.ExpectStringOutputValue(name string, value string), and similar. Do we want to add those to this PR, add an issue and implement later, or wait for an indication of community interest/utility?

@bendbennett bendbennett added the enhancement New feature or request label Dec 18, 2023
@bendbennett bendbennett added this to the v1.7.0 milestone Dec 18, 2023
@bendbennett bendbennett marked this pull request as ready for review December 20, 2023 14:21
@bendbennett bendbennett requested a review from a team as a code owner December 20, 2023 14:21
Copy link
Contributor

@bflad bflad left a comment

Choose a reason for hiding this comment

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

Overall super excited about this as its a really nice enhancement for the testing logic. Please let me know if you want to chat about any of this.

internal/plugintest/working_dir.go Show resolved Hide resolved
Comment on lines 8 to 9
// Equal should perform equality testing.
Equal(other any) bool
Copy link
Contributor

Choose a reason for hiding this comment

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

Open question: Do we want to call this "check", "match", or another name? While exact equality is one use case with these, there are other use cases as well, e.g.

  • Regular expressions for strings
  • Minimum/maximum for numbers
  • Partial matching for collections or objects
  • Returning true for any value

I guess I'm just trying to ensure folks aren't confused or turned away for the not-exactly-equal use cases.

Seeing everything written out now, I almost wonder if this interface should be something like:

type Check interface {
  CheckValue(value any) error

  String() string
}

There's at least two benefits I see here beyond the potentially clearer naming:

  • They can set their own error messaging.
  • They can raise implementation errors rather than potentially hiding those types of errors based on a specific boolean answer.
  • Provider developers can implement their own implementations without needing to reimplement something like ExpectKnownValue

I hesitate to say going down the fully-type-named interfaces, e.g.

type BoolCheck interface {
  CheckBool(value bool) error

  String() string
}

Unless we'd consider passing json.Number directly for number checks. We could still have nice "int64", etc implementations even if we did that though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like the idea of returning an error and the additional flexibility that this yields.

In terms of the fully-type-named interfaces, I'm wondering about the internals. For instance, implementation of CheckBool() would be straightforward:

func (v BoolValue) CheckBool(other bool) error {
	if other != v.value {
		return fmt.Errorf("%t does not equal %t", other, v.value)
	}

	return nil
}

With the call to CheckBool() from expectKnownValue.CheckPlan() looking something like the following:

	switch reflect.TypeOf(result).Kind() {
	case reflect.Bool:
		v, ok := e.knownValue.(knownvalue.BoolValue)

		if !ok {
			resp.Error = fmt.Errorf("wrong type: attribute value is bool, known value type is %T", e.knownValue)

			return
		}

		err := v.CheckBool(result.(bool))

		if err != nil {
			resp.Error = fmt.Errorf("attribute value: %v does not equal expected value: %s", result, v)

			return
		}

However, the internals for all of the KnownValue types that deal with maps, or slices, would, I believe, require some internal type assertion to determine whether the fully-type-named interface methods could be called. For example:

func (v ObjectValue) CheckObject(other map[string]any) error {
	if len(other) != len(v.value) {
		return fmt.Errorf("%v does not equal %v", other, v.value)
	}

	for k, v := range v.value {
		otherItem, ok := other[k]

		if !ok {
			return fmt.Errorf("%s is missing from %v", k, other)
		}

		switch reflect.TypeOf(otherItem).Kind() {
		case reflect.Bool:
			boolCheck, ok := v.(BoolValue)

			if !ok {
				return fmt.Errorf("wrong type: %T, known value type is %T", otherItem, v)
			}

			if err := boolCheck.CheckBool(otherItem.(bool)); err != nil {
				return fmt.Errorf("%v does not equal %v", otherItem, v)
			} 
		// Reflection logic for reflect.Map, reflect.Slice, reflect.String
		default:
			return fmt.Errorf("unrecognised type: %T, known value type is %T", otherItem, v)
		}
	}

	return nil
}

I'll go ahead and implement the Check interface, and perhaps we can go from there?

Copy link
Contributor

Choose a reason for hiding this comment

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

In that situation, yes, I would expect the collection/object checks to need to figure out what their element or per-object-attribute check type is. Another option would be to not support that functionality and leave it up to developer implementations to figure out what to do with making more generic collection/object checks that support elements/object attributes, but I think there's a lot of value in being able to offer that out of the box.

Not sure if it will make the logic easier/harder, but another way of doing this is performing a type switch on the v.value (presumably the per-object-attribute checks in the given example code) and raising an error if the otherItem doesn't conform to the given check type. This can help make sure the error messaging is tailored for the what the developer put into their object check definition rather than tailored for the data returned from Terraform, which without additional context can be harder to reason about in my opinion. e.g.

// note also including the object attribute name (k from example) and not assuming
// it was an equality check error in error messaging (the checks can say so
// themselves, amongst other possible errors :) )
switch check := v.(type) {
case BoolValue:
  otherValue, ok := otherItem.(bool)

  if !ok {
    return fmt.Errorf("%s object attribute: expected bool value for BoolValue check, got: %T", k, otherItem)
  }

  if err := check.CheckBool(otherValue); err != nil {
    return fmt.Errorf("%s object attribute: %s", k, err)
  }
// ...
default:
  
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess a potential wrinkle here is, what if there is a dynamically typed value is being checked? Hmm. I would normally presume the value should be statically typed based on the given Terraform configuration. If not, we would be fine as long as there is some sort of escape hatch for a "I don't know the value type and I'll do all the work to figure it out" type of check, e.g. CheckDynamic(value any) error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've refactored to use the Check interface:

type Check interface {
  CheckValue(value any) error

  String() string
}

Before proceeding down the route of using fully-type-named interface, thought it might be worth considering how the ExpectKnownValue, ExpectKnownOutputValue, and ExpectKnownOutputValueAtPath would handle these fully-type-named interfaces.

Currently, taking ExpectKnownValue for instance, the type and constructor look as follows:

var _ PlanCheck = expectKnownValue{}

type expectKnownValue struct {
	resourceAddress string
	attributePath   tfjsonpath.Path
	knownValue      knownvalue.Check
}

func (e expectKnownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) {
	/* ... */
}

func ExpectKnownValue(resourceAddress string, attributePath tfjsonpath.Path, knownValue knownvalue.Check) PlanCheck {
	return expectKnownValue{
		resourceAddress: resourceAddress,
		attributePath:   attributePath,
		knownValue:      knownValue,
	}
}

As an example, BoolValue currently implements the Check interface:

var _ Check = BoolValue{}

type BoolValue struct {
	value bool
}

func (v BoolValue) CheckValue(other any) error {
	/* ... */
}

/* ... */

func BoolValueExact(value bool) BoolValue {
	return BoolValue{
		value: value,
	}
}

If we refactor BoolValue to implement the BoolCheck interface:

type BoolCheck interface {
	CheckBool(value bool) error

	String() string
}

var _ BoolCheck = BoolValue{}

type BoolValue struct {
	value bool
}

func (v BoolValue) CheckBool(value bool) error {
	/* ... */
}

/* ... */

func BoolValueExact(value bool) BoolValue {
	return BoolValue{
		value: value,
	}
}

Using fully-type-named interfaces will require that we reconsider how the constructor and struct field holding the checks will be handled for ExpectKnownValue, ExpectKnownOutputValue, and ExpectKnownOutputValueAtPath, as we'll no longer be able to construct an ExpectKnownValue plan check, for instance, using the following:

plancheck.ExpectKnownValue(
	"test_resource.one",
	tfjsonpath.New("bool_attribute"),
	knownvalue.BoolValueExact(true),
),

knownvalue/known_value.go Outdated Show resolved Hide resolved
knownvalue/bool.go Outdated Show resolved Hide resolved
knownvalue/bool_test.go Outdated Show resolved Hide resolved
Comment on lines 64 to 65
// NewObjectValuePartial returns a KnownValue for asserting partial equality between the
// supplied map[string]KnownValue and the value passed to the Equal method.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe its obvious, but in case it might not be, it might be good to talk about the "partial equality" a little bit more for the "Partial" implementations, e.g. in this case that the map keys are intended to be object attribute names and that only those attribute values are checked.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have added some further clarification around "partial equality". Let me know if you think we need more here.

})
```

## Known Value Types
Copy link
Contributor

Choose a reason for hiding this comment

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

I love these tables but I wonder if we should consider laying this out a little differently based first on the type system types, then each type's available checks, instead of being based on the kind of check (e.g. exact vs partial vs whatever). I don't think we should be shy about making separate pages since there is a lot of good/relevant content.

One potential way to do this would be move this page to known-value(-checks)/index.mdx and treat it as an overview page where the tables then link to checks available for each type system type, e.g. known-value(-checks)/list.mdx. That page could then walk through the available list type checks, e.g. exact, partial, number of elements, etc. A string page could walk through the available string type checks, e.g. exact, regular expression, etc. A new custom page could walk through how developers can implement their own.

Copy link
Contributor Author

@bendbennett bendbennett Jan 4, 2024

Choose a reason for hiding this comment

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

Have refactored, splitting out by Known Value Check type.

Also added a Custom Known Value Check page. Thought I'd hold off expanding on this until we finalise the interface(s) we're going to use.

A consequence of allowing custom known value checks is that the internal logic within ExpectKnownValue, ExpectKnownOutputValue, and ExpectKnownOutputValueAtPath should be able to handle any known value checks that fill the Check interface. The internals of the the Check function for these three types has therefore been refactored to provide this flexibility.

knownvalue/num_elements.go Outdated Show resolved Hide resolved
Copy link
Contributor

@bflad bflad left a comment

Choose a reason for hiding this comment

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

Excellent work! Other than some minor quibbles over exporting less things upfront and the forever naming bikeshedding, this looks good to me 🚀 Excited to see what developers build with the interface.


// BoolValue is a Check for asserting equality between the value supplied
// to BoolValueExact and the value passed to the CheckValue method.
type BoolValue struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

Two questions:

  1. Should we leave these Check implementations as unexported except for the functions (e.g. boolValue here)? It'll clean up the public Go package documentation nicely and I'm not sure if there is anything that should need to reference/extend the implementation directly since its value field is unexported.
  2. If doing that, maybe these names can then match the exported functions? e.g. boolValueExact for some additional clarity while reading the code

We can always export additional things in the future if we need! 👍

Copy link
Member

Choose a reason for hiding this comment

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

+1 for reducing the amount of exported types 👍🏻

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good to me.

The known value check types have all be modified so that they are camel-cased versions of the exported functions, for example:

  • boolValueExact => BoolValueExact
  • listElementsExact => ListElementsExact`
  • listValueExact => ListValueExact
  • listValuePartial => ListValuePartial (note that the constructor for the know value partial types has changed from <List|Map|Object|Set>ValuePartialMatch to <List|Map|Object|Set>ValuePartial)

otherVal, ok := other.(bool)

if !ok {
return fmt.Errorf("expected bool value for BoolValue check, got: %T", other)
Copy link
Contributor

Choose a reason for hiding this comment

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

Followup to above: If the only exported thing is the function, then these errors here and in others can reference the function name (BoolValueExact) which is super nice since that's how developers will be familiar with them 😄


// equateErrorMessage reports errors to be equal if both are nil
// or both have the same message.
var equateErrorMessage = cmp.Comparer(func(x, y error) bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

Sometimes I wish this was natively in go-cmp 😂

knownvalue/known_value.go Outdated Show resolved Hide resolved

// ListElementsExact returns a Check for asserting that
// a list num elements.
func ListElementsExact(num int) ListElements {
Copy link
Contributor

Choose a reason for hiding this comment

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

❤️ having room for things like ListElementsAtLeast, etc. which is the important part here. Not sure if folks might be more familiar with "size" over "elements" as a synonymous with "element count" though, since for example, we name these validators SizeAtLeast, etc. Just wanted to mention, not necessarily say it has to be that way or anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Totally happy to refactor to <List|Map|Set>SizeExact. Having some level of parity with the validators seems beneficial to me.

Only question I have if we go this route is the naming for the object equivalent - ObjectNumAttributesExact, ObjectLenAttributesExact, ObjectAttributesCountExact.........

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, for objects I would argue that its a little awkward to count their attributes since that's part of their static type definition. This differs from map elements where there could be an arbitrary amount from an API.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have renamed to <List|Map|Set>SizeExact, and removed ObjectAttributesExact.

knownvalue/map_elements.go Outdated Show resolved Hide resolved
Comment on lines 51 to 52
// NumberValueExact returns a Check for asserting equality between the
// supplied *big.Float and the value passed to the CheckValue method.
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if it might be good to call out the 512-bit precision here too 😄

reflect.Slice,
reflect.String:
if err := e.knownValue.CheckValue(result); err != nil {
resp.Error = err
Copy link
Contributor

Choose a reason for hiding this comment

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

Should these errors include the path string as well for developers?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've added a String() method to tfjson.Path for this purpose.

Copy link
Contributor

Choose a reason for hiding this comment

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

Awesome, thanks!

reflect.Slice,
reflect.String:
if err := e.knownValue.CheckValue(result); err != nil {
resp.Error = err
Copy link
Contributor

Choose a reason for hiding this comment

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

Similarly here regarding the path string

func TestExpectKnownValue_CheckPlan_Bool(t *testing.T) {
t.Parallel()

r.Test(t, r.TestCase{
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd suggest using resource written out in these examples to match the import without aliasing.

Copy link
Member

@austinvalle austinvalle left a comment

Choose a reason for hiding this comment

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

I had a question about the naming scheme below, but not a hard stance by any means. All of this code looks awesome! :shipit:

Copy link
Member

Choose a reason for hiding this comment

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

super nit 🦸🏻 - Should this file be named check.go now?


// BoolValue is a Check for asserting equality between the value supplied
// to BoolValueExact and the value passed to the CheckValue method.
type BoolValue struct {
Copy link
Member

Choose a reason for hiding this comment

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

+1 for reducing the amount of exported types 👍🏻

Comment on lines 42 to 46
func BoolValueExact(value bool) BoolValue {
return BoolValue{
value: value,
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Wanted to bring up the first thing I thought of with the naming. Thinking there may be some context that I'm missing that's important to the explicitness of the naming.

Is there a benefit in duplicating the Value in some of these implementations? Since it's already in the package name as knownvalue.BoolValueExact() vs. knownvalue.BoolExact(). Does this naming scheme break down/not make sense in certain use-cases?

Some examples:

  • knownvalue.Null()
  • knownvalue.ObjectPartial()
  • knownvalue.ObjectExact()
  • knownvalue.ListExact()
  • knownvalue.ListPartialMatch()
  • knownvalue.MapExact()
  • knownvalue.MapPartialMatch()
  • knownvalue.StringExact()

Leaving room for others that become clearer with the additional words, like:

  • knownvalue.ListElementsExact()
  • knownvalue.MapElementsExact()

Copy link
Contributor Author

@bendbennett bendbennett Jan 11, 2024

Choose a reason for hiding this comment

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

I'm on board with this naming change, and potentially the change in names for the "Size" checks too.

Happy to go with consensus here. What are your thoughts @bflad ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm +1 for removing repeated words for when folks need to use something, so I'm +1 for removing Value out of the naming in the knownvalue package.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have refactored. Thanks for the suggestion.

@bendbennett bendbennett merged commit 198c751 into main Jan 15, 2024
27 checks passed
@bendbennett bendbennett deleted the bendbennett/issues-243 branch January 15, 2024 12:28
Copy link

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 Feb 15, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Consider Known Value and Type Plan Checks for Resource Attributes and Output Values
3 participants