Skip to content

Strip stale provider defaults from inputs during Diff - #3405

Open
kmosher wants to merge 13 commits into
mainfrom
kmosher/strip-stale-defaults-in-diff
Open

Strip stale provider defaults from inputs during Diff#3405
kmosher wants to merge 13 commits into
mainfrom
kmosher/strip-stale-defaults-in-diff

Conversation

@kmosher

@kmosher kmosher commented Apr 10, 2026

Copy link
Copy Markdown

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 SchemaVersion number and registers StateUpgraders — functions that migrate old state representations to the new format. For example, terraform-provider-aws v6 set auth_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 when auth_token isn't configured.

The Pulumi bridge invokes these state upgraders automatically via the Terraform SDK's gRPC UpgradeResourceState method. When the bridge loads old state from the Pulumi state store, it checks the schema_version in __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:

  • Outputs (state): the last known cloud state, stored with __meta containing schema_version
  • Inputs: the last known desired configuration, stored with __defaults listing which values were injected by the provider rather than set by the user

The 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's Diff method 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, refresh reuses the original provider version that created the resource, however when --run-program is 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. 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:

"auth_token_update_strategy": "auth_token" must be specified

The --run-program flag 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 PlanResourceChange and fails validation:

Provider Resource Field Change
AWS ElastiCache ReplicationGroup auth_token_update_strategy Default: "ROTATE" removed in v6 (the original bug)
AWS Route53 Resolver Rule port, protocol Default: 53 / Default: "Do53" removed, switched to Optional+Computed (#46928)
AWS ASG instance_refresh max_healthy_percentage Default: 100 removed — API treats omitted vs 100 differently (#47188)
Azure Redis Cache data_persistence_authentication_method Default removed, was wrong for Basic/Standard SKUs (#27069)
GCP Compute Instance Template disk.type, disk.mode, disk.interface Hardcoded defaults removed, API handles them (#24055)

Defaults changed (e.g. Azure LB sku going Basic → Standard, GCP disable_on_destroy going 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: X with Optional+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 on PlanResourceChange to handle it, which works for Terraform but breaks for Pulumi's stored __defaults.

The fix

In the Diff and Update methods, before building the TF config from news, strip fields listed in __defaults that 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 reach PlanResourceChange and could trigger validation errors.

The strip predicate is deliberately narrow:

Field's __defaults entry has... Action Why
Bridge default (auto-naming, EnvVars, From) preserve Bridge owns the default; Check re-derives it. Stripping would break auto-naming stability.
Current TF schema Default (changed or unchanged) preserve Avoids the changed-default phantom diff (see below) and maintains the legacy-stack falsy round-trip from PR #3420.
Removed/Deprecated marker on the field (SchemaInfo.Removed, TF Removed, or TF Deprecated && !Required) strip Mirrors applyDefaults' eligibility gate: if the field would not be defaulted by Check, it must not be forwarded to PlanResourceChange.
No bridge default AND no current TF Default strip The recorded value is genuinely stale — the field's Default was removed, or the field itself was removed from the schema. This is the case that fails validation today.

The shared eligibility gate defaultExcluded(sch, psi) lives in pkg/tfbridge/schema.go and is called from applyDefaults' overlay branch, applyDefaults' TF branch, and shouldStripStaleDefault in provider.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 __defaults with 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 what terraform apply would 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

  • Changed TF schema default (v1 → v2): the field is preserved with its stale v1 value because the schema still declares a 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

  • Verified end-to-end against a real @pulumi/aws v6→v7 upgrade: built a custom pulumi-resource-aws with this bridge change and ran pulumi up --refresh --run-program on a stack with an ElastiCache ReplicationGroup that had authTokenUpdateStrategy: "ROTATE" in __defaults — the error is gone
  • Unit tests in pkg/tfbridge/strip_stale_defaults_test.go covering: top-level strip/preserve classification (TF Default present/absent/removed/deprecated, bridge Default with Value/EnvVars/From/ComputeDefault, the parity carve-outs for Required-and-Deprecated and bridge Removed shadowing 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; __defaults listing a key absent from news; non-string __defaults entries
  • Integration tests in pkg/tests/strip_stale_defaults_integration_test.go exercising the runtime SDKv2 paths through pulcheck: 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 RPCs
  • Updated TestRegress1020 and TestRegressAws2352 to include SchemaInfo.Default for auto-named fields, matching real provider behavior
  • Verified TestUpdatePreservesLegacyFalsyTFDefaults (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 unaffected

Alternatives 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 UpgradeResourceState would 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 news during refresh diff — The most architecturally clean fix. The refresh diff currently sends news = old stored inputs, but it could use program inputs (from --run-program) or skip calling the provider's Diff entirely 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_version on inputs (parallel to __meta on 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 so PlanResourceChange can 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 feed PlanResourceChange, both receive news from 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:

  1. The legacy-stack falsy round-trip invariant from PR #3420. That invariant relies on stored falsy values for fields whose schema has a matching Default reaching RawConfig as cty.False/0/"" rather than null — providers read RawConfig presence as meaningful. A broader strip would regress this.
  2. The deeper "old default" reuse path. Even if the strip removed a changed-default field from news, applyDefaults's overlay branch reads olds[__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-empty SchemaInfo.Default overlay. The proper fix is to stop tracking TF schema defaults in __defaults at 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: applyDefaults should stop tracking TF schema defaults in __defaults at 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 in news — 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, GetRawConfig semantics, provider Configure with Required + DefaultFunc, data source Invoke). Tracked as #3434 and referenced by a TODO near stripStaleDefaults.

@kmosher
kmosher requested a review from pgavlin April 10, 2026 19:15
@codecov

codecov Bot commented Apr 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.15%. Comparing base (cb30acd) to head (952b215).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
pkg/tfbridge/provider.go 96.46% 2 Missing and 2 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch from a4c7bbd to 3a05e53 Compare April 10, 2026 19:17

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • stripStaleDefaults has 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 every Diff hot path. See inline comment on the function declaration for a suggested test matrix.

Non-blocking observations (inline comments posted)

  • tfSchema == nil edge 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 Check if 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

Comment thread pkg/tfbridge/provider.go Outdated
Comment thread pkg/tfbridge/provider.go
Comment thread pkg/tfbridge/provider.go Outdated
Comment thread pkg/tfbridge/provider.go Outdated
@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch 6 times, most recently from c32c6f6 to 32f4685 Compare April 10, 2026 20:37
@kmosher
kmosher requested a review from a team April 10, 2026 20:54
@kmosher
kmosher marked this pull request as ready for review April 10, 2026 20:55
@guineveresaenger

Copy link
Copy Markdown
Contributor

I think #3398 may be related.

@guineveresaenger

Copy link
Copy Markdown
Contributor

/review-again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — the tfSchema == nil guard is a safe bail-out since MakeTerraformConfig silently 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 MakeTerraformConfig and before NewInputs is passed to p.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

Comment thread pkg/tfbridge/provider.go Outdated
Comment thread pkg/tests/regress_aws_2352_test.go
@kmosher
kmosher requested a review from corymhall April 14, 2026 20:20

@pgavlin pgavlin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/tfbridge/provider.go
// 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if this needs to recur on property values. I think default application is recursive.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on the recursion. Added that and tests.

@kmosher

kmosher commented Apr 25, 2026

Copy link
Copy Markdown
Author

Review findings and analysis

Ran this through several code reviewers (Claude code-reviewer agent, Codex codex review, Gemini CLI). Sharing the findings and analysis.

Addressed in code

  • Secret-wrapped nested objectsstripStaleDefaultsValue now unwraps secrets, recurses, and re-wraps. Stale defaults in sensitive blocks are correctly stripped.
  • TypeMap-of-objects — Added handling for TypeMap with Elem=Resource. Map values (keyed by arbitrary user keys) are now recursed into individually.
  • Performance (scalar skip) — Added early exit for non-Object/non-Array values before the getInfoFromPulumiName lookup, avoiding the O(n) schema scan for every scalar field.
  • Deep nesting — Added test for object → array → object (2 levels). The existing recursive design already handles arbitrary depth correctly.

TypeSet hash identity

The concern

When stripStaleDefaults removes a defaulted field from a TypeSet element, the element's hash could change. TypeSet uses hash-based identity to match elements between prior state and proposed config — if the hashes differ, TF sees the old element as "deleted" and the new one as "added" rather than recognizing them as the same element being modified. This could trigger spurious force-replaces on resources with TypeSet blocks.

For example, a TypeSet element {type: "A", protocol: "tcp"} where protocol was provider-defaulted. After stripping, the config has {type: "A"}. If the hash is computed on the stripped config, it won't match the prior state hash of {type: "A", protocol: "tcp"}, and TF would treat this as a different element entirely.

Analysis

This is a false alarm. In the TF SDK v2 diff flow, schemaMap.Diff() applies defaults to config fields before set element hashes are computed. The sequence is:

  1. Raw config received (with protocol absent)
  2. SDK applies Default: "tcp" to fill in the missing field
  3. Resolved config is {type: "A", protocol: "tcp"}
  4. Hash is computed on the resolved config → matches prior state hash

So {type: "A"} with protocol stripped becomes {type: "A", protocol: "tcp"} after default application — producing the same hash as prior state. No spurious delete+add. If TF hashed raw config before applying defaults, it would also break native Terraform (where users routinely omit defaulted fields), so this ordering is well-established.


DefaultFunc re-derivation

The concern

Fields populated via DefaultFunc derive their values from runtime context — provider configuration, environment variables, or computed state. Common examples include GCP's project/region/zone fields, which default to the provider-level configuration via DefaultFunc.

If such a field is in __defaults and we strip it, PlanResourceChange must re-derive the value by calling DefaultFunc again. Two failure modes:

  1. Environment changed: If the user had GOOGLE_PROJECT=foo when they originally deployed but now has GOOGLE_PROJECT=bar (or unset), stripping the stored project: "foo" would cause PlanResourceChange to derive a different value (or fail entirely). The old behavior (keeping the stored value) provided stability — the resource kept its original project regardless of env changes.

  2. DefaultFunc context differs between Check and Plan: The bridge calls DefaultFunc during Check (when applying defaults) and TF calls it again during PlanResourceChange. If DefaultFunc behaves differently in these two contexts (different available state, different provider config), the re-derived value might not match what was originally stored.

Analysis

Not 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 PlanResourceChange.

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 __defaults), so the resource should track the current provider configuration rather than preserving a stale value from a previous environment.

The narrow failure case (env var removed but provider still initializes and DefaultFunc returns nil) is extremely unlikely in practice. And even if it did happen, the resulting error ("project is required") would correctly tell the user their configuration is incomplete — better than silently preserving a stale project from a defunct environment.


DetailedDiff coarseness for TypeSet

The concern

stripStaleDefaults modifies news before it's passed both to MakeTerraformConfig (for TF) and as NewInputs in shim.DiffOptions (for Pulumi's detailed diff computation). The detailed diff logic in makeDetailedDiffV2 uses validInputsFromPlan to correlate planned state elements with user inputs. This matching assumes "inputs will have defaults already applied" — it compares planned state values against input values to identify which set elements correspond to each other.

If we strip defaults from inputs but TF re-applies them in the planned state, there's a mismatch: the planned state has {type: "A", protocol: "tcp"} but inputs only have {type: "A"}. The matching logic may fail to correlate these, and the bridge falls back to reporting a coarse "Update" or "UpdateReplace" for the entire set rather than showing which specific element changed.

This affects the granularity of pulumi preview output — instead of seeing per-element diffs, users would see a single "~update" for the whole set block.

Analysis

Real but low-severity and cosmetic. The impact is:

  • Only affects TypeSet resourcesvalidInputsFromPlan is specifically for matching set elements. Top-level scalar fields and TypeList fields use positional matching and are unaffected.
  • Only when a stripped default participates in set matching — If the defaulted field isn't part of what validInputsFromPlan uses to correlate elements, there's no issue.
  • The diff is less granular, not incorrect — The resource still shows as needing an update; the user just sees "~update set block" instead of "~update set element[0].field". No incorrect replaces or missed changes.

If this turns out to be noisy in practice, we could maintain separate property maps: stripped inputs for MakeTerraformConfig (the TF config path) and original inputs for NewInputs (the Pulumi detailed diff path). But this seems premature without reports of the issue, and the fix is straightforward if needed.

@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch 10 times, most recently from 1acf88e to 174d674 Compare May 6, 2026 22:51
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.
@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch from 174d674 to ef0be18 Compare May 7, 2026 00:15
@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch from ef0be18 to cf6fc5f Compare May 7, 2026 00:27
@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch 3 times, most recently from 2c3c2bf to 2293435 Compare May 7, 2026 14:26

@corymhall corymhall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few non-blocking review comments around default detection and test coverage.

Comment thread pkg/tfbridge/provider.go Outdated
continue
}
if tfSchema != nil {
if dv, _ := tfSchema.DefaultValue(); dv != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/tfbridge/provider.go Outdated
Comment thread pkg/tfbridge/provider.go Outdated
}

if v.IsObject() {
if tfSchema.Type() == shim.TypeMap {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch 3 times, most recently from 2ebf32f to 7174512 Compare May 7, 2026 17:01
… 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>
@kmosher
kmosher force-pushed the kmosher/strip-stale-defaults-in-diff branch from 7174512 to dad9fbd Compare May 7, 2026 17:05
kmosher and others added 9 commits May 7, 2026 14:34
- 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>
kmosher and others added 2 commits May 8, 2026 15:40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants