From 8cfee9933614d2d7af6f84569bab02eb457abce8 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 20 Apr 2026 17:45:09 -0400 Subject: [PATCH 1/2] feat: add state migration to remove ServiceUserRole resources Add Version_1_1_0 migration that removes all swarm.service_user_role resources from etcd state and scrubs dependency references from all remaining resources. Covers MCP, RAG, and PostgREST service types. --- server/internal/resource/migrations/1_1_0.go | 39 ++++ .../resource/migrations/1_1_0_test.go | 168 ++++++++++++++++++ .../golden_test/TestVersion_1_0_0/empty.json | 2 +- .../TestVersion_1_0_0/no_nodes.json | 2 +- .../populate_n3_with_n1_source.json | 2 +- .../single_node_with_replicas.json | 2 +- .../TestVersion_1_0_0/three_nodes.json | 2 +- .../with_restore_config.json | 2 +- .../internal/resource/migrations/provide.go | 1 + server/internal/resource/state.go | 3 +- 10 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 server/internal/resource/migrations/1_1_0.go create mode 100644 server/internal/resource/migrations/1_1_0_test.go diff --git a/server/internal/resource/migrations/1_1_0.go b/server/internal/resource/migrations/1_1_0.go new file mode 100644 index 00000000..b2691e5a --- /dev/null +++ b/server/internal/resource/migrations/1_1_0.go @@ -0,0 +1,39 @@ +package migrations + +import ( + "github.com/pgEdge/control-plane/server/internal/ds" + "github.com/pgEdge/control-plane/server/internal/resource" +) + +var _ resource.StateMigration = (*Version_1_1_0)(nil) + +// Version_1_1_0 removes swarm.service_user_role resources and scrubs +// references to them from all other resources' dependency lists. +// Services now use connect_as to reference database_users directly. +type Version_1_1_0 struct{} + +func (v *Version_1_1_0) Version() *ds.Version { + return resource.StateVersion_1_1_0 +} + +func (v *Version_1_1_0) Run(state *resource.State) error { + const serviceUserRoleType resource.Type = "swarm.service_user_role" + + // 1. Delete all service_user_role resources from state + delete(state.Resources, serviceUserRoleType) + + // 2. Remove service_user_role from all other resources' dependency lists + for _, resources := range state.Resources { + for _, data := range resources { + filtered := data.Dependencies[:0] + for _, dep := range data.Dependencies { + if dep.Type != serviceUserRoleType { + filtered = append(filtered, dep) + } + } + data.Dependencies = filtered + } + } + + return nil +} diff --git a/server/internal/resource/migrations/1_1_0_test.go b/server/internal/resource/migrations/1_1_0_test.go new file mode 100644 index 00000000..3dad2147 --- /dev/null +++ b/server/internal/resource/migrations/1_1_0_test.go @@ -0,0 +1,168 @@ +package migrations_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/pgEdge/control-plane/server/internal/resource/migrations" +) + +func TestVersion_1_1_0(t *testing.T) { + serviceUserRoleType := resource.Type("swarm.service_user_role") + mcpConfigType := resource.Type("swarm.mcp_config") + serviceInstanceSpecType := resource.Type("swarm.service_instance_spec") + networkType := resource.Type("swarm.network") + dirType := resource.Type("swarm.dir") + + svcRoleRO := resource.Identifier{ID: "appmcp-ro", Type: serviceUserRoleType} + svcRoleRW := resource.Identifier{ID: "appmcp-rw", Type: serviceUserRoleType} + networkDep := resource.Identifier{ID: "db-network", Type: networkType} + dirDep := resource.Identifier{ID: "data-dir", Type: dirType} + + t.Run("removes service_user_role resources", func(t *testing.T) { + state := &resource.State{ + Version: resource.StateVersion_1_0_0.Clone(), + Resources: map[resource.Type]map[string]*resource.ResourceData{ + serviceUserRoleType: { + "appmcp-ro": {Identifier: svcRoleRO}, + "appmcp-rw": {Identifier: svcRoleRW}, + }, + mcpConfigType: { + "mcp-cfg": { + Identifier: resource.Identifier{ID: "mcp-cfg", Type: mcpConfigType}, + Dependencies: []resource.Identifier{dirDep, svcRoleRO, svcRoleRW}, + }, + }, + serviceInstanceSpecType: { + "svc-spec": { + Identifier: resource.Identifier{ID: "svc-spec", Type: serviceInstanceSpecType}, + Dependencies: []resource.Identifier{networkDep, svcRoleRO, svcRoleRW}, + }, + }, + }, + } + + migration := &migrations.Version_1_1_0{} + err := migration.Run(state) + require.NoError(t, err) + + // service_user_role resources should be gone + _, exists := state.Resources[serviceUserRoleType] + assert.False(t, exists, "service_user_role resources should be deleted") + + // mcp_config should have service_user_role deps removed + mcpCfg := state.Resources[mcpConfigType]["mcp-cfg"] + require.NotNil(t, mcpCfg) + assert.Equal(t, []resource.Identifier{dirDep}, mcpCfg.Dependencies) + + // service_instance_spec should have service_user_role deps removed + svcSpec := state.Resources[serviceInstanceSpecType]["svc-spec"] + require.NotNil(t, svcSpec) + assert.Equal(t, []resource.Identifier{networkDep}, svcSpec.Dependencies) + }) + + t.Run("removes service_user_role resources across all service types", func(t *testing.T) { + ragConfigType := resource.Type("swarm.rag_config") + postgrestConfigType := resource.Type("swarm.postgrest_config") + + mcpRO := resource.Identifier{ID: "appmcp-ro", Type: serviceUserRoleType} + mcpRW := resource.Identifier{ID: "appmcp-rw", Type: serviceUserRoleType} + ragRO := resource.Identifier{ID: "apprag-ro", Type: serviceUserRoleType} + prstRO := resource.Identifier{ID: "appprst-ro", Type: serviceUserRoleType} + prstRW := resource.Identifier{ID: "appprst-rw", Type: serviceUserRoleType} + + state := &resource.State{ + Version: resource.StateVersion_1_0_0.Clone(), + Resources: map[resource.Type]map[string]*resource.ResourceData{ + serviceUserRoleType: { + "appmcp-ro": {Identifier: mcpRO}, + "appmcp-rw": {Identifier: mcpRW}, + "apprag-ro": {Identifier: ragRO}, + "appprst-ro": {Identifier: prstRO}, + "appprst-rw": {Identifier: prstRW}, + }, + mcpConfigType: { + "mcp-cfg": { + Identifier: resource.Identifier{ID: "mcp-cfg", Type: mcpConfigType}, + Dependencies: []resource.Identifier{dirDep, mcpRO, mcpRW}, + }, + }, + ragConfigType: { + "rag-cfg": { + Identifier: resource.Identifier{ID: "rag-cfg", Type: ragConfigType}, + Dependencies: []resource.Identifier{dirDep, ragRO}, + }, + }, + postgrestConfigType: { + "prst-cfg": { + Identifier: resource.Identifier{ID: "prst-cfg", Type: postgrestConfigType}, + Dependencies: []resource.Identifier{dirDep, prstRO, prstRW}, + }, + }, + serviceInstanceSpecType: { + "svc-spec": { + Identifier: resource.Identifier{ID: "svc-spec", Type: serviceInstanceSpecType}, + Dependencies: []resource.Identifier{networkDep, mcpRO, mcpRW, ragRO, prstRO, prstRW}, + }, + }, + }, + } + + migration := &migrations.Version_1_1_0{} + err := migration.Run(state) + require.NoError(t, err) + + // All service_user_role resources should be gone + _, exists := state.Resources[serviceUserRoleType] + assert.False(t, exists, "service_user_role resources should be deleted") + + // MCP config: only dirDep remains + assert.Equal(t, []resource.Identifier{dirDep}, state.Resources[mcpConfigType]["mcp-cfg"].Dependencies) + + // RAG config: only dirDep remains + assert.Equal(t, []resource.Identifier{dirDep}, state.Resources[ragConfigType]["rag-cfg"].Dependencies) + + // PostgREST config: only dirDep remains + assert.Equal(t, []resource.Identifier{dirDep}, state.Resources[postgrestConfigType]["prst-cfg"].Dependencies) + + // service_instance_spec: only networkDep remains + assert.Equal(t, []resource.Identifier{networkDep}, state.Resources[serviceInstanceSpecType]["svc-spec"].Dependencies) + }) + + t.Run("no-op when no service_user_role resources exist", func(t *testing.T) { + state := &resource.State{ + Version: resource.StateVersion_1_0_0.Clone(), + Resources: map[resource.Type]map[string]*resource.ResourceData{ + mcpConfigType: { + "mcp-cfg": { + Identifier: resource.Identifier{ID: "mcp-cfg", Type: mcpConfigType}, + Dependencies: []resource.Identifier{dirDep}, + }, + }, + }, + } + + migration := &migrations.Version_1_1_0{} + err := migration.Run(state) + require.NoError(t, err) + + // mcp_config should be untouched + mcpCfg := state.Resources[mcpConfigType]["mcp-cfg"] + require.NotNil(t, mcpCfg) + assert.Equal(t, []resource.Identifier{dirDep}, mcpCfg.Dependencies) + }) + + t.Run("empty state", func(t *testing.T) { + state := &resource.State{ + Version: resource.StateVersion_1_0_0.Clone(), + Resources: map[resource.Type]map[string]*resource.ResourceData{}, + } + + migration := &migrations.Version_1_1_0{} + err := migration.Run(state) + require.NoError(t, err) + }) +} diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json index 74ed7f74..d9804c06 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json @@ -1,4 +1,4 @@ { - "version": "1.0.0", + "version": "1.1.0", "resources": {} } \ No newline at end of file diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json index a55c9b64..68e83356 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json index d5160a8e..c66b5d07 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json index 771f8f26..c203adf9 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json index 04886b72..c39e4d2d 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json index 9fe553bb..fbce1c63 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/provide.go b/server/internal/resource/migrations/provide.go index f532e634..ec9797b0 100644 --- a/server/internal/resource/migrations/provide.go +++ b/server/internal/resource/migrations/provide.go @@ -14,6 +14,7 @@ func provideStateMigrations(i *do.Injector) { do.Provide(i, func(i *do.Injector) (*resource.StateMigrations, error) { return resource.NewStateMigrations([]resource.StateMigration{ &Version_1_0_0{}, + &Version_1_1_0{}, }), nil }) } diff --git a/server/internal/resource/state.go b/server/internal/resource/state.go index 551ef87b..b562ccbd 100644 --- a/server/internal/resource/state.go +++ b/server/internal/resource/state.go @@ -15,8 +15,9 @@ import ( var ( StateVersion_1_0_0 = ds.MustParseVersion("1.0.0") + StateVersion_1_1_0 = ds.MustParseVersion("1.1.0") - CurrentVersion = StateVersion_1_0_0 + CurrentVersion = StateVersion_1_1_0 ) var ( From 08f887fef766c7482f49233b8eaa23d53ba8df9c Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Tue, 21 Apr 2026 09:27:11 -0400 Subject: [PATCH 2/2] fix: pin v1.0.0 migration test to StateVersion_1_0_0 Avoid golden file churn by constructing state with an explicit version instead of NewState(), which picks up CurrentVersion. --- server/internal/resource/migrations/1_0_0_test.go | 5 ++++- .../migrations/golden_test/TestVersion_1_0_0/empty.json | 2 +- .../migrations/golden_test/TestVersion_1_0_0/no_nodes.json | 2 +- .../TestVersion_1_0_0/populate_n3_with_n1_source.json | 2 +- .../TestVersion_1_0_0/single_node_with_replicas.json | 2 +- .../golden_test/TestVersion_1_0_0/three_nodes.json | 2 +- .../golden_test/TestVersion_1_0_0/with_restore_config.json | 2 +- 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/server/internal/resource/migrations/1_0_0_test.go b/server/internal/resource/migrations/1_0_0_test.go index 703880a2..fb74a858 100644 --- a/server/internal/resource/migrations/1_0_0_test.go +++ b/server/internal/resource/migrations/1_0_0_test.go @@ -139,7 +139,10 @@ func TestVersion_1_0_0(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - state := resource.NewState() + state := &resource.State{ + Version: resource.StateVersion_1_0_0, + Resources: map[resource.Type]map[string]*resource.ResourceData{}, + } state.Add(tc.in...) migration := &migrations.Version_1_0_0{} diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json index d9804c06..74ed7f74 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/empty.json @@ -1,4 +1,4 @@ { - "version": "1.1.0", + "version": "1.0.0", "resources": {} } \ No newline at end of file diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json index 68e83356..a55c9b64 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/no_nodes.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.0.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json index c66b5d07..d5160a8e 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/populate_n3_with_n1_source.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.0.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json index c203adf9..771f8f26 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/single_node_with_replicas.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.0.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json index c39e4d2d..04886b72 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/three_nodes.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.0.0", "resources": { "database.instance": { "instance-1": { diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json index fbce1c63..9fe553bb 100644 --- a/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_0_0/with_restore_config.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.0.0", "resources": { "database.instance": { "instance-1": {