Unable to handle empty list attribute in resource

I am creating my very first provider using the plugin framework tutorial for hashicups. This is also my very first attempt at Go, so bear with me :slight_smile:

One of the resources has an optional tags property which is a list of strings, defined in the model as:

Tags     types.List   `tfsdk:"tags"`

And in the schema as:

"tags": schema.ListAttribute{
	Description: "List of tags for this client",
	ElementType: types.StringType,
	Optional:    true,
},

I am having an issue when the resource wants to supply an empty list for the attribute:

resource "my_client" "test" {
  name = "Test Client"
  tags = []
}

Whenever this is supplied, I get the following error:

my_client.test: Creating...
â•·
│ Error: Provider produced inconsistent result after apply
│ 
│ When applying changes to my_client.test, provider "provider produced an unexpected new value: .tags: was cty.ListValEmpty(cty.String), but now null.

I am trying to handle this scenario, and having some issues.

This is the current logic on Create, while evaluating what’s in the plan. I only do something with it when there are elements:

if len(plan.Tags.Elements()) > 0 {
	diags = plan.Tags.ElementsAs(ctx, &clientPlan.Tags, false)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}
}

From what I can tell, when tags is not supplied (or supplied as tags = null), the result in the plan has state: ValueStateNull (0) = 0x0:

While when tags = [] is supplied, the result in the plan has state: ValueStateKnown (2) = 0x0, despite neither having elements:

I’ve attempted recreating plan.Tags when there are no elements by updating the logic on Create:

if len(plan.Tags.Elements()) > 0 {
	diags = plan.Tags.ElementsAs(ctx, &clientPlan.Tags, false)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}
} else if !plan.Tags.IsNull() {
	// normalize back to a null list in case an empty array was passed
	plan.Tags = types.ListNull(types.StringType)
}

I can confirm plan.Tags has state: ValueStateNull (0) = 0x0 after this change, but then the test fails with:

error: Error running apply: exit status 1

What’s more interesting to me is I am unable to catch this in the debugger. I can step through all the way to the end of Create, but after it exits that function, the code never reaches Read, it simply exits with that error (no idea why, I blame my lack of experience with Go).

I appreciate any assistance in getting this sorted out. Thanks!

1 Like

The error indicates that the plan value for this attribute (what you got from req.Plan.Get()) doesn’t match what you offered to the state with resp.State.Set().

The attribute wasn’t marked with Computed: true in your schema, so these values should match.

The approach I take in Create() is:

  • read into plan struct with resp.Plan.Get()
  • set plan elements which are marked Computed: true (do not modify non-computed elements)
  • use the same plan object in resp.State.Set() - ultimately the state should match the plan, so, in my opinion, there’s no need to instantiate a second state object in Create(). Just re-use the object you got from req.Plan.Get()

Don’t modify non-Computed elements of the plan.

As an aside…

Currently your schema allows users to use both of these configurations:

resource "my_client" "test" {
  name = "Test Client"
  tags = [] // empty list
}

and

resource "my_client" "test" {
  name = "Test Client"
  // tags = [] // null list
}

This might complicate your life when writing the resource Read() function if you’re not able to make a meaningful distinction between “empty” and “null” based only the API’s reply. If you disallow users specifying an empty tag list you can set the tags null in Read() without having to worry about whether the user gave you null or empty input:

"tags": schema.ListAttribute{
	Description: "List of tags for this client",
	ElementType: types.StringType,
	Optional:    true,
        Validators: []validator.List{listvalidator.SizeAtLeast(1)},
},

One more thing (wild speculation): Are tags an ordered list according to the API you’re talking to? This might be better as a Set than List.

1 Like

Thanks for your reply, very helpful.

I think I am going the validator path, but I am also considering your other suggestions, especially the Computed one. Re-using the plan for state and only updating what’s needed also seems to be a smart idea.

Regarding doing a List or a Set, yes, there should be only distinct values, so a Set makes more sense.

Lastly, just to satisfy my curiosity as I am very new to Go: if I wanted the state to have an empty list ([]), would it be possible? If so, how?

Thanks

1 Like

This is more of a “peculiarities of Terraform’s parallel Type system” than a Go problem.

From your earlier post, whatever.Tags is a types.List. List of what? We don’t know yet. Joys of the parallel type system. You can get a types.List using types.ListValueFrom(), types.ListValueMust(), Types.ListValue(), types.ListNull() and types.ListUnknown().

ListValueFrom() is the one I get the most mileage from. Here we tell it we want a list of strings, and feed it an empty slice of strings:

	var diags diag.Diagnostics
	whatever.Tags, diags = types.ListValueFrom(ctx, types.StringType, []string{})
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

The ListValueFrom function is a little more complicated than you might expect because while ListValueFrom() knows it’s building a list, it doesn’t know the shape of the elements (strings in this case). That explains the second argument. If it was a list of complicated objects, we’d need an attr.Type which spells out the shape of those complicated objects instead of just types.StringType.

I think this would also do it: types.ListValue(types.StringType, []attr.Value{}).

Because my provider doesn’t deal in empty lists, I use the following helper function which never make an empty list, but rather marks the list as null when there are no elements.

// ListValueOrNull returns a types.List based on the supplied elements. If the
// supplied elements is empty, the returned types.List will be flagged as null.
func ListValueOrNull[T any](ctx context.Context, elementType attr.Type, elements []T, diags *diag.Diagnostics) types.List {
	if len(elements) == 0 {
		return types.ListNull(elementType)
	}

	result, d := types.ListValueFrom(ctx, elementType, elements)
	diags.Append(d...)
	return result
}
1 Like

Makes great sense @hQnVyLRx, thanks so much for all the insight and help.