diff --git a/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml b/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml new file mode 100644 index 000000000000..a3d9aebb2f4e --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: improve detection of deprecated resource attributes / blocks +time: 2026-01-20T17:28:31.861321+01:00 +custom: + Issue: "38077" diff --git a/internal/configs/configschema/validate_traversal.go b/internal/configs/configschema/validate_traversal.go index 9178768a9164..06d9e010752a 100644 --- a/internal/configs/configschema/validate_traversal.go +++ b/internal/configs/configschema/validate_traversal.go @@ -77,27 +77,6 @@ func (b *Block) StaticValidateTraversal(traversal hcl.Traversal) tfdiags.Diagnos } if attrS, exists := b.Attributes[name]; exists { - // Check for Deprecated status of this attribute. - // We currently can't provide the user with any useful guidance because - // the deprecation string is not part of the schema, but we can at - // least warn them. - // - // This purposely does not attempt to recurse into nested attribute - // types. Because nested attribute values are often not accessed via a - // direct traversal to the leaf attributes, we cannot reliably detect - // if a nested, deprecated attribute value is actually used from the - // traversal alone. More precise detection of deprecated attributes - // would require adding metadata like marks to the cty value itself, to - // be caught during evaluation. - if attrS.Deprecated { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: `Deprecated attribute`, - Detail: fmt.Sprintf(`The attribute %q is deprecated. Refer to the provider documentation for details.`, name), - Subject: next.SourceRange().Ptr(), - }) - } - // For attribute validation we will just apply the rest of the // traversal to an unknown value of the attribute type and pass // through HCL's own errors, since we don't want to replicate all diff --git a/internal/configs/configschema/validate_traversal_test.go b/internal/configs/configschema/validate_traversal_test.go index 80107ab88497..efe721fb5654 100644 --- a/internal/configs/configschema/validate_traversal_test.go +++ b/internal/configs/configschema/validate_traversal_test.go @@ -224,10 +224,6 @@ func TestStaticValidateTraversal(t *testing.T) { `obj.nested_map["key"].optional`, ``, }, - { - `obj.deprecated`, - `Deprecated attribute: The attribute "deprecated" is deprecated. Refer to the provider documentation for details.`, - }, } for _, test := range tests { diff --git a/internal/deprecation/deprecation.go b/internal/deprecation/deprecation.go index aa808e06930c..956da60b4761 100644 --- a/internal/deprecation/deprecation.go +++ b/internal/deprecation/deprecation.go @@ -133,3 +133,7 @@ func (d *Deprecations) IsModuleCallDeprecationSuppressed(addr addrs.Module) bool } return false } + +func (d *Deprecations) DiagnosticsForValueMarks(valueMarks cty.ValueMarks, module addrs.Module, rng *hcl.Range) tfdiags.Diagnostics { + return d.deprecationMarksToDiagnostics(marks.FilterDeprecationMarks(valueMarks), module, rng) +} diff --git a/internal/deprecation/schema.go b/internal/deprecation/schema.go new file mode 100644 index 000000000000..6fb2b765b300 --- /dev/null +++ b/internal/deprecation/schema.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package deprecation + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/zclconf/go-cty/cty" +) + +// MarkDeprecatedValues inspects the given cty.Value according to the given +// configschema.Block schema, and marks any deprecated attributes or blocks +// found within the value with deprecation marks. +// It works based on the given cty.Value's structure matching the given schema. +func MarkDeprecatedValues(val cty.Value, schema *configschema.Block, origin string) cty.Value { + if schema == nil { + return val + } + newVal := val + + // Check if the block is deprecated + if schema.Deprecated { + newVal = newVal.Mark(marks.NewDeprecation("Deprecated resource used as value", origin)) + } + + if !newVal.IsKnown() { + return newVal + } + + // Even if the block itself is not deprecated, its attributes might be + // deprecated as well + if val.Type().IsObjectType() || val.Type().IsMapType() || val.Type().IsCollectionType() { + // We ignore the error, so errors are not allowed in the transform function + newVal, _ = cty.Transform(newVal, func(p cty.Path, v cty.Value) (cty.Value, error) { + + attr := schema.AttributeByPath(p) + if attr != nil && attr.Deprecated { + v = v.Mark(marks.NewDeprecation(fmt.Sprintf("Deprecated resource attribute %q used", p), fmt.Sprintf("%s.%s", origin, p))) + } + + block := schema.BlockByPath(p) + if block != nil && block.Deprecated { + v = v.Mark(marks.NewDeprecation(fmt.Sprintf("Deprecated resource block %q used", p), fmt.Sprintf("%s.%s", origin, p))) + } + + return v, nil + }) + } + + return newVal +} diff --git a/internal/deprecation/schema_test.go b/internal/deprecation/schema_test.go new file mode 100644 index 000000000000..f36f61fafbfe --- /dev/null +++ b/internal/deprecation/schema_test.go @@ -0,0 +1,1019 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package deprecation + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/zclconf/go-cty/cty" +) + +func TestMarkDeprecatedValues_NilSchema(t *testing.T) { + val := cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }) + + result := MarkDeprecatedValues(val, nil, "origin") + + if !result.RawEquals(val) { + t.Errorf("expected value to be unchanged when schema is nil") + } +} + +func TestMarkDeprecatedValues_NoDeprecations(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + Deprecated: false, + }, + "bar": { + Type: cty.Number, + Optional: true, + Deprecated: false, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("hello"), + "bar": cty.NumberIntVal(42), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + if result.IsMarked() { + t.Errorf("expected value to not be marked when nothing is deprecated") + } + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + if len(pathMarks) > 0 { + t.Errorf("expected no marks, got %d marks", len(pathMarks)) + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_DeprecatedBlock(t *testing.T) { + schema := &configschema.Block{ + Deprecated: true, + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + if !result.IsMarked() { + t.Fatalf("expected result to be marked") + } + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path") + } + + // The root value itself should be marked as deprecated + foundRootDeprecation := false + for _, pvm := range pathMarks { + if len(pvm.Path) == 0 { + for mark := range pvm.Marks { + if _, ok := mark.(marks.DeprecationMark); ok { + foundRootDeprecation = true + break + } + } + } + } + + if !foundRootDeprecation { + t.Errorf("expected root value to be marked as deprecated") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_DeprecatedAttribute(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "deprecated_attr": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "normal_attr": { + Type: cty.String, + Optional: true, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "deprecated_attr": cty.StringVal("old"), + "normal_attr": cty.StringVal("new"), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) != 1 { + t.Fatalf("expected exactly 1 deprecated path, got %d", len(deprecatedPaths)) + } + + expectedPath := cty.GetAttrPath("deprecated_attr") + if !deprecatedPaths[0].Equals(expectedPath) { + t.Errorf("expected deprecated path to be %#v, got %#v", expectedPath, deprecatedPaths[0]) + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_MultipleDeprecatedAttributes(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "deprecated_one": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "deprecated_two": { + Type: cty.Number, + Optional: true, + Deprecated: true, + }, + "normal_attr": { + Type: cty.Bool, + Optional: true, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "deprecated_one": cty.StringVal("old1"), + "deprecated_two": cty.NumberIntVal(123), + "normal_attr": cty.BoolVal(true), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) != 2 { + t.Fatalf("expected exactly 2 deprecated paths, got %d", len(deprecatedPaths)) + } + + pathSet := make(map[string]bool) + for _, p := range deprecatedPaths { + if len(p) == 1 { + if getAttr, ok := p[0].(cty.GetAttrStep); ok { + pathSet[getAttr.Name] = true + } + } + } + + if !pathSet["deprecated_one"] || !pathSet["deprecated_two"] { + t.Errorf("expected both deprecated_one and deprecated_two to be marked as deprecated") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedBlock(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Deprecated: true, + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("test"), + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("item1"), + }), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path for nested block") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedDeprecatedAttribute(t *testing.T) { + schema := &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "deprecated_field": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "normal_field": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "config": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "deprecated_field": cty.StringVal("old"), + "normal_field": cty.StringVal("new"), + }), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path") + } + + // Check that the deprecated field within the nested block is marked + foundDeprecatedField := false + for _, pvm := range pathMarks { + for i, step := range pvm.Path { + if getAttr, ok := step.(cty.GetAttrStep); ok && getAttr.Name == "deprecated_field" { + // Check if it's inside the config list + if i > 0 { + foundDeprecatedField = true + break + } + } + } + } + + if !foundDeprecatedField { + t.Errorf("expected nested deprecated_field to be marked") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NonObjectTypes(t *testing.T) { + tests := []struct { + name string + schema *configschema.Block + val cty.Value + }{ + { + name: "string value", + schema: &configschema.Block{ + Deprecated: false, + }, + val: cty.StringVal("test"), + }, + { + name: "number value", + schema: &configschema.Block{ + Deprecated: false, + }, + val: cty.NumberIntVal(42), + }, + { + name: "bool value", + schema: &configschema.Block{ + Deprecated: false, + }, + val: cty.BoolVal(true), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MarkDeprecatedValues(tt.val, tt.schema, "origin") + + // For non-object types, the function should handle gracefully + // and not crash + if result.IsNull() { + t.Errorf("result should not be null") + } + }) + } +} + +func TestMarkDeprecatedValues_DeprecatedBlockAndAttribute(t *testing.T) { + schema := &configschema.Block{ + Deprecated: true, + Attributes: map[string]*configschema.Attribute{ + "deprecated_attr": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "normal_attr": { + Type: cty.String, + Optional: true, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "deprecated_attr": cty.StringVal("old"), + "normal_attr": cty.StringVal("new"), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + // Should have both the block itself and the deprecated attribute marked + if len(deprecatedPaths) < 1 { + t.Fatalf("expected at least 1 deprecated path, got %d", len(deprecatedPaths)) + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_EmptyObject(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + } + + val := cty.EmptyObjectVal + + result := MarkDeprecatedValues(val, schema, "origin") + + // Should not crash on empty object + if result.IsNull() { + t.Errorf("result should not be null for empty object") + } +} + +func TestMarkDeprecatedValues_NullValue(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + } + + val := cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + })) + + result := MarkDeprecatedValues(val, schema, "origin") + + // Should handle null values gracefully + if !result.IsNull() { + t.Errorf("null input should remain null") + } +} + +func TestMarkDeprecatedValues_MapType(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "tags": { + Type: cty.Map(cty.String), + Optional: true, + Deprecated: false, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "tags": cty.MapVal(map[string]cty.Value{ + "env": cty.StringVal("prod"), + "team": cty.StringVal("platform"), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + // Should handle map types without crashing + unmarkedResult, _ := result.UnmarkDeepWithPaths() + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_ListType(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "items": { + Type: cty.List(cty.String), + Optional: true, + Deprecated: true, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "items": cty.ListVal([]cty.Value{ + cty.StringVal("one"), + cty.StringVal("two"), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected deprecated list attribute to be marked") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_NestingSingle(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Optional: true, + }, + "config": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "deprecated_field": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "normal_field": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-id"), + "config": cty.ObjectVal(map[string]cty.Value{ + "deprecated_field": cty.StringVal("old"), + "normal_field": cty.StringVal("new"), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path") + } + + // Check that the deprecated field within the nested type is marked + foundDeprecatedField := false + for _, path := range deprecatedPaths { + if len(path) >= 2 { + if getAttr, ok := path[0].(cty.GetAttrStep); ok && getAttr.Name == "config" { + if getAttr2, ok := path[1].(cty.GetAttrStep); ok && getAttr2.Name == "deprecated_field" { + foundDeprecatedField = true + break + } + } + } + } + + if !foundDeprecatedField { + t.Errorf("expected config.deprecated_field to be marked as deprecated") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_NestingList(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Optional: true, + }, + "disks": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingList, + Attributes: map[string]*configschema.Attribute{ + "mount_point": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "size": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-id"), + "disks": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/mnt/data"), + "size": cty.StringVal("100GB"), + }), + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/mnt/backup"), + "size": cty.StringVal("200GB"), + }), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path") + } + + // Should have deprecated marks for mount_point in both list items + mountPointCount := 0 + for _, path := range deprecatedPaths { + for _, step := range path { + if getAttr, ok := step.(cty.GetAttrStep); ok && getAttr.Name == "mount_point" { + mountPointCount++ + break + } + } + } + + if mountPointCount != 2 { + t.Errorf("expected 2 deprecated mount_point fields, got %d", mountPointCount) + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_NestingSet(t *testing.T) { + // Note: The current implementation of AttributeByPath only handles GetAttrStep, + // not IndexStep, so nested attributes within set elements cannot be individually + // marked. This test verifies that the entire set attribute can still be marked + // if it is deprecated. + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "tags": { + Deprecated: true, // Mark the entire attribute as deprecated + NestedType: &configschema.Object{ + Nesting: configschema.NestingSet, + Attributes: map[string]*configschema.Attribute{ + "key": { + Type: cty.String, + Optional: true, + }, + "value": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "tags": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "key": cty.StringVal("env"), + "value": cty.StringVal("prod"), + }), + cty.ObjectVal(map[string]cty.Value{ + "key": cty.StringVal("team"), + "value": cty.StringVal("platform"), + }), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path") + } + + // The entire tags attribute should be marked as deprecated + foundDeprecatedTags := false + for _, path := range deprecatedPaths { + if len(path) == 1 { + if getAttr, ok := path[0].(cty.GetAttrStep); ok && getAttr.Name == "tags" { + foundDeprecatedTags = true + break + } + } + } + + if !foundDeprecatedTags { + t.Errorf("expected tags attribute to be marked as deprecated") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_NestingMap(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "metadata": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingMap, + Attributes: map[string]*configschema.Attribute{ + "description": { + Type: cty.String, + Optional: true, + }, + "deprecated_label": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "metadata": cty.MapVal(map[string]cty.Value{ + "primary": cty.ObjectVal(map[string]cty.Value{ + "description": cty.StringVal("Primary config"), + "deprecated_label": cty.StringVal("old_label"), + }), + "secondary": cty.ObjectVal(map[string]cty.Value{ + "description": cty.StringVal("Secondary config"), + "deprecated_label": cty.StringVal("old_label_2"), + }), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) == 0 { + t.Fatalf("expected at least one deprecated path") + } + + // Check that deprecated_label fields are marked + deprecatedLabelCount := 0 + for _, path := range deprecatedPaths { + for _, step := range path { + if getAttr, ok := step.(cty.GetAttrStep); ok && getAttr.Name == "deprecated_label" { + deprecatedLabelCount++ + break + } + } + } + + if deprecatedLabelCount != 2 { + t.Errorf("expected 2 deprecated_label fields, got %d", deprecatedLabelCount) + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_DeprecatedNestedAttribute(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "deprecated_config": { + Deprecated: true, + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "field": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "deprecated_config": cty.ObjectVal(map[string]cty.Value{ + "field": cty.StringVal("value"), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) != 1 { + t.Fatalf("expected at least one deprecated path") + } + + // The entire deprecated_config attribute should be marked + foundDeprecatedConfig := false + for _, path := range deprecatedPaths { + if len(path) == 1 { + if getAttr, ok := path[0].(cty.GetAttrStep); ok && getAttr.Name == "deprecated_config" { + foundDeprecatedConfig = true + break + } + } + } + + if !foundDeprecatedConfig { + t.Errorf("expected deprecated_config to be marked as deprecated") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_MultipleDeprecatedFields(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "connection": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "deprecated_host": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + "deprecated_port": { + Type: cty.Number, + Optional: true, + Deprecated: true, + }, + "username": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "connection": cty.ObjectVal(map[string]cty.Value{ + "deprecated_host": cty.StringVal("example.com"), + "deprecated_port": cty.NumberIntVal(8080), + "username": cty.StringVal("admin"), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) != 2 { + t.Fatalf("expected exactly 2 deprecated paths, got %d", len(deprecatedPaths)) + } + + // Check that both deprecated_host and deprecated_port are marked + pathSet := make(map[string]bool) + for _, path := range deprecatedPaths { + if len(path) == 2 { + if getAttr, ok := path[1].(cty.GetAttrStep); ok { + pathSet[getAttr.Name] = true + } + } + } + + if !pathSet["deprecated_host"] || !pathSet["deprecated_port"] { + t.Errorf("expected both deprecated_host and deprecated_port to be marked as deprecated") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_EmptyList(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "items": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingList, + Attributes: map[string]*configschema.Attribute{ + "deprecated_field": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "items": cty.ListValEmpty(cty.Object(map[string]cty.Type{ + "deprecated_field": cty.String, + })), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + // Should handle empty lists without crashing + if result.IsNull() { + t.Errorf("result should not be null for empty list") + } + + unmarkedResult, _ := result.UnmarkDeepWithPaths() + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_NullNestedValue(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "config": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "deprecated_field": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "config": cty.NullVal(cty.Object(map[string]cty.Type{ + "deprecated_field": cty.String, + })), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + // Should handle null nested values gracefully + if result.IsNull() { + t.Errorf("result should not be null") + } + + unmarkedResult, _ := result.UnmarkDeepWithPaths() + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} + +func TestMarkDeprecatedValues_NestedType_MixedWithBlockTypes(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "nested_attr": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "deprecated_field": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + }, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "deprecated_block_attr": { + Type: cty.String, + Optional: true, + Deprecated: true, + }, + }, + }, + }, + }, + } + + val := cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.ObjectVal(map[string]cty.Value{ + "deprecated_field": cty.StringVal("attr_value"), + }), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "deprecated_block_attr": cty.StringVal("block_value"), + }), + }) + + result := MarkDeprecatedValues(val, schema, "origin") + + unmarkedResult, pathMarks := result.UnmarkDeepWithPaths() + deprecatedPaths, _ := marks.PathsWithMark(pathMarks, marks.Deprecation) + + if len(deprecatedPaths) != 2 { + t.Fatalf("expected exactly 2 deprecated paths (one from NestedType, one from BlockType), got %d", len(deprecatedPaths)) + } + + // Check that both deprecated fields are marked + pathSet := make(map[string]bool) + for _, path := range deprecatedPaths { + if len(path) >= 2 { + if getAttr, ok := path[1].(cty.GetAttrStep); ok { + pathSet[getAttr.Name] = true + } + } + } + + if !pathSet["deprecated_field"] || !pathSet["deprecated_block_attr"] { + t.Errorf("expected both deprecated_field and deprecated_block_attr to be marked") + } + + if !unmarkedResult.RawEquals(val) { + t.Errorf("expected unmarked value to equal original value") + } +} diff --git a/internal/terraform/context_validate_test.go b/internal/terraform/context_validate_test.go index e77a1d3621e9..3b10cc65915e 100644 --- a/internal/terraform/context_validate_test.go +++ b/internal/terraform/context_validate_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/provisioners" @@ -2459,38 +2460,544 @@ resource "aws_instance" "test" { } func TestContext2Validate_deprecatedAttr(t *testing.T) { - p := testProvider("aws") - p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{ - ResourceTypes: map[string]*configschema.Block{ - "aws_instance": { - Attributes: map[string]*configschema.Attribute{ - "foo": {Type: cty.String, Optional: true, Deprecated: true}, + for name, tc := range map[string]struct { + attributeSchema map[string]*configschema.Attribute + blockSchema map[string]*configschema.NestedBlock + module map[string]string + expectedValidationDiags func(*configs.Config) tfdiags.Diagnostics + expectedPlanDiags func(*configs.Config) tfdiags.Diagnostics + expectedApplyDiags func(*configs.Config) tfdiags.Diagnostics + }{ + "in locals": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Optional: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + } + locals { + deprecated = aws_instance.test.foo + } + `, + }, + expectedValidationDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 28, Byte: 108}, + End: hcl.Pos{Line: 5, Column: 49, Byte: 129}, + }, + }) + }, + }, + + "in count": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.Number, Required: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + foo = 2 + } + resource "aws_instance" "test2" { + count = aws_instance.test.foo + foo = 1 + } + `, + }, + expectedValidationDiags: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 23, Byte: 152}, + End: hcl.Pos{Line: 6, Column: 44, Byte: 173}, + }, + }) + }, + }, + + "in for_each": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.Set(cty.String), Required: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + foo = ["a", "b"] + } + resource "aws_instance" "test2" { + for_each = aws_instance.test.foo + foo = ["x"] + } + `, + }, + expectedValidationDiags: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 26, Byte: 164}, + End: hcl.Pos{Line: 6, Column: 47, Byte: 185}, + }, + }) + }, + }, + + "in output": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Optional: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + } + output "deprecated_output" { + value = aws_instance.test.foo + } + `, + }, + expectedValidationDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 23, Byte: 123}, + End: hcl.Pos{Line: 5, Column: 44, Byte: 144}, + }, + }) + }, + // During apply we take the planned value for the output. Since the plan + // does not contain marks we can not detect this usage at apply time. + expectedApplyDiags: func(c *configs.Config) tfdiags.Diagnostics { return tfdiags.Diagnostics{} }, + }, + + "in resource attribute": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Optional: true, Deprecated: true}, + "bar": {Type: cty.String, Optional: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + } + resource "aws_instance" "test2" { + bar = aws_instance.test.foo + } + `, + }, + expectedValidationDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 21, Byte: 126}, + End: hcl.Pos{Line: 5, Column: 42, Byte: 147}, + }, + }) + }, + }, + + "in precondition": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Required: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + foo = "bar" + } + resource "aws_instance" "test2" { + foo = "baz" + lifecycle { + precondition { + condition = aws_instance.test.foo != "" + error_message = "foo must not be empty" + } + } + } + `, + }, + expectedPlanDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 9, Column: 31, Byte: 245}, + End: hcl.Pos{Line: 9, Column: 58, Byte: 272}, + }, + }) + }, + }, + + "in postcondition condition": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Required: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + foo = "bar" + lifecycle { + postcondition { + condition = self.foo != "" + error_message = "foo must not be empty" + } + } + } + `, + }, + expectedPlanDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 31, Byte: 160}, + End: hcl.Pos{Line: 6, Column: 45, Byte: 174}, + }, + }) + }, + }, + + "in dynamic block": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.Set(cty.String), Computed: true, Deprecated: true}, + }, + blockSchema: map[string]*configschema.NestedBlock{ + "bar": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "baz": { + Type: cty.String, + Required: false, + Optional: true, + }, + }, + }, }, }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + foo = ["a", "b"] + } + resource "aws_instance" "test2" { + dynamic "bar" { + for_each = aws_instance.test.foo + content { + baz = bar.value + } + } + } + `, + }, + expectedPlanDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 45, Byte: 135}, + End: hcl.Pos{Line: 5, Column: 45, Byte: 135}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 45, Byte: 135}, + End: hcl.Pos{Line: 5, Column: 45, Byte: 135}, + }, + }) + }, }, - }) + + "in check assertion": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Required: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + foo = "bar" + } + check "test_check" { + assert { + condition = aws_instance.test.foo != "" + error_message = "foo must not be empty" + } + } + `, + }, + expectedPlanDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 29, Byte: 170}, + End: hcl.Pos{Line: 7, Column: 56, Byte: 197}, + }, + }) + }, + }, + + "in module input": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Optional: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + } + module "child" { + source = "./child" + input = aws_instance.test.foo + } + `, + "child/main.tf": ` + variable "input" { + type = string + } + `, + }, + expectedValidationDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 23, Byte: 144}, + End: hcl.Pos{Line: 6, Column: 44, Byte: 165}, + }, + }) + }, + }, + + "in provisioner and connection block": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Optional: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + } + resource "aws_instance" "test2" { + provisioner "shell" { + test_string = aws_instance.test.foo + connection { + type = "ssh" + host = aws_instance.test.foo + } + } + } + `, + }, + expectedValidationDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 36, Byte: 177}, + End: hcl.Pos{Line: 6, Column: 57, Byte: 198}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 9, Column: 26, Byte: 284}, + End: hcl.Pos{Line: 9, Column: 47, Byte: 305}, + }, + }) + }, + expectedPlanDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{} // We can not connect this during planning + }, + expectedApplyDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 36, Byte: 177}, + End: hcl.Pos{Line: 6, Column: 57, Byte: 198}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 9, Column: 26, Byte: 284}, + End: hcl.Pos{Line: 9, Column: 47, Byte: 305}, + }, + }) + }, + }, + + "in action config": { + attributeSchema: map[string]*configschema.Attribute{ + "foo": {Type: cty.String, Optional: true, Deprecated: true}, + }, + module: map[string]string{ + "main.tf": ` + resource "aws_instance" "test" { + } + action "aws_register" "example" { + config { + host = aws_instance.test.foo + } + } + `, + }, + expectedValidationDiags: func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: `Deprecated value used`, + Detail: `Deprecated resource attribute [{{} "foo"}] used`, + Subject: &hcl.Range{ + Filename: filepath.Join(c.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 24, Byte: 152}, + End: hcl.Pos{Line: 6, Column: 45, Byte: 173}, + }, + }) + }, + }, + } { + t.Run(name, func(t *testing.T) { + // Default values + if tc.expectedValidationDiags == nil { + tc.expectedValidationDiags = func(c *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{} + } + } + // By default we want the same validations in plan as in validate + if tc.expectedPlanDiags == nil { + tc.expectedPlanDiags = tc.expectedValidationDiags + } + // And the same validations in apply as in plan + if tc.expectedApplyDiags == nil { + tc.expectedApplyDiags = tc.expectedPlanDiags + } + + pr := simpleMockProvisioner() + p := testProvider("aws") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "aws_instance": { + Attributes: tc.attributeSchema, + BlockTypes: tc.blockSchema, + }, + }, + Actions: map[string]*providers.ActionSchema{ + "aws_register": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "host": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }) + m := testModuleInline(t, tc.module) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + Provisioners: map[string]provisioners.Factory{ + "shell": testProvisionerFuncFixed(pr), + }, + }) + + t.Run("validate", func(t *testing.T) { + validateDiags := ctx.Validate(m, nil) + tfdiags.AssertDiagnosticsMatch(t, validateDiags, tc.expectedValidationDiags(m)) + }) + + var plan *plans.Plan + t.Run("plan", func(t *testing.T) { + var planDiags tfdiags.Diagnostics + plan, planDiags = ctx.Plan(m, nil, SimplePlanOpts(plans.NormalMode, InputValues{})) + tfdiags.AssertDiagnosticsMatch(t, planDiags, tc.expectedPlanDiags(m)) + }) + + t.Run("apply", func(t *testing.T) { + _, applyDiags := ctx.Apply(plan, m, &ApplyOpts{}) + tfdiags.AssertDiagnosticsMatch(t, applyDiags, tc.expectedApplyDiags(m)) + }) + }) + } +} + +func TestContext2Validate_deprecated_resource(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` -resource "aws_instance" "test" { +resource "test_resource" "test" { # WARNING + attr = "value" } -locals { - deprecated = aws_instance.test.foo +output "a" { + value = test_resource.test.attr # WARNING } - - `, +`, + }) + p := new(testing_provider.MockProvider) + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_resource": { + Deprecated: true, + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, }) - ctx := testContext2(t, &ContextOpts{ Providers: map[addrs.Provider]providers.Factory{ - addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), }, }) - - diags := ctx.Validate(m, nil) - warn := diags.ErrWithWarnings().Error() - if !strings.Contains(warn, `The attribute "foo" is deprecated`) { - t.Fatalf("expected deprecated warning, got: %q\n", warn) - } + diags := ctx.Validate(m, &ValidateOpts{}) + tfdiags.AssertDiagnosticsMatch(t, diags, tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Deprecated value used", + Detail: `Deprecated resource used as value`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 13, Byte: 91}, + End: hcl.Pos{Line: 6, Column: 36, Byte: 114}, + }, + })) } func TestContext2Validate_unknownForEach(t *testing.T) { diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 2c0176dd7249..800ac2adc896 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/deprecation" "github.com/hashicorp/terraform/internal/didyoumean" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -649,9 +650,13 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc // resource has (using d.Evaluator.Instances.ResourceInstanceKeys) and // then retrieving the value for each instance to assemble into the // result, using some per-resource-mode logic maintained elsewhere. - return d.getEphemeralResource(addr, rng) + val, epehemeralDiags := d.getEphemeralResource(addr, rng) + diags = diags.Append(epehemeralDiags) + return deprecation.MarkDeprecatedValues(val, schema.Body, addr.String()), diags case addrs.ListResourceMode: - return d.getListResource(config, rng) + val, listDiags := d.getListResource(config, rng) + diags = diags.Append(listDiags) + return deprecation.MarkDeprecatedValues(val, schema.Body, addr.String()), diags default: // continue with the rest of the function } @@ -796,11 +801,21 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc // We should only end up here during the validate walk (or // console/eval), since later walks should have at least partial // states populated for all resources in the configuration. - ret := cty.DynamicVal - if schema.Body.Deprecated { - ret = ret.Mark(marks.NewDeprecation(fmt.Sprintf("Resource %q is deprecated", addr.Type), addr.String())) + switch { + case config.Count != nil: + return deprecation.MarkDeprecatedValues(cty.DynamicVal, schema.Body, addr.String()), diags + case config.ForEach != nil: + return deprecation.MarkDeprecatedValues(cty.DynamicVal, schema.Body, addr.String()), diags + default: + // We don't know the values of the single resource instance, but we know the general + // shape these values will take. + content := map[string]cty.Value{} + for attr, attrType := range ty.AttributeTypes() { + content[attr] = cty.UnknownVal(attrType) + } + + return deprecation.MarkDeprecatedValues(cty.ObjectVal(content), schema.Body, addr.String()), diags } - return ret, diags } } @@ -830,7 +845,7 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc continue } - vals[int(intKey)] = instance + vals[int(intKey)] = deprecation.MarkDeprecatedValues(instance, schema.Body, addr.Instance(key).String()) } // Insert unknown values where there are any missing instances @@ -852,7 +867,7 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc // old key that is being dropped and not used for evaluation continue } - vals[string(strKey)] = instance + vals[string(strKey)] = deprecation.MarkDeprecatedValues(instance, schema.Body, addr.Instance(key).String()) } if len(vals) > 0 { @@ -872,11 +887,7 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc val = cty.UnknownVal(ty) } - ret = val - } - - if schema.Body.Deprecated { - ret = ret.Mark(marks.NewDeprecation(fmt.Sprintf("Resource %q is deprecated", addr.Type), addr.String())) + ret = deprecation.MarkDeprecatedValues(val, schema.Body, addr.String()) } return ret, diags