Skip to content

fix(kubernetes_manifest): preserve dynamicpseudotype in value convers…#2822

Closed
leefowlercu wants to merge 2 commits intohashicorp:mainfrom
leefowlercu:fix/dynamic-pseudo-type-preservation
Closed

fix(kubernetes_manifest): preserve dynamicpseudotype in value convers…#2822
leefowlercu wants to merge 2 commits intohashicorp:mainfrom
leefowlercu:fix/dynamic-pseudo-type-preservation

Conversation

@leefowlercu
Copy link

fix(kubernetes_manifest): preserve DynamicPseudoType in value conversion pipeline

Summary

This PR fixes the "Provider produced inconsistent result after apply" error that occurs when using kubernetes_manifest with CRDs that have x-kubernetes-preserve-unknown-fields: true (represented as DynamicPseudoType in the Terraform type system).

The fix ensures type consistency between the planned state and the actual state by:

  1. Preserving DynamicPseudoType in the type conversion pipeline
  2. Morphing the apply output type structure to match the deserialized planned state type

Fixes

Fixes #2821

Root Cause

The bug has two interacting causes:

Cause 1: Type Conversion Used Actual Type Instead of Schema Type

The value conversion pipeline was using the actual value's type instead of the schema type when constructing output objects.

Cause 2: DynamicPseudoType Lost During Serialization

Even when the plan phase correctly produces types with DynamicPseudoType, this type information is lost during Terraform protocol serialization/deserialization:

  1. Plan phase produces state with DynamicPseudoType in type structure
  2. Serialization converts the value to MessagePack
  3. Deserialization in apply infers types from data, producing concrete types
  4. Apply phase receives planned state with concrete types (e.g., Object[])
  5. If apply produces DynamicPseudoType, type mismatch occurs

Example:

Plan phase output:    Object{masquerade: DynamicPseudoType}
After serialization:  Object{masquerade: Object[]}  (type inferred from data)
Apply phase output:   Object{masquerade: DynamicPseudoType}  <-- Mismatch!

The Terraform SDK validates that planned and actual types match, causing the error:

Error: Provider produced inconsistent result after apply
.object: wrong final value type: incorrect object attributes

Changes Made

manifest/payload/to_value.go

Modified mapToTFObjectValue to preserve DynamicPseudoType from the schema when constructing output types:

// Before (buggy):
oTypes[k] = nv.Type()

// After (fixed):
if kt.Is(tftypes.DynamicPseudoType) {
    oTypes[k] = tftypes.DynamicPseudoType
} else {
    oTypes[k] = nv.Type()
}

Similar changes applied to:

  • sliceToTFListValue
  • sliceToTFTupleValue
  • sliceToTFSetValue
  • mapToTFMapValue

manifest/morph/scaffold.go

Modified DeepUnknown to preserve DynamicPseudoType in both Object and Tuple cases:

Object case:

// Preserve DynamicPseudoType from schema, otherwise use actual type
if att.Is(tftypes.DynamicPseudoType) {
    otypes[name] = tftypes.DynamicPseudoType
} else {
    otypes[name] = nv.Type()
}

Tuple expansion case:

// Preserve DynamicPseudoType when expanding tuples
originalType := t.(tftypes.Tuple).ElementTypes[0]
for i := range v.Type().(tftypes.Tuple).ElementTypes {
    if originalType.Is(tftypes.DynamicPseudoType) {
        atts[i] = tftypes.DynamicPseudoType
    } else {
        atts[i] = v.Type().(tftypes.Tuple).ElementTypes[i]
    }
}

manifest/morph/morph.go

  1. Modified morphObjectToType and morphTupleIntoType to preserve DynamicPseudoType from the target type schema.

  2. Added new function MorphTypeStructure (~170 lines): This function recursively converts a value's type structure to match a target type while preserving the underlying data. It handles the case where serialization converted DynamicPseudoType to concrete types.

// MorphTypeStructure converts a value to have a different type structure while preserving
// the underlying data. This is needed when plan and apply produce values with different
// type structures due to DynamicPseudoType being converted to concrete types during
// serialization/deserialization.
func MorphTypeStructure(source tftypes.Value, targetType tftypes.Type, p *tftypes.AttributePath) (tftypes.Value, error)

manifest/provider/apply.go

Added call to MorphTypeStructure after computing the apply result to ensure the output type structure matches the deserialized planned state's type:

// Morph the computed object's type structure to match the planned state's type.
// This is necessary because DynamicPseudoType in the schema gets converted to concrete
// types during serialization/deserialization. The planned state (after deserialization)
// has concrete types, so our output must match.
plannedObjectType := plannedStateVal["object"].Type()
morphedObj, err := morph.MorphTypeStructure(compObj, plannedObjectType, tftypes.NewAttributePath())
if err != nil {
    // ... error handling
}
plannedStateVal["object"] = morph.UnknownToNull(morphedObj)

Testing

Unit Tests Added

6 new regression tests that verify output TYPE structure preserves DynamicPseudoType:

manifest/payload/to_value_test.go:

  • empty-object-dynamic-pseudotype: Verifies empty object with DynamicPseudoType schema (KubeVirt scenario)
  • nested-dynamic-pseudotype-leaf: Verifies DynamicPseudoType preserved in nested structures

manifest/morph/scaffold_test.go:

  • object-dynamic-pseudotype: Verifies DeepUnknown preserves DynamicPseudoType in Object schema
  • tuple-dynamic-expansion: Verifies DeepUnknown preserves DynamicPseudoType when tuples expand

manifest/morph/morph_test.go:

  • object(empty-dynamic) -> object: Verifies morphObjectToType handles empty objects with DynamicPseudoType schema
  • NEW: TestMorphTypeStructure: 6 test cases verifying MorphTypeStructure correctly morphs type structures:
    • dynamic-to-concrete-empty-object: KubeVirt masquerade scenario
    • nested-dynamic-to-concrete: Deeply nested DynamicPseudoType conversion
    • list-with-dynamic-elements: List element type conversion
    • same-type-passthrough: Passthrough when types already match
    • null-value: Null value handling
    • unknown-value: Unknown value handling

Acceptance Test Added

manifest/test/acceptance/customresource_x_preserve_unknown_fields_test.go:

  • TestKubernetesManifest_CustomResource_x_preserve_unknown_fields_empty_object: End-to-end regression test that creates a CR with an empty object (resources = {}) in a field marked with x-kubernetes-preserve-unknown-fields. Without the fix, this test would fail with "Provider produced inconsistent result after apply".

Test fixture: manifest/test/acceptance/testdata/x-kubernetes-preserve-unknown-fields/test-cr-empty.tf

Manual Testing

Manual testing was performed with a real KubeVirt VirtualMachine CRD to verify the fix works with production-grade complex CRDs.

Test Environment

Component Version/Details
Platform Red Hat OpenShift Service on AWS (ROSA)
OpenShift 4.20.5
Kubernetes v1.33.5
KubeVirt v4.20.3 (OpenShift Virtualization)
Terraform v1.14.2

Test Configuration

resource "kubernetes_manifest" "virtual_machine" {
  manifest = {
    apiVersion = "kubevirt.io/v1"
    kind       = "VirtualMachine"
    metadata = {
      name      = "tf-fix-test-vm"
      namespace = "default"
    }
    spec = {
      running = true
      template = {
        spec = {
          domain = {
            devices = {
              interfaces = [{
                name       = "default"
                masquerade = {}  # Empty object - triggers the bug
                model      = "virtio"
              }]
            }
          }
          networks = [{
            name = "default"
            pod  = {}  # Empty object - triggers the bug
          }]
        }
      }
    }
  }
}

Test Results

Before Fix:

Error: Provider produced inconsistent result after apply
.object: wrong final value type: incorrect object attributes

After Fix:

$ terraform apply -auto-approve
module.vm.kubernetes_manifest.virtual_machine: Creating...
module.vm.kubernetes_manifest.virtual_machine: Creation complete after 0s

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:
vm_name = "tf-fix-test-vm"
vm_namespace = "default"

Verification:

$ oc get vm tf-fix-test-vm -n default
NAME              AGE   STATUS    READY
tf-fix-test-vm    30s   Running   True

Cleanup:

$ terraform destroy -auto-approve
module.vm.kubernetes_manifest.virtual_machine: Destroying...
module.vm.kubernetes_manifest.virtual_machine: Destruction complete after 5s

Destroy complete! Resources: 1 destroyed.

Testing Environments

Acceptance tests were run in two environments to ensure broad compatibility:

Environment 1: KinD (Recommended per CONTRIBUTING.md)

Component Version
KinD v0.31.0
Kubernetes v1.35.0
Platform darwin/arm64
Config .github/config/acceptance_tests_kind_config.yaml
$ kind create cluster --name tf-k8s-acc-test \
    --config=./.github/config/acceptance_tests_kind_config.yaml

Environment 2: Red Hat OpenShift (ROSA with HyperShift)

This environment was used for initial bug reproduction and verification with real-world KubeVirt VirtualMachine CRDs.

Cluster Details:

Component Version/Details
Platform Red Hat OpenShift Service on AWS (ROSA)
Topology HyperShift (hosted control plane)
OpenShift 4.20.5 (stable-4.20 channel)
Kubernetes v1.33.5
Infrastructure AWS

Node Configuration:

Property Value
Node Count 3 worker nodes
Architecture arm64 (aarch64)
OS Red Hat Enterprise Linux CoreOS 9.6 (Plow)
Kernel 5.14.0-570.66.1.el9_6.aarch64
Container Runtime cri-o://1.33.5

OpenShift Virtualization (CNV) Operator:

Component Version
Operator kubevirt-hyperconverged-operator v4.20.3
HyperConverged CR kubevirt-hyperconverged
Namespace openshift-cnv
KubeVirt API kubevirt.io/v1

This environment provides access to the virtualmachines.kubevirt.io CRD which uses x-kubernetes-preserve-unknown-fields: true extensively throughout its schema, making it an ideal real-world test case for the DynamicPseudoType preservation fix.

Test Results

Unit Tests:

$ go test ./manifest/... -v
=== RUN   TestToTFValue
--- PASS: TestToTFValue (0.00s)
=== RUN   TestDeepUnknown
--- PASS: TestDeepUnknown (0.00s)
=== RUN   TestMorphValueToType
--- PASS: TestMorphValueToType (0.00s)
=== RUN   TestMorphTypeStructure
--- PASS: TestMorphTypeStructure (0.00s)
...
PASS
ok      github.com/hashicorp/terraform-provider-kubernetes/manifest     0.XXXs

Acceptance Tests (KinD - Kubernetes v1.35.0):

$ go test -tags acceptance ./test/acceptance -v -run "TestKubernetesManifest_CustomResource_x_preserve_unknown_fields"
2025/12/22 14:04:37 Testing against Kubernetes API version: v1.35.0
=== RUN   TestKubernetesManifest_CustomResource_x_preserve_unknown_fields
--- PASS: TestKubernetesManifest_CustomResource_x_preserve_unknown_fields (7.17s)
=== RUN   TestKubernetesManifest_CustomResource_x_preserve_unknown_fields_empty_object
--- PASS: TestKubernetesManifest_CustomResource_x_preserve_unknown_fields_empty_object (6.90s)
PASS
ok      github.com/hashicorp/terraform-provider-kubernetes/manifest/test/acceptance    14.572s

Acceptance Tests (OpenShift - Kubernetes v1.33.5):

$ go test -tags acceptance ./test/acceptance -v -run "TestKubernetesManifest_CustomResource"
2025/12/22 13:25:25 Testing against Kubernetes API version: v1.33.5
=== RUN   TestKubernetesManifest_CustomResource_Multiversion
--- PASS: TestKubernetesManifest_CustomResource_Multiversion (3.44s)
=== RUN   TestKubernetesManifest_CustomResource
--- PASS: TestKubernetesManifest_CustomResource (12.85s)
=== RUN   TestKubernetesManifest_CustomResource_x_preserve_unknown_fields
--- PASS: TestKubernetesManifest_CustomResource_x_preserve_unknown_fields (14.86s)
=== RUN   TestKubernetesManifest_CustomResource_x_preserve_unknown_fields_empty_object
--- PASS: TestKubernetesManifest_CustomResource_x_preserve_unknown_fields_empty_object (18.23s)
=== RUN   TestKubernetesManifest_CustomResource_OAPIv3_metadata
--- PASS: TestKubernetesManifest_CustomResource_OAPIv3_metadata (16.32s)
=== RUN   TestKubernetesManifest_CustomResource_OAPIv3
--- PASS: TestKubernetesManifest_CustomResource_OAPIv3 (16.29s)
=== RUN   TestKubernetesManifest_CustomResourceDefinition
--- PASS: TestKubernetesManifest_CustomResourceDefinition (5.78s)
PASS
ok      github.com/hashicorp/terraform-provider-kubernetes/manifest/test/acceptance    70.261s

Related Issues

These issues report the same "wrong final value type: incorrect object attributes" error:

Checklist

  • Unit tests pass
  • Acceptance tests pass in KinD (recommended per CONTRIBUTING.md)
  • Acceptance tests pass in OpenShift (production environment)
  • Manual testing with KubeVirt VirtualMachine CRD
  • Unit regression tests added (12 tests total) that would FAIL if fix is reverted
  • Acceptance regression test added that would FAIL if fix is reverted
  • No breaking changes to existing behavior

Rollback Plan

If a change needs to be reverted, we will publish an updated version of the library.

Changes to Security Controls

Are there any changes to security controls (access controls, encryption, logging) in this pull request? If so, explain.

  • No changes to security controls

Release Note

kubernetes_manifest: fix for 'Provider produced inconsistent result after apply' error in `terraform apply` when using CRs with CRDs using `x-kubernetes-preserve-unknown-fields`
  • Please vote on this issue by adding a 👍 reaction to the original issue to help the community and maintainers prioritize this request
  • If you are interested in working on this issue or have submitted a pull request, please leave a comment

…ion pipeline

fixes "provider produced inconsistent result after apply" error when using
kubernetes_manifest with crds that have x-kubernetes-preserve-unknown-fields

the fix ensures type consistency between planned and actual state by:
- preserving dynamicpseudotype from schema in type conversion functions
- morphing apply output type structure to match deserialized planned state type
@leefowlercu leefowlercu requested a review from a team as a code owner December 22, 2025 21:39
@5aaee9
Copy link

5aaee9 commented Jan 28, 2026

Found a problem during local testing. It causes a crash with the following configuration.

Terraform config

resource "kubernetes_manifest" "test" {
  manifest = {
    apiVersion = "company.local/v1alpha1"
    kind       = "Foo"

    metadata = {
      name = lower(data.manidae_parameter.name.value)
      namespace = var.Kubernetes_namespace
    }
    spec = {
      mapdata = {
        test = {
          datas = var.kubernetes_tunnel_extra_routes
        }
        # skipped data field on this object
        test2 = {

        }
      }
    }
  }
}

CRD define

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: foos.company.local
spec:
  group: company.local
  names:
    kind: Foo
    listKind: FooList
    plural: foos
    singular: foo
  scope: Namespaced
  versions:
  - additionalPrinterColumns:
    - jsonPath: .status.phase
      name: Phase
      type: string
    name: v1alpha1
    schema:
      openAPIV3Schema:
        description: Test spec field
        properties:
          apiVersion:
            description: |-
            type: string
          kind:
            description: |-
            type: string
          metadata:
            type: object
          spec:
            description: Test spec field
            properties:
              mapdata:
                additionalProperties:
                  description: mapdata
                  properties:
                    datas:
                      description: |-
                        Datas test field
                      x-kubernetes-preserve-unknown-fields: true
                  type: object
                description: mapdatatest
                type: object
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

Error message

!!!!!!!!!!!!!!!!!!!!!!!!!!! TERRAFORM CRASH !!!!!!!!!!!!!!!!!!!!!!!!!!!!

Terraform crashed! This is always indicative of a bug within Terraform.
Please report the crash with Terraform[1] so that we can fix this.

When reporting bugs, please include your terraform version, the stack trace
shown below, and any additional information which may help replicate the issue.

[1]: https://github.com/hashicorp/terraform/issues

!!!!!!!!!!!!!!!!!!!!!!!!!!! TERRAFORM CRASH !!!!!!!!!!!!!!!!!!!!!!!!!!!!

panic: inconsistent map element types (cty.Object(map[string]cty.Type{"datas":cty.List(cty.String)}) then cty.Object(map[string]cty.Type{"datas":cty.DynamicPseudoType}))
goroutine 667 [running]:
runtime/debug.Stack()
        runtime/debug/stack.go:26 +0x5e
github.com/hashicorp/terraform/internal/logging.PanicHandler()
        github.com/hashicorp/terraform/internal/logging/panic.go:84 +0x16a
panic({0x36b03c0?, 0xc003bcd930?})
        runtime/panic.go:783 +0x132
github.com/hashicorp/terraform/internal/terraform.(*Graph).walk.func1.1()
        github.com/hashicorp/terraform/internal/terraform/graph.go:59 +0x4c5
panic({0x36b03c0?, 0xc003bcd930?})
        runtime/panic.go:783 +0x132
github.com/zclconf/go-cty/cty.MapVal(0xc00283f270)
        github.com/zclconf/go-cty@v1.16.3/cty/value_init.go:220 +0x46e
github.com/zclconf/go-cty/cty/msgpack.unmarshalMap(0xc004206750, {{0x44c3dd0?, 0xc003bcd500?}}, {0xc000da4d80, 0x3, 0x4})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:227 +0x585
github.com/zclconf/go-cty/cty/msgpack.unmarshal(0xc004206750, {{0x44c3fd0?, 0xc003bcd510?}}, {0xc000da4d80, 0x3, 0x4})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:55 +0x476
github.com/zclconf/go-cty/cty/msgpack.unmarshalObject(0xc004206750, 0xc0036470b0, {0xc00283feb8, 0x2, 0x2})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:296 +0x4f3
github.com/zclconf/go-cty/cty/msgpack.unmarshal(0xc004206750, {{0x44c3dd0?, 0xc003bcd530?}}, {0xc00283feb8, 0x2, 0x2})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:59 +0x50b
github.com/zclconf/go-cty/cty/msgpack.unmarshalObject(0xc004206750, 0xc0036470e0, {0xc00283feb8, 0x1, 0x2})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:296 +0x4f3
github.com/zclconf/go-cty/cty/msgpack.unmarshal(0xc004206750, {{0x44c3dd0?, 0xc003bcd570?}}, {0xc00283feb8, 0x1, 0x2})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:59 +0x50b
github.com/zclconf/go-cty/cty/msgpack.unmarshalDynamic(0xc004206750, {0xc00283feb8, 0x1, 0x2})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:332 +0x73d
github.com/zclconf/go-cty/cty/msgpack.unmarshal(0xc004206750, {{0x44c47f8?, 0x6423fe0?}}, {0xc00283feb8, 0x1, 0x2})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:36 +0x60f
github.com/zclconf/go-cty/cty/msgpack.unmarshalObject(0xc004206750, 0xc0031dd2f0, {0x0, 0x0, 0x0})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:296 +0x4f3
github.com/zclconf/go-cty/cty/msgpack.unmarshal(0xc004206750, {{0x44c3dd0?, 0xc003bcc5d0?}}, {0x0, 0x0, 0x0})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:59 +0x50b
github.com/zclconf/go-cty/cty/msgpack.Unmarshal({0xc00373c700, 0x676, 0x700}, {{0x44c3dd0?, 0xc003bcc5d0?}})
        github.com/zclconf/go-cty@v1.16.3/cty/msgpack/unmarshal.go:22 +0xae
github.com/hashicorp/terraform/internal/plugin6.decodeDynamicValue(0x0?, {{0x44c3dd0?, 0xc003bcc5d0?}})
        github.com/hashicorp/terraform/internal/plugin6/grpc_provider.go:1754 +0x92
github.com/hashicorp/terraform/internal/plugin6.(*GRPCProvider).PlanResourceChange(_, {{0xc000b16858, 0x13}, {{{0x44c3dd0, 0xc000a5d430}}, {0x0, 0x0}}, {{{0x44c3dd0, 0xc0028f0250}}, {0x37f3240, ...}}, ...})
        github.com/hashicorp/terraform/internal/plugin6/grpc_provider.go:703 +0xbec
github.com/hashicorp/terraform/internal/terraform.(*NodeAbstractResourceInstance).plan(0xc00281f688, {0x44f1448, 0xc003a8c5a0}, 0x0, 0x0, 0x0, 0x0)
        github.com/hashicorp/terraform/internal/terraform/node_resource_abstract_instance.go:979 +0x2145
github.com/hashicorp/terraform/internal/terraform.(*NodePlannableResourceInstance).managedResourceExecute(0xc0028001e0, {0x44f1448, 0xc003a8c5a0})
        github.com/hashicorp/terraform/internal/terraform/node_resource_plan_instance.go:355 +0x1cf0
github.com/hashicorp/terraform/internal/terraform.(*NodePlannableResourceInstance).Execute(0xc000eeb730?, {0x44f1448?, 0xc003a8c5a0?}, 0x58?)
        github.com/hashicorp/terraform/internal/terraform/node_resource_plan_instance.go:74 +0xb5
github.com/hashicorp/terraform/internal/terraform.(*ContextGraphWalker).Execute(0xc001c52580, {0x44f1448, 0xc003a8c5a0}, {0x7fbe64855010, 0xc0028001e0})
        github.com/hashicorp/terraform/internal/terraform/graph_walk_context.go:166 +0xaf
github.com/hashicorp/terraform/internal/terraform.(*Graph).walk.func1({0x3d7f560, 0xc0028001e0})
        github.com/hashicorp/terraform/internal/terraform/graph.go:143 +0x755
github.com/hashicorp/terraform/internal/dag.(*Walker).walkVertex(0xc00206cf60, {0x3d7f560, 0xc0028001e0}, 0xc0026c7140)
        github.com/hashicorp/terraform/internal/dag/walk.go:393 +0x2d1
created by github.com/hashicorp/terraform/internal/dag.(*Walker).Update in goroutine 558
        github.com/hashicorp/terraform/internal/dag/walk.go:316 +0xf13

@leefowlercu leefowlercu deleted the fix/dynamic-pseudo-type-preservation branch February 24, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kubernetes_manifest: "Provider produced inconsistent result after apply" with DynamicPseudoType fields (x-kubernetes-preserve-unknown-fields)

2 participants