diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 1564e36..4a1a0f4 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -16,7 +16,7 @@ func listToStrings(ctx context.Context, l types.List) []string { } func stringsToList(ctx context.Context, ss []string) types.List { - if len(ss) == 0 { + if ss == nil { return types.ListNull(types.StringType) } elems := make([]types.String, len(ss)) @@ -26,3 +26,14 @@ func stringsToList(ctx context.Context, ss []string) types.List { list, _ := types.ListValueFrom(ctx, types.StringType, elems) return list } + +// preserveListNullEmptySemantics keeps the prior null/empty distinction when +// the API returns null for a field that was previously an empty list. +// Without this, OpenTofu reports "inconsistent result after apply" because +// the plan/state had [] but the provider returned null. +func preserveListNullEmptySemantics(prior, current types.List) types.List { + if current.IsNull() && !prior.IsNull() && len(prior.Elements()) == 0 { + return prior + } + return current +} diff --git a/internal/provider/helpers_test.go b/internal/provider/helpers_test.go index 619aaa7..8a06ed8 100644 --- a/internal/provider/helpers_test.go +++ b/internal/provider/helpers_test.go @@ -63,8 +63,11 @@ func TestListToStrings_WithValues(t *testing.T) { func TestStringsToList_EmptySlice(t *testing.T) { ctx := context.Background() result := stringsToList(ctx, []string{}) - if !result.IsNull() { - t.Fatalf("expected null list for empty slice, got %v", result) + if result.IsNull() { + t.Fatalf("expected empty list for empty slice, got null") + } + if len(result.Elements()) != 0 { + t.Fatalf("expected 0 elements, got %d", len(result.Elements())) } } @@ -103,3 +106,42 @@ func TestStringsToList_SingleValue(t *testing.T) { t.Fatalf("expected [\"solo\"], got %v", back) } } + +func TestPreserveListNullEmptySemantics(t *testing.T) { + ctx := context.Background() + emptyList, _ := types.ListValueFrom(ctx, types.StringType, []types.String{}) + nullList := types.ListNull(types.StringType) + populatedList := stringsToList(ctx, []string{"a"}) + + t.Run("prior empty + current null → preserves empty", func(t *testing.T) { + result := preserveListNullEmptySemantics(emptyList, nullList) + if result.IsNull() { + t.Fatal("expected empty list, got null") + } + if len(result.Elements()) != 0 { + t.Fatalf("expected 0 elements, got %d", len(result.Elements())) + } + }) + + t.Run("prior null + current null → stays null", func(t *testing.T) { + result := preserveListNullEmptySemantics(nullList, nullList) + if !result.IsNull() { + t.Fatal("expected null, got non-null") + } + }) + + t.Run("prior empty + current populated → uses current", func(t *testing.T) { + result := preserveListNullEmptySemantics(emptyList, populatedList) + elems := listToStrings(ctx, result) + if len(elems) != 1 || elems[0] != "a" { + t.Fatalf("expected [a], got %v", elems) + } + }) + + t.Run("prior populated + current null → stays null", func(t *testing.T) { + result := preserveListNullEmptySemantics(populatedList, nullList) + if !result.IsNull() { + t.Fatal("expected null (prior was populated, not empty — no preservation)") + } + }) +} diff --git a/internal/provider/resource_remote.go b/internal/provider/resource_remote.go index edfbcc7..25587c2 100644 --- a/internal/provider/resource_remote.go +++ b/internal/provider/resource_remote.go @@ -181,6 +181,7 @@ func (r *remoteResource) Create(ctx context.Context, req resource.CreateRequest, } state := r.apiToModel(ctx, created) + reconcileOptionalLists(&plan, &state) resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } @@ -203,6 +204,7 @@ func (r *remoteResource) Read(ctx context.Context, req resource.ReadRequest, res } newState := r.apiToModel(ctx, remote) + reconcileOptionalLists(&state, &newState) resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) } @@ -223,6 +225,7 @@ func (r *remoteResource) Update(ctx context.Context, req resource.UpdateRequest, } state := r.apiToModel(ctx, updated) + reconcileOptionalLists(&plan, &state) resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } @@ -243,6 +246,14 @@ func (r *remoteResource) ImportState(ctx context.Context, req resource.ImportSta resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) } +func reconcileOptionalLists(prior, current *remoteResourceModel) { + current.Patterns = preserveListNullEmptySemantics(prior.Patterns, current.Patterns) + current.Blocklist = preserveListNullEmptySemantics(prior.Blocklist, current.Blocklist) + current.MutablePatterns = preserveListNullEmptySemantics(prior.MutablePatterns, current.MutablePatterns) + current.ImmutablePatterns = preserveListNullEmptySemantics(prior.ImmutablePatterns, current.ImmutablePatterns) + current.BanTags = preserveListNullEmptySemantics(prior.BanTags, current.BanTags) +} + func (r *remoteResource) modelToAPI(ctx context.Context, m remoteResourceModel) remoteAPI { api := remoteAPI{ Name: m.Name.ValueString(),