diff --git a/server/internal/api/apiv1/validate.go b/server/internal/api/apiv1/validate.go index 554ed811..4071c643 100644 --- a/server/internal/api/apiv1/validate.go +++ b/server/internal/api/apiv1/validate.go @@ -183,10 +183,25 @@ func validateDatabaseUpdate(old *database.Spec, new *api.DatabaseSpec) error { newNodeNames.Add(n.Name) } - // Validate services with isUpdate=true to reject bootstrap-only fields + // Build a set of service IDs that already exist in the deployment. This is used + // below to distinguish newly added services from existing ones. Currently this + // distinction only affects MCP services, which have bootstrap-only fields + // (init_token, init_users) that may only be set during initial provisioning of + // the service. Because a service can be added to an existing database via + // update-database, "initial provisioning" means "first time this service_id + // appears in the spec" — not "the create-database call was used". + existingServiceIDs := make(ds.Set[string], len(old.Services)) + for _, svc := range old.Services { + existingServiceIDs.Add(svc.ServiceID) + } + + // Validate each service. Pass isUpdate=false for services being added for the + // first time so that bootstrap-only fields are accepted. For service types that + // have no bootstrap fields (e.g. postgrest) the flag has no effect. for i, svc := range new.Services { svcPath := []string{"services", arrayIndexPath(i)} - errs = append(errs, validateServiceSpec(svc, svcPath, true, newNodeNames)...) + isExistingService := existingServiceIDs.Has(string(svc.ServiceID)) + errs = append(errs, validateServiceSpec(svc, svcPath, isExistingService, newNodeNames)...) } return errors.Join(errs...) diff --git a/server/internal/api/apiv1/validate_test.go b/server/internal/api/apiv1/validate_test.go index 6b6c9b6a..dd8e16f2 100644 --- a/server/internal/api/apiv1/validate_test.go +++ b/server/internal/api/apiv1/validate_test.go @@ -5,6 +5,7 @@ import ( "testing" api "github.com/pgEdge/control-plane/api/apiv1/gen/control_plane" + "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/ds" "github.com/pgEdge/control-plane/server/internal/utils" "github.com/stretchr/testify/assert" @@ -1416,3 +1417,97 @@ func TestValidateOrchestratorOpts(t *testing.T) { }) } } + +func TestValidateDatabaseUpdate_ServiceBootstrapFields(t *testing.T) { + // A minimal valid MCP config shared across test cases. + validMCPConfig := map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + } + + mcpWithBootstrap := func() map[string]any { + cfg := make(map[string]any, len(validMCPConfig)+2) + for k, v := range validMCPConfig { + cfg[k] = v + } + cfg["init_token"] = "my-token" + cfg["init_users"] = []any{map[string]any{"username": "alice", "password": "pw"}} + return cfg + } + + newMCPService := func(id string, config map[string]any) *api.ServiceSpec { + return &api.ServiceSpec{ + ServiceID: api.Identifier(id), + ServiceType: "mcp", + Version: "latest", + HostIds: []api.Identifier{"host-1"}, + Config: config, + } + } + + oldSpecWithMCP := &database.Spec{ + Services: []*database.ServiceSpec{ + {ServiceID: "appmcp"}, + }, + } + + for _, tc := range []struct { + name string + old *database.Spec + new *api.DatabaseSpec + expected []string // empty means no error expected + }{ + { + name: "new service added via update-database - bootstrap fields allowed", + old: &database.Spec{}, + new: &api.DatabaseSpec{ + Services: []*api.ServiceSpec{ + newMCPService("appmcp", mcpWithBootstrap()), + }, + }, + }, + { + name: "no existing services - bootstrap fields allowed", + old: &database.Spec{Services: nil}, + new: &api.DatabaseSpec{ + Services: []*api.ServiceSpec{ + newMCPService("appmcp", mcpWithBootstrap()), + }, + }, + }, + { + name: "existing service updated - bootstrap fields rejected", + old: oldSpecWithMCP, + new: &api.DatabaseSpec{ + Services: []*api.ServiceSpec{ + newMCPService("appmcp", mcpWithBootstrap()), + }, + }, + expected: []string{ + "init_token can only be set during initial provisioning", + "init_users can only be set during initial provisioning", + }, + }, + { + name: "existing service updated without bootstrap fields - no error", + old: oldSpecWithMCP, + new: &api.DatabaseSpec{ + Services: []*api.ServiceSpec{ + newMCPService("appmcp", validMCPConfig), + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateDatabaseUpdate(tc.old, tc.new) + if len(tc.expected) < 1 { + assert.NoError(t, err) + } else { + for _, expected := range tc.expected { + assert.ErrorContains(t, err, expected) + } + } + }) + } +}