Handle ignoring computed fields in ignore_changes blocks for testing (#3646)
Some checks are pending
build / Build for linux_386 (push) Waiting to run
build / Build for openbsd_386 (push) Waiting to run
build / Build for windows_386 (push) Waiting to run
build / Build for freebsd_amd64 (push) Waiting to run
build / Build for solaris_amd64 (push) Waiting to run
build / Build for windows_amd64 (push) Waiting to run
build / Build for freebsd_386 (push) Waiting to run
build / End-to-end Tests for linux_386 (push) Waiting to run
build / End-to-end Tests for windows_386 (push) Waiting to run
build / End-to-end Tests for linux_amd64 (push) Waiting to run
Website checks / Test Installation Instructions (push) Blocked by required conditions
Website checks / List files changed for pull request (push) Waiting to run
Website checks / Build (push) Blocked by required conditions
build / Build for linux_amd64 (push) Waiting to run
build / Build for openbsd_amd64 (push) Waiting to run
build / Build for freebsd_arm (push) Waiting to run
build / Build for linux_arm (push) Waiting to run
build / Build for linux_arm64 (push) Waiting to run
build / Build for darwin_amd64 (push) Waiting to run
build / End-to-end Tests for darwin_amd64 (push) Waiting to run
build / End-to-end Tests for windows_amd64 (push) Waiting to run
build / Build for darwin_arm64 (push) Waiting to run
Quick Checks / List files changed for pull request (push) Waiting to run
Quick Checks / Unit tests for linux_386 (push) Blocked by required conditions
Quick Checks / Unit tests for linux_amd64 (push) Blocked by required conditions
Quick Checks / Unit tests for windows_amd64 (push) Blocked by required conditions
Quick Checks / Unit tests for linux_arm (push) Blocked by required conditions
Quick Checks / Unit tests for darwin_arm64 (push) Blocked by required conditions
Quick Checks / Unit tests for linux_arm64 (push) Blocked by required conditions
Quick Checks / Race Tests (push) Blocked by required conditions
Quick Checks / End-to-end Tests (push) Blocked by required conditions
Quick Checks / Code Consistency Checks (push) Blocked by required conditions
Quick Checks / License Checks (push) Waiting to run

Signed-off-by: James Humphries <james@james-humphries.co.uk>
This commit is contained in:
James Humphries 2026-01-08 10:51:40 +00:00 committed by GitHub
parent 65ee51c736
commit 16f6b2d119
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 213 additions and 2 deletions

View file

@ -23,6 +23,7 @@ BUG FIXES:
- `for_each` inside `dynamic` blocks can now call provider-defined functions. ([#3429](https://github.com/opentofu/opentofu/issues/3429))
- In the unlikely event that text included in a diagnostic message includes C0 control characters (e.g. terminal escape sequences), OpenTofu will now replace them with printable characters to avoid the risk of inadvertently changing terminal state when stdout or stderr is a terminal. ([#3479](https://github.com/opentofu/opentofu/issues/3479))
- Fixed `length(module.foo)` returning 0 for module instances without outputs, even when `count` or `for_each` is set. ([#3067](https://github.com/opentofu/opentofu/issues/3067))
- Fixed `tofu test` with `mock_provider` failing during cleanup when `lifecycle { ignore_changes }` references a block. ([#3644](https://github.com/opentofu/opentofu/issues/3644))
## Previous Releases

View file

@ -80,3 +80,43 @@ func TestMocksAndOverrides(t *testing.T) {
t.Errorf("output doesn't have expected success string:\n%s", stdout)
}
}
// TestMockProviderComputedBlockCleanup ensures we don't regress
// a fix for this issue https://github.com/opentofu/opentofu/issues/3644
//
// The bug occurs when:
// 1. A resource has lifecycle { ignore_changes = [block] } on a BLOCK (not a simple attribute)
// 2. mock_provider is used
// 3. Cleanup/destroy runs after apply
// The cleanup fails with "Config value can not be specified for computed field"
func TestMockProviderComputedBlockCleanup(t *testing.T) {
// This test fetches providers from registry.
skipIfCannotAccessNetwork(t)
tf := e2e.NewBinary(t, tofuBin, filepath.Join("testdata", "mock-computed-block-cleanup"))
stdout, stderr, err := tf.Run("init")
if err != nil {
t.Errorf("unexpected error on 'init': %v", err)
}
if stderr != "" {
t.Errorf("unexpected stderr output on 'init':\n%s", stderr)
}
if stdout == "" {
t.Errorf("expected some output on 'init', got nothing")
}
stdout, stderr, err = tf.Run("test")
if err != nil {
if strings.Contains(stdout, "Config value can not be specified for computed field") {
t.Errorf("Bug reproduced: mock provider fails with computed field error.\n"+
"This is the bug from https://github.com/opentofu/opentofu/issues/3644\n"+
"stdout:\n%s", stdout)
return
}
t.Errorf("unexpected error on 'test': %v\nstderr:\n%s\nstdout:\n%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "1 passed, 0 failed") {
t.Errorf("output doesn't have expected success string:\n%s", stdout)
}
}

View file

@ -0,0 +1,21 @@
# Minimal reproducer for https://github.com/opentofu/opentofu/issues/3644
# Bug requires ignore_changes on a block (not a simple attribute), here I use network_interface
terraform {
required_providers {
vsphere = {
source = "vmware/vsphere"
version = "~> 2.14"
}
}
}
resource "vsphere_virtual_machine" "vm" {
name = "x"
resource_pool_id = "x"
network_interface { network_id = "x" }
lifecycle {
ignore_changes = [network_interface]
}
}

View file

@ -0,0 +1,5 @@
# Minimal reproducer for https://github.com/opentofu/opentofu/issues/3644
mock_provider "vsphere" {}
run "create" {}

View file

@ -11,6 +11,7 @@ import (
"hash/fnv"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/configs/hcl2shim"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/tfdiags"
@ -68,14 +69,36 @@ func (p providerForTest) PlanResourceChange(_ context.Context, r providers.PlanR
resSchema, _ := p.schema.SchemaForResourceType(addrs.ManagedResourceMode, r.TypeName)
var resp providers.PlanResourceChangeResponse
// Filter out computed-only attributes from the schema to avoid them being used incorrectly
// later on. This resolves https://github.com/opentofu/opentofu/issues/3644
filteredConfig := filterComputedOnlyAttributes(resSchema, r.Config)
var resp providers.PlanResourceChangeResponse
resp.PlannedState, resp.Diagnostics = newMockValueComposer(r.TypeName).
ComposeBySchema(resSchema, r.Config, p.overrideValues)
ComposeBySchema(resSchema, filteredConfig, p.overrideValues)
return resp
}
// filterComputedOnlyAttributes returns a copy of value where all computed-only attributes
// (i.e. computed and not optional) defined in resSchema are replaced with null values.
func filterComputedOnlyAttributes(resSchema *configschema.Block, value cty.Value) cty.Value {
if resSchema == nil || value.IsNull() || !value.IsKnown() {
// Nothing to filter here, or we dont know how to filter
// so we should move on
return value
}
ret, _ := cty.Transform(value, func(path cty.Path, v cty.Value) (cty.Value, error) {
attr := resSchema.AttributeByPath(path)
if attr != nil && attr.Computed && !attr.Optional {
return cty.NullVal(v.Type()), nil
}
return v, nil
})
return ret
}
func (p providerForTest) ApplyResourceChange(_ context.Context, r providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
return providers.ApplyResourceChangeResponse{
NewState: r.PlannedState,

View file

@ -9,7 +9,9 @@ import (
"strings"
"testing"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/providers"
"github.com/zclconf/go-cty/cty"
)
func TestProviderForTest_ReadResource(t *testing.T) {
@ -34,3 +36,122 @@ func TestProviderForTest_ReadResource(t *testing.T) {
t.Fatalf("expected prior state not found error but got: %s", errMsg)
}
}
func TestFilterComputedOnlyAttributes(t *testing.T) {
tests := []struct {
name string
schema *configschema.Block
value cty.Value
expected cty.Value
}{
{
name: "nil schema returns value unchanged",
schema: nil,
value: cty.ObjectVal(map[string]cty.Value{"foo": cty.StringVal("bar")}),
expected: cty.ObjectVal(map[string]cty.Value{"foo": cty.StringVal("bar")}),
},
{
name: "null value returns null",
schema: &configschema.Block{},
value: cty.NullVal(cty.Object(map[string]cty.Type{"foo": cty.String})),
expected: cty.NullVal(cty.Object(map[string]cty.Type{"foo": cty.String})),
},
{
name: "unknown value returns unknown",
schema: &configschema.Block{},
value: cty.UnknownVal(cty.Object(map[string]cty.Type{"foo": cty.String})),
expected: cty.UnknownVal(cty.Object(map[string]cty.Type{"foo": cty.String})),
},
{
name: "computed-only attribute is nulled out",
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"computed_only": {Computed: true, Optional: false, Type: cty.String},
"normal": {Optional: true, Type: cty.String},
},
},
value: cty.ObjectVal(map[string]cty.Value{
"computed_only": cty.StringVal("should be nulled"),
"normal": cty.StringVal("keep me"),
}),
expected: cty.ObjectVal(map[string]cty.Value{
"computed_only": cty.NullVal(cty.String),
"normal": cty.StringVal("keep me"),
}),
},
{
name: "optional+computed attribute is NOT nulled",
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"optional_computed": {Computed: true, Optional: true, Type: cty.String},
},
},
value: cty.ObjectVal(map[string]cty.Value{
"optional_computed": cty.StringVal("keep me"),
}),
expected: cty.ObjectVal(map[string]cty.Value{
"optional_computed": cty.StringVal("keep me"),
}),
},
{
name: "required attribute is NOT nulled",
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"required": {Required: true, Type: cty.String},
},
},
value: cty.ObjectVal(map[string]cty.Value{
"required": cty.StringVal("keep me"),
}),
expected: cty.ObjectVal(map[string]cty.Value{
"required": cty.StringVal("keep me"),
}),
},
{
name: "nested block with computed-only attribute is nulled",
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"name": {Optional: true, Type: cty.String},
},
BlockTypes: map[string]*configschema.NestedBlock{
"nested": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Computed: true, Optional: false, Type: cty.String},
"user_specified": {Optional: true, Type: cty.String},
},
},
},
},
},
value: cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("test"),
"nested": cty.ListVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("computed-id"),
"user_specified": cty.StringVal("user-value"),
}),
}),
}),
expected: cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("test"),
"nested": cty.ListVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"id": cty.NullVal(cty.String),
"user_specified": cty.StringVal("user-value"),
}),
}),
}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := filterComputedOnlyAttributes(tt.schema, tt.value)
if !result.RawEquals(tt.expected) {
t.Errorf("filterComputedOnlyAttributes() = %v, want %v", result.GoString(), tt.expected.GoString())
}
})
}
}