Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions server/internal/api/apiv1/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
95 changes: 95 additions & 0 deletions server/internal/api/apiv1/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
})
}
}