Skip to content

fix: add UseStateForUnknown plan modifiers to prevent phantom diffs#83

Open
adamtaylor13 wants to merge 5 commits into
render-oss:mainfrom
pilot-west-studios:main
Open

fix: add UseStateForUnknown plan modifiers to prevent phantom diffs#83
adamtaylor13 wants to merge 5 commits into
render-oss:mainfrom
pilot-west-studios:main

Conversation

@adamtaylor13

@adamtaylor13 adamtaylor13 commented Apr 24, 2026

Copy link
Copy Markdown

Summary

Adds UseStateForUnknown() plan modifiers to computed attributes that were causing phantom diffs on every terraform plan.

The Problem

Before this fix, every terraform plan would show changes like this, even when nothing actually changed:

# render_web_service.my_app will be updated in-place
~ resource "render_web_service" "my_app" {
    ~ env_vars = {
        ~ "DATABASE_URL" = {
            ~ value = (sensitive value)
          }
      }
    ~ notification_override = {
        ~ notifications_to_send         = "default" -> (known after apply)
        ~ preview_notifications_enabled = "default" -> (known after apply)
      }
    + num_instances                 = (known after apply)
    + max_shutdown_delay_seconds    = (known after apply)
    ~ previews = {
        ~ generation = "off" -> (known after apply)
      }
    + root_directory                = (known after apply)
    ~ slug                          = "my-app" -> (known after apply)
  }

The root cause: connection_info on postgres resources lacked plan modifiers, so Terraform marked it as unknown on every plan. Any resource interpolating connection_info.internal_connection_string into an env var would inherit this "unknown" status.

After This Fix

No changes. Your infrastructure matches the configuration.

Changes

postgres/resource/schema.go - Add plan modifiers to connection_info:

"connection_info": schema.SingleNestedAttribute{
    Computed:  true,
    Sensitive: true,
    PlanModifiers: []planmodifier.Object{
        objectplanmodifier.UseStateForUnknown(),
    },
    Attributes: map[string]schema.Attribute{
        "internal_connection_string": schema.StringAttribute{
            Computed:  true,
            Sensitive: true,
            PlanModifiers: []planmodifier.String{
                stringplanmodifier.UseStateForUnknown(),
            },
        },
        // ... same for other nested attributes
    },
},

types/resource/services.go - Add plan modifiers to service attributes:

var NumInstances = schema.Int64Attribute{
    Optional: true,
    Computed: true,
    PlanModifiers: []planmodifier.Int64{
        int64planmodifier.UseStateForUnknown(),
    },
}

var Slug = schema.StringAttribute{
    Computed: true,
    PlanModifiers: []planmodifier.String{
        stringplanmodifier.UseStateForUnknown(),
    },
}

types/resource/logstreamoverride.go and notificationoverride.go - Convert from datasource/schema to resource/schema to support plan modifiers:

// Before: used datasource/schema (no plan modifier support)
import "github.com/hashicorp/terraform-plugin-framework/datasource/schema"

// After: uses resource/schema with plan modifiers
import "github.com/hashicorp/terraform-plugin-framework/resource/schema"

var LogStreamOverride = schema.SingleNestedAttribute{
    Optional: true,
    Computed: true,
    PlanModifiers: []planmodifier.Object{
        objectplanmodifier.UseStateForUnknown(),
    },
    // ...
}

Testing

  • Built and installed provider locally with go install
  • Configured ~/.terraformrc with dev_overrides
  • Ran terraform plan against existing infrastructure with postgres + web services + background workers
  • Confirmed phantom diffs are resolved

Related Issues

@adamtaylor13

adamtaylor13 commented Apr 24, 2026

Copy link
Copy Markdown
Author

This is my first stab at contributing to OSS mostly through AI. I know this is a hotly debated subject so I want to be very upfront—Opus 4.7 was driving, but I tested these changes on my own infrastructure after building the changes locally. I did confirm it worked as I expected it to.

EDIT: The consquence of the above statement is that I don't fully understand the implications of some of these changes, such as swapping the log stream from datasource/schema to resource/schema. So please be aware—I verified the behavior appears correct to me, but I can't actually tell if it's correct based on my limited understanding of how terraform providers work under the hood.

@adamtaylor13

Copy link
Copy Markdown
Author

Note to self, if I want to pull up the previous convo w/ Claude:

claude --resume d2577a1f-a07e-4807-8657-0124dca9eee3

@adamtaylor13

adamtaylor13 commented May 13, 2026

Copy link
Copy Markdown
Author

Do I need to fix the tests in CI to proceed here?

@adamtaylor13

Copy link
Copy Markdown
Author

@jakemalachowski I looked at the latest failing test and from what I can tell it's not related to the actual code, it appears to be related to the terraform CLI missing..? Do I need to do anything here?

@tuckerchapin

Copy link
Copy Markdown
Contributor

Do I need to fix the tests in CI to proceed here?

@adamtaylor13 Apologies for that, the CI tests are fixed now (#85). If you incorporate the latest you should be good to go.

Add UseStateForUnknown() plan modifiers to computed attributes that were
causing phantom diffs on every terraform plan. This fixes the issue where
attributes like connection_info, num_instances, max_shutdown_delay_seconds,
and others would show as changing even when no actual changes occurred.

Changes:
- postgres: connection_info and nested attributes
- postgres: disk_size_gb
- services: slug, num_instances, max_shutdown_delay_seconds, root_directory
- services: previews, KeyValueConnectionInfo, RedisConnectionInfo
- logstreamoverride: convert to resource/schema, add plan modifiers
- notificationoverride: convert to resource/schema, add plan modifiers

Fixes render-oss#59, render-oss#60, and related phantom diff issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@tuckerchapin tuckerchapin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for opening this and your patience in getting it reviewed. It looks like when this was forked, our CI was borked. That's now fixed. If you could pull in that fix, take a look at the feedback, and confirm that the cassette tests are passing/updated accordingly, that would be great!

  • there are a few places where I think we'd prefer a Default over relying on unknowns from state
  • there's also logstreamoverride and notificationoverride where the UseStateForUknown will likely break the removal semantics, so we should be intentional about doing so and I don't think doing so at the top/parent level is wise

Comment on lines 177 to 187
var NumInstances = schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "Number of replicas of the service to run. Defaults to 1 on service creation and current instance count on update. If you want to manage the service's instance count outside Terraform, leave num_instances unset.",
Validators: []validator.Int64{
int64validator.Between(1, 100),
},
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you want to manage the service's instance count outside Terraform, leave num_instances unset.

Because of the intent in this description, I don't think this is the right move here. Or at least worth calling out. With UseStateForUnknown I believe users would have to tf state rm, rather than just "...leave num_instances_unset".

If this is the aim, then we should update the description accordingly.

@adamtaylor13 adamtaylor13 May 21, 2026

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.

@tuckerchapin My understanding is that the biggest edge-case here is if someone has already set this value, and then wishes to remove it later, can you confirm? In that case, would be it appropriate to simply update the description to explain that "handing control back to Render" involves removing the local state entry for this field? I shipped an update to the description in 1198b46. Let me know if that addresses the concern here.

EDIT: Oops... I think that commit included some other changes. Standby...

EDIT 2: Commit reference should be correct now.

Comment thread internal/provider/types/resource/logstreamoverride.go Outdated
Comment thread internal/provider/types/resource/services.go
adamtaylor13 and others added 3 commits May 21, 2026 11:51
Drop parent-level UseStateForUnknown on log_stream_override. With it,
removing the block from config preserved the prior value in state, so
the provider issued a PUT instead of clearing the override — breaking
both the documented behavior and the cassette tests for background
workers, web services, cron jobs, redis, keyvalue, postgres, and
private services.

The inner endpoint UseStateForUnknown is kept so users who set
log_stream_override without specifying endpoint do not see a phantom
diff on subsequent plans.

Addresses inline review comment on logstreamoverride.go:43.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With UseStateForUnknown, removing root_directory from config left the
prior value in the plan but the API returned an empty string, producing
"Provider produced inconsistent result after apply" for the static_site
resource.

root_directory is Optional+Computed; dropping the modifier returns to
the pre-PR behavior where plan transitions to unknown when removed from
config, matching what the API actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UseStateForUnknown on num_instances means simply unsetting the field in
config no longer hands control back to Render — the last-known value
stays in state. Update the description (and regenerated docs) to spell
out that users must `terraform state rm` the attribute to release it
back to Render's management.

Addresses inline review comment on services.go:187.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Render API rejects PATCH requests that include
max_shutdown_delay_seconds for services with a disk attached
(see render community thread #36752), but the same API still
defaults the value to 30 on creation for those services. With
UseStateForUnknown, the provider preserves that 30 in plan and
re-sends it on every PATCH — which the API then rejects on any
update to a disk-backed service.

Drop the modifier and accept the phantom diff. The cleaner fix
(detect disk-backed services in the request builder, or add a
plan-time validator rejecting both fields together) is tracked
separately and out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <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.

num_instances and digest cause plan noise

2 participants