From ebffbe1b9b6b37cebd4f4595066eeaa0289eecc5 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 24 Jul 2023 09:52:01 -0400 Subject: [PATCH 1/2] Introduce `tfjsonpath` Package and Sensitive and Unknown Plan Checks (#154) * Implement `tfjsonpath` package * Implement `ExpectSensitiveValue` plan check * Implement `ExpectUnknownValue` plan check * Update website documentation * Resolve linting errors * Add copyright headers * Add test for `null` values * Reword bool assertion error * Add Changie Entries * Correct documentation typos Co-authored-by: Brian Flad * Add error for when resource is not found * Add additional wording to attribute path subsections * Resolve linting errors --------- Co-authored-by: Brian Flad --- .../unreleased/FEATURES-20230720-163447.yaml | 6 + .../unreleased/FEATURES-20230720-163627.yaml | 6 + .../unreleased/FEATURES-20230720-163828.yaml | 6 + plancheck/expect_sensitive_value.go | 61 ++ plancheck/expect_sensitive_value_test.go | 300 ++++++++ plancheck/expect_unknown_value.go | 61 ++ plancheck/expect_unknown_value_test.go | 352 +++++++++ tfjsonpath/doc.go | 6 + tfjsonpath/path.go | 107 +++ tfjsonpath/path_test.go | 276 +++++++ tfjsonpath/step.go | 14 + website/data/plugin-testing-nav-data.json | 18 +- .../testing/acceptance-tests/plan-checks.mdx | 12 +- .../testing/acceptance-tests/tfjson-paths.mdx | 690 ++++++++++++++++++ 14 files changed, 1907 insertions(+), 8 deletions(-) create mode 100644 .changes/unreleased/FEATURES-20230720-163447.yaml create mode 100644 .changes/unreleased/FEATURES-20230720-163627.yaml create mode 100644 .changes/unreleased/FEATURES-20230720-163828.yaml create mode 100644 plancheck/expect_sensitive_value.go create mode 100644 plancheck/expect_sensitive_value_test.go create mode 100644 plancheck/expect_unknown_value.go create mode 100644 plancheck/expect_unknown_value_test.go create mode 100644 tfjsonpath/doc.go create mode 100644 tfjsonpath/path.go create mode 100644 tfjsonpath/path_test.go create mode 100644 tfjsonpath/step.go create mode 100644 website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx diff --git a/.changes/unreleased/FEATURES-20230720-163447.yaml b/.changes/unreleased/FEATURES-20230720-163447.yaml new file mode 100644 index 000000000..4ebc80328 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230720-163447.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'tfjsonpath: Introduced new `tfjsonpath` package which contains methods that + allow traversal of Terraform JSON data' +time: 2023-07-20T16:34:47.373683-04:00 +custom: + Issue: "154" diff --git a/.changes/unreleased/FEATURES-20230720-163627.yaml b/.changes/unreleased/FEATURES-20230720-163627.yaml new file mode 100644 index 000000000..d277734c4 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230720-163627.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectUnknownValue` built-in plan check, which asserts that + a given attribute has an unknown value' +time: 2023-07-20T16:36:27.361538-04:00 +custom: + Issue: "154" diff --git a/.changes/unreleased/FEATURES-20230720-163828.yaml b/.changes/unreleased/FEATURES-20230720-163828.yaml new file mode 100644 index 000000000..8411348f3 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230720-163828.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectSensitiveValue` built-in plan check, which asserts + that a given attribute has a sensitive value' +time: 2023-07-20T16:38:28.94511-04:00 +custom: + Issue: "154" diff --git a/plancheck/expect_sensitive_value.go b/plancheck/expect_sensitive_value.go new file mode 100644 index 000000000..b6c3a5194 --- /dev/null +++ b/plancheck/expect_sensitive_value.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectSensitiveValue{} + +type expectSensitiveValue struct { + resourceAddress string + attributePath tfjsonpath.Path +} + +// CheckPlan implements the plan check logic. +func (e expectSensitiveValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + + for _, rc := range req.Plan.ResourceChanges { + if e.resourceAddress != rc.Address { + continue + } + + result, err := tfjsonpath.Traverse(rc.Change.AfterSensitive, e.attributePath) + if err != nil { + resp.Error = err + return + } + + isSensitive, ok := result.(bool) + if !ok { + resp.Error = fmt.Errorf("invalid path: the path value cannot be asserted as bool") + return + } + + if !isSensitive { + resp.Error = fmt.Errorf("attribute at path is not sensitive") + return + } + + return + } + + resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) +} + +// ExpectSensitiveValue returns a plan check that asserts that the specified attribute at the given resource has a sensitive value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of sensitive +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of sensitive values, such +// as marking whole maps as sensitive rather than individual element values. +func ExpectSensitiveValue(resourceAddress string, attributePath tfjsonpath.Path) PlanCheck { + return expectSensitiveValue{ + resourceAddress: resourceAddress, + attributePath: attributePath, + } +} diff --git a/plancheck/expect_sensitive_value_test.go b/plancheck/expect_sensitive_value_test.go new file mode 100644 index 000000000..9cddc9737 --- /dev/null +++ b/plancheck/expect_sensitive_value_test.go @@ -0,0 +1,300 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "context" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func Test_ExpectSensitiveValue_SensitiveStringAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_string_attribute = "test" + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_string_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SensitiveListAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_list_attribute = ["value1"] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SensitiveSetAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_set_attribute = ["value1"] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SensitiveMapAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_map_attribute = { + key1 = "value1", + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_ListNestedBlock_SensitiveAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + list_nested_block_sensitive_attribute { + sensitive_list_nested_block_attribute = "sensitive-test" + list_nested_block_attribute = "test" + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("list_nested_block_sensitive_attribute").AtSliceIndex(0). + AtMapKey("sensitive_list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SetNestedBlock_SensitiveAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_nested_block_sensitive_attribute { + sensitive_set_nested_block_attribute = "sensitive-test" + set_nested_block_attribute = "test" + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("set_nested_block_sensitive_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_ExpectError_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" {} + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.two", tfjsonpath.New("set_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`test_resource.two - Resource not found in plan ResourceChanges`), + }, + }, + }) +} + +func testProviderSensitive() *schema.Provider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test_resource": { + CreateContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + d.SetId("test") + return nil + }, + UpdateContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "sensitive_string_attribute": { + Sensitive: true, + Optional: true, + Type: schema.TypeString, + }, + "sensitive_list_attribute": { + Sensitive: true, + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "sensitive_set_attribute": { + Sensitive: true, + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "sensitive_map_attribute": { + Sensitive: true, + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "list_nested_block_sensitive_attribute": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + "sensitive_list_nested_block_attribute": { + Sensitive: true, + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + "set_nested_block_sensitive_attribute": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "set_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + "sensitive_set_nested_block_attribute": { + Sensitive: true, + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/plancheck/expect_unknown_value.go b/plancheck/expect_unknown_value.go new file mode 100644 index 000000000..1569397c2 --- /dev/null +++ b/plancheck/expect_unknown_value.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectUnknownValue{} + +type expectUnknownValue struct { + resourceAddress string + attributePath tfjsonpath.Path +} + +// CheckPlan implements the plan check logic. +func (e expectUnknownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + + for _, rc := range req.Plan.ResourceChanges { + if e.resourceAddress != rc.Address { + continue + } + + result, err := tfjsonpath.Traverse(rc.Change.AfterUnknown, e.attributePath) + if err != nil { + resp.Error = err + return + } + + isUnknown, ok := result.(bool) + if !ok { + resp.Error = fmt.Errorf("invalid path: the path value cannot be asserted as bool") + return + } + + if !isUnknown { + resp.Error = fmt.Errorf("attribute at path is known") + return + } + + return + } + + resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) +} + +// ExpectUnknownValue returns a plan check that asserts that the specified attribute at the given resource has an unknown value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of unknown +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of unknown values, such +// as marking whole maps as unknown rather than individual element values. +func ExpectUnknownValue(resourceAddress string, attributePath tfjsonpath.Path) PlanCheck { + return expectUnknownValue{ + resourceAddress: resourceAddress, + attributePath: attributePath, + } +} diff --git a/plancheck/expect_unknown_value_test.go b/plancheck/expect_unknown_value_test.go new file mode 100644 index 000000000..31798b9d0 --- /dev/null +++ b/plancheck/expect_unknown_value_test.go @@ -0,0 +1,352 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "context" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func Test_ExpectUnknownValue_StringAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + string_attribute = time_static.one.rfc3339 + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("string_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_ListAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + list_attribute = ["value1", time_static.one.rfc3339] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_SetAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + set_attribute = ["value1", time_static.one.rfc3339] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_MapAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + map_attribute = { + key1 = "value1", + key2 = time_static.one.rfc3339 + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_ListNestedBlock(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + list_nested_block { + list_nested_block_attribute = time_static.one.rfc3339 + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", + tfjsonpath.New("list_nested_block").AtSliceIndex(0).AtMapKey("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_SetNestedBlock(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + set_nested_block { + set_nested_block_attribute = time_static.one.rfc3339 + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", + tfjsonpath.New("set_nested_block").AtSliceIndex(0).AtMapKey("set_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_ExpectError_KnownValue(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_attribute = ["value1"] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.one", tfjsonpath.New("set_attribute").AtSliceIndex(0)), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is known`), + }, + }, + }) +} + +func Test_ExpectUnknownValue_ExpectError_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" {} + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("set_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`test_resource.two - Resource not found in plan ResourceChanges`), + }, + }, + }) +} + +func testProvider() *schema.Provider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test_resource": { + CreateContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + d.SetId("test") + return nil + }, + UpdateContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "string_attribute": { + Optional: true, + Type: schema.TypeString, + }, + + "list_attribute": { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "set_attribute": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "map_attribute": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "root_map_attribute": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + + "list_nested_block": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + "set_nested_block": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "set_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/tfjsonpath/doc.go b/tfjsonpath/doc.go new file mode 100644 index 000000000..4b1a4923b --- /dev/null +++ b/tfjsonpath/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package tfjsonpath implements terraform-json path functionality, which defines +// traversals into Terraform JSON data, for testing purposes. +package tfjsonpath diff --git a/tfjsonpath/path.go b/tfjsonpath/path.go new file mode 100644 index 000000000..ef3930dfb --- /dev/null +++ b/tfjsonpath/path.go @@ -0,0 +1,107 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfjsonpath + +import ( + "fmt" +) + +// Path represents exact traversal steps specifying a value inside +// Terraform JSON data. These steps always start from a MapStep with a key +// specifying the name of a top-level JSON object or array. +// +// The [terraform-json] library serves as the de facto documentation +// for JSON format of Terraform data. +// +// Use the New() function to create a Path with an initial AtMapKey() step. +// Path functionality follows a builder pattern, which allows for chaining method +// calls to construct a full path. The available traversal steps after Path +// creation are: +// +// - AtSliceIndex(): Step into a slice at a specific 0-based index +// - AtMapKey(): Step into a map at a specific key +// +// For example, to represent the first element of a JSON array +// underneath a "some_array" property of this JSON value: +// +// { +// "some_array": [true] +// } +// +// The path code would be represented by: +// +// tfjsonpath.New("some_array").AtSliceIndex(0) +// +// [terraform-json]: (https://pkg.go.dev/github.com/hashicorp/terraform-json) +type Path struct { + steps []step +} + +// New creates a new path with an initial MapStep. +func New(name string) Path { + return Path{ + steps: []step{ + MapStep(name), + }, + } +} + +// AtSliceIndex returns a copied Path with a new SliceStep at the end. +func (s Path) AtSliceIndex(index int) Path { + newSteps := append(s.steps, SliceStep(index)) + s.steps = newSteps + return s +} + +// AtMapKey returns a copied Path with a new MapStep at the end. +func (s Path) AtMapKey(key string) Path { + newSteps := append(s.steps, MapStep(key)) + s.steps = newSteps + return s +} + +// Traverse returns the element found when traversing the given +// object using the specified Path. The object is an unmarshalled +// JSON object representing Terraform data. +// +// Traverse returns an error if the value specified by the Path +// is not found in the given object or if the given object does not +// conform to format of Terraform JSON data. +func Traverse(object any, attrPath Path) (any, error) { + _, ok := object.(map[string]any) + + if !ok { + return nil, fmt.Errorf("cannot convert given object to map[string]any") + } + + result := object + + for _, step := range attrPath.steps { + switch s := step.(type) { + case MapStep: + mapObj, ok := result.(map[string]any) + if !ok { + return nil, fmt.Errorf("path not found: cannot convert object at MapStep %s to map[string]any", string(s)) + } + result, ok = mapObj[string(s)] + if !ok { + return nil, fmt.Errorf("path not found: specified key %s not found in map", string(s)) + } + + case SliceStep: + sliceObj, ok := result.([]any) + if !ok { + return nil, fmt.Errorf("path not found: cannot convert object at SliceStep %d to []any", s) + } + + if int(s) >= len(sliceObj) { + return nil, fmt.Errorf("path not found: SliceStep index %d is out of range with slice length %d", s, len(sliceObj)) + } + + result = sliceObj[s] + } + } + + return result, nil +} diff --git a/tfjsonpath/path_test.go b/tfjsonpath/path_test.go new file mode 100644 index 000000000..9a37c9918 --- /dev/null +++ b/tfjsonpath/path_test.go @@ -0,0 +1,276 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfjsonpath + +import ( + "encoding/json" + "strings" + "testing" +) + +func Test_Traverse_StringValue(t *testing.T) { + t.Parallel() + + path := New("StringValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := "example" + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + +func Test_Traverse_NumberValue(t *testing.T) { + t.Parallel() + + path := New("NumberValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := 0.0 + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + +func Test_Traverse_BooleanValue(t *testing.T) { + t.Parallel() + + path := New("BooleanValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := false + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + +func Test_Traverse_NullValue(t *testing.T) { + t.Parallel() + + path := New("NullValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + + if actual != nil { + t.Errorf("Output %v not equal to expected %v", actual, nil) + } +} + +func Test_Traverse_Array(t *testing.T) { + t.Parallel() + + testCases := []struct { + path Path + expected any + }{ + { + path: New("Array").AtSliceIndex(0), + expected: 10.0, + }, + { + path: New("Array").AtSliceIndex(1), + expected: 15.2, + }, + { + path: New("Array").AtSliceIndex(2), + expected: "example2", + }, + { + path: New("Array").AtSliceIndex(3), + expected: nil, + }, + { + path: New("Array").AtSliceIndex(4), + expected: true, + }, + { + path: New("Array").AtSliceIndex(5).AtMapKey("NestedStringValue"), + expected: "example3", + }, + { + path: New("Array").AtSliceIndex(6).AtSliceIndex(0), + expected: true, + }, + } + + for _, tc := range testCases { + actual, err := Traverse(createTestObject(), tc.path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := tc.expected + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } + } +} + +func Test_Traverse_Object(t *testing.T) { + t.Parallel() + + testCases := []struct { + path Path + expected any + }{ + { + path: New("Object").AtMapKey("StringValue"), + expected: "example", + }, + { + path: New("Object").AtMapKey("NumberValue"), + expected: 0.0, + }, + { + path: New("Object").AtMapKey("BooleanValue"), + expected: false, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(0), + expected: 10.0, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(1), + expected: 15.2, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(2), + expected: "example2", + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(3), + expected: nil, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(4), + expected: true, + }, + { + path: New("Object").AtMapKey("ObjectValue").AtMapKey("NestedStringValue"), + expected: "example3", + }, + } + + for _, tc := range testCases { + actual, err := Traverse(createTestObject(), tc.path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := tc.expected + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } + } +} + +func Test_Traverse_ExpectError(t *testing.T) { + t.Parallel() + + testCases := []struct { + path Path + expectedError func(err error) bool + }{ + // specified key not found + { + path: New("ObjectA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: specified key ObjectA not found in map`) + }, + }, + { + path: New("Object").AtMapKey("MapValueA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: specified key MapValueA not found in map`) + }, + }, + + // cannot convert object + { + path: New("StringValue").AtSliceIndex(0), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at SliceStep`) + }, + }, + { + path: New("StringValue").AtMapKey("MapKeyA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep`) + }, + }, + { + path: New("Array").AtSliceIndex(0).AtMapKey("MapValueA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep`) + }, + }, + + // index out of bounds + { + path: New("Array").AtSliceIndex(10), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: SliceStep index 10 is out of range with slice length 7`) + }, + }, + { + path: New("Array").AtSliceIndex(7), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: SliceStep index 7 is out of range with slice length 7`) + }, + }, + } + + for _, tc := range testCases { + _, err := Traverse(createTestObject(), tc.path) + if err == nil { + t.Fatalf("Expected error but got none") + } + + if !tc.expectedError(err) { + t.Errorf("Unexpected error: %s", err) + } + } +} + +func createTestObject() any { + var jsonObject any + jsonstring := + `{ + "StringValue": "example", + "NumberValue": 0, + "BooleanValue": false, + "NullValue": null, + "Array": [10, 15.2, "example2", null, true, {"NestedStringValue": "example3"}, [true]], + "Object":{ + "StringValue": "example", + "NumberValue": 0, + "BooleanValue": false, + "ArrayValue": [10, 15.2, "example2", null, true], + "ObjectValue": { + "NestedStringValue": "example3" + } + } + }` + err := json.Unmarshal([]byte(jsonstring), &jsonObject) + if err != nil { + return nil + } + + return jsonObject +} diff --git a/tfjsonpath/step.go b/tfjsonpath/step.go new file mode 100644 index 000000000..7b6813d60 --- /dev/null +++ b/tfjsonpath/step.go @@ -0,0 +1,14 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfjsonpath + +// step represents a traversal type indicating the underlying Go type +// representation for a Terraform JSON value. +type step interface{} + +// MapStep represents a traversal for map[string]any +type MapStep string + +// SliceStep represents a traversal for []any +type SliceStep int diff --git a/website/data/plugin-testing-nav-data.json b/website/data/plugin-testing-nav-data.json index 5e6468792..1616b0935 100644 --- a/website/data/plugin-testing-nav-data.json +++ b/website/data/plugin-testing-nav-data.json @@ -1,7 +1,15 @@ [ - { "heading": "Testing" }, - { "title": "Overview", "path": "" }, - { "title": "Migrating from SDK", "path": "migrating" }, + { + "heading": "Testing" + }, + { + "title": "Overview", + "path": "" + }, + { + "title": "Migrating from SDK", + "path": "migrating" + }, { "title": "Acceptance Testing", "routes": [ @@ -28,6 +36,10 @@ { "title": "Sweepers", "path": "acceptance-tests/sweepers" + }, + { + "title": "Terraform JSON Paths", + "path": "acceptance-tests/tfjson-paths" } ] }, diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx index fa91a402f..fa6a93b38 100644 --- a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx @@ -21,11 +21,13 @@ A **plan check** is a test assertion that inspects the plan file at a specific p The `terraform-plugin-testing` module provides a package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) with built-in plan checks for common use-cases: -| Check | Description | -|---|---| -| [`plancheck.ExpectEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan) | Asserts the entire plan has no operations for apply. | -| [`plancheck.ExpectNonEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNonEmptyPlan) | Asserts the entire plan contains at least one operation for apply. | -| [`plancheck.ExpectResourceAction(address, operation)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction) | Asserts the given resource has the specified operation for apply. | +| Check | Description | +|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| [`plancheck.ExpectEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan) | Asserts the entire plan has no operations for apply. | +| [`plancheck.ExpectNonEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNonEmptyPlan) | Asserts the entire plan contains at least one operation for apply. | +| [`plancheck.ExpectResourceAction(address, operation)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction) | Asserts the given resource has the specified operation for apply. | +| [`plancheck.ExpectUnknownValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownValue) | Asserts the specified attribute at the given resource has an unknown value. | +| [`plancheck.ExpectSensitiveValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectSensitiveValue) | Asserts the specified attribute at the given resource has a sensitive value. | ### Examples using `plancheck.ExpectResourceAction` diff --git a/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx new file mode 100644 index 000000000..e169026d2 --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx @@ -0,0 +1,690 @@ +--- +page_title: 'Plugin Development - Acceptance Testing: Terraform JSON Paths' +description: >- + How to implement attribute paths in the testing module. + Attribute paths represent the location of an attribute within Terraform JSON data. +--- + +# Terraform JSON Paths + +An exact location within Terraform JSON data is referred to as a Terraform JSON or tfjson path. + +## Usage + +Example uses in the testing module include: + +- The `ExpectUnknownValue()` and `ExpectSensitiveValue()` [built-in plan checks](/terraform/plugin/testing/acceptance-tests/plan-checks#built-in-plan-checks) for specifying an attribute to make the check assertion against. + +## Concepts + +Terraform JSON Paths are designed around the underlying Go types corresponding to the Terraform JSON implementation of a schema and schema-based data. The [terraform-json](https://pkg.go.dev/github.com/hashicorp/terraform-json) library serves as the de-facto documentation for Terraform JSON data. Paths are always absolute and start from the root, or top level, of a JSON object. + +Given the tree structure of JSON objects, descriptions of paths and their steps borrow certain hierarchy terminology such as parent and child. A parent path describes a path without one or more of the final steps of a given path, or put differently, a partial path closer to the root of the object. A child path describes a path with one or more additional steps beyond a given path, or put differently, a path containing the given path but further from the root of the object. + +## Building Paths + +The `terraform-plugin-testing` module implementation for tfjson paths is in the [`tfjsonpath` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath), with the [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) being the main provider developer interaction point. Call the [`tfjsonpath.New()` function](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#New) with a property name at the root of the object to begin a path. + +Given the following JSON object + +```json +{ + "first_name": "John", + "last_name": "Doe", + "age": 18, + "street_address": "123 Terraform Dr.", + "phone_numbers": [ + { "mobile": "111-111-1111" }, + { "home": "222-222-2222" } + ] +} +``` + +The call to `tfjsonpath.New()` which matches the location of `first_name` string value is: + +```go +tfjsonpath.New("first_name") +``` + +Once a `tfjsonpath.Path` is started, it supports a builder pattern, which allows for chaining method calls to construct a full path. + +The path which matches the location of the string value `"222-222-222"` is: + +```go +tfjsonpath.New("phone_numbers").AtSliceIndex(1).AtMapKey("home") +``` + +The most common usage of `tfjsonpath.Path` is to specify an attribute within Terraform JSON data. When used in this way, the root of the JSON object is the same as the root of a schema. +The follow sections show how to build attribute paths for [primitive attributes](#building-attribute-paths), [aggregate attributes](#building-aggregate-type-attribute-paths), [nested attributes](#building-nested-attribute-paths), and [blocks](#building-block-paths). + +### Building Attribute Paths + +The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for attribute implementations. Attribute types that cannot be traversed further are shown with N/A (not applicable). + +| Framework Attribute Type | SDKv2 Attribute Type | Child Path Method | +|---------------------------|----------------------|-------------------| +| `schema.BoolAttribute` | `schema.TypeBool` | N/A | +| `schema.Float64Attribute` | `schema.TypeFloat` | N/A | +| `schema.Int64Attribute` | `schema.TypeInt` | N/A | +| `schema.ListAttribute` | `schema.TypeList` | `AtSliceIndex()` | +| `schema.MapAttribute` | `schema.TypeMap` | `AtMapKey()` | +| `schema.NumberAttribute` | N/A | N/A | +| `schema.ObjectAttribute` | N/A | `AtMapKey()` | +| `schema.SetAttribute` | `schema.TypeSet` | `AtSliceIndex()` | +| `schema.StringAttribute` | `schema.TypeString` | N/A | + + +Given this example schema with a root attribute named `example_root_attribute`: + +```go +//Terraform Plugin Framework +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_root_attribute": schema.StringAttribute{ + Required: true, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "example_root_attribute": { + Type: schema.TypeString, + Required: true, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "example_root_attribute": "example-value" +} +``` + +The call to `tfjsonpath.New()` which matches the location of `example_root_attribute` string value is: + +```go +tfjsonpath.New("example_root_attribute") +``` + +For blocks, the beginning of a path is similarly defined. + +Given this example schema with a root block named `example_root_block`: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "example_root_block": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{/* ... */}, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "example_root_block": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{/* ... */}, + }, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "example_root_block": [ + {} + ] +} +``` + +The call to `tfjsonpath.New()` which matches the location of `example_root_block` slice value is: + +```go +tfjsonpath.New("example_root_block") +``` + +### Building Aggregate Type Attribute Paths + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_map_attribute": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + }, + "root_list_attribute": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + }, + "root_set_attribute": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "root_map_attribute": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, + "root_list_attribute": { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, + "root_set_attribute": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_map_attribute": { + "example-key": "map-value" + }, + "root_list_attribute": [ + "list-value1", + "list-value2" + ], + "root_set_attribute": [ + "set-value1", + "set-value2" + ] +} +``` + +The path which matches the string value associated with the map key `example-key` of the `root_map_attribute` attribute is: + +```go +tfjsonpath.New("root_map_attribute").AtMapKey("example-key") +``` + +The path which matches the string value `list-value1` in the `root_list_attribute` attribute is: + +```go +tfjsonpath.New("root_list_attribute").AtSliceIndex(0) +``` + +The path which matches the string value `set-value2` in the `root_set_attribute` attribute is: + +```go +tfjsonpath.New("root_set_attribute").AtSliceIndex(1) +``` + +Note that because Sets are unordered in Terraform, the ordering of Set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. + +### Building Nested Attribute Paths + +The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for nested attributes. + +| Nested Attribute Type | Child Path Method(s) | +|--------------------------------|-----------------------------| +| `schema.ListNestedAttribute` | `AtSliceIndex().AtMapKey()` | +| `schema.MapNestedAttribute` | `AtMapKey().AtMapKey()` | +| `schema.SetNestedAttribute` | `AtSliceIndex().AtMapKey()` | +| `schema.SingleNestedAttribute` | `AtMapKey()` | + +Nested attributes eventually follow the same path rules as attributes at child paths, which follow the methods shown in the [Building Attribute Paths section](#building-attribute-paths). + +#### Building List Nested Attributes Paths + +An attribute that implements `schema.ListNestedAttribute` conceptually is a slice containing a map with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_list_attribute": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_list_attribute": [ + { + "nested_string_attribute": "value" + } + ] +} +``` + +The path which matches the slice associated with the `root_list_attribute` attribute is: + +```go +tfjsonpath.New("root_list_attribute") +``` + +The path which matches the first map in the slice associated with the `root_list_attribute` attribute is: + +```go +tfjsonpath.New("root_list_attribute").AtSliceIndex(0) +``` + +The path which matches the `nested_string_attribute` map key in the first map in the slice associated with `root_list_attribute` attribute is: + +```go +tfjsonpath.New("root_list_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") +``` + +#### Building Map Nested Attributes Paths + +An attribute that implements `schema.MapNestedAttribute` conceptually is a map containing values of maps with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_map_attribute": schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_map_attribute": { + "example-key" : { + "nested_string_attribute": "value" + } + } +} +``` + +The path which matches the map associated with the `root_map_attribute` attribute is: + +```go +tfjsonpath.New("root_map_attribute") +``` + +The path which matches the `"example-key"` object in the map associated with the `root_map_attribute` attribute is: + +```go +tfjsonpath.New("root_map_attribute").AtMapKey("example-key") +``` + +The path which matches the `nested_string_attribute` string value in a `"example-key"` object in the map associated with `root_map_attribute` attribute is: + +```go +tfjsonpath.New("root_map_attribute").AtMapKey("example-key").AtMapKey("nested_string_attribute") +``` + +#### Building Set Nested Attributes Paths + +An attribute that implements `schema.SetNestedAttribute` conceptually is a slice containing maps with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_set_attribute": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_set_attribute": [ + { + "nested_string_attribute": "value" + } + ] +} +``` + +The path which matches the set associated with the `root_set_attribute` attribute is: + +```go +tfjsonpath.New("root_set_attribute") +``` + +The path which matches the first map in the slice associated with the `root_set_attribute` attribute is: + +```go +tfjsonpath.New("root_set_attribute").AtSliceIndex(0) +``` + +Note that because Sets are unordered in Terraform, the ordering of Set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. + +The path which matches the `nested_string_attribute` map key in the first map in the slice associated with `root_set_attribute` attribute is: + +```go +tfjsonpath.New("root_set_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") +``` + +#### Building Single Nested Attributes Paths + +An attribute that implements `schema.SingleNestedAttribute` conceptually is a map with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_grouped_attributes": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_grouped_attributes": { + "nested_string_attribute": "value" + } +} +``` + +The path which matches the map associated with the `root_grouped_attributes` attribute is: + +```go +tfjsonpath.New("root_grouped_attributes") +``` + +The path which matches the `nested_string_attribute` string value in the map associated with the `root_grouped_attributes` attribute is: + +```go +tfjsonpath.New("root_grouped_attributes").AtMapKey("nested_string_attribute") +``` + +### Building Block Paths + +The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for blocks. + +| Block Type | Child Path Method(s) | +|---------------------|-----------------------------| +| `ListNestedBlock` | `AtSliceIndex().AtMapKey()` | +| `SetNestedBlock` | `AtSliceIndex().AtMapKey()` | +| `SingleNestedBlock` | `AtMapKey()` | + +Blocks can implement nested blocks. Paths can continue to be built using the associated method with each level of the block type. + +Blocks eventually follow the same path rules as attributes at child paths, which follow the methods shown in the [Building Attribute Paths section](#building-attribute-paths). Blocks cannot contain nested attributes. + +#### Building List Block Paths + +A `ListNestedBlock` conceptually is a slice containing maps with attribute or block names as keys. + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "root_list_block": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + Blocks: map[string]schema.Block{ + "nested_list_block": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "nested_block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "root_list_block": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "block_string_attribute": { + Type: schema.TypeString, + Required: true, + }, + "nested_list_block": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "nested_block_string_attribute": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + }, + }, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_list_block": [ + { + "block_string_attribute": "value1", + "nested_list_block": [ + {"nested_block_string_attribute": "value2"} + ] + } + ] +} +``` + +The path which matches the slice associated with the `root_list_block` block is: + +```go +tfjsonpath.New("root_list_block") +``` + +The path which matches the first map in the slice associated with the `root_list_block` block is: + +```go +tfjsonpath.New("root_list_block").AtSliceIndex(0) +``` + +The path which matches the `block_string_attribute` string value in the first map in the slice associated with `root_list_block` block is: + +```go +tfjsonpath.New("root_list_block").AtSliceIndex(0).AtMapKey("block_string_attribute") +``` + +The path which matches the `nested_list_block` slice in the first object in the slice associated with `root_list_block` block is: + +```go +tfjsonpath.New("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block") +``` + +The path which matches the `nested_block_string_attribute` string value in the first map in the slice associated with the `nested_list_block` slice in the first map in the slice associated with `root_list_block` block is: + +```go +tfjsonpath.New("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block").AtSliceIndex(0).AtMapKey("nested_block_string_attribute") +``` + +#### Building Set Block Paths + +A `SetNestedBlock` conceptually is a slice containing maps with attribute or block names as keys. + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "root_set_block": schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "block_string_attribute": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "root_set_block": { + Type: schema.TypeSet, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "block_string_attribute": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_set_block": [ + { + "block_string_attribute": "value1" + } + ] +} +``` + +The path which matches the slice associated with the `root_set_block` block is: + +```go +tfjsonpath.New("root_set_block") +``` + + +The path which matches the first map in the slice associated with the `root_set_block` block is: + +```go +tfjsonpath.New("root_set_block").AtSliceIndex(0) +``` + +Note that because sets are unordered in Terraform, the ordering of set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. + +The path which matches the `block_string_attribute` string value in the first map in the slice associated with `root_set_block` block is: + +```go +tfjsonpath.New("root_set_block").AtSliceIndex(0).AtMapKey("block_string_attribute") +```` + +#### Building Single Block Paths + +A `SingleNestedBlock` conceptually is a map with attribute or block names as keys. + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "root_single_block": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + Blocks: map[string]schema.Block{ + "nested_single_block": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "nested_block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, +} +``` + +The path which matches the map associated with the `root_single_block` block is: + +```go +tfjsonpath.New("root_single_block") +``` + +The path which matches the `block_string_attribute` string value in the map associated with `root_single_block` block is: + +```go +tfjsonpath.New("root_single_block").AtMapKey("block_string_attribute") +``` + +The path which matches the `nested_single_block` map in the map associated with `root_single_block` block is: + +```go +tfjsonpath.New("root_single_block").AtMapKey("nested_single_block") +``` + +The path which matches the `nested_block_string_attribute` string value in the map associated with the `nested_single_block` in the map associated with `root_single_block` block is: + +```go +tfjsonpath.New("root_single_block").AtMapKey("nested_single_block").AtMapKey("nested_block_string_attribute") +``` From c5dfd5e8edfa828487a6b362553d0f305c2be5d7 Mon Sep 17 00:00:00 2001 From: hc-github-team-tf-provider-devex Date: Mon, 24 Jul 2023 14:26:33 +0000 Subject: [PATCH 2/2] Update changelog --- .changes/1.4.0.md | 8 ++++++++ .changes/unreleased/FEATURES-20230720-163447.yaml | 6 ------ .changes/unreleased/FEATURES-20230720-163627.yaml | 6 ------ .changes/unreleased/FEATURES-20230720-163828.yaml | 6 ------ CHANGELOG.md | 8 ++++++++ 5 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 .changes/1.4.0.md delete mode 100644 .changes/unreleased/FEATURES-20230720-163447.yaml delete mode 100644 .changes/unreleased/FEATURES-20230720-163627.yaml delete mode 100644 .changes/unreleased/FEATURES-20230720-163828.yaml diff --git a/.changes/1.4.0.md b/.changes/1.4.0.md new file mode 100644 index 000000000..2e8cb5815 --- /dev/null +++ b/.changes/1.4.0.md @@ -0,0 +1,8 @@ +## 1.4.0 (July 24, 2023) + +FEATURES: + +* tfjsonpath: Introduced new `tfjsonpath` package which contains methods that allow traversal of Terraform JSON data ([#154](https://github.com/hashicorp/terraform-plugin-testing/issues/154)) +* plancheck: Added `ExpectUnknownValue` built-in plan check, which asserts that a given attribute has an unknown value ([#154](https://github.com/hashicorp/terraform-plugin-testing/issues/154)) +* plancheck: Added `ExpectSensitiveValue` built-in plan check, which asserts that a given attribute has a sensitive value ([#154](https://github.com/hashicorp/terraform-plugin-testing/issues/154)) + diff --git a/.changes/unreleased/FEATURES-20230720-163447.yaml b/.changes/unreleased/FEATURES-20230720-163447.yaml deleted file mode 100644 index 4ebc80328..000000000 --- a/.changes/unreleased/FEATURES-20230720-163447.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: FEATURES -body: 'tfjsonpath: Introduced new `tfjsonpath` package which contains methods that - allow traversal of Terraform JSON data' -time: 2023-07-20T16:34:47.373683-04:00 -custom: - Issue: "154" diff --git a/.changes/unreleased/FEATURES-20230720-163627.yaml b/.changes/unreleased/FEATURES-20230720-163627.yaml deleted file mode 100644 index d277734c4..000000000 --- a/.changes/unreleased/FEATURES-20230720-163627.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: FEATURES -body: 'plancheck: Added `ExpectUnknownValue` built-in plan check, which asserts that - a given attribute has an unknown value' -time: 2023-07-20T16:36:27.361538-04:00 -custom: - Issue: "154" diff --git a/.changes/unreleased/FEATURES-20230720-163828.yaml b/.changes/unreleased/FEATURES-20230720-163828.yaml deleted file mode 100644 index 8411348f3..000000000 --- a/.changes/unreleased/FEATURES-20230720-163828.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: FEATURES -body: 'plancheck: Added `ExpectSensitiveValue` built-in plan check, which asserts - that a given attribute has a sensitive value' -time: 2023-07-20T16:38:28.94511-04:00 -custom: - Issue: "154" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1784e1671..3670a9619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.4.0 (July 24, 2023) + +FEATURES: + +* tfjsonpath: Introduced new `tfjsonpath` package which contains methods that allow traversal of Terraform JSON data ([#154](https://github.com/hashicorp/terraform-plugin-testing/issues/154)) +* plancheck: Added `ExpectUnknownValue` built-in plan check, which asserts that a given attribute has an unknown value ([#154](https://github.com/hashicorp/terraform-plugin-testing/issues/154)) +* plancheck: Added `ExpectSensitiveValue` built-in plan check, which asserts that a given attribute has a sensitive value ([#154](https://github.com/hashicorp/terraform-plugin-testing/issues/154)) + ## 1.3.0 (June 13, 2023) FEATURES: