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 State Checks for Known Type and Value, and Sensitive Checks #275

Merged
merged 30 commits into from
Jan 25, 2024

Conversation

bendbennett
Copy link
Contributor

@bendbennett bendbennett commented Jan 15, 2024

Reference: #266

This PR forms part of the changes outlined in #266, specifically it includes state checks for the following:

  • ExpectKnownValue
  • ExpectKnownOutputValue
  • ExpectKnownOutputValueAtPath
  • ExpectSensitiveValue

The implementation in this PR differs from the way in which checks for known type and value are currently being handled for plan checks, in that the ExpectKnown<Value|OutputValue|OutputValueAtPath> state checks allow for explicitly checking for a null value by using a newly introduced null known value check type. An alternative can be seen in the plan check implementations, where the ExpectKnown<Value|OutputValue|OutputValueAtPath> plan checks return an error if a null value is found, and instead there are explicit checks for null values (e.g., ExpectNullOutputValue).

In this context, a significant difference between tfjson.Plan and tfjson.State is the handling of null output values. In the case of tfjson.Plan, an output that specifies an attribute, for instance, which is null appears in the plan, whereas null output values do not appear in state (refer to Allow null value outputs to be present in json output). As a consequence, there is no way for state checks that leverage tfjson.State to discriminate between output values that are null, and output values that do not exist. However, it is worth noting that output values that reference, for instance, an object that has attributes which are null will appear in tfjson.State.

Together this raises the question of whether we want to:

  • Modify plan checks for ExpectKnown<Value|OutputValue|OutputValueAtPath> to handle detection of nulls (to bring them into line with the state checks for ExpectKnown<Value|OutputValue|OutputValueAtPath> in this PR).
  • Modify state checks for ExpectKnown<Value|OutputValue|OutputValueAtPath> in this PR to align their behaviour with the analogous plan checks (i.e., return an error when null is detected).
  • Depending upon how the known value checks are handled, decide whether we want to remove expect null value plan checks, or add expect null value state checks.

Further Considerations

  • The implementation in this PR runs state checks after "post-apply, post-refresh. Do we want to consider running state checks at any other points? I avoided adding state checks for the "refresh" mode as the refresh command is deprecated, but we could consider this if it is thought to valuable. Should we consider state checks for import?
  • We could also consider adding the equivalent state checks for ExpectEmptyPlan and ExpectNonEmptyPlan if there is possible value in having these built-in checks made available.

@bendbennett bendbennett added the enhancement New feature or request label Jan 15, 2024
@bendbennett bendbennett added this to the v1.7.0 milestone Jan 15, 2024
@bendbennett bendbennett requested a review from a team as a code owner January 15, 2024 17:22
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.

Everything looks great to me, good work @bendbennett! Left a couple comments but nothing major.

Love all the docs as well ❤️ :shipit:

plancheck/expect_known_output_value.go Show resolved Hide resolved
plancheck/expect_known_value.go Show resolved Hide resolved
plancheck/expect_null_output_value.go Show resolved Hide resolved
knownvalue/null.go Show resolved Hide resolved

// CheckValue determines whether the passed value is of nil.
func (v nullExact) CheckValue(other any) error {
if other != nil {
Copy link
Member

Choose a reason for hiding this comment

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

I vaguely remember checking an any/interface{} for nil and I believe it's not as straightforward as this for things like maps. (I don't know if the underlying tfjson implementation will expose this problem, so it may not be relevant)

This test will fail:
image

And delve shows it slightly different, but still should be considered null:
image

Copy link
Member

Choose a reason for hiding this comment

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

This article explains the problem a bit, may have to dip into some reflection if we need to cover nil cases like maps and slices.

https://vitaneri.com/posts/check-for-nil-interface-in-go

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for raising this.

I've examined the values returned when using the tfjson implementation.

With a schema that looks as follows:

func (e *exampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			/* ... */
			"list_attribute": schema.ListAttribute{
				Optional:    true,
				/* ... */
			},
			"map_attribute": schema.MapAttribute{
				Optional:    true,
				/* ... */
			},
			"object_attribute": schema.ObjectAttribute{
				Optional: true,
				AttributeTypes: map[string]attr.Type{
					/* ... */
				},
			},
			"set_attribute": schema.SetAttribute{
				Optional:    true,
				/* ... */
			},
			"list_nested_attribute": schema.ListNestedAttribute{
				Optional: true,
				NestedObject: schema.NestedAttributeObject{
					Attributes: map[string]schema.Attribute{
						/* ... */
					},
				},
			},
			"map_nested_attribute": schema.MapNestedAttribute{
				Optional: true,
				NestedObject: schema.NestedAttributeObject{
					Attributes: map[string]schema.Attribute{
						/* ... */
					},
				},
			},
			"set_nested_attribute": schema.SetNestedAttribute{
				Optional: true,
				NestedObject: schema.NestedAttributeObject{
					Attributes: map[string]schema.Attribute{
						/* ... */
					},
				},
			},
			"single_nested_attribute": schema.SingleNestedAttribute{
				Optional: true,
				Attributes: map[string]schema.Attribute{
					/* ... */
				},
			},
		},
		Blocks: map[string]schema.Block{
			"list_nested_block": schema.ListNestedBlock{
				NestedObject: schema.NestedBlockObject{
					Attributes: map[string]schema.Attribute{
						/* ... */
					},
					Blocks: map[string]schema.Block{
						"list_nested_nested_block": schema.ListNestedBlock{
							NestedObject: schema.NestedBlockObject{
								Attributes: map[string]schema.Attribute{
									/* ... */
								},
							},
						},
					},
				},
			},

			"set_nested_block": schema.SetNestedBlock{
				NestedObject: schema.NestedBlockObject{
					Attributes: map[string]schema.Attribute{
						/* ... */
					},
					Blocks: map[string]schema.Block{
						"set_nested_nested_block": schema.SetNestedBlock{
							NestedObject: schema.NestedBlockObject{
								Attributes: map[string]schema.Attribute{
									/* ... */
								},
							},
						},
					},
				},
			},
			"single_nested_block": schema.SingleNestedBlock{
				Attributes: map[string]schema.Attribute{
					/* ... */
				},
			},
		},
	}
}

The plan output looks as follows:

{
  "format_version": "1.2",
  "terraform_version": "1.7.0",
  "planned_values": {
    "root_module": {
      "resources": [
        {
          "address": "example_resource.example",
          "mode": "managed",
          "type": "example_resource",
          "name": "example",
          "provider_name": "registry.terraform.io/bendbennett/playground",
          "schema_version": 0,
          "values": {
            "list_attribute": null,
            "list_nested_attribute": null,
            "list_nested_block": [],
            "map_attribute": null,
            "map_nested_attribute": null,
            "object_attribute": null,
            "set_attribute": null,
            "set_nested_attribute": null,
            "set_nested_block": [],
            "single_nested_attribute": null,
            "single_nested_block": null,
          },
          "sensitive_values": {
            "list_nested_block": [],
            "set_nested_block": []
          }
        }
      ]
    }
  },
  "resource_changes": [
    {
      "address": "example_resource.example",
      "mode": "managed",
      "type": "example_resource",
      "name": "example",
      "provider_name": "registry.terraform.io/bendbennett/playground",
      "change": {
        "actions": [
          "create"
        ],
        "before": null,
        "after": {
          "list_attribute": null,
          "list_nested_attribute": null,
          "list_nested_block": [],
          "map_attribute": null,
          "map_nested_attribute": null,
          "object_attribute": null,
          "set_attribute": null,
          "set_nested_attribute": null,
          "set_nested_block": [],
          "single_nested_attribute": null,
          "single_nested_block": null,
        },
        "after_unknown": {
          "id": true,
          "list_nested_block": [],
          "set_nested_block": []
        },
        "before_sensitive": false,
        "after_sensitive": {
          "list_nested_block": [],
          "set_nested_block": []
        }
      }
    }
  ],
  "configuration": {
    "provider_config": {
      "example": {
        "name": "example",
        "full_name": "registry.terraform.io/bendbennett/playground"
      },
      "playground": {
        "name": "playground",
        "full_name": "registry.terraform.io/bendbennett/playground"
      }
    },
    "root_module": {
      "resources": [
        {
          "address": "example_resource.example",
          "mode": "managed",
          "type": "example_resource",
          "name": "example",
          "provider_config_key": "example",
          "schema_version": 0
        }
      ]
    }
  },
  "timestamp": "2024-01-22T10:12:40Z",
  "errored": false
}

The state looks as follows:

{
  "version": 4,
  "terraform_version": "1.7.0",
  "serial": 1,
  "lineage": "2c8dadf4-11dd-22b9-c419-5d07aa808890",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "example_resource",
      "name": "example",
      "provider": "provider[\"registry.terraform.io/bendbennett/playground\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "bool_attribute": null,
            "float64_attribute": null,
            "id": "example-id",
            "int64_attribute": null,
            "list_attribute": null,
            "list_nested_attribute": null,
            "list_nested_block": [],
            "map_attribute": null,
            "map_nested_attribute": null,
            "number_attribute": null,
            "object_attribute": null,
            "set_attribute": null,
            "set_nested_attribute": null,
            "set_nested_block": [],
            "single_nested_attribute": null,
            "single_nested_block": null,
            "string_attribute": null
          },
          "sensitive_attributes": []
        }
      ]
    }
  ],
  "check_results": null
}

Debugging during test execution shows the following:

image

image

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 some additional test coverage to the following:

  • TestExpectKnownValue_CheckState_AttributeValueNull
  • TestExpectKnownOutputValueAtPath_CheckState_AttributeValueNull
  • TestExpectKnownValue_CheckPlan_AttributeValueNull
  • TestExpectKnownOutputValueAtPath_CheckPlan_AttributeValueNull
  • TestExpectKnownOutputValue_CheckPlan_AttributeValueNull

Copy link
Contributor

Choose a reason for hiding this comment

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

Today I learned! I think we have historically avoided these sorts of issues by not performing (re-)assignment like that article shows. Hopefully we can rely on the static analysis tooling to catch the situation if for some reason it does get introduced and the unit testing does not catch it, because I'm a proponent of simpler == better where possible.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for adding the tests, good to know it shouldn't be a problem for us 🙂

I'm a proponent of simpler == better where possible.

Agreed 👍🏻

statecheck/expect_known_output_value.go Outdated Show resolved Hide resolved
statecheck/expect_known_value.go Outdated Show resolved Hide resolved
statecheck/expect_known_value.go Outdated Show resolved Hide resolved
tfversion/versions.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.

Overall very thrilled with the direction here! Great job. 🚀


// NullExact returns a Check for asserting equality nil
// and the value passed to the CheckValue method.
func NullExact() nullExact {
Copy link
Contributor

Choose a reason for hiding this comment

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

Bikeshed (I'm so sorry): Are there other checks that could be run against a null value? Maybe this can be just Null if there is only the possibility for the value being null or not? I guess you could also say the same for boolean checks, but all the other typed checks could have additional checks. 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. Agree with the suggested naming change here. I've made the following changes:

  • NullExact => Null
  • nullExact => null
  • BoolExact => bool
  • boolExact => boolValue

The docs have been updated in accordance with these changes.

@@ -795,6 +803,10 @@ type RefreshPlanChecks struct {
PostRefresh []plancheck.PlanCheck
}

// ConfigStateChecks runs all state checks in the slice. This occurs after the apply and refresh of a Config test are run.
// All errors by state checks in this slice are aggregated, reported, and will result in a test failure.
type ConfigStateChecks []statecheck.StateCheck
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 need this additional exported type? I'm guessing this was for a little bit of consistency with ConfigPlanChecks, however that is there because we need a structure to capture the various times plan checks could be ran. In my experience, typically you would introduce something like this if there were plans to attach methods to the type, but since there are not any here right now, I'm curious if there were future plans for that. Another option would also be to put this type in the statecheck package, so all the implementation details live in one place. I'm just wondering if we should treat this similar to how the plan checks do use []plancheck.PlanCheck directly once its at that "level". Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're assumption is correct, the exported ConfigStateChecks type was purely for consistency. I've removed the type and replaced with the usage of []statecheck.StateCheck throughout.

Comment on lines 316 to 317
// Run post-apply, post-refresh state checks
if len(step.ConfigStateChecks) > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Historically we have ran state checks immediately after apply and before the additional plan checks (e.g. line 181 area) -- do we want to also do that here for consistency/ease of migration? In reality, the timing of the checks will determine which errors developers might see first: whether it be unexpectedly non-empty plans after apply or whether their state assertions are incorrect.

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 moved the execution of the ConfigStateChecks so that they run immediately after any Check (i.e., line 210).

plancheck/expect_null_output_value.go Show resolved Hide resolved
result = append(result, resp.Error)
}

return errors.Join(result...)
Copy link
Contributor

Choose a reason for hiding this comment

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

No more resource.ComposeAggregateTestCheckFunc vs resource.ComposeTestCheckFunc debates!

@bendbennett bendbennett merged commit 1b803b4 into main Jan 25, 2024
29 checks passed
@bendbennett bendbennett deleted the bendbennett/issues-266 branch January 25, 2024 13: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 25, 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.

3 participants