diff --git a/.changes/unreleased/FEATURES-20241111-165312.yaml b/.changes/unreleased/FEATURES-20241111-165312.yaml new file mode 100644 index 000000000..fe239a458 --- /dev/null +++ b/.changes/unreleased/FEATURES-20241111-165312.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'echoprovider: Introduced new `echoprovider` package, which contains a v6 Terraform + provider that can be used to test ephemeral resource data.' +time: 2024-11-11T16:53:12.399802-05:00 +custom: + Issue: "389" diff --git a/.changes/unreleased/NOTES-20241111-165206.yaml b/.changes/unreleased/NOTES-20241111-165206.yaml new file mode 100644 index 000000000..37dc28142 --- /dev/null +++ b/.changes/unreleased/NOTES-20241111-165206.yaml @@ -0,0 +1,6 @@ +kind: NOTES +body: 'echoprovider: The `echoprovider` package is considered experimental and may + be altered or removed in a subsequent release' +time: 2024-11-11T16:52:06.287978-05:00 +custom: + Issue: "389" diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index d0cde864e..05b8918d8 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -39,7 +39,7 @@ jobs: terraform_version: ${{ matrix.terraform }} terraform_wrapper: false - run: go mod download - - run: go test -coverprofile=coverage.out ./... + - run: go test -v -coverprofile=coverage.out ./... env: TF_ACC: "1" - name: Remove wildcard suffix from TF version diff --git a/echoprovider/doc.go b/echoprovider/doc.go new file mode 100644 index 000000000..753097f82 --- /dev/null +++ b/echoprovider/doc.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package echoprovider contains a protocol v6 Terraform provider that can be used to transfer data from +// provider configuration to state via a managed resource. This is only meant for provider acceptance testing +// of data that cannot be stored in Terraform artifacts (plan/state), such as an ephemeral resource. +// +// Example Usage: +// +// // Ephemeral resource that is under test +// ephemeral "examplecloud_thing" "this" { +// name = "thing-one" +// } +// +// provider "echo" { +// data = ephemeral.examplecloud_thing.this +// } +// +// resource "echo" "test" {} // The `echo.test.data` attribute will contain the ephemeral data from `ephemeral.examplecloud_thing.this` +package echoprovider diff --git a/echoprovider/server.go b/echoprovider/server.go new file mode 100644 index 000000000..dc4d90c8b --- /dev/null +++ b/echoprovider/server.go @@ -0,0 +1,408 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package echoprovider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// NewProviderServer returns the "echo" provider, which is a protocol v6 Terraform provider meant only to be used for testing +// data which cannot be stored in Terraform artifacts (plan/state), such as an ephemeral resource. The "echo" provider can be included in +// an acceptance test with the `(resource.TestCase).ProtoV6ProviderFactories` field, for example: +// +// resource.UnitTest(t, resource.TestCase{ +// // .. other TestCase fields +// ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ +// "echo": echoprovider.NewProviderServer(), +// }, +// +// // .. TestSteps +// }) +// +// The "echo" provider configuration accepts in a dynamic "data" attribute, which will be stored in the "echo" managed resource "data" attribute, for example: +// +// // Ephemeral resource that is under test +// ephemeral "examplecloud_thing" "this" { +// name = "thing-one" +// } +// +// provider "echo" { +// data = ephemeral.examplecloud_thing.this +// } +// +// resource "echo" "test" {} // The `echo.test.data` attribute will contain the ephemeral data from `ephemeral.examplecloud_thing.this` +func NewProviderServer() func() (tfprotov6.ProviderServer, error) { + return func() (tfprotov6.ProviderServer, error) { + return &echoProviderServer{}, nil + } +} + +// echoProviderServer is a lightweight protocol version 6 provider server that saves data from the provider configuration (which is considered ephemeral) +// and then stores that data into state during ApplyResourceChange. +// +// As provider configuration is ephemeral, it's possible for the data to change between plan and apply. As a result of this, the echo provider +// will never propose new changes after it has been created, making it immutable (during plan, echo will always use prior state for it's plan, +// regardless of what the provider configuration is set to). This prevents the managed resource from continuously proposing new planned changes +// if the ephemeral data changes. +type echoProviderServer struct { + // The value of the "data" attribute during provider configuration. Will be directly echoed to the echo.data attribute. + providerConfigData tftypes.Value +} + +const echoResourceType = "echo" + +func (e *echoProviderServer) providerSchema() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Description: "This provider is used to output the data attribute provided to the provider configuration into all resources instances of echo. " + + "This is only useful for testing ephemeral resources where the data isn't stored to state.", + DescriptionKind: tfprotov6.StringKindPlain, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "data", + Type: tftypes.DynamicPseudoType, + Description: "Dynamic data to provide to the echo resource.", + DescriptionKind: tfprotov6.StringKindPlain, + Optional: true, + }, + }, + }, + } +} + +func (e *echoProviderServer) testResourceSchema() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "data", + Type: tftypes.DynamicPseudoType, + Description: "Dynamic data that was provided to the provider configuration.", + DescriptionKind: tfprotov6.StringKindPlain, + Computed: true, + }, + }, + }, + } +} + +func (e *echoProviderServer) ApplyResourceChange(ctx context.Context, req *tfprotov6.ApplyResourceChangeRequest) (*tfprotov6.ApplyResourceChangeResponse, error) { + resp := &tfprotov6.ApplyResourceChangeResponse{} + + if req.TypeName != echoResourceType { + resp.Diagnostics = []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unsupported Resource", + Detail: fmt.Sprintf("ApplyResourceChange was called for a resource type that is not supported by this provider: %q", req.TypeName), + }, + } + + return resp, nil + } + + echoTestSchema := e.testResourceSchema() + + plannedState, diag := dynamicValueToValue(echoTestSchema, req.PlannedState) + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + // Destroy Op, just return planned state, which is null + if plannedState.IsNull() { + resp.NewState = req.PlannedState + return resp, nil + } + + // Take the provider config "data" attribute verbatim and put back into state. It shares the same type (DynamicPseudoType) + // as the echo "data" attribute. + newVal := tftypes.NewValue(echoTestSchema.ValueType(), map[string]tftypes.Value{ + "data": e.providerConfigData, + }) + + newState, diag := valuetoDynamicValue(echoTestSchema, newVal) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.NewState = newState + + return resp, nil +} + +func (e *echoProviderServer) CallFunction(ctx context.Context, req *tfprotov6.CallFunctionRequest) (*tfprotov6.CallFunctionResponse, error) { + return &tfprotov6.CallFunctionResponse{}, nil +} + +func (e *echoProviderServer) ConfigureProvider(ctx context.Context, req *tfprotov6.ConfigureProviderRequest) (*tfprotov6.ConfigureProviderResponse, error) { + resp := &tfprotov6.ConfigureProviderResponse{} + + configVal, diags := dynamicValueToValue(e.providerSchema(), req.Config) + if diags != nil { + resp.Diagnostics = append(resp.Diagnostics, diags) + return resp, nil + } + + objVal := map[string]tftypes.Value{} + err := configVal.As(&objVal) + if err != nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Error reading Config", + Detail: err.Error(), + } + resp.Diagnostics = append(resp.Diagnostics, diag) + return resp, nil //nolint:nilerr // error via diagnostic, not gRPC + } + + dynamicDataVal, ok := objVal["data"] + if !ok { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: `Attribute "data" not found in config`, + } + resp.Diagnostics = append(resp.Diagnostics, diag) + return resp, nil //nolint:nilerr // error via diagnostic, not gRPC + } + + e.providerConfigData = dynamicDataVal.Copy() + + return resp, nil +} + +func (e *echoProviderServer) GetFunctions(ctx context.Context, req *tfprotov6.GetFunctionsRequest) (*tfprotov6.GetFunctionsResponse, error) { + return &tfprotov6.GetFunctionsResponse{}, nil +} + +func (e *echoProviderServer) GetMetadata(ctx context.Context, req *tfprotov6.GetMetadataRequest) (*tfprotov6.GetMetadataResponse, error) { + return &tfprotov6.GetMetadataResponse{ + Resources: []tfprotov6.ResourceMetadata{ + { + TypeName: echoResourceType, + }, + }, + }, nil +} + +func (e *echoProviderServer) GetProviderSchema(ctx context.Context, req *tfprotov6.GetProviderSchemaRequest) (*tfprotov6.GetProviderSchemaResponse, error) { + return &tfprotov6.GetProviderSchemaResponse{ + Provider: e.providerSchema(), + // MAINTAINER NOTE: This provider is only really built to support a single special resource type ("echo"). In the future, if we want + // to add more resource types to this provider, we'll likely need to refactor other RPCs in the provider server to handle that. + ResourceSchemas: map[string]*tfprotov6.Schema{ + echoResourceType: e.testResourceSchema(), + }, + }, nil +} + +func (e *echoProviderServer) ImportResourceState(ctx context.Context, req *tfprotov6.ImportResourceStateRequest) (*tfprotov6.ImportResourceStateResponse, error) { + return &tfprotov6.ImportResourceStateResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unsupported Resource Operation", + Detail: "ImportResourceState is not supported by this provider.", + }, + }, + }, nil +} + +func (e *echoProviderServer) MoveResourceState(ctx context.Context, req *tfprotov6.MoveResourceStateRequest) (*tfprotov6.MoveResourceStateResponse, error) { + return &tfprotov6.MoveResourceStateResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unsupported Resource Operation", + Detail: "MoveResourceState is not supported by this provider.", + }, + }, + }, nil +} + +func (e *echoProviderServer) PlanResourceChange(ctx context.Context, req *tfprotov6.PlanResourceChangeRequest) (*tfprotov6.PlanResourceChangeResponse, error) { + resp := &tfprotov6.PlanResourceChangeResponse{} + + if req.TypeName != echoResourceType { + resp.Diagnostics = []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unsupported Resource", + Detail: fmt.Sprintf("PlanResourceChange was called for a resource type that is not supported by this provider: %q", req.TypeName), + }, + } + + return resp, nil + } + + echoTestSchema := e.testResourceSchema() + priorState, diag := dynamicValueToValue(echoTestSchema, req.PriorState) + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + proposedNewState, diag := dynamicValueToValue(echoTestSchema, req.ProposedNewState) + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + // Destroying the resource, just return proposed new state (which is null) + if proposedNewState.IsNull() { + return &tfprotov6.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + }, nil + } + + // If the echo resource has prior state, don't plan anything new as it's valid for the ephemeral data to change + // between operations and we don't want to produce constant diffs. This resource is only for testing data, which a + // single plan/apply should suffice. + if !priorState.IsNull() { + return &tfprotov6.PlanResourceChangeResponse{ + PlannedState: req.PriorState, + }, nil + } + + // If we are creating, mark data as unknown in the plan. + // + // We can't set the proposed new state to the provider config data because it could change between plan/apply (provider config is ephemeral). + unknownVal := tftypes.NewValue(echoTestSchema.ValueType(), map[string]tftypes.Value{ + "data": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }) + + plannedState, diag := valuetoDynamicValue(echoTestSchema, unknownVal) + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.PlannedState = plannedState + + return resp, nil +} + +func (e *echoProviderServer) ReadDataSource(ctx context.Context, req *tfprotov6.ReadDataSourceRequest) (*tfprotov6.ReadDataSourceResponse, error) { + return &tfprotov6.ReadDataSourceResponse{}, nil +} + +func (e *echoProviderServer) ReadResource(ctx context.Context, req *tfprotov6.ReadResourceRequest) (*tfprotov6.ReadResourceResponse, error) { + // Just return current state, since the data doesn't need to be refreshed. + return &tfprotov6.ReadResourceResponse{ + NewState: req.CurrentState, + }, nil +} + +func (e *echoProviderServer) StopProvider(ctx context.Context, req *tfprotov6.StopProviderRequest) (*tfprotov6.StopProviderResponse, error) { + return &tfprotov6.StopProviderResponse{}, nil +} + +func (e *echoProviderServer) UpgradeResourceState(ctx context.Context, req *tfprotov6.UpgradeResourceStateRequest) (*tfprotov6.UpgradeResourceStateResponse, error) { + resp := &tfprotov6.UpgradeResourceStateResponse{} + + if req.TypeName != echoResourceType { + resp.Diagnostics = []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unsupported Resource", + Detail: fmt.Sprintf("UpgradeResourceState was called for a resource type that is not supported by this provider: %q", req.TypeName), + }, + } + + return resp, nil + } + + // Define options to be used when unmarshalling raw state. + // IgnoreUndefinedAttributes will silently skip over fields in the JSON + // that do not have a matching entry in the schema. + unmarshalOpts := tfprotov6.UnmarshalOpts{ + ValueFromJSONOpts: tftypes.ValueFromJSONOpts{ + IgnoreUndefinedAttributes: true, + }, + } + + providerSchema := e.providerSchema() + + if req.Version != providerSchema.Version { + resp.Diagnostics = []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unsupported Resource", + Detail: "UpgradeResourceState was called for echo, which does not support multiple schema versions", + }, + } + + return resp, nil + } + + // Terraform CLI can call UpgradeResourceState even if the stored state + // version matches the current schema. Presumably this is to account for + // the previous terraform-plugin-sdk implementation, which handled some + // state fixups on behalf of Terraform CLI. This will attempt to roundtrip + // the prior RawState to a state matching the current schema. + rawStateValue, err := req.RawState.UnmarshalWithOpts(providerSchema.ValueType(), unmarshalOpts) + + if err != nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Read Previously Saved State for UpgradeResourceState", + Detail: "There was an error reading the saved resource state using the current resource schema: " + err.Error(), + } + + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil //nolint:nilerr // error via diagnostic, not gRPC + } + + upgradedState, diag := valuetoDynamicValue(providerSchema, rawStateValue) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.UpgradedState = upgradedState + + return resp, nil +} + +func (e *echoProviderServer) ValidateDataResourceConfig(ctx context.Context, req *tfprotov6.ValidateDataResourceConfigRequest) (*tfprotov6.ValidateDataResourceConfigResponse, error) { + return &tfprotov6.ValidateDataResourceConfigResponse{}, nil +} + +func (e *echoProviderServer) ValidateProviderConfig(ctx context.Context, req *tfprotov6.ValidateProviderConfigRequest) (*tfprotov6.ValidateProviderConfigResponse, error) { + return &tfprotov6.ValidateProviderConfigResponse{}, nil +} + +func (e *echoProviderServer) ValidateResourceConfig(ctx context.Context, req *tfprotov6.ValidateResourceConfigRequest) (*tfprotov6.ValidateResourceConfigResponse, error) { + return &tfprotov6.ValidateResourceConfigResponse{}, nil +} + +func (e *echoProviderServer) OpenEphemeralResource(ctx context.Context, req *tfprotov6.OpenEphemeralResourceRequest) (*tfprotov6.OpenEphemeralResourceResponse, error) { + return &tfprotov6.OpenEphemeralResourceResponse{}, nil +} + +func (e *echoProviderServer) RenewEphemeralResource(ctx context.Context, req *tfprotov6.RenewEphemeralResourceRequest) (*tfprotov6.RenewEphemeralResourceResponse, error) { + return &tfprotov6.RenewEphemeralResourceResponse{}, nil +} + +func (e *echoProviderServer) CloseEphemeralResource(ctx context.Context, req *tfprotov6.CloseEphemeralResourceRequest) (*tfprotov6.CloseEphemeralResourceResponse, error) { + return &tfprotov6.CloseEphemeralResourceResponse{}, nil +} + +func (e *echoProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req *tfprotov6.ValidateEphemeralResourceConfigRequest) (*tfprotov6.ValidateEphemeralResourceConfigResponse, error) { + return &tfprotov6.ValidateEphemeralResourceConfigResponse{}, nil +} diff --git a/echoprovider/server_test.go b/echoprovider/server_test.go new file mode 100644 index 000000000..bbc6dd656 --- /dev/null +++ b/echoprovider/server_test.go @@ -0,0 +1,283 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package echoprovider_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/echoprovider" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestEchoProviderServer_primitive(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // echo provider is protocol version 6 + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, + Steps: []resource.TestStep{ + { + Config: ` + provider "echo" { + data = "hello world" + } + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_one", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_one", tfjsonpath.New("data"), knownvalue.StringExact("hello world")), + }, + }, + { + Config: ` + provider "echo" { + data = 200 + } + resource "echo" "test_two" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_two", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_two", tfjsonpath.New("data"), knownvalue.Int64Exact(200)), + }, + }, + { + Config: ` + provider "echo" { + data = true + } + resource "echo" "test_three" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_three", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_three", tfjsonpath.New("data"), knownvalue.Bool(true)), + }, + }, + }, + }) +} + +func TestEchoProviderServer_complex(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // echo provider is protocol version 6 + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, + Steps: []resource.TestStep{ + { + Config: ` + provider "echo" { + data = tolist(["hello", "world"]) + } + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_one", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_one", tfjsonpath.New("data"), + knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("hello"), + knownvalue.StringExact("world"), + }), + ), + }, + }, + { + Config: ` + provider "echo" { + data = tomap({"key1": "hello", "key2": "world"}) + } + resource "echo" "test_two" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_two", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_two", tfjsonpath.New("data"), + knownvalue.MapExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("hello"), + "key2": knownvalue.StringExact("world"), + }), + ), + }, + }, + { + Config: ` + provider "echo" { + data = tomap({"key1": "hello", "key2": "world"}) + } + resource "echo" "test_two" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} + +func TestEchoProviderServer_null(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // echo provider is protocol version 6 + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, + Steps: []resource.TestStep{ + { + Config: ` + provider "echo" {} + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_one", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_one", tfjsonpath.New("data"), knownvalue.Null()), + }, + }, + }, + }) +} + +func TestEchoProviderServer_unknown(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // echo provider is protocol version 6 + }, + ExternalProviders: map[string]resource.ExternalProvider{ + "random": { + Source: "hashicorp/random", + }, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, + Steps: []resource.TestStep{ + { + Config: ` + resource "random_string" "str" { + length = 12 + } + provider "echo" { + data = random_string.str.result + } + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_one", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_one", tfjsonpath.New("data"), knownvalue.StringRegexp(regexp.MustCompile(`\S{12}`))), + }, + }, + { + Config: ` + resource "random_string" "str" { + length = 12 + } + provider "echo" { + data = random_string.str.result + } + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} + +func TestEchoProviderServer_immutable(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // echo provider is protocol version 6 + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, + Steps: []resource.TestStep{ + { + Config: ` + provider "echo" { + data = "original value" + } + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("echo.test_one", tfjsonpath.New("data")), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_one", tfjsonpath.New("data"), knownvalue.StringExact("original value")), + }, + }, + { + // Despite the provider config data changing, the "echo.test_one" resource will never change as it's immutable. + Config: ` + provider "echo" { + data = ["tuple", "of", "values"] + } + resource "echo" "test_one" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_one", tfjsonpath.New("data"), knownvalue.StringExact("original value")), + }, + }, + }, + }) +} diff --git a/echoprovider/tftypes.go b/echoprovider/tftypes.go new file mode 100644 index 000000000..54e160a3c --- /dev/null +++ b/echoprovider/tftypes.go @@ -0,0 +1,64 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package echoprovider + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func valuetoDynamicValue(schema *tfprotov6.Schema, value tftypes.Value) (*tfprotov6.DynamicValue, *tfprotov6.Diagnostic) { + if schema == nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Value", + Detail: "Converting the Value to DynamicValue returned an unexpected error: missing schema", + } + + return nil, diag + } + + dynamicValue, err := tfprotov6.NewDynamicValue(schema.ValueType(), value) + if err != nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Value", + Detail: "Converting the Value to DynamicValue returned an unexpected error: " + err.Error(), + } + + return &dynamicValue, diag + } + + return &dynamicValue, nil +} + +func dynamicValueToValue(schema *tfprotov6.Schema, dynamicValue *tfprotov6.DynamicValue) (tftypes.Value, *tfprotov6.Diagnostic) { + if schema == nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert DynamicValue", + Detail: "Converting the DynamicValue to Value returned an unexpected error: missing schema", + } + + return tftypes.NewValue(tftypes.Object{}, nil), diag + } + + if dynamicValue == nil { + return tftypes.NewValue(schema.ValueType(), nil), nil + } + + value, err := dynamicValue.Unmarshal(schema.ValueType()) + + if err != nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert DynamicValue", + Detail: "Converting the DynamicValue to Value returned an unexpected error: " + err.Error(), + } + + return value, diag + } + + return value, nil +} diff --git a/internal/testing/testsdk/providerserver/providerserver.go b/internal/testing/testsdk/providerserver/providerserver.go index 4cc3f84ad..3c763914c 100644 --- a/internal/testing/testsdk/providerserver/providerserver.go +++ b/internal/testing/testsdk/providerserver/providerserver.go @@ -58,20 +58,16 @@ type ProviderServer struct { Provider provider.Provider } -func (s ProviderServer) CallFunction(ctx context.Context, req *tfprotov6.CallFunctionRequest) (*tfprotov6.CallFunctionResponse, error) { - return &tfprotov6.CallFunctionResponse{}, nil -} - -func (s ProviderServer) GetFunctions(ctx context.Context, req *tfprotov6.GetFunctionsRequest) (*tfprotov6.GetFunctionsResponse, error) { - return &tfprotov6.GetFunctionsResponse{}, nil -} - func (s ProviderServer) MoveResourceState(ctx context.Context, req *tfprotov6.MoveResourceStateRequest) (*tfprotov6.MoveResourceStateResponse, error) { return &tfprotov6.MoveResourceStateResponse{}, nil } func (s ProviderServer) GetMetadata(ctx context.Context, request *tfprotov6.GetMetadataRequest) (*tfprotov6.GetMetadataResponse, error) { resp := &tfprotov6.GetMetadataResponse{ + // Functions and ephemeral resources not supported in this test SDK + Functions: []tfprotov6.FunctionMetadata{}, + EphemeralResources: []tfprotov6.EphemeralResourceMetadata{}, + ServerCapabilities: &tfprotov6.ServerCapabilities{ GetProviderSchemaOptional: true, PlanDestroy: true, @@ -252,6 +248,10 @@ func (s ProviderServer) GetProviderSchema(ctx context.Context, req *tfprotov6.Ge s.Provider.Schema(ctx, providerReq, providerResp) resp := &tfprotov6.GetProviderSchemaResponse{ + // Functions and ephemeral resources not supported in this test SDK + Functions: map[string]*tfprotov6.Function{}, + EphemeralResourceSchemas: map[string]*tfprotov6.Schema{}, + DataSourceSchemas: map[string]*tfprotov6.Schema{}, Diagnostics: providerResp.Diagnostics, Provider: providerResp.Schema, @@ -795,3 +795,29 @@ func (s ProviderServer) ValidateResourceConfig(ctx context.Context, req *tfproto return resp, nil } + +// Functions are not currently implemented in this test SDK +func (s ProviderServer) CallFunction(ctx context.Context, req *tfprotov6.CallFunctionRequest) (*tfprotov6.CallFunctionResponse, error) { + return &tfprotov6.CallFunctionResponse{}, nil +} + +func (s ProviderServer) GetFunctions(ctx context.Context, req *tfprotov6.GetFunctionsRequest) (*tfprotov6.GetFunctionsResponse, error) { + return &tfprotov6.GetFunctionsResponse{}, nil +} + +// Ephemeral resources are not currently implemented in this test SDK +func (s ProviderServer) OpenEphemeralResource(ctx context.Context, req *tfprotov6.OpenEphemeralResourceRequest) (*tfprotov6.OpenEphemeralResourceResponse, error) { + return &tfprotov6.OpenEphemeralResourceResponse{}, nil +} + +func (s ProviderServer) RenewEphemeralResource(ctx context.Context, req *tfprotov6.RenewEphemeralResourceRequest) (*tfprotov6.RenewEphemeralResourceResponse, error) { + return &tfprotov6.RenewEphemeralResourceResponse{}, nil +} + +func (s ProviderServer) CloseEphemeralResource(ctx context.Context, req *tfprotov6.CloseEphemeralResourceRequest) (*tfprotov6.CloseEphemeralResourceResponse, error) { + return &tfprotov6.CloseEphemeralResourceResponse{}, nil +} + +func (s ProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req *tfprotov6.ValidateEphemeralResourceConfigRequest) (*tfprotov6.ValidateEphemeralResourceConfigResponse, error) { + return &tfprotov6.ValidateEphemeralResourceConfigResponse{}, nil +} diff --git a/internal/testing/testsdk/providerserver/providerserver_protov5.go b/internal/testing/testsdk/providerserver/providerserver_protov5.go index 4e6452b67..5704b288a 100644 --- a/internal/testing/testsdk/providerserver/providerserver_protov5.go +++ b/internal/testing/testsdk/providerserver/providerserver_protov5.go @@ -69,10 +69,12 @@ func (s Protov5ProviderServer) GetProviderSchema(ctx context.Context, req *tfpro s.Provider.Schema(ctx, providerReq, providerResp) resp := &tfprotov5.GetProviderSchemaResponse{ - DataSourceSchemas: map[string]*tfprotov5.Schema{}, - Diagnostics: providerResp.Diagnostics, - Provider: providerResp.Schema, - ResourceSchemas: map[string]*tfprotov5.Schema{}, + DataSourceSchemas: map[string]*tfprotov5.Schema{}, + EphemeralResourceSchemas: map[string]*tfprotov5.Schema{}, + Functions: map[string]*tfprotov5.Function{}, + Diagnostics: providerResp.Diagnostics, + Provider: providerResp.Schema, + ResourceSchemas: map[string]*tfprotov5.Schema{}, ServerCapabilities: &tfprotov5.ServerCapabilities{ PlanDestroy: true, }, @@ -116,3 +118,19 @@ func (s Protov5ProviderServer) ValidateDataSourceConfig(ctx context.Context, req func (s Protov5ProviderServer) ValidateResourceTypeConfig(ctx context.Context, request *tfprotov5.ValidateResourceTypeConfigRequest) (*tfprotov5.ValidateResourceTypeConfigResponse, error) { return &tfprotov5.ValidateResourceTypeConfigResponse{}, nil } + +func (s Protov5ProviderServer) OpenEphemeralResource(ctx context.Context, req *tfprotov5.OpenEphemeralResourceRequest) (*tfprotov5.OpenEphemeralResourceResponse, error) { + return &tfprotov5.OpenEphemeralResourceResponse{}, nil +} + +func (s Protov5ProviderServer) RenewEphemeralResource(ctx context.Context, req *tfprotov5.RenewEphemeralResourceRequest) (*tfprotov5.RenewEphemeralResourceResponse, error) { + return &tfprotov5.RenewEphemeralResourceResponse{}, nil +} + +func (s Protov5ProviderServer) CloseEphemeralResource(ctx context.Context, req *tfprotov5.CloseEphemeralResourceRequest) (*tfprotov5.CloseEphemeralResourceResponse, error) { + return &tfprotov5.CloseEphemeralResourceResponse{}, nil +} + +func (s Protov5ProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req *tfprotov5.ValidateEphemeralResourceConfigRequest) (*tfprotov5.ValidateEphemeralResourceConfigResponse, error) { + return &tfprotov5.ValidateEphemeralResourceConfigResponse{}, nil +} diff --git a/tfversion/versions.go b/tfversion/versions.go index 4dcf5d14a..3db43e02e 100644 --- a/tfversion/versions.go +++ b/tfversion/versions.go @@ -30,10 +30,11 @@ var ( Version1_4_0 *version.Version = version.Must(version.NewVersion("1.4.0")) // Version1_4_6 fixed inclusion of sensitive values in `terraform show -json` output. // Reference: https://github.com/hashicorp/terraform/releases/tag/v1.4.6 - Version1_4_6 *version.Version = version.Must(version.NewVersion("1.4.6")) - Version1_5_0 *version.Version = version.Must(version.NewVersion("1.5.0")) - Version1_6_0 *version.Version = version.Must(version.NewVersion("1.6.0")) - Version1_7_0 *version.Version = version.Must(version.NewVersion("1.7.0")) - Version1_8_0 *version.Version = version.Must(version.NewVersion("1.8.0")) - Version1_9_0 *version.Version = version.Must(version.NewVersion("1.9.0")) + Version1_4_6 *version.Version = version.Must(version.NewVersion("1.4.6")) + Version1_5_0 *version.Version = version.Must(version.NewVersion("1.5.0")) + Version1_6_0 *version.Version = version.Must(version.NewVersion("1.6.0")) + Version1_7_0 *version.Version = version.Must(version.NewVersion("1.7.0")) + Version1_8_0 *version.Version = version.Must(version.NewVersion("1.8.0")) + Version1_9_0 *version.Version = version.Must(version.NewVersion("1.9.0")) + Version1_10_0 *version.Version = version.Must(version.NewVersion("1.10.0")) ) diff --git a/website/data/plugin-testing-nav-data.json b/website/data/plugin-testing-nav-data.json index be9cff643..5eee96627 100644 --- a/website/data/plugin-testing-nav-data.json +++ b/website/data/plugin-testing-nav-data.json @@ -160,6 +160,10 @@ { "title": "Terraform Configuration", "path": "acceptance-tests/configuration" + }, + { + "title": "Ephemeral Resources", + "path": "acceptance-tests/ephemeral-resources" } ] }, diff --git a/website/docs/plugin/testing/acceptance-tests/ephemeral-resources.mdx b/website/docs/plugin/testing/acceptance-tests/ephemeral-resources.mdx new file mode 100644 index 000000000..36c6fea29 --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/ephemeral-resources.mdx @@ -0,0 +1,271 @@ +--- +page_title: 'Plugin Development - Acceptance Testing: Ephemeral Resources' +description: >- + Guidance on how to test ephemeral resources and data. +--- + + + +Ephemeral resource support is in technical preview and offered without compatibility promises until Terraform 1.10 is generally available. + + + +# Ephemeral Resources + +[Ephemeral Resources](/terraform/language/v1.10.x/resources/ephemeral) are an abstraction that allows Terraform to reference external data, similar to [data sources](/terraform/language/data-sources), without persisting that data to plan or state artifacts. The `terraform-plugin-testing` module exclusively uses Terraform plan and state artifacts for it's assertion-based test checks, like [plan checks](/terraform/plugin/testing/acceptance-tests/plan-checks) or [state checks](/terraform/plugin/testing/acceptance-tests/state-checks), which means that ephemeral resource data cannot be asserted using these methods alone. + +The following is a test for a hypothetical `examplecloud_secret` ephemeral resource which is referenced by a provider configuration that has a single managed resource. For this test to pass, the ephemeral `examplecloud_secret` resource must return valid data, specifically a kerberos `username`, `password`, and `realm`, which are used to configure the `dns` provider and create a DNS record via the `dns_a_record_set` managed resource. + +```go +func TestExampleCloudSecret_DnsKerberos(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // Ephemeral resources are only available in 1.10 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ExternalProviders: map[string]resource.ExternalProvider{ + "dns": { + Source: "hashicorp/dns", + }, + }, + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "examplecloud": providerserver.NewProtocol5WithError(New()), + }, + Steps: []resource.TestStep{ + { + Config: ` + # Retrieves a secret containing user kerberos configuration + ephemeral "examplecloud_secret" "krb" { + name = "example_kerberos_user" + } + + # Ephemeral data can be referenced in provider configuration + provider "dns" { + update { + server = "ns.example.com" + gssapi { + realm = ephemeral.examplecloud_secret.krb.secret_data.realm + username = ephemeral.examplecloud_secret.krb.secret_data.username + password = ephemeral.examplecloud_secret.krb.secret_data.password + } + } + } + + # If we can create this DNS record successfully, then the ephemeral resource returned valid data. + resource "dns_a_record_set" "record_set" { + zone = "example.com." + addresses = [ + "192.168.0.1", + "192.168.0.2", + "192.168.0.3", + ] + } + `, + }, + }, + }) +} +``` + +See the Terraform [ephemeral documentation](http://localhost:3000/terraform/language/v1.10.x/resources/ephemeral#referencing-ephemeral-resources) for more details on where ephemeral data can be referenced in configurations. + +## Testing ephemeral data with `echo` provider + +Test assertions on [result data](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/ephemeral#OpenResponse.Result) returned by an ephemeral resource during [`Open`](/terraform/plugin/framework/ephemeral-resources/open) can be arranged using the `echoprovider` package. + +This package contains a [Protocol V6 Terraform Provider](/terraform/plugin/terraform-plugin-protocol#protocol-version-6) named `echo`, with a single managed resource also named `echo`. Using the `echo` provider configuration and an instance of the managed resource, ephemeral data can be "echoed" from the provider configuration into Terraform state, where it can be referenced in test assertions with [state checks](/terraform/plugin/testing/acceptance-tests/state-checks). For example: + +```terraform +ephemeral "examplecloud_secret" "krb" { + name = "example_kerberos_user" +} + +provider "echo" { + # Provide the ephemeral data we want to run test assertions against + data = ephemeral.examplecloud_secret.krb.secret_data +} + +# The ephemeral data will be echoed into state +resource "echo" "test_krb" {} +``` + + + +This provider is designed specifically to be used as a utility for acceptance testing ephemeral data and is only available via the `terraform-plugin-testing` Go module. + + + +### Using `echo` provider in acceptance tests + +First, we include the `echo` provider using the [`echoprovider.NewProviderServer`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/echoprovider#NewProviderServer) function in the `(TestCase).ProtoV6ProviderFactories` property: + +```go +import ( + // .. other imports + + "github.com/hashicorp/terraform-plugin-testing/echoprovider" +) + +func TestExampleCloudSecret(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // Ephemeral resources are only available in 1.10 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + // Include the provider we want to test: `examplecloud` + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "examplecloud": providerserver.NewProtocol5WithError(New()), + }, + // Include `echo` as a v6 provider from `terraform-plugin-testing` + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, + Steps: []resource.TestStep{ + // .. test step configurations can now use the `echo` and `examplecloud` providers + }, + }) +} +``` + +After including both providers, our test step `Config` references the ephemeral data from `examplecloud_secret` in the `echo` provider configuration `data` attribute: + +```go +func TestExampleCloudSecret(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // .. test case setup + + Steps: []resource.TestStep{ + { + Config: ` + ephemeral "examplecloud_secret" "krb" { + name = "example_kerberos_user" + } + + provider "echo" { + data = ephemeral.examplecloud_secret.krb.secret_data + } + + resource "echo" "test_krb" {} + `, + }, + }, + }) +} +``` + +The `echo.test_krb` managed resource has a single computed `data` attribute, which will contain the provider configuration `data` results. This data is then used in assertions with the [state check](/terraform/plugin/testing/acceptance-tests/state-checks) functionality: + +```go +func TestExampleCloudSecret(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // .. test case setup + + Steps: []resource.TestStep{ + { + Config: ` + ephemeral "examplecloud_secret" "krb" { + name = "example_kerberos_user" + } + + provider "echo" { + data = ephemeral.examplecloud_secret.krb.secret_data + } + + resource "echo" "test_krb" {} + `, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_krb", tfjsonpath.New("data").AtMapKey("realm"), knownvalue.StringExact("EXAMPLE.COM")), + statecheck.ExpectKnownValue("echo.test_krb", tfjsonpath.New("data").AtMapKey("username"), knownvalue.StringExact("john-doe")), + statecheck.ExpectKnownValue("echo.test_krb", tfjsonpath.New("data").AtMapKey("password"), knownvalue.StringRegexp(regexp.MustCompile(`^.{12}$`))), + }, + }, + }, + }) +} +``` + +`data` is a `dynamic` attribute, so whatever [type](/terraform/language/expressions/types) you pass in will be directly reflected in the managed resource `data` attribute. In the config above, we reference an object (`secret_data`) from the ephemeral resource instance, so the resulting type of `echo.test_krb.data` is also an `object`. + +You can also reference the entire ephemeral resource instance for assertions, rather than specific attributes: + +```go +func TestExampleCloudSecret(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // .. test case setup + + Steps: []resource.TestStep{ + { + Config: ` + ephemeral "examplecloud_secret" "krb" { + name = "example_kerberos_user" + } + + provider "echo" { + data = ephemeral.examplecloud_secret.krb + } + + resource "echo" "test_krb" {} + `, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_krb", tfjsonpath.New("data").AtMapKey("name"), knownvalue.StringExact("example_kerberos_user")), + }, + }, + }, + }) +} +``` + +### Caveats with `echo` provider + +Since data produced by an ephemeral resource is allowed to change between plan/apply operations, the `echo` resource has special handling to allow this data to be used in the `terraform-plugin-testing` Go module without producing confusing error messages: + +* During plan, if the `echo` resource is being created, the `data` attribute will always be marked as unknown. +* During plan, if the `echo` resource already exists and is not being destroyed, prior state will always be fully preserved regardless of changes to the provider configuration. This essentially means an instance of the `echo` resource is immutable. +* During refresh, the prior state of the `echo` resource is always returned, regardless of changes to the provider configuration. + +Due to this special handling, if multiple test steps are required for testing data, provider developers should create new instances of `echo` for each new test step, for example: + +```go +func TestExampleCloudSecret(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // .. test case setup + + Steps: []resource.TestStep{ + { + Config: ` + ephemeral "examplecloud_secret" "krb" { + name = "user_one" + } + + provider "echo" { + data = ephemeral.examplecloud_secret.krb + } + + # First test object -> 1 + resource "echo" "test_krb_one" {} + `, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_krb_one", tfjsonpath.New("data").AtMapKey("name"), knownvalue.StringExact("user_one")), + }, + }, + { + Config: ` + ephemeral "examplecloud_secret" "krb" { + name = "user_two" + } + + provider "echo" { + data = ephemeral.examplecloud_secret.krb + } + + # New test object -> 2 + resource "echo" "test_krb_two" {} + `, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.test_krb_two", tfjsonpath.New("data").AtMapKey("name"), knownvalue.StringExact("user_two")), + }, + }, + }, + }) +} +``` \ No newline at end of file