Boolean optional default value migration to Framework

We are migrating our resources from SDKv2 to plugin Framework. One issue I’m running into is default values for existing resources.

In SDKv2 resource, we have below attribute in our schema:

"attr_settings": {
				Type:     schema.TypeBool,
				Optional: true,
				Default:  true,
			},

to migrate this to Framework schema, I have updated it as below:

"attr_settings": schema.BoolAttribute{
				Optional: true,
				Computed: true,
				Default:  booldefault.StaticBool(true),
			},

Issue:
While the attribute is correctly defaulted to true if no value is provided, problem is if I terraform plan on an existing resource that was created with SDKv2 (.tfstate file shows "attr_settings": true), ‘attr_settings’ is always shown as being added in the plan causing an in-place update:

  # rs_abc.main will be updated in-place
  ~ resource "rs_abc" "main" {
        id                                               = "345354345345"
        name                                             = "name-abc"
      + attr_settings                     = true
        # (9 unchanged attributes hidden)
    }

Expectation is that with the migrated resource, no changes should be detected which is the case with SDKv2 implementation.

I have also tried adding a plan modifier to set the default value but results in the same behavior.

How can we set this default value if the user has not provided it in the config but also ensure no changes are detected if this is already set in the state?

Hi @maastha :wave:

I’ve just tried to reproduce the issue you describe but I’m unable to do so with the following code and configuration.

main.go

func main() {
	ctx := context.Background()

	var debug bool

	flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve")
	flag.Parse()

	providers := []func() tfprotov5.ProviderServer{
		providerserver.NewProtocol5(provider.New()), // Example terraform-plugin-framework provider
		provider_sdk.Provider().GRPCProvider,        // Example terraform-plugin-sdk provider
	}

	muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...)

	if err != nil {
		log.Fatal(err)
	}

	var serveOpts []tf5server.ServeOpt

	if debug {
		serveOpts = append(serveOpts, tf5server.WithManagedDebug())
	}

	err = tf5server.Serve(
		"registry.terraform.io/bendbennett/playground",
		muxServer.ProviderServer,
		serveOpts...,
	)

	if err != nil {
		log.Fatal(err)
	}
}

provider_sdk/provider.go

func Provider() *schema.Provider {
	return &schema.Provider{
		ResourcesMap: map[string]*schema.Resource{
			"playground_example": exampleSdkResource(),
		},
	}
}

provider_sdk/example_sdk_resource.go

func exampleSdkResource() *schema.Resource {
	return &schema.Resource{
		CreateContext: create,
		ReadContext:   read,
		UpdateContext: update,
		DeleteContext: schema.NoopContext,

		Importer: &schema.ResourceImporter{
			StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) {
				return nil, nil
			},
		},

		Schema: map[string]*schema.Schema{
			"attr_settings": {
				Type:     schema.TypeBool,
				Optional: true,
				Default:  true,
			},
			"id": {
				Computed: true,
				Type:     schema.TypeString,
			},
		},
	}
}

func create(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	d.SetId("example-id")

	return nil
}

func read(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	return nil
}

func update(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	return nil
}

Using the following configuration and executing terraform apply produces the following CLI output and state file:

terraform {
  required_providers {
    playground = {
      source = "bendbennett/playground"
    }
  }
}

resource "playground_example" "example" {
}
Terraform will perform the following actions:

  # playground_example.example will be created
  + resource "playground_example" "example" {
      + attr_settings = true
      + id            = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.
  "resources": [
    {
      "mode": "managed",
      "type": "playground_example",
      "name": "example",
      "provider": "provider[\"registry.terraform.io/bendbennett/playground\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "attr_settings": true,
            "id": "example-id"
          },
          "sensitive_attributes": [],
          "private": "bnVsbA=="
        }
      ]
    }
  ],

Switching to using a framework-based implementation of the provider and resource.

provider/provider.go

var _ provider.Provider = &playgroundProvider{}

type playgroundProvider struct{}

func (p *playgroundProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
	resp.TypeName = "playground"
}

func (p *playgroundProvider) Schema(context.Context, provider.SchemaRequest, *provider.SchemaResponse) {
}

func (p *playgroundProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
}

func (p *playgroundProvider) Resources(ctx context.Context) []func() resource.Resource {
	return []func() resource.Resource{
		NewResource,
	}
}

func (p *playgroundProvider) DataSources(context.Context) []func() datasource.DataSource {
	return []func() datasource.DataSource{
		NewDatasource,
	}
}

func New() provider.Provider {
	return &playgroundProvider{}
}

provider/example_resource.go

var _ resource.Resource = (*exampleResource)(nil)
var _ resource.ResourceWithImportState = (*exampleResource)(nil)

func (r *exampleResource) Schema(ctx context.Context, request resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"attr_settings": schema.BoolAttribute{
				Optional: true,
				Computed: true,
				Default:  booldefault.StaticBool(true),
			},
			"id": schema.StringAttribute{
				Computed: true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
		},
	}
}

func NewResource() resource.Resource {
	return &exampleResource{}
}

type exampleResourceData struct {
	AttrSettings types.Bool   `tfsdk:"attr_settings"`
	Id           types.String `tfsdk:"id"`
}

type exampleResource struct {
	provider playgroundProvider
}

func (r *exampleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
	resp.TypeName = req.ProviderTypeName + "_example"
}

func (r *exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	data.Id = types.StringValue("example-id")

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

func (r *exampleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
	var data exampleResourceData

	diags := req.State.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

func (r *exampleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

func (r *exampleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
	var data exampleResourceData

	diags := req.State.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}
}

func (r *exampleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
	resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

Using the same configuration as described above and executing terraform apply produces the following CLI output and state file:

No changes. Your infrastructure matches the configuration.
  "resources": [
    {
      "mode": "managed",
      "type": "playground_example",
      "name": "example",
      "provider": "provider[\"registry.terraform.io/bendbennett/playground\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "attr_settings": true,
            "id": "example-id"
          },
          "sensitive_attributes": []
        }
      ]
    }
  ],

Could there perhaps be something missing from the code that you supplied which is causing the in-place update that you describe?

Thank you @bendbennett for all the efforts on this!

What I think is happening is that this attribute is correctly set to default “true” if no value provided BUT when plugin performs Read() during plan at that time we set the state before returning - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)

So this is the order of events:

  1. run terraform plan
  2. resource Read() is invoked:
func (r *exampleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
	var data exampleResourceData

	diags := req.State.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}


       data = getObjectModelFromAPI(id)      // get new object

	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}
  1. When data = getObjectFromAPI(id) is invoked in Read(), getObjectFromAPI() does not return a value for attr_settings which is overwritten to null in the state.

Are you able to confirm this? This would mean I’d need to manually make sure defaulted values are not overwritten during Read()?

Correct, if the API doesn’t return the value for attr_settings and you want to preserve its prior state, you need to do that in the Read logic.

I think you may find that you will be required to tolerate a one-time in-place update doing things like

at the time of your SDKv2 to Framework transition. (Well, unless you first change your SDKv2 provider to do these updates, before the move to Framework.)

The reason is you seem to be exploiting the escape hatch granted to the legacy SDK, which allows for setting state to values other than the planned value - an escape hatch which is not granted to Framework-based providers. (SDK is allowed to break the rules as a hack to allow historic code to work.)