Model apps as a set instead of an ordered list (#2)
ci/woodpecker/tag/release Pipeline was successful

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: #2
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #2.
This commit is contained in:
2026-08-23 00:08:57 +10:00
committed by BenVincent
parent 8535e579ae
commit 35a1dcf7bb
2 changed files with 32 additions and 14 deletions
@@ -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)
@@ -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.