From 35a1dcf7bb61ef21edd1348d87551dd95e09a286 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 23 Aug 2026 00:08:57 +1000 Subject: [PATCH] Model apps as a set instead of an ordered list (#2) The arrstack engine returns `apps` alphabetically sorted regardless of the order they were written in, so modelling it as an ordered List makes any config whose order differs fail apply with "Provider produced inconsistent result after apply" and produce perpetual re-diffs. `apps` is semantically a set of app names, so it is now modelled as one. - Changes the `apps` attribute on `arrstack_secret_backend_role` from `types.List`/`schema.ListAttribute` to `types.Set`/`schema.SetAttribute` - Reads engine responses back via `types.SetValueFrom` - Updates unit tests for the set type and adds an order-insensitivity test proving a sorted engine response equals a differently-ordered config value - No other resources or data sources use the List-of-apps pattern Reviewed-on: https://git.unkin.net/unkin/terraform-provider-vault-secrets-arrstack/pulls/2 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- .../provider/resource_secret_backend_role.go | 8 ++-- .../resource_secret_backend_role_test.go | 38 ++++++++++++++----- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/internal/provider/resource_secret_backend_role.go b/internal/provider/resource_secret_backend_role.go index 23bd5f0..47133aa 100644 --- a/internal/provider/resource_secret_backend_role.go +++ b/internal/provider/resource_secret_backend_role.go @@ -25,7 +25,7 @@ type secretBackendRoleResource struct { type secretBackendRoleModel struct { Backend types.String `tfsdk:"backend"` Name types.String `tfsdk:"name"` - Apps types.List `tfsdk:"apps"` + Apps types.Set `tfsdk:"apps"` TTL types.Int64 `tfsdk:"ttl"` MaxTTL types.Int64 `tfsdk:"max_ttl"` } @@ -56,7 +56,7 @@ func (r *secretBackendRoleResource) Schema(_ context.Context, _ resource.SchemaR stringplanmodifier.RequiresReplace(), }, }, - "apps": schema.ListAttribute{ + "apps": schema.SetAttribute{ Description: "arr apps a generated key may access (subset of sonarr, radarr, prowlarr).", ElementType: types.StringType, Required: true, @@ -212,9 +212,9 @@ func applyRoleData(m *secretBackendRoleModel, role map[string]interface{}) diag. var diags diag.Diagnostics apps := toStringSlice(role["apps"]) - appList, appDiags := types.ListValueFrom(context.Background(), types.StringType, apps) + appSet, appDiags := types.SetValueFrom(context.Background(), types.StringType, apps) diags.Append(appDiags...) - m.Apps = appList + m.Apps = appSet if n, ok := toInt64(role["ttl"]); ok && n != 0 { m.TTL = types.Int64Value(n) diff --git a/internal/provider/resource_secret_backend_role_test.go b/internal/provider/resource_secret_backend_role_test.go index c42b111..ce1e5a2 100644 --- a/internal/provider/resource_secret_backend_role_test.go +++ b/internal/provider/resource_secret_backend_role_test.go @@ -8,18 +8,18 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -func listOf(t *testing.T, vals ...string) types.List { +func setOf(t *testing.T, vals ...string) types.Set { t.Helper() - l, diags := types.ListValueFrom(context.Background(), types.StringType, vals) + s, diags := types.SetValueFrom(context.Background(), types.StringType, vals) if diags.HasError() { - t.Fatalf("building list: %v", diags) + t.Fatalf("building set: %v", diags) } - return l + return s } func TestRoleDataOmitsUnsetTTLs(t *testing.T) { m := secretBackendRoleModel{ - Apps: listOf(t, "sonarr", "radarr", "prowlarr"), + Apps: setOf(t, "sonarr", "radarr", "prowlarr"), TTL: types.Int64Null(), MaxTTL: types.Int64Null(), } @@ -41,7 +41,7 @@ func TestRoleDataOmitsUnsetTTLs(t *testing.T) { func TestRoleDataIncludesTTLs(t *testing.T) { m := secretBackendRoleModel{ - Apps: listOf(t, "prowlarr"), + Apps: setOf(t, "prowlarr"), TTL: types.Int64Value(60), MaxTTL: types.Int64Value(86400), } @@ -70,16 +70,34 @@ func TestApplyRoleDataMapsEngineResponse(t *testing.T) { if diags := applyRoleData(&m, role); diags.HasError() { t.Fatalf("applyRoleData: %v", diags) } - var apps []string - m.Apps.ElementsAs(context.Background(), &apps, false) - if len(apps) != 3 || apps[2] != "prowlarr" { - t.Errorf("apps = %v, want [sonarr radarr prowlarr]", apps) + if !m.Apps.Equal(setOf(t, "sonarr", "radarr", "prowlarr")) { + t.Errorf("apps = %v, want {sonarr radarr prowlarr}", m.Apps) } if m.TTL.ValueInt64() != 60 || m.MaxTTL.ValueInt64() != 86400 { t.Errorf("ttl/max_ttl = %d/%d, want 60/86400", m.TTL.ValueInt64(), m.MaxTTL.ValueInt64()) } } +func TestApplyRoleDataOrderInsensitive(t *testing.T) { + // The engine returns apps alphabetically sorted regardless of the order + // they were written in; the read-back must still equal the config value. + configApps := setOf(t, "sonarr", "prowlarr", "radarr") + role := map[string]interface{}{ + "apps": []interface{}{"prowlarr", "radarr", "sonarr"}, + } + m := secretBackendRoleModel{ + Apps: configApps, + TTL: types.Int64Null(), + MaxTTL: types.Int64Null(), + } + if diags := applyRoleData(&m, role); diags.HasError() { + t.Fatalf("applyRoleData: %v", diags) + } + if !m.Apps.Equal(configApps) { + t.Errorf("apps after read-back = %v, not equal to config value %v", m.Apps, configApps) + } +} + func TestApplyRoleDataZeroTTLLeavesNull(t *testing.T) { // The engine returns 0 for an unset TTL, which must not clobber the null // model value into a spurious 0.