Strip stale provider defaults from inputs during Diff - #3405
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3405 +/- ##
==========================================
+ Coverage 70.06% 70.15% +0.08%
==========================================
Files 343 343
Lines 37164 37276 +112
==========================================
+ Hits 26040 26152 +112
+ Misses 9370 9369 -1
- Partials 1754 1755 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
a4c7bbd to
3a05e53
Compare
There was a problem hiding this comment.
Review summary
The fix is logically sound for the described scenario (provider upgrade removes a Default from a schema field, stale value persists in __defaults, and the stale value later causes PlanResourceChange validation to fail). The call site placement in Diff—before MakeTerraformConfig—is correct, and the shallow-copy approach avoids mutating the caller's map.
Blocking
stripStaleDefaultshas no unit tests. The PR description acknowledges this ("Need to add unit tests"), but they must be present before this merges given the function lives on everyDiffhot path. See inline comment on the function declaration for a suggested test matrix.
Non-blocking observations (inline comments posted)
tfSchema == niledge case: fields completely removed from the TF schema (not just their default removed) are silently kept rather than stripped. Likely harmless today but worth a comment or an explicit decision.- Consider adding the same stripping in
Checkif it can ever be called with stored (rather than program) inputs. - A
glog.V(9)log when keys are stripped would greatly aid future debugging.
Reviewed by Internal Trusted PR Reviewer
To install this agentic workflow, run
gh aw add pulumi-labs/gh-aw-internal/.github/workflows/gh-aw-pr-review.md@99a309bf18258dbae98c8176ea10657e0b08deca
c32c6f6 to
32f4685
Compare
|
I think #3398 may be related. |
|
/review-again |
There was a problem hiding this comment.
Review summary
The fix is well-motivated and correctly implemented. The core logic in stripStaleDefaults is sound:
- Only fields that appear in
__defaults(bridge/TF-injected, never explicitly user-set) are candidates for stripping. - A field is stripped only when
tfSchema != nil && !hasTFDefault && !hasBridgeDefault— thetfSchema == nilguard is a safe bail-out sinceMakeTerraformConfigsilently drops fully-removed fields anyway. - The shallow-copy before mutating (
result := make(resource.PropertyMap, len(m))) correctly avoids mutating the caller's map, and the unit test"does not mutate original map"directly verifies this. - The function is called before
MakeTerraformConfigand beforeNewInputsis passed top.tf.Diff, so both the config and the inputs side of the diff see consistent (stripped) values.
The test modifications to TestRegress1020 and TestRegressAws2352 are necessary: both tests carry name in __defaults in their stored-input fixtures, and without SchemaInfo.Default registered, stripStaleDefaults would incorrectly classify the auto-named field as stale. Adding the bridge default preserves the original test intent.
Two minor non-blocking comments inline — no changes required to merge.
Reviewed by Internal PR Re-Review (Slash Command)
To install this agentic workflow, run
gh aw add pulumi-labs/gh-aw-internal/.github/workflows/gh-aw-pr-rereview.md@99a309bf18258dbae98c8176ea10657e0b08deca
pgavlin
left a comment
There was a problem hiding this comment.
The stale default (e.g. authTokenUpdateStrategy: "ROTATE") is still in the old inputs under __defaults. The bridge converts it into the TF config. PlanResourceChange then runs CustomizeDiff validation, which checks diff.GetRawConfig() (the config, not the state). The v7 provider's validation sees auth_token_update_strategy in the config without auth_token and rejects it:
this is interesting. so TF providers are doing validation in Diff via PlanResourceChange? that's a bit unfortunate...
this is sort of an odd interaction in general. the way we do diffs for refresh steps relies on the engine calling Diff with the last inputs passed in by the program--which in this case is exactly what's triggering the issue. I suppose TF doesn't have this problem for two reasons:
- TF doesn't run diff on refresh results
- TF doesn't persist defaults anywhere but in state
so a simple state upgrade works fine.
overall I think the approach seems sound? worth looking for some specific test cases in addition to what's here though.
| // in news. This is safe because MakeTerraformConfig -> makeTerraformInputsWithOptions | ||
| // drops fields with no corresponding TF schema entry during Pulumi-to-TF conversion, | ||
| // so removed fields never reach PlanResourceChange regardless. | ||
| func stripStaleDefaults( |
There was a problem hiding this comment.
wondering if this needs to recur on property values. I think default application is recursive.
There was a problem hiding this comment.
Good call on the recursion. Added that and tests.
Review findings and analysisRan this through several code reviewers (Claude code-reviewer agent, Codex Addressed in code
TypeSet hash identityThe concernWhen For example, a TypeSet element AnalysisThis is a false alarm. In the TF SDK v2 diff flow,
So DefaultFunc re-derivationThe concernFields populated via If such a field is in
AnalysisNot a practical concern. Provider initialization requires these values — GCP provider won't start without project configuration, AWS provider won't start without region, etc. So the values are always available during If the environment genuinely changed (user switched projects), showing a diff is arguably correct behavior. The field was never explicitly set by the user (it's in The narrow failure case (env var removed but provider still initializes and DetailedDiff coarseness for TypeSetThe concern
If we strip defaults from inputs but TF re-applies them in the planned state, there's a mismatch: the planned state has This affects the granularity of AnalysisReal but low-severity and cosmetic. The impact is:
If this turns out to be noisy in practice, we could maintain separate property maps: stripped inputs for |
1acf88e to
174d674
Compare
When a provider upgrade removes a default value from a schema field, the old default persists in stored inputs via __defaults. During `pulumi up --refresh`, the Diff method receives these stale inputs as `news` and converts them to a TF config for PlanResourceChange. The stale default in the config can trigger CustomizeDiff validation errors in the new provider version. This was observed upgrading @pulumi/aws v6→v7: the v6 provider defaulted `authTokenUpdateStrategy` to "ROTATE" on ElastiCache ReplicationGroup resources even when `authToken` was not set. The v7 provider removed this default and added validation requiring `authToken` when `authTokenUpdateStrategy` is present. The stale "ROTATE" in __defaults caused every refresh to fail with: "auth_token_update_strategy": "auth_token" must be specified The fix checks each field in __defaults against the current schema. If the field no longer has Default() or DefaultFunc(), it is stripped from the inputs before building the TF config.
174d674 to
ef0be18
Compare
ef0be18 to
cf6fc5f
Compare
2c3c2bf to
2293435
Compare
corymhall
left a comment
There was a problem hiding this comment.
Left a few non-blocking review comments around default detection and test coverage.
| continue | ||
| } | ||
| if tfSchema != nil { | ||
| if dv, _ := tfSchema.DefaultValue(); dv != nil { |
There was a problem hiding this comment.
I think this predicate should avoid calling DefaultValue().
DefaultValue() executes DefaultFunc(), and this code ignores the returned error. For schemas that still have a current DefaultFunc, especially env-backed or Required + DefaultFunc shapes, a nil/error result at Diff time would classify the stored value as stale and strip it from news. That defeats the old-default reuse path in MakeTerraformConfig and can change raw-config presence semantics.
The safer predicate seems to be schema ownership rather than evaluated value:
if tfSchema != nil && (tfSchema.Default() != nil || tfSchema.DefaultFunc() != nil) {
keptDefaults = append(keptDefaults, key)
continue
}Can we also add a test where the current schema has a DefaultFunc that returns nil or errors, and verify that a stored __defaults value is preserved?
created with Codex
There was a problem hiding this comment.
Good catch — fixed in b7a44781. Switched the predicate to tfSchema.Default() != nil || tfSchema.DefaultFunc() != nil (structural ownership) and added a unit test field with current TF DefaultFunc is preserved (even if it would return nil) that uses a DefaultFunc returning (nil, nil) to confirm a runtime-nil result no longer classifies the field as stale.
| require.Equal(t, "old-default", preInputs["optField"], | ||
| "sanity: imported state should still have the stale value before Up") | ||
|
|
||
| pt2.Preview(t, optpreview.Diff()) |
There was a problem hiding this comment.
This preview result is discarded, so the test does not actually prove the “no spurious diff” behavior described above.
As written, the test would still pass if preview showed an update/replacement and Up later cleaned the stored inputs. Since avoiding the refresh/preview failure is the user-facing behavior this PR is fixing, can we capture the result and assert the change summary?
res := pt2.Preview(t, optpreview.Diff())
assertNoChanges(t, res.ChangeSummary, "DefaultRemoved")Same pattern looks worth applying to the field-removed and nested migration tests.
created with Codex
There was a problem hiding this comment.
Acted on this with a small adjustment. Schema-migration scenarios (_DefaultRemoved, _FieldRemovedFromSchema, _NestedTypeListBlock, _TypeListOfBlocks) are expected to show a transitional update diff — the strip removes the stale value, so TF sees the field going from present-in-state to absent-in-config and produces an in-place update. assertNoChanges would fail there.
What would be a real bug is the strip causing a replace (e.g. via a TypeSet hash regression). Added a new helper assertOnlySameOrUpdate and applied it on the Preview result in all four schema-migration tests; the post-Up state assertion remains as the strong outcome guarantee.
| } | ||
|
|
||
| if v.IsObject() { | ||
| if tfSchema.Type() == shim.TypeMap { |
There was a problem hiding this comment.
Can we clarify or test this shape?
This treats every TypeMap with Elem().(shim.Resource) as a map of nested objects. The existing makeTerraformInput object path treats Elem().(shim.Resource) as a single object schema when the Pulumi value is an object. If this TypeMap-of-Resource shape is impossible or not relevant for SDKv2 providers, a short comment would help. If it is possible, I think we should add a test that proves stripStaleDefaults and MakeTerraformConfig agree on the shape.
created with Codex
There was a problem hiding this comment.
You're right, and the answer is stronger than "add a test." Per pkg/tfshim/shim.go:175-181, TypeMap+Elem=Resource represents a single-nested block in PF, but SDKv2 reinterprets it as a string-string map. Since this strip only runs on the SDKv2 Diff path, the values would always be scalars, not nested objects — stripMapOfBlocks was handling a shape that doesn't exist for SDKv2 providers (the inner.IsObject() guard would skip every element).
Removed the TypeMap special case, deleted the stripMapOfBlocks helper, and removed the two unit tests that were exercising the fictional shape. The IsObject() branch now falls through to stripStaleDefaults directly, which would also handle PF correctly if/when this strip is extended there.
2ebf32f to
7174512
Compare
… and secrets - Recurse into nested objects (TypeList MaxItems=1), array elements (TypeList/TypeSet), TypeMap-of-objects values, and secret-wrapped values at any nesting level - Strip all non-bridge defaults (not just removed ones), letting PlanResourceChange re-apply the current provider default; handles both removed and changed defaults - Add unwrapSecret/rewrapSecret helpers to avoid duplicated secret-unwrapping pattern - Add 18 test cases covering all new code paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7174512 to
dad9fbd
Compare
- Fix precedence inversion in classifyStaleDefault: removal markers (TF Removed, TF Deprecated && !Required) must take precedence over HasDefault preservation. Without this, an overlay author with SchemaInfo.Default for a TF attribute marked Removed kept the stale value and forwarded it to PlanResourceChange. - Extract schemaMarkersSkipDefault as the shared marker gate. Both applyDefaults branches and shouldStripStaleDefault now call through it, so the parity invariant lives in one place instead of three. - Apply the strip in Update's path as well as Diff's. Check normally sanitizes news upstream so this isn't user-observable today, but the symmetry hardens the invariant against future drift. - Drop the unused ctx parameter and replace the two-value disposition enum with a bool return. - Add unit tests for the precedence fix and an integration regression guard for the Diff/Update symmetry.
- Generalize the shared marker gate to defaultExcluded(sch, psi), closing the parity gap where applyDefaults' TF branch did not consult psi.Removed. All three call sites (overlay branch, TF branch, shouldStripStaleDefault) now go through one helper. - Trim stripStaleDefaults doc from ~50 lines to a focused purpose + scope + recursion + limitations summary; move the architectural roadmap reference to issue #3434 only. - Fix stale doc references (function name, schema.go line numbers, PR-archaeology) flagged by review. - Filter the V(9) strip-removal log to entries actually present in m (phantom __defaults entries no longer produce misleading "stripping" log lines, but __defaults bookkeeping still cleans them up). - Skip array allocation in stripArrayOfBlocks no-change path: it now returns nil instead of NewArrayProperty(arr) when nothing changed. - Split stripStaleDefaults into a public PropertyMap-returning wrapper and an unexported recursive form that retains the changed-bool used internally. - Use unwrapSecret/rewrapSecret consistently in stripStaleDefaultsValue. - Strengthen the "secret-wrapped scalar" test to also assert __defaults bookkeeping after the strip. - Add a unit test that locks in the new psi.Removed parity (bridge Removed beats current TF Default).
…ld function name in test comments Mirrors the cleanup applied to the implementation files in the prior commit. Caught by a fresh-context legibility-review pass.
…m, rename helper - pkg/tests/strip_stale_defaults_integration_test.go: file header now describes runtime SDKv2 paths (Diff and Update) instead of the obsolete "Diff RPC path"; removed the "only in Diff" wording from the changed-default note; replaced two stale provider.go line numbers with symbolic references; renamed assertOnlySameOrUpdate to assertNoUnexpectedOps to match what it actually proves. - pkg/tfbridge/provider.go: trimmed the unverified "map-of-objects" claim from stripStaleDefaultsValue's doc and the dead TypeMap rationale from its IsObject branch. SDKv2 reinterprets TypeMap as string-string and no test exercises a PF map-of-objects path, so the documentation now matches what the suite actually covers.
…chema can't re-derive" The "current schema can't re-derive" framing was both wordy and slightly inaccurate — the strip fires when the schema no longer declares a default, not because some derivation mechanism is missing. Replace with concrete phrasing where helpful (function header) and the natural "stale" elsewhere. Tightens the call-site comments at the same time: lead with what each variable feeds, drop the redundant pointer back to the function doc.
Comments across the strip path were accurate but roundabout — the take-home
buried by setup clauses, mechanism restated, hedge phrases ("such a", "this
applies whether", "essentially"), passive voice, and explicit redundancy
between adjacent sentences.
Trimmed each block applying:
- Lead with the conclusion in sentence 1.
- Drop "the function does X, which means Y" → "X for Y".
- Cut sentences that restate the function name or the next line of code.
- Active voice; remove hedge phrases.
- Trust readers to follow signature shape; document only the non-obvious why.
Net: ~40 lines of comment removed, no information lost.
The earlier trim pass (506b432) optimized for compression too aggressively. A clean-context bake-off comparing each (old, new) comment pair with the surrounding code identified concrete information lost on 10 of 14 trims: marker enumerations on shouldStripStaleDefault, when-to-call disambiguation on stripStaleDefaultsValue, phantom-entry definition in the log comment, TypeSet-rejected reasoning chain on the v.IsArray branch, default-source enumeration in defaultExcluded, mechanism explanation in the TF-Default- preserved test (strong opinion). Restored on: - assertNoChanges, assertNoUnexpectedOps doc - "Strong assertion" inline in DefaultRemoved - StripAppliesInUpdatePath header - shouldStripStaleDefault rule list - stripStaleDefaultsValue header - "nestedTFS is nil" enumeration - v.IsArray TypeSet-rejected note - defaultExcluded contract - Log-only-present-keys phantom note - "field with current TF Default is preserved" test rationale (with a small tweak: use the more-accurate "applyDefaults old-default reuse path" framing for #2 instead of the original's "Update silently fails to apply" wording, which we now know is wrong post-Update-strip) Kept the trim where the new version genuinely won (file header, assertNoUnexpectedOps, stripStaleDefaultsRec, stripArrayOfBlocks). Lesson: prose-density heuristics should be applied with bake-off verification, not unilaterally — the user's intuition was correct.
…d test rationale Round 2 of the comment-quality experiment ran 5 agent writer styles plus 4 human-baseline comments past 4 reviewers framed by knowledge level (median / domain-experienced / code-familiar / AI-with-research) using question-driven evaluation. Two candidates won unanimous top-3 across every reviewer: - stripStaleDefaults function doc: the "competent peer" style — assumes Pulumi/TF familiarity, names the parity contract with applyDefaults explicitly, calls out the Diff/Update-only scope and the unstripped-news-to-NewInputs asymmetry, and gives the why for TypeSet's skip without tutorial scaffolding. - TF-Default-preserved test rationale: the structured "why preservation is correct" framing with both invariants (falsy round-trip per TestUpdatePreservesLegacyFalsyTFDefaults, and changed-default phantom-diff guard with explicit #3434 reference), plus the load-bearing caveat that result == m would also be satisfied by an identity function — separating this case's specific contract from the wider table's coverage. Refinements applied to the function doc beyond the raw winner: - Added the AI reviewer's recommended sentence stating the parity contract in one line ("a key is stripped iff applyDefaults would not re-supply it on the next Check"). - Replaced the candidate's "field is no longer Optional" trigger with the accurate "field is marked Removed/Deprecated" — matches the actual defaultExcluded predicate. The previous comments were the result of an earlier round's old-vs-new bake-off restoration; this round's panel preferred different candidates, chiefly because the new "competent peer" writer style (added between rounds) filled the gap between tech-correctness's edge-case enumeration and reader- onramp's tutorial framing. Calibration finding from this round: human-written comments mixed in blind held their own against agent comments. Only the truly thin human entries (one-line signature paraphrases) lost — and they lost for the same reasons weak agent comments lose: pure mechanism restatement, no why.
…nup, strip TypeSet bailout removed - Add CustomizeDiff hooks to DefaultRemoved and NestedTypeListBlock integration tests that record when the stripped field reaches PlanResourceChange's RawConfig — the load-bearing assertion, since post-Up stored inputs are shaped by Check and wouldn't discriminate the strip on their own - Remove unused sync/atomic import after staleSeen atomic.Int64 was replaced - Replace remaining for-k-v-range loop in Diff with maps.Copy (modernize hint) - Remove TypeSet bailout: stripping stale defaults from TypeSet elements causes a one-time element rearrangement on the next plan (TF parity for same schema change), which is preferable to retaining a value the schema can no longer attribute to a default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Strip stale provider defaults from inputs during Diff and Update to prevent validation errors when provider upgrades remove a default value or remove a field from the schema entirely.
Background
How Terraform state upgrades work
When a Terraform provider changes its resource schema, it bumps a
SchemaVersionnumber and registersStateUpgraders— functions that migrate old state representations to the new format. For example, terraform-provider-aws v6 setauth_token_update_strategy: "ROTATE"as a default on ElastiCache ReplicationGroup. In the v6→v7 upgrade, a state upgrader (v2→v3) was added to remove this field from state whenauth_tokenisn't configured.The Pulumi bridge invokes these state upgraders automatically via the Terraform SDK's gRPC
UpgradeResourceStatemethod. When the bridge loads old state from the Pulumi state store, it checks theschema_versionin__meta, and if it's lower than the current schema version, the upgraders run and clean up the state. This part works correctly.The gap: state vs inputs
In Pulumi, each resource has two stored representations:
__metacontainingschema_version__defaultslisting which values were injected by the provider rather than set by the userThe Terraform state upgrade mechanism only operates on outputs/state. There is no equivalent mechanism for inputs. When a provider changes or removes a default value, the state upgrader cleans it from the outputs, but the stale default persists in inputs via
__defaults.How this causes failures during refresh
During
pulumi up --refresh, the engine calls the provider'sDiffmethod as part of the refresh phase. In this call:olds= old outputs → the bridge creates the TF prior state from this, running state upgraders (which remove the stale field) ✓news= old inputs → the bridge creates the TF config from this, but no upgraders run on inputs ✗Normally,
refreshreuses the original provider version that created the resource, however when--run-programis added, problems arise. This is an engine bug tracked in pulumi/pulumi#19922.The stale default (e.g.
authTokenUpdateStrategy: "ROTATE") is still in the old inputs under__defaults. The bridge converts it into the TF config.PlanResourceChangethen runsCustomizeDiffvalidation, which checksdiff.GetRawConfig()(the config, not the state). The v7 provider's validation seesauth_token_update_strategyin the config withoutauth_tokenand rejects it:The
--run-programflag doesn't help because it only provides clean program inputs for the update diff — the refresh diff (which fires first) still uses old stored inputs.This is a widespread problem
Default changes happen routinely across all major Terraform providers, in both major and minor versions. A survey of the three largest bridged providers shows the scale:
Defaults removed — the original failure case, where a stale value reaches
PlanResourceChangeand fails validation:auth_token_update_strategyDefault: "ROTATE"removed in v6 (the original bug)port,protocolDefault: 53/Default: "Do53"removed, switched to Optional+Computed (#46928)max_healthy_percentageDefault: 100removed — API treats omitted vs 100 differently (#47188)data_persistence_authentication_methoddisk.type,disk.mode,disk.interfaceDefaults changed (e.g. Azure LB
skugoing Basic → Standard, GCPdisable_on_destroygoing true → false) — these silently preserve the stored old value across the upgrade. This PR intentionally does not change that behavior; see "Why these scope decisions" below for the rationale and the architectural follow-up that addresses it.Hardcoded → API-computed — providers increasingly replace
Default: XwithOptional+Computed, letting the API determine the value. When the field is removed from the schema (no Default and no schema entry), this PR strips the stale value, matching the field-removed case above.The current universe of defaults is large: Azure alone has 548 files with
Default:values, 197 files with StateUpgraders, and 167 files with CustomizeDiff validators. Most default changes happen without state upgraders — providers rely onPlanResourceChangeto handle it, which works for Terraform but breaks for Pulumi's stored__defaults.The fix
In the
DiffandUpdatemethods, before building the TF config fromnews, strip fields listed in__defaultsthat have neither a bridge-managed default nor a current TF schema default. Those values are genuinely stale — the schema no longer has any way to derive them — and would otherwise reachPlanResourceChangeand could trigger validation errors.The strip predicate is deliberately narrow:
SchemaInfo.Removed, TFRemoved, or TFDeprecated && !Required)applyDefaults' eligibility gate: if the field would not be defaulted by Check, it must not be forwarded to PlanResourceChange.The shared eligibility gate
defaultExcluded(sch, psi)lives inpkg/tfbridge/schema.goand is called fromapplyDefaults' overlay branch,applyDefaults' TF branch, andshouldStripStaleDefaultinprovider.go. Keeping the parity invariant in one place makes future schema-marker additions land in a single function.The strip recurses into nested objects and array elements — including TypeSet elements — since each nested block can carry its own
__defaultswith independently-stale entries. Stripping a stale field from a Set element does change the element's content hash, so the next plan surfaces a one-time membership rearrangement. That mirrors whatterraform applywould show against the same provider schema change and is preferable to silently retaining a value the current schema can no longer attribute to a default. For Set fields whose elements back identity-keyed cloud objects (security-group rules, IAM bindings, ALB listener rules), the rearrangement may translate to a delete-then-create at the API layer; this is a one-time effect that lands the stack in a stable state matching the v2 schema, after which subsequent plans show no further churn.Known limitations
Default. Users silently keep the old default until they explicitly set the new value in their program. The architectural follow-up in #3434 resolves this.Test plan
@pulumi/awsv6→v7 upgrade: built a custompulumi-resource-awswith this bridge change and ranpulumi up --refresh --run-programon a stack with an ElastiCache ReplicationGroup that hadauthTokenUpdateStrategy: "ROTATE"in__defaults— the error is gonepkg/tfbridge/strip_stale_defaults_test.gocovering: top-level strip/preserve classification (TF Default present/absent/removed/deprecated, bridge Default withValue/EnvVars/From/ComputeDefault, the parity carve-outs for Required-and-Deprecated and bridgeRemovedshadowing TF Default); recursion through nested objects, TypeList-of-blocks, MaxItemsOne blocks, secret-wrapped objects/arrays/scalars including fully-secret-wrapped Set arrays; TypeSet element stripping (both array-shaped and MaxItemsOne-flattened); deep nesting (object → array → object); top-level and nested simultaneous strip; non-mutation of the original map;__defaultslisting a key absent from news; non-string__defaultsentriespkg/tests/strip_stale_defaults_integration_test.goexercising the runtime SDKv2 paths throughpulcheck: default-removed, field-removed-from-schema, nested TypeListBlock, TypeListOfBlocks, BridgeDefaultPreserved, stale-default-in-Set-element lifecycle (verifies Up succeeds, the strip lands in stable state, and a follow-up preview reports no further churn), TypeSet hash stability for the still-declared-default case, and a regression guard for strip symmetry across the Diff and Update RPCsTestRegress1020andTestRegressAws2352to includeSchemaInfo.Defaultfor auto-named fields, matching real provider behaviorTestUpdatePreservesLegacyFalsyTFDefaults(the PR Preserve required falsy default config values #3420 invariant) continues to pass — the strip predicate preserves any field whose schema declares a Default, so legacy-stack falsy-default round-trip semantics are unaffectedAlternatives considered
Run state upgraders on inputs too — Conceptually the most "correct" fix: if inputs had the same upgrade pipeline as outputs, the problem vanishes. But state upgraders expect the full output shape (all computed fields, IDs, etc.) and inputs are a subset. Feeding inputs through
UpgradeResourceStatewould fail on missing fields or corrupt data. A separate "input upgrader" mechanism doesn't exist in Terraform and would be a bridge-only invention.Engine fix: don't pass old inputs as
newsduring refresh diff — The most architecturally clean fix. The refresh diff currently sendsnews = old stored inputs, but it could use program inputs (from--run-program) or skip calling the provider'sDiffentirely for the refresh comparison (compute it at the engine level by comparing property maps). This fixes the root cause — stale inputs never reach the provider. But it requires Pulumi engine changes (not bridge). The engine bug is tracked by pulumi/pulumi#19922. However, even if we fix the issue there, we probably still want this default cleanup function as the first piece of #3434.Store
schema_versionon inputs (parallel to__metaon outputs) — If inputs tracked their schema version, the bridge could detect stale inputs and run cleanup. But this requires a new metadata field with state migration for all existing stacks, and there's still no upgrader to run on inputs.This PR: strip stale defaults in Diff and Update — Targeted, backward-compatible, bridge-only. Uses metadata that already exists (
__defaults) to identify provider-injected values and strips them soPlanResourceChangecan re-determine them with the current schema. This fix would still be useful as defense-in-depth even if the engine fix lands later.Why these scope decisions
Why apply the strip in both Diff and Update? Diff is the user-visible failure path: the engine's refresh phase calls Diff with stored inputs as
news, and that is where stale values reach the provider unfiltered. The strip is also applied in Update for symmetry — both RPCs feedPlanResourceChange, both receivenewsfrom Pulumi's engine, and both should see the same shape. In current code Check sanitizes news upstream of Update, so the Update-path strip is defense-in-depth for paths where Check is bypassed (custom state-edit hooks, future RPC additions, refresh flows that don't pass through Check).Why preserve fields whose Default just changed (v1 → v2)? The strip predicate intentionally treats "schema has any current Default or DefaultFunc" as preserve, even when the value changed across versions. Two reasons:
RawConfigascty.False/0/""rather than null — providers readRawConfigpresence as meaningful. A broader strip would regress this.news,applyDefaults's overlay branch readsolds[__defaults](not news) and re-injects the stored old value via the "old default" reuse path. So a strip in this case would be silently undone for any non-emptySchemaInfo.Defaultoverlay. The proper fix is to stop tracking TF schema defaults in__defaultsat all — the architectural follow-up below.A user who wants to migrate to a new default value can set it explicitly in their program; Check will then record it as user-set rather than provider-defaulted.
Why not run state upgraders on inputs? Upgraders expect the full output shape (computed fields, IDs) and would corrupt the input subset. No Terraform-side mechanism does what we'd need.
Architectural follow-up (not in this PR)
The cleanup that fully resolves the changed-default case:
applyDefaultsshould stop tracking TF schema defaults in__defaultsat all. Then Check would re-derive TF defaults from the current schema each call, the "old default" reuse path would only fire for bridge-managed defaults (auto-naming, EnvVars, From), and a changed default would simply produce the new value innews— no stripping needed at runtime, no phantom diff. The strip in this PR remains as the migration path for legacy stacks with already-stored stale entries.PR #3398 took the first step (suppressing fresh falsy TF defaults from being materialized) but explicitly held off on the broader change pending evidence that it is safe across the full SDKv2 lifecycle (validation interactions,
GetRawConfigsemantics, provider Configure withRequired + DefaultFunc, data source Invoke). Tracked as #3434 and referenced by aTODOnearstripStaleDefaults.