From 3007f1b6a78f84a95dbf7658901d1ce0e7b4cd92 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 2 Mar 2026 14:47:00 -0500 Subject: [PATCH 1/6] feat: replace MCP env-var config with bind-mounted YAML config files Replace environment-variable-based MCP service configuration with CP-generated YAML config files (config.yaml, tokens.yaml, users.yaml) bind-mounted into the service container at /app/data/. config.yaml is CP-owned and regenerated on every update, carrying database connection, LLM provider, tool toggles, and pool settings. tokens.yaml and users.yaml are bootstrap-once: written on initial create if init_token/init_users are provided, then left for the running MCP server to manage. This separates CP provisioning concerns from application-level auth management. Key changes: - Add MCPServiceConfig typed parser with validation for all supported config keys (LLM, embedding, pool, tool toggles, bootstrap auth) - Add MCPConfigResource lifecycle (Create writes all three files, Update regenerates only config.yaml, Refresh verifies file exists) - Add token/user file generators with SHA-256 and bcrypt hashing - Wire MCPConfigResource into the service instance resource chain between DirResource and ServiceInstanceSpec - Resolve database host/port from co-located Postgres instance in plan_update for the config.yaml connection block - Redact sensitive config keys (API keys, init_users) from API responses - Align service user grants with MCP server security guide: public schema only, pg_read_all_settings, no spock access - Add TestUpdateMCPServiceConfig E2E test for config update path PLAT-444 --- e2e/service_provisioning_test.go | 127 +++ server/internal/api/apiv1/convert.go | 1 + server/internal/api/apiv1/validate.go | 65 +- server/internal/api/apiv1/validate_test.go | 18 +- .../internal/database/mcp_service_config.go | 448 ++++++++++ .../database/mcp_service_config_test.go | 826 ++++++++++++++++++ .../orchestrator/swarm/mcp_auth_files.go | 119 +++ .../orchestrator/swarm/mcp_auth_files_test.go | 318 +++++++ .../internal/orchestrator/swarm/mcp_config.go | 231 +++++ .../orchestrator/swarm/mcp_config_resource.go | 264 ++++++ .../orchestrator/swarm/mcp_config_test.go | 595 +++++++++++++ .../orchestrator/swarm/orchestrator.go | 37 +- .../internal/orchestrator/swarm/resources.go | 1 + .../swarm/service_instance_spec.go | 12 +- .../orchestrator/swarm/service_spec.go | 72 +- .../orchestrator/swarm/service_user_role.go | 30 +- .../generate_service_instance_resources.go | 2 +- server/internal/workflows/plan_update.go | 15 +- 18 files changed, 3048 insertions(+), 133 deletions(-) create mode 100644 server/internal/database/mcp_service_config.go create mode 100644 server/internal/database/mcp_service_config_test.go create mode 100644 server/internal/orchestrator/swarm/mcp_auth_files.go create mode 100644 server/internal/orchestrator/swarm/mcp_auth_files_test.go create mode 100644 server/internal/orchestrator/swarm/mcp_config.go create mode 100644 server/internal/orchestrator/swarm/mcp_config_resource.go create mode 100644 server/internal/orchestrator/swarm/mcp_config_test.go diff --git a/e2e/service_provisioning_test.go b/e2e/service_provisioning_test.go index 094c6433..56e6f118 100644 --- a/e2e/service_provisioning_test.go +++ b/e2e/service_provisioning_test.go @@ -755,6 +755,133 @@ func TestUpdateDatabaseServiceStable(t *testing.T) { t.Log("Service stability test completed successfully") } +// TestUpdateMCPServiceConfig tests that updating a service's config (e.g., +// changing the LLM model) triggers a successful database update and the service +// instance remains running. This exercises the MCPConfigResource.Update() path +// where config.yaml is regenerated while tokens.yaml/users.yaml are preserved. +func TestUpdateMCPServiceConfig(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database with MCP service (claude-sonnet-4-5)") + + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_update_mcp_config", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "latest", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test-key-config", + }, + }, + }, + }, + }) + + require.Len(t, db.ServiceInstances, 1, "Expected 1 service instance") + + // Wait for service to reach "running" state before recording baseline + if db.ServiceInstances[0].State != "running" { + t.Log("Service not yet running, waiting...") + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(5 * time.Second) + err := db.Refresh(ctx) + require.NoError(t, err, "Failed to refresh database") + if len(db.ServiceInstances) > 0 && db.ServiceInstances[0].State == "running" { + break + } + } + } + require.Equal(t, "running", db.ServiceInstances[0].State, "Service should be running") + + // Record identifiers before the update to verify stability after + serviceInstanceID := db.ServiceInstances[0].ServiceInstanceID + createdAtBefore := db.ServiceInstances[0].CreatedAt + + t.Logf("Baseline: service_instance_id=%s, created_at=%s", serviceInstanceID, createdAtBefore) + + t.Log("Updating database with changed service config (claude-sonnet-4-5 -> claude-haiku-4-5)") + + // Update database with a changed service config (different LLM model). + // This should trigger MCPConfigResource.Update() to regenerate config.yaml + // without deleting/recreating the service instance. + err := db.Update(ctx, UpdateOptions{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_update_mcp_config", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "latest", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-haiku-4-5", + "anthropic_api_key": "sk-ant-test-key-config", + }, + }, + }, + }, + }) + require.NoError(t, err, "Failed to update database") + + t.Log("Database updated, verifying service instance was updated in-place") + + // Verify the database is available and service is running + require.Len(t, db.ServiceInstances, 1, "Should still have 1 service instance") + assert.Equal(t, "running", db.ServiceInstances[0].State, "Service should still be running") + + // The key assertions: ServiceInstanceID and CreatedAt should be unchanged, + // proving the service was updated in-place (config.yaml regenerated) rather + // than deleted and recreated. + assert.Equal(t, serviceInstanceID, db.ServiceInstances[0].ServiceInstanceID, "Service instance ID should be unchanged (not recreated)") + assert.Equal(t, createdAtBefore, db.ServiceInstances[0].CreatedAt, "Service created_at should be unchanged (not recreated)") + + t.Logf("Service instance %s updated in-place after config change", serviceInstanceID) + t.Log("MCP service config update test completed successfully") +} + // TestUpdateDatabaseRemoveService tests removing a service from a database. func TestUpdateDatabaseRemoveService(t *testing.T) { t.Parallel() diff --git a/server/internal/api/apiv1/convert.go b/server/internal/api/apiv1/convert.go index 142cb0a9..f7d3a74c 100644 --- a/server/internal/api/apiv1/convert.go +++ b/server/internal/api/apiv1/convert.go @@ -28,6 +28,7 @@ func isSensitiveConfigKey(key string) bool { "api_key", "apikey", "api-key", "credential", "private_key", "private-key", "access_key", "access-key", + "init_users", // mcp 'init_users' contains embedded passwords and must be stripped } for _, p := range patterns { if strings.Contains(k, p) { diff --git a/server/internal/api/apiv1/validate.go b/server/internal/api/apiv1/validate.go index 766599dd..2c6d5ac0 100644 --- a/server/internal/api/apiv1/validate.go +++ b/server/internal/api/apiv1/validate.go @@ -139,7 +139,7 @@ func validateDatabaseSpec(spec *api.DatabaseSpec) error { } seenServiceIDs.Add(string(svc.ServiceID)) - errs = append(errs, validateServiceSpec(svc, svcPath)...) + errs = append(errs, validateServiceSpec(svc, svcPath, false)...) } return errors.Join(errs...) @@ -176,6 +176,12 @@ func validateDatabaseUpdate(old *database.Spec, new *api.DatabaseSpec) error { } } + // Validate services with isUpdate=true to reject bootstrap-only fields + for i, svc := range new.Services { + svcPath := []string{"services", arrayIndexPath(i)} + errs = append(errs, validateServiceSpec(svc, svcPath, true)...) + } + return errors.Join(errs...) } @@ -232,7 +238,7 @@ func validateNode(node *api.DatabaseNodeSpec, path []string) []error { return errs } -func validateServiceSpec(svc *api.ServiceSpec, path []string) []error { +func validateServiceSpec(svc *api.ServiceSpec, path []string, isUpdate bool) []error { var errs []error // Validate service_id @@ -269,7 +275,7 @@ func validateServiceSpec(svc *api.ServiceSpec, path []string) []error { // Validate config based on service_type if svc.ServiceType == "mcp" { - errs = append(errs, validateMCPServiceConfig(svc.Config, appendPath(path, "config"))...) + errs = append(errs, validateMCPServiceConfig(svc.Config, appendPath(path, "config"), isUpdate)...) } // Validate cpus if provided @@ -288,54 +294,13 @@ func validateServiceSpec(svc *api.ServiceSpec, path []string) []error { return errs } -// TODO: this is still a WIP based on use-case reqs... -func validateMCPServiceConfig(config map[string]any, path []string) []error { - var errs []error - - // Required fields for MCP service - requiredFields := []string{"llm_provider", "llm_model"} - for _, field := range requiredFields { - if _, ok := config[field]; !ok { - err := fmt.Errorf("missing required field '%s'", field) - errs = append(errs, newValidationError(err, path)) - } +func validateMCPServiceConfig(config map[string]any, path []string, isUpdate bool) []error { + _, errs := database.ParseMCPServiceConfig(config, isUpdate) + var result []error + for _, err := range errs { + result = append(result, newValidationError(err, path)) } - - // Validate llm_provider - if val, exists := config["llm_provider"]; exists { - provider, ok := val.(string) - if !ok { - err := errors.New("llm_provider must be a string") - errs = append(errs, newValidationError(err, appendPath(path, mapKeyPath("llm_provider")))) - } else { - validProviders := []string{"anthropic", "openai", "ollama"} - if !slices.Contains(validProviders, provider) { - err := fmt.Errorf("unsupported llm_provider '%s' (must be one of: %s)", provider, strings.Join(validProviders, ", ")) - errs = append(errs, newValidationError(err, appendPath(path, mapKeyPath("llm_provider")))) - } - - // Provider-specific API key validation - switch provider { - case "anthropic": - if _, ok := config["anthropic_api_key"]; !ok { - err := errors.New("missing required field 'anthropic_api_key' for anthropic provider") - errs = append(errs, newValidationError(err, path)) - } - case "openai": - if _, ok := config["openai_api_key"]; !ok { - err := errors.New("missing required field 'openai_api_key' for openai provider") - errs = append(errs, newValidationError(err, path)) - } - case "ollama": - if _, ok := config["ollama_url"]; !ok { - err := errors.New("missing required field 'ollama_url' for ollama provider") - errs = append(errs, newValidationError(err, path)) - } - } - } - } - - return errs + return result } func validateCPUs(value *string, path []string) []error { diff --git a/server/internal/api/apiv1/validate_test.go b/server/internal/api/apiv1/validate_test.go index 18b5d7b4..30725be0 100644 --- a/server/internal/api/apiv1/validate_test.go +++ b/server/internal/api/apiv1/validate_test.go @@ -621,8 +621,8 @@ func TestValidateDatabaseSpec(t *testing.T) { }, }, expected: []string{ - "services[0].config: missing required field 'llm_model'", - "services[0].config[llm_provider]: unsupported llm_provider 'unknown'", + "services[0].config: llm_model is required", + "services[0].config: llm_provider must be one of: anthropic, openai, ollama", }, }, { @@ -860,7 +860,7 @@ func TestValidateServiceSpec(t *testing.T) { }, }, expected: []string{ - "config: missing required field 'llm_provider'", + "config: llm_provider is required", }, }, { @@ -875,7 +875,7 @@ func TestValidateServiceSpec(t *testing.T) { }, }, expected: []string{ - "config: missing required field 'llm_model'", + "config: llm_model is required", }, }, { @@ -891,7 +891,7 @@ func TestValidateServiceSpec(t *testing.T) { }, }, expected: []string{ - "config[llm_provider]: unsupported llm_provider 'unknown'", + "config: llm_provider must be one of: anthropic, openai, ollama", }, }, { @@ -907,7 +907,7 @@ func TestValidateServiceSpec(t *testing.T) { }, }, expected: []string{ - "config: missing required field 'anthropic_api_key'", + "config: anthropic_api_key is required when llm_provider is \"anthropic\"", }, }, { @@ -923,7 +923,7 @@ func TestValidateServiceSpec(t *testing.T) { }, }, expected: []string{ - "config: missing required field 'openai_api_key'", + "config: openai_api_key is required when llm_provider is \"openai\"", }, }, { @@ -939,7 +939,7 @@ func TestValidateServiceSpec(t *testing.T) { }, }, expected: []string{ - "config: missing required field 'ollama_url'", + "config: ollama_url is required when llm_provider is \"ollama\"", }, }, { @@ -1045,7 +1045,7 @@ func TestValidateServiceSpec(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - err := errors.Join(validateServiceSpec(tc.svc, nil)...) + err := errors.Join(validateServiceSpec(tc.svc, nil, false)...) if len(tc.expected) < 1 { assert.NoError(t, err) } else { diff --git a/server/internal/database/mcp_service_config.go b/server/internal/database/mcp_service_config.go new file mode 100644 index 00000000..f28f0c62 --- /dev/null +++ b/server/internal/database/mcp_service_config.go @@ -0,0 +1,448 @@ +package database + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" +) + +// MCPServiceUser represents a bootstrap user account for the MCP service. +type MCPServiceUser struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// MCPServiceConfig is the typed internal representation of MCP service configuration. +// It is parsed from the ServiceSpec.Config map[string]any and validated. +type MCPServiceConfig struct { + // Required + LLMProvider string `json:"llm_provider"` + LLMModel string `json:"llm_model"` + AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` + OpenAIAPIKey *string `json:"openai_api_key,omitempty"` + OllamaURL *string `json:"ollama_url,omitempty"` + + // Optional - security + AllowWrites *bool `json:"allow_writes,omitempty"` + InitToken *string `json:"init_token,omitempty"` + InitUsers []MCPServiceUser `json:"init_users,omitempty"` + + // Optional - embeddings + EmbeddingProvider *string `json:"embedding_provider,omitempty"` + EmbeddingModel *string `json:"embedding_model,omitempty"` + EmbeddingAPIKey *string `json:"embedding_api_key,omitempty"` + + // Optional - LLM tuning (overridable defaults) + LLMTemperature *float64 `json:"llm_temperature,omitempty"` + LLMMaxTokens *int `json:"llm_max_tokens,omitempty"` + + // Optional - connection pool (overridable defaults) + PoolMaxConns *int `json:"pool_max_conns,omitempty"` + + // Optional - tool toggles (all enabled by default) + DisableQueryDatabase *bool `json:"disable_query_database,omitempty"` + DisableGetSchemaInfo *bool `json:"disable_get_schema_info,omitempty"` + DisableSimilaritySearch *bool `json:"disable_similarity_search,omitempty"` + DisableExecuteExplain *bool `json:"disable_execute_explain,omitempty"` + DisableGenerateEmbedding *bool `json:"disable_generate_embedding,omitempty"` + DisableSearchKnowledgebase *bool `json:"disable_search_knowledgebase,omitempty"` + DisableCountRows *bool `json:"disable_count_rows,omitempty"` +} + +// mcpKnownKeys is the set of all valid config keys for MCP service configuration. +var mcpKnownKeys = map[string]bool{ + "llm_provider": true, + "llm_model": true, + "anthropic_api_key": true, + "openai_api_key": true, + "ollama_url": true, + "allow_writes": true, + "init_token": true, + "init_users": true, + "embedding_provider": true, + "embedding_model": true, + "embedding_api_key": true, + "llm_temperature": true, + "llm_max_tokens": true, + "pool_max_conns": true, + "disable_query_database": true, + "disable_get_schema_info": true, + "disable_similarity_search": true, + "disable_execute_explain": true, + "disable_generate_embedding": true, + "disable_search_knowledgebase": true, + "disable_count_rows": true, +} + +var validLLMProviders = []string{"anthropic", "openai", "ollama"} +var validEmbeddingProviders = []string{"voyage", "openai", "ollama"} + +// ParseMCPServiceConfig parses and validates a config map into a typed MCPServiceConfig. +// If isUpdate is true, bootstrap-only fields (init_token, init_users) are rejected. +func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceConfig, []error) { + var errs []error + + // Check for unknown keys + errs = append(errs, validateUnknownKeys(config)...) + + // Check for bootstrap-only fields on update + if isUpdate { + if _, ok := config["init_token"]; ok { + errs = append(errs, fmt.Errorf("init_token can only be set during initial provisioning")) + } + if _, ok := config["init_users"]; ok { + errs = append(errs, fmt.Errorf("init_users can only be set during initial provisioning")) + } + } + + // Parse required string fields + llmProvider, providerErrs := requireString(config, "llm_provider") + errs = append(errs, providerErrs...) + + llmModel, modelErrs := requireString(config, "llm_model") + errs = append(errs, modelErrs...) + + // Validate llm_provider enum + if llmProvider != "" && !slices.Contains(validLLMProviders, llmProvider) { + errs = append(errs, fmt.Errorf("llm_provider must be one of: %s", strings.Join(validLLMProviders, ", "))) + } + + // Provider-specific API key cross-validation + var anthropicKey, openaiKey, ollamaURL *string + if llmProvider != "" && slices.Contains(validLLMProviders, llmProvider) { + switch llmProvider { + case "anthropic": + key, keyErrs := requireStringForProvider(config, "anthropic_api_key", "anthropic") + errs = append(errs, keyErrs...) + if key != "" { + anthropicKey = &key + } + case "openai": + key, keyErrs := requireStringForProvider(config, "openai_api_key", "openai") + errs = append(errs, keyErrs...) + if key != "" { + openaiKey = &key + } + case "ollama": + url, urlErrs := requireStringForProvider(config, "ollama_url", "ollama") + errs = append(errs, urlErrs...) + if url != "" { + ollamaURL = &url + } + } + } + + // Parse optional fields + allowWrites, awErrs := optionalBool(config, "allow_writes") + errs = append(errs, awErrs...) + + initToken, itErrs := optionalString(config, "init_token") + errs = append(errs, itErrs...) + + initUsers, iuErrs := parseInitUsers(config) + errs = append(errs, iuErrs...) + + embeddingProvider, epErrs := optionalString(config, "embedding_provider") + errs = append(errs, epErrs...) + + embeddingModel, emErrs := optionalString(config, "embedding_model") + errs = append(errs, emErrs...) + + embeddingAPIKey, eakErrs := optionalString(config, "embedding_api_key") + errs = append(errs, eakErrs...) + + llmTemperature, ltErrs := optionalFloat64(config, "llm_temperature") + errs = append(errs, ltErrs...) + + llmMaxTokens, lmtErrs := optionalInt(config, "llm_max_tokens") + errs = append(errs, lmtErrs...) + + poolMaxConns, pmcErrs := optionalInt(config, "pool_max_conns") + errs = append(errs, pmcErrs...) + + // Tool toggles + disableQueryDB, dqErrs := optionalBool(config, "disable_query_database") + errs = append(errs, dqErrs...) + disableGetSchema, dgsErrs := optionalBool(config, "disable_get_schema_info") + errs = append(errs, dgsErrs...) + disableSimilarity, dssErrs := optionalBool(config, "disable_similarity_search") + errs = append(errs, dssErrs...) + disableExplain, deErrs := optionalBool(config, "disable_execute_explain") + errs = append(errs, deErrs...) + disableGenEmbed, dgeErrs := optionalBool(config, "disable_generate_embedding") + errs = append(errs, dgeErrs...) + disableSearchKB, dskErrs := optionalBool(config, "disable_search_knowledgebase") + errs = append(errs, dskErrs...) + disableCountRows, dcrErrs := optionalBool(config, "disable_count_rows") + errs = append(errs, dcrErrs...) + + // Range validations + if llmTemperature != nil { + if *llmTemperature < 0.0 || *llmTemperature > 2.0 { + errs = append(errs, fmt.Errorf("llm_temperature must be between 0.0 and 2.0")) + } + } + if llmMaxTokens != nil { + if *llmMaxTokens <= 0 { + errs = append(errs, fmt.Errorf("llm_max_tokens must be a positive integer")) + } + } + if poolMaxConns != nil { + if *poolMaxConns <= 0 { + errs = append(errs, fmt.Errorf("pool_max_conns must be a positive integer")) + } + } + + // Embedding config cross-validation + if embeddingProvider != nil { + if !slices.Contains(validEmbeddingProviders, *embeddingProvider) { + errs = append(errs, fmt.Errorf("embedding_provider must be one of: %s", strings.Join(validEmbeddingProviders, ", "))) + } else { + if embeddingModel == nil { + errs = append(errs, fmt.Errorf("embedding_model is required when embedding_provider is set")) + } + // Providers that require an API key + switch *embeddingProvider { + case "voyage", "openai": + if embeddingAPIKey == nil { + errs = append(errs, fmt.Errorf("embedding_api_key is required when embedding_provider is %q", *embeddingProvider)) + } + } + } + } + + if len(errs) > 0 { + return nil, errs + } + + return &MCPServiceConfig{ + LLMProvider: llmProvider, + LLMModel: llmModel, + AnthropicAPIKey: anthropicKey, + OpenAIAPIKey: openaiKey, + OllamaURL: ollamaURL, + AllowWrites: allowWrites, + InitToken: initToken, + InitUsers: initUsers, + EmbeddingProvider: embeddingProvider, + EmbeddingModel: embeddingModel, + EmbeddingAPIKey: embeddingAPIKey, + LLMTemperature: llmTemperature, + LLMMaxTokens: llmMaxTokens, + PoolMaxConns: poolMaxConns, + DisableQueryDatabase: disableQueryDB, + DisableGetSchemaInfo: disableGetSchema, + DisableSimilaritySearch: disableSimilarity, + DisableExecuteExplain: disableExplain, + DisableGenerateEmbedding: disableGenEmbed, + DisableSearchKnowledgebase: disableSearchKB, + DisableCountRows: disableCountRows, + }, nil +} + +// validateUnknownKeys checks for keys not in the known set. +func validateUnknownKeys(config map[string]any) []error { + var unknown []string + for k := range config { + if !mcpKnownKeys[k] { + unknown = append(unknown, k) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + if len(unknown) == 1 { + return []error{fmt.Errorf("unknown config key %q", unknown[0])} + } + quoted := make([]string, len(unknown)) + for i, k := range unknown { + quoted[i] = fmt.Sprintf("%q", k) + } + return []error{fmt.Errorf("unknown config keys: %s", strings.Join(quoted, ", "))} +} + +// requireString extracts a required non-empty string from the config map. +func requireString(config map[string]any, key string) (string, []error) { + val, ok := config[key] + if !ok { + return "", []error{fmt.Errorf("%s is required", key)} + } + s, ok := val.(string) + if !ok { + return "", []error{fmt.Errorf("%s must be a string", key)} + } + if s == "" { + return "", []error{fmt.Errorf("%s must not be empty", key)} + } + return s, nil +} + +// requireStringForProvider extracts a required non-empty string for a specific provider. +func requireStringForProvider(config map[string]any, key, provider string) (string, []error) { + val, ok := config[key] + if !ok { + return "", []error{fmt.Errorf("%s is required when llm_provider is %q", key, provider)} + } + s, ok := val.(string) + if !ok { + return "", []error{fmt.Errorf("%s must be a string", key)} + } + if s == "" { + return "", []error{fmt.Errorf("%s must not be empty", key)} + } + return s, nil +} + +// optionalString extracts an optional string from the config map. +func optionalString(config map[string]any, key string) (*string, []error) { + val, ok := config[key] + if !ok { + return nil, nil + } + s, ok := val.(string) + if !ok { + return nil, []error{fmt.Errorf("%s must be a string", key)} + } + if s == "" { + return nil, []error{fmt.Errorf("%s must not be empty", key)} + } + return &s, nil +} + +// optionalBool extracts an optional boolean from the config map. +func optionalBool(config map[string]any, key string) (*bool, []error) { + val, ok := config[key] + if !ok { + return nil, nil + } + b, ok := val.(bool) + if !ok { + return nil, []error{fmt.Errorf("%s must be a boolean", key)} + } + return &b, nil +} + +// optionalFloat64 extracts an optional float64 from the config map. +// JSON numbers may arrive as float64 or json.Number. +func optionalFloat64(config map[string]any, key string) (*float64, []error) { + val, ok := config[key] + if !ok { + return nil, nil + } + switch v := val.(type) { + case float64: + return &v, nil + case json.Number: + f, err := v.Float64() + if err != nil { + return nil, []error{fmt.Errorf("%s must be a number", key)} + } + return &f, nil + default: + return nil, []error{fmt.Errorf("%s must be a number", key)} + } +} + +// optionalInt extracts an optional integer from the config map. +// JSON numbers arrive as float64; we reject non-integer values. +func optionalInt(config map[string]any, key string) (*int, []error) { + val, ok := config[key] + if !ok { + return nil, nil + } + switch v := val.(type) { + case float64: + i := int(v) + if float64(i) != v { + return nil, []error{fmt.Errorf("%s must be an integer", key)} + } + return &i, nil + case json.Number: + i, err := v.Int64() + if err != nil { + return nil, []error{fmt.Errorf("%s must be an integer", key)} + } + intVal := int(i) + return &intVal, nil + default: + return nil, []error{fmt.Errorf("%s must be an integer", key)} + } +} + +// parseInitUsers extracts and validates the init_users field. +func parseInitUsers(config map[string]any) ([]MCPServiceUser, []error) { + val, ok := config["init_users"] + if !ok { + return nil, nil + } + + arr, ok := val.([]any) + if !ok { + return nil, []error{fmt.Errorf("init_users must be an array")} + } + if len(arr) == 0 { + return nil, []error{fmt.Errorf("init_users must contain at least one entry")} + } + + var errs []error + users := make([]MCPServiceUser, 0, len(arr)) + seenUsernames := make(map[string]bool, len(arr)) + + for i, entry := range arr { + m, ok := entry.(map[string]any) + if !ok { + errs = append(errs, fmt.Errorf("init_users[%d] must be an object", i)) + continue + } + + username, uOk := m["username"] + password, pOk := m["password"] + + if !uOk { + errs = append(errs, fmt.Errorf("init_users[%d].username is required", i)) + } + if !pOk { + errs = append(errs, fmt.Errorf("init_users[%d].password is required", i)) + } + + var usernameStr, passwordStr string + if uOk { + usernameStr, ok = username.(string) + if !ok { + errs = append(errs, fmt.Errorf("init_users[%d].username must be a string", i)) + } else if usernameStr == "" { + errs = append(errs, fmt.Errorf("init_users[%d].username must not be empty", i)) + } + } + if pOk { + passwordStr, ok = password.(string) + if !ok { + errs = append(errs, fmt.Errorf("init_users[%d].password must be a string", i)) + } else if passwordStr == "" { + errs = append(errs, fmt.Errorf("init_users[%d].password must not be empty", i)) + } + } + + if usernameStr != "" { + if seenUsernames[usernameStr] { + errs = append(errs, fmt.Errorf("init_users contains duplicate username %q", usernameStr)) + } + seenUsernames[usernameStr] = true + } + + if usernameStr != "" && passwordStr != "" { + users = append(users, MCPServiceUser{ + Username: usernameStr, + Password: passwordStr, + }) + } + } + + if len(errs) > 0 { + return nil, errs + } + return users, nil +} diff --git a/server/internal/database/mcp_service_config_test.go b/server/internal/database/mcp_service_config_test.go new file mode 100644 index 00000000..64edbe2d --- /dev/null +++ b/server/internal/database/mcp_service_config_test.go @@ -0,0 +1,826 @@ +package database_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ptr helpers to keep test cases terse. +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func intPtr(i int) *int { return &i } +func float64Ptr(f float64) *float64 { return &f } + +// anthropicBase returns a minimal valid config for the anthropic provider. +func anthropicBase() map[string]any { + return map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-3-5-sonnet-20241022", + "anthropic_api_key": "sk-ant-key", + } +} + +// openaiBase returns a minimal valid config for the openai provider. +func openaiBase() map[string]any { + return map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4o", + "openai_api_key": "sk-openai-key", + } +} + +// ollamaBase returns a minimal valid config for the ollama provider. +func ollamaBase() map[string]any { + return map[string]any{ + "llm_provider": "ollama", + "llm_model": "llama3.2", + "ollama_url": "http://localhost:11434", + } +} + +// joinedErr joins a []error into a single error for assertion convenience. +func joinedErr(errs []error) error { + return errors.Join(errs...) +} + +func TestParseMCPServiceConfig(t *testing.T) { + t.Run("happy paths", func(t *testing.T) { + t.Run("minimal anthropic config", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(anthropicBase(), false) + require.Empty(t, errs) + assert.Equal(t, "anthropic", cfg.LLMProvider) + assert.Equal(t, "claude-3-5-sonnet-20241022", cfg.LLMModel) + require.NotNil(t, cfg.AnthropicAPIKey) + assert.Equal(t, "sk-ant-key", *cfg.AnthropicAPIKey) + assert.Nil(t, cfg.OpenAIAPIKey) + assert.Nil(t, cfg.OllamaURL) + }) + + t.Run("minimal openai config", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(openaiBase(), false) + require.Empty(t, errs) + assert.Equal(t, "openai", cfg.LLMProvider) + assert.Equal(t, "gpt-4o", cfg.LLMModel) + require.NotNil(t, cfg.OpenAIAPIKey) + assert.Equal(t, "sk-openai-key", *cfg.OpenAIAPIKey) + assert.Nil(t, cfg.AnthropicAPIKey) + assert.Nil(t, cfg.OllamaURL) + }) + + t.Run("minimal ollama config", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(ollamaBase(), false) + require.Empty(t, errs) + assert.Equal(t, "ollama", cfg.LLMProvider) + assert.Equal(t, "llama3.2", cfg.LLMModel) + require.NotNil(t, cfg.OllamaURL) + assert.Equal(t, "http://localhost:11434", *cfg.OllamaURL) + assert.Nil(t, cfg.AnthropicAPIKey) + assert.Nil(t, cfg.OpenAIAPIKey) + }) + + t.Run("all optional fields populated", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-3-5-sonnet-20241022", + "anthropic_api_key": "sk-ant-key", + "allow_writes": true, + "init_token": "my-init-token", + "init_users": []any{map[string]any{"username": "alice", "password": "secret"}}, + "embedding_provider": "voyage", + "embedding_model": "voyage-3", + "embedding_api_key": "voy-key", + "llm_temperature": float64(0.7), + "llm_max_tokens": float64(2048), + "pool_max_conns": float64(10), + "disable_query_database": true, + "disable_get_schema_info": false, + "disable_similarity_search": true, + "disable_execute_explain": false, + "disable_generate_embedding": true, + "disable_search_knowledgebase": false, + "disable_count_rows": true, + } + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + + assert.Equal(t, "anthropic", cfg.LLMProvider) + assert.Equal(t, "claude-3-5-sonnet-20241022", cfg.LLMModel) + require.NotNil(t, cfg.AllowWrites) + assert.True(t, *cfg.AllowWrites) + require.NotNil(t, cfg.InitToken) + assert.Equal(t, "my-init-token", *cfg.InitToken) + require.Len(t, cfg.InitUsers, 1) + assert.Equal(t, "alice", cfg.InitUsers[0].Username) + assert.Equal(t, "secret", cfg.InitUsers[0].Password) + require.NotNil(t, cfg.EmbeddingProvider) + assert.Equal(t, "voyage", *cfg.EmbeddingProvider) + require.NotNil(t, cfg.EmbeddingModel) + assert.Equal(t, "voyage-3", *cfg.EmbeddingModel) + require.NotNil(t, cfg.EmbeddingAPIKey) + assert.Equal(t, "voy-key", *cfg.EmbeddingAPIKey) + require.NotNil(t, cfg.LLMTemperature) + assert.InDelta(t, 0.7, *cfg.LLMTemperature, 1e-9) + require.NotNil(t, cfg.LLMMaxTokens) + assert.Equal(t, 2048, *cfg.LLMMaxTokens) + require.NotNil(t, cfg.PoolMaxConns) + assert.Equal(t, 10, *cfg.PoolMaxConns) + require.NotNil(t, cfg.DisableQueryDatabase) + assert.True(t, *cfg.DisableQueryDatabase) + require.NotNil(t, cfg.DisableGetSchemaInfo) + assert.False(t, *cfg.DisableGetSchemaInfo) + require.NotNil(t, cfg.DisableSimilaritySearch) + assert.True(t, *cfg.DisableSimilaritySearch) + require.NotNil(t, cfg.DisableExecuteExplain) + assert.False(t, *cfg.DisableExecuteExplain) + require.NotNil(t, cfg.DisableGenerateEmbedding) + assert.True(t, *cfg.DisableGenerateEmbedding) + require.NotNil(t, cfg.DisableSearchKnowledgebase) + assert.False(t, *cfg.DisableSearchKnowledgebase) + require.NotNil(t, cfg.DisableCountRows) + assert.True(t, *cfg.DisableCountRows) + }) + }) + + t.Run("required fields", func(t *testing.T) { + t.Run("missing llm_provider", func(t *testing.T) { + config := map[string]any{ + "llm_model": "claude-3-5-sonnet-20241022", + "anthropic_api_key": "sk-ant-key", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_provider is required") + }) + + t.Run("missing llm_model", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "anthropic_api_key": "sk-ant-key", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_model is required") + }) + + t.Run("empty llm_provider", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "", + "llm_model": "claude-3-5-sonnet-20241022", + "anthropic_api_key": "sk-ant-key", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_provider must not be empty") + }) + + t.Run("empty llm_model", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "llm_model": "", + "anthropic_api_key": "sk-ant-key", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_model must not be empty") + }) + }) + + t.Run("provider cross-validation", func(t *testing.T) { + t.Run("anthropic without anthropic_api_key", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-3-5-sonnet-20241022", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "anthropic_api_key is required when llm_provider is") + }) + + t.Run("anthropic with empty anthropic_api_key", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-3-5-sonnet-20241022", + "anthropic_api_key": "", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "anthropic_api_key must not be empty") + }) + + t.Run("openai without openai_api_key", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4o", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "openai_api_key is required when llm_provider is") + }) + + t.Run("openai with empty openai_api_key", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4o", + "openai_api_key": "", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "openai_api_key must not be empty") + }) + + t.Run("ollama without ollama_url", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "ollama", + "llm_model": "llama3.2", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "ollama_url is required when llm_provider is") + }) + + t.Run("ollama with empty ollama_url", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "ollama", + "llm_model": "llama3.2", + "ollama_url": "", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "ollama_url must not be empty") + }) + }) + + t.Run("invalid provider", func(t *testing.T) { + t.Run("unknown llm_provider value", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "bedrock", + "llm_model": "some-model", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + combined := joinedErr(errs).Error() + assert.Contains(t, combined, "llm_provider must be one of") + assert.Contains(t, combined, "anthropic") + assert.Contains(t, combined, "openai") + assert.Contains(t, combined, "ollama") + }) + }) + + t.Run("type errors", func(t *testing.T) { + t.Run("llm_provider wrong type", func(t *testing.T) { + config := map[string]any{ + "llm_provider": 42, + "llm_model": "some-model", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_provider must be a string") + }) + + t.Run("llm_model wrong type", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "llm_model": true, + "anthropic_api_key": "sk-ant-key", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_model must be a string") + }) + + t.Run("anthropic_api_key wrong type", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-3-5-sonnet-20241022", + "anthropic_api_key": 12345, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "anthropic_api_key must be a string") + }) + + t.Run("allow_writes wrong type", func(t *testing.T) { + config := anthropicBase() + config["allow_writes"] = "yes" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "allow_writes must be a boolean") + }) + + t.Run("llm_temperature wrong type (string)", func(t *testing.T) { + config := anthropicBase() + config["llm_temperature"] = "warm" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_temperature must be a number") + }) + + t.Run("llm_max_tokens wrong type (string)", func(t *testing.T) { + config := anthropicBase() + config["llm_max_tokens"] = "lots" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_max_tokens must be an integer") + }) + + t.Run("pool_max_conns wrong type (bool)", func(t *testing.T) { + config := anthropicBase() + config["pool_max_conns"] = false + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "pool_max_conns must be an integer") + }) + + t.Run("llm_max_tokens non-integer float", func(t *testing.T) { + config := anthropicBase() + config["llm_max_tokens"] = float64(10.5) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_max_tokens must be an integer") + }) + + t.Run("init_token wrong type", func(t *testing.T) { + config := anthropicBase() + config["init_token"] = 9999 + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_token must be a string") + }) + }) + + t.Run("unknown keys", func(t *testing.T) { + t.Run("single unknown key", func(t *testing.T) { + config := anthropicBase() + config["mystery_field"] = "value" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), `"mystery_field"`) + }) + + t.Run("multiple unknown keys", func(t *testing.T) { + config := anthropicBase() + config["aaa_unknown"] = "x" + config["zzz_unknown"] = "y" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + combined := joinedErr(errs).Error() + assert.Contains(t, combined, `"aaa_unknown"`) + assert.Contains(t, combined, `"zzz_unknown"`) + }) + }) + + t.Run("optional field validation", func(t *testing.T) { + t.Run("llm_temperature below zero", func(t *testing.T) { + config := anthropicBase() + config["llm_temperature"] = float64(-0.1) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_temperature must be between 0.0 and 2.0") + }) + + t.Run("llm_temperature above 2.0", func(t *testing.T) { + config := anthropicBase() + config["llm_temperature"] = float64(2.1) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_temperature must be between 0.0 and 2.0") + }) + + t.Run("llm_temperature boundary values are valid", func(t *testing.T) { + for _, temp := range []float64{0.0, 2.0} { + config := anthropicBase() + config["llm_temperature"] = temp + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.LLMTemperature) + assert.InDelta(t, temp, *cfg.LLMTemperature, 1e-9) + } + }) + + t.Run("llm_max_tokens zero", func(t *testing.T) { + config := anthropicBase() + config["llm_max_tokens"] = float64(0) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_max_tokens must be a positive integer") + }) + + t.Run("llm_max_tokens negative", func(t *testing.T) { + config := anthropicBase() + config["llm_max_tokens"] = float64(-100) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_max_tokens must be a positive integer") + }) + + t.Run("pool_max_conns zero", func(t *testing.T) { + config := anthropicBase() + config["pool_max_conns"] = float64(0) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "pool_max_conns must be a positive integer") + }) + + t.Run("pool_max_conns negative", func(t *testing.T) { + config := anthropicBase() + config["pool_max_conns"] = float64(-5) + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "pool_max_conns must be a positive integer") + }) + }) + + t.Run("embedding config", func(t *testing.T) { + t.Run("valid voyage embedding config", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "voyage" + config["embedding_model"] = "voyage-3" + config["embedding_api_key"] = "voy-key" + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.EmbeddingProvider) + assert.Equal(t, "voyage", *cfg.EmbeddingProvider) + require.NotNil(t, cfg.EmbeddingModel) + assert.Equal(t, "voyage-3", *cfg.EmbeddingModel) + require.NotNil(t, cfg.EmbeddingAPIKey) + assert.Equal(t, "voy-key", *cfg.EmbeddingAPIKey) + }) + + t.Run("valid openai embedding config", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "openai" + config["embedding_model"] = "text-embedding-3-small" + config["embedding_api_key"] = "sk-embed-key" + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.EmbeddingProvider) + assert.Equal(t, "openai", *cfg.EmbeddingProvider) + }) + + t.Run("valid ollama embedding config (no api key required)", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "ollama" + config["embedding_model"] = "nomic-embed-text" + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.EmbeddingProvider) + assert.Equal(t, "ollama", *cfg.EmbeddingProvider) + assert.Nil(t, cfg.EmbeddingAPIKey) + }) + + t.Run("embedding_provider without embedding_model", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "voyage" + config["embedding_api_key"] = "voy-key" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "embedding_model is required when embedding_provider is set") + }) + + t.Run("voyage without embedding_api_key", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "voyage" + config["embedding_model"] = "voyage-3" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), `embedding_api_key is required when embedding_provider is "voyage"`) + }) + + t.Run("openai embedding without embedding_api_key", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "openai" + config["embedding_model"] = "text-embedding-3-small" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), `embedding_api_key is required when embedding_provider is "openai"`) + }) + + t.Run("unknown embedding_provider", func(t *testing.T) { + config := anthropicBase() + config["embedding_provider"] = "bedrock" + config["embedding_model"] = "some-model" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "embedding_provider must be one of") + }) + }) + + t.Run("init_users", func(t *testing.T) { + t.Run("valid single user", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice", "password": "hunter2"}, + } + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.Len(t, cfg.InitUsers, 1) + assert.Equal(t, "alice", cfg.InitUsers[0].Username) + assert.Equal(t, "hunter2", cfg.InitUsers[0].Password) + }) + + t.Run("valid multiple users", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice", "password": "pass1"}, + map[string]any{"username": "bob", "password": "pass2"}, + } + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.Len(t, cfg.InitUsers, 2) + assert.Equal(t, "alice", cfg.InitUsers[0].Username) + assert.Equal(t, "bob", cfg.InitUsers[1].Username) + }) + + t.Run("missing username", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"password": "secret"}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0].username is required") + }) + + t.Run("missing password", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice"}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0].password is required") + }) + + t.Run("empty username", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "", "password": "secret"}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0].username must not be empty") + }) + + t.Run("empty password", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice", "password": ""}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0].password must not be empty") + }) + + t.Run("duplicate usernames", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice", "password": "pass1"}, + map[string]any{"username": "alice", "password": "pass2"}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), `duplicate username "alice"`) + }) + + t.Run("empty array rejected", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users must contain at least one entry") + }) + + t.Run("non-array type", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = "alice" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users must be an array") + }) + + t.Run("non-object entry", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{"alice"} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0] must be an object") + }) + + t.Run("username wrong type", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": 42, "password": "secret"}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0].username must be a string") + }) + + t.Run("password wrong type", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice", "password": true}, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users[0].password must be a string") + }) + }) + + t.Run("init_token", func(t *testing.T) { + t.Run("valid token string", func(t *testing.T) { + config := anthropicBase() + config["init_token"] = "my-secret-token" + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.InitToken) + assert.Equal(t, "my-secret-token", *cfg.InitToken) + }) + }) + + t.Run("isUpdate=true", func(t *testing.T) { + t.Run("init_token rejected on update", func(t *testing.T) { + config := anthropicBase() + config["init_token"] = "some-token" + _, errs := database.ParseMCPServiceConfig(config, true) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_token can only be set during initial provisioning") + }) + + t.Run("init_users rejected on update", func(t *testing.T) { + config := anthropicBase() + config["init_users"] = []any{ + map[string]any{"username": "alice", "password": "pass"}, + } + _, errs := database.ParseMCPServiceConfig(config, true) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "init_users can only be set during initial provisioning") + }) + + t.Run("valid update without bootstrap fields", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(anthropicBase(), true) + require.Empty(t, errs) + assert.Equal(t, "anthropic", cfg.LLMProvider) + assert.Nil(t, cfg.InitToken) + assert.Nil(t, cfg.InitUsers) + }) + }) + + t.Run("multiple errors", func(t *testing.T) { + t.Run("missing both required fields returns multiple errors", func(t *testing.T) { + config := map[string]any{} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + // Both errors are separate entries in the slice + combined := joinedErr(errs).Error() + assert.Contains(t, combined, "llm_provider is required") + assert.Contains(t, combined, "llm_model is required") + assert.Greater(t, len(errs), 1, "expected multiple errors in slice") + }) + + t.Run("unknown key plus missing required field accumulates errors", func(t *testing.T) { + config := map[string]any{ + "llm_provider": "anthropic", + "mystery_field": "oops", + // llm_model missing, anthropic_api_key missing + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + combined := joinedErr(errs).Error() + assert.Contains(t, combined, "mystery_field") + assert.Contains(t, combined, "llm_model is required") + assert.Contains(t, combined, "anthropic_api_key") + }) + + t.Run("init_token and init_users both rejected on update", func(t *testing.T) { + config := anthropicBase() + config["init_token"] = "tok" + config["init_users"] = []any{map[string]any{"username": "alice", "password": "pass"}} + _, errs := database.ParseMCPServiceConfig(config, true) + require.NotEmpty(t, errs) + combined := joinedErr(errs).Error() + assert.Contains(t, combined, "init_token can only be set during initial provisioning") + assert.Contains(t, combined, "init_users can only be set during initial provisioning") + }) + }) + + t.Run("tool toggles", func(t *testing.T) { + t.Run("all disable fields parsed correctly when true", func(t *testing.T) { + config := anthropicBase() + config["disable_query_database"] = true + config["disable_get_schema_info"] = true + config["disable_similarity_search"] = true + config["disable_execute_explain"] = true + config["disable_generate_embedding"] = true + config["disable_search_knowledgebase"] = true + config["disable_count_rows"] = true + + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + + require.NotNil(t, cfg.DisableQueryDatabase) + assert.True(t, *cfg.DisableQueryDatabase) + require.NotNil(t, cfg.DisableGetSchemaInfo) + assert.True(t, *cfg.DisableGetSchemaInfo) + require.NotNil(t, cfg.DisableSimilaritySearch) + assert.True(t, *cfg.DisableSimilaritySearch) + require.NotNil(t, cfg.DisableExecuteExplain) + assert.True(t, *cfg.DisableExecuteExplain) + require.NotNil(t, cfg.DisableGenerateEmbedding) + assert.True(t, *cfg.DisableGenerateEmbedding) + require.NotNil(t, cfg.DisableSearchKnowledgebase) + assert.True(t, *cfg.DisableSearchKnowledgebase) + require.NotNil(t, cfg.DisableCountRows) + assert.True(t, *cfg.DisableCountRows) + }) + + t.Run("all disable fields parsed correctly when false", func(t *testing.T) { + config := anthropicBase() + config["disable_query_database"] = false + config["disable_get_schema_info"] = false + config["disable_similarity_search"] = false + config["disable_execute_explain"] = false + config["disable_generate_embedding"] = false + config["disable_search_knowledgebase"] = false + config["disable_count_rows"] = false + + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + + require.NotNil(t, cfg.DisableQueryDatabase) + assert.False(t, *cfg.DisableQueryDatabase) + require.NotNil(t, cfg.DisableGetSchemaInfo) + assert.False(t, *cfg.DisableGetSchemaInfo) + require.NotNil(t, cfg.DisableSimilaritySearch) + assert.False(t, *cfg.DisableSimilaritySearch) + require.NotNil(t, cfg.DisableExecuteExplain) + assert.False(t, *cfg.DisableExecuteExplain) + require.NotNil(t, cfg.DisableGenerateEmbedding) + assert.False(t, *cfg.DisableGenerateEmbedding) + require.NotNil(t, cfg.DisableSearchKnowledgebase) + assert.False(t, *cfg.DisableSearchKnowledgebase) + require.NotNil(t, cfg.DisableCountRows) + assert.False(t, *cfg.DisableCountRows) + }) + + t.Run("disable fields absent when not specified", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(anthropicBase(), false) + require.Empty(t, errs) + assert.Nil(t, cfg.DisableQueryDatabase) + assert.Nil(t, cfg.DisableGetSchemaInfo) + assert.Nil(t, cfg.DisableSimilaritySearch) + assert.Nil(t, cfg.DisableExecuteExplain) + assert.Nil(t, cfg.DisableGenerateEmbedding) + assert.Nil(t, cfg.DisableSearchKnowledgebase) + assert.Nil(t, cfg.DisableCountRows) + }) + }) + + t.Run("json.Number types", func(t *testing.T) { + t.Run("llm_temperature as json.Number", func(t *testing.T) { + config := anthropicBase() + config["llm_temperature"] = json.Number("1.5") + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.LLMTemperature) + assert.InDelta(t, 1.5, *cfg.LLMTemperature, 1e-9) + }) + + t.Run("llm_max_tokens as json.Number", func(t *testing.T) { + config := anthropicBase() + config["llm_max_tokens"] = json.Number("4096") + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.LLMMaxTokens) + assert.Equal(t, 4096, *cfg.LLMMaxTokens) + }) + + t.Run("pool_max_conns as json.Number", func(t *testing.T) { + config := anthropicBase() + config["pool_max_conns"] = json.Number("20") + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.PoolMaxConns) + assert.Equal(t, 20, *cfg.PoolMaxConns) + }) + + t.Run("llm_max_tokens as non-integer json.Number", func(t *testing.T) { + config := anthropicBase() + config["llm_max_tokens"] = json.Number("10.5") + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_max_tokens must be an integer") + }) + }) +} + +// Ensure the unused ptr helpers don't cause compilation errors. +var _ = strPtr +var _ = boolPtr +var _ = intPtr +var _ = float64Ptr diff --git a/server/internal/orchestrator/swarm/mcp_auth_files.go b/server/internal/orchestrator/swarm/mcp_auth_files.go new file mode 100644 index 00000000..cb93aba3 --- /dev/null +++ b/server/internal/orchestrator/swarm/mcp_auth_files.go @@ -0,0 +1,119 @@ +package swarm + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/pgEdge/control-plane/server/internal/database" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +// mcpTokenStore mirrors the MCP server's TokenStore YAML format. +type mcpTokenStore struct { + Tokens map[string]*mcpToken `yaml:"tokens"` +} + +// mcpToken mirrors the MCP server's Token struct. +type mcpToken struct { + Hash string `yaml:"hash"` + ExpiresAt *time.Time `yaml:"expires_at"` + Annotation string `yaml:"annotation"` + CreatedAt time.Time `yaml:"created_at"` + Database string `yaml:"database,omitempty"` +} + +// mcpUserStore mirrors the MCP server's UserStore YAML format. +type mcpUserStore struct { + Users map[string]*mcpUser `yaml:"users"` +} + +// mcpUser mirrors the MCP server's User struct. +type mcpUser struct { + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` + CreatedAt time.Time `yaml:"created_at"` + LastLogin *time.Time `yaml:"last_login,omitempty"` + Enabled bool `yaml:"enabled"` + Annotation string `yaml:"annotation"` + FailedAttempts int `yaml:"failed_attempts"` +} + +// GenerateTokenFile generates a tokens.yaml file from the given init token. +// The token is SHA256-hashed to match the MCP server's auth.HashToken() format. +func GenerateTokenFile(initToken string) ([]byte, error) { + hash := sha256.Sum256([]byte(initToken)) + hashHex := hex.EncodeToString(hash[:]) + + store := &mcpTokenStore{ + Tokens: map[string]*mcpToken{ + "bootstrap-token": { + Hash: hashHex, + CreatedAt: time.Now().UTC(), + Annotation: "Bootstrap token from control plane", + }, + }, + } + + data, err := yaml.Marshal(store) + if err != nil { + return nil, fmt.Errorf("failed to marshal token store to YAML: %w", err) + } + return data, nil +} + +// GenerateUserFile generates a users.yaml file from the given bootstrap users. +// Passwords are bcrypt-hashed with cost 12 to match the MCP server's auth.HashPassword() format. +func GenerateUserFile(users []database.MCPServiceUser) ([]byte, error) { + store := &mcpUserStore{ + Users: make(map[string]*mcpUser, len(users)), + } + + now := time.Now().UTC() + for _, u := range users { + hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12) + if err != nil { + return nil, fmt.Errorf("failed to hash password for user %q: %w", u.Username, err) + } + store.Users[u.Username] = &mcpUser{ + Username: u.Username, + PasswordHash: string(hash), + CreatedAt: now, + Enabled: true, + Annotation: "Bootstrap user from control plane", + FailedAttempts: 0, + } + } + + data, err := yaml.Marshal(store) + if err != nil { + return nil, fmt.Errorf("failed to marshal user store to YAML: %w", err) + } + return data, nil +} + +// GenerateEmptyTokenFile generates an empty but valid tokens.yaml file. +func GenerateEmptyTokenFile() ([]byte, error) { + store := &mcpTokenStore{ + Tokens: make(map[string]*mcpToken), + } + data, err := yaml.Marshal(store) + if err != nil { + return nil, fmt.Errorf("failed to marshal empty token store to YAML: %w", err) + } + return data, nil +} + +// GenerateEmptyUserFile generates an empty but valid users.yaml file. +func GenerateEmptyUserFile() ([]byte, error) { + store := &mcpUserStore{ + Users: make(map[string]*mcpUser), + } + data, err := yaml.Marshal(store) + if err != nil { + return nil, fmt.Errorf("failed to marshal empty user store to YAML: %w", err) + } + return data, nil +} diff --git a/server/internal/orchestrator/swarm/mcp_auth_files_test.go b/server/internal/orchestrator/swarm/mcp_auth_files_test.go new file mode 100644 index 00000000..38dca829 --- /dev/null +++ b/server/internal/orchestrator/swarm/mcp_auth_files_test.go @@ -0,0 +1,318 @@ +package swarm + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + "time" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +func TestGenerateTokenFile(t *testing.T) { + t.Run("valid YAML with tokens top-level key", func(t *testing.T) { + data, err := GenerateTokenFile("my-secret-token") + require.NoError(t, err) + require.NotEmpty(t, data) + + var store mcpTokenStore + err = yaml.Unmarshal(data, &store) + require.NoError(t, err) + assert.NotNil(t, store.Tokens) + }) + + t.Run("bootstrap-token entry exists", func(t *testing.T) { + data, err := GenerateTokenFile("my-secret-token") + require.NoError(t, err) + + var store mcpTokenStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + token, ok := store.Tokens["bootstrap-token"] + require.True(t, ok, "expected 'bootstrap-token' key in tokens map") + assert.NotNil(t, token) + }) + + t.Run("hash matches SHA256 of input", func(t *testing.T) { + input := "super-secret-init-token" + data, err := GenerateTokenFile(input) + require.NoError(t, err) + + var store mcpTokenStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + token := store.Tokens["bootstrap-token"] + require.NotNil(t, token) + + sum := sha256.Sum256([]byte(input)) + expected := hex.EncodeToString(sum[:]) + assert.Equal(t, expected, token.Hash) + }) + + t.Run("annotation is set correctly", func(t *testing.T) { + data, err := GenerateTokenFile("tok") + require.NoError(t, err) + + var store mcpTokenStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + token := store.Tokens["bootstrap-token"] + require.NotNil(t, token) + assert.Equal(t, "Bootstrap token from control plane", token.Annotation) + }) + + t.Run("created_at is set to a recent time", func(t *testing.T) { + before := time.Now().UTC().Add(-2 * time.Second) + data, err := GenerateTokenFile("tok") + require.NoError(t, err) + after := time.Now().UTC().Add(2 * time.Second) + + var store mcpTokenStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + token := store.Tokens["bootstrap-token"] + require.NotNil(t, token) + assert.True(t, token.CreatedAt.After(before), "created_at should be after test start") + assert.True(t, token.CreatedAt.Before(after), "created_at should be before test end") + }) + + t.Run("expires_at is nil", func(t *testing.T) { + data, err := GenerateTokenFile("tok") + require.NoError(t, err) + + var store mcpTokenStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + token := store.Tokens["bootstrap-token"] + require.NotNil(t, token) + assert.Nil(t, token.ExpiresAt) + }) + + t.Run("different tokens produce different hashes", func(t *testing.T) { + data1, err := GenerateTokenFile("token-a") + require.NoError(t, err) + data2, err := GenerateTokenFile("token-b") + require.NoError(t, err) + + var store1, store2 mcpTokenStore + require.NoError(t, yaml.Unmarshal(data1, &store1)) + require.NoError(t, yaml.Unmarshal(data2, &store2)) + + assert.NotEqual(t, store1.Tokens["bootstrap-token"].Hash, store2.Tokens["bootstrap-token"].Hash) + }) +} + +func TestGenerateUserFile(t *testing.T) { + t.Run("single user: valid YAML with users top-level key", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "alice", Password: "password123"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + require.NotEmpty(t, data) + + var store mcpUserStore + err = yaml.Unmarshal(data, &store) + require.NoError(t, err) + assert.NotNil(t, store.Users) + }) + + t.Run("single user: correct username in output", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "alice", Password: "password123"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + user, ok := store.Users["alice"] + require.True(t, ok, "expected 'alice' key in users map") + assert.Equal(t, "alice", user.Username) + }) + + t.Run("single user: password_hash is valid bcrypt matching input", func(t *testing.T) { + password := "s3cr3tP@ssword" + users := []database.MCPServiceUser{ + {Username: "bob", Password: password}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + user := store.Users["bob"] + require.NotNil(t, user) + require.NotEmpty(t, user.PasswordHash) + + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + assert.NoError(t, err, "bcrypt hash should match original password") + }) + + t.Run("single user: enabled is true", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "carol", Password: "pass"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + user := store.Users["carol"] + require.NotNil(t, user) + assert.True(t, user.Enabled) + }) + + t.Run("single user: annotation is set correctly", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "dave", Password: "pass"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + user := store.Users["dave"] + require.NotNil(t, user) + assert.Equal(t, "Bootstrap user from control plane", user.Annotation) + }) + + t.Run("single user: created_at is set to a recent time", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "eve", Password: "pass"}, + } + before := time.Now().UTC().Add(-2 * time.Second) + data, err := GenerateUserFile(users) + require.NoError(t, err) + after := time.Now().UTC().Add(2 * time.Second) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + user := store.Users["eve"] + require.NotNil(t, user) + assert.True(t, user.CreatedAt.After(before), "created_at should be after test start") + assert.True(t, user.CreatedAt.Before(after), "created_at should be before test end") + }) + + t.Run("single user: failed_attempts is zero", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "frank", Password: "pass"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + user := store.Users["frank"] + require.NotNil(t, user) + assert.Equal(t, 0, user.FailedAttempts) + }) + + t.Run("multiple users: all appear in output", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "user1", Password: "pass1"}, + {Username: "user2", Password: "pass2"}, + {Username: "user3", Password: "pass3"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + assert.Len(t, store.Users, 3) + for _, u := range users { + entry, ok := store.Users[u.Username] + require.True(t, ok, "expected user %q in output", u.Username) + assert.Equal(t, u.Username, entry.Username) + + err = bcrypt.CompareHashAndPassword([]byte(entry.PasswordHash), []byte(u.Password)) + assert.NoError(t, err, "bcrypt hash should match password for user %q", u.Username) + } + }) + + t.Run("multiple users: each has unique hash", func(t *testing.T) { + users := []database.MCPServiceUser{ + {Username: "x", Password: "passX"}, + {Username: "y", Password: "passY"}, + } + data, err := GenerateUserFile(users) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + + assert.NotEqual(t, store.Users["x"].PasswordHash, store.Users["y"].PasswordHash) + }) + + t.Run("empty user list produces empty users map", func(t *testing.T) { + data, err := GenerateUserFile([]database.MCPServiceUser{}) + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + assert.Empty(t, store.Users) + }) +} + +func TestGenerateEmptyTokenFile(t *testing.T) { + t.Run("returns no error", func(t *testing.T) { + _, err := GenerateEmptyTokenFile() + assert.NoError(t, err) + }) + + t.Run("valid YAML", func(t *testing.T) { + data, err := GenerateEmptyTokenFile() + require.NoError(t, err) + require.NotEmpty(t, data) + + var raw map[string]any + err = yaml.Unmarshal(data, &raw) + assert.NoError(t, err) + }) + + t.Run("tokens key is present and empty", func(t *testing.T) { + data, err := GenerateEmptyTokenFile() + require.NoError(t, err) + + var store mcpTokenStore + require.NoError(t, yaml.Unmarshal(data, &store)) + assert.Empty(t, store.Tokens) + }) +} + +func TestGenerateEmptyUserFile(t *testing.T) { + t.Run("returns no error", func(t *testing.T) { + _, err := GenerateEmptyUserFile() + assert.NoError(t, err) + }) + + t.Run("valid YAML", func(t *testing.T) { + data, err := GenerateEmptyUserFile() + require.NoError(t, err) + require.NotEmpty(t, data) + + var raw map[string]any + err = yaml.Unmarshal(data, &raw) + assert.NoError(t, err) + }) + + t.Run("users key is present and empty", func(t *testing.T) { + data, err := GenerateEmptyUserFile() + require.NoError(t, err) + + var store mcpUserStore + require.NoError(t, yaml.Unmarshal(data, &store)) + assert.Empty(t, store.Users) + }) +} diff --git a/server/internal/orchestrator/swarm/mcp_config.go b/server/internal/orchestrator/swarm/mcp_config.go new file mode 100644 index 00000000..1e428b92 --- /dev/null +++ b/server/internal/orchestrator/swarm/mcp_config.go @@ -0,0 +1,231 @@ +package swarm + +import ( + "fmt" + + "github.com/pgEdge/control-plane/server/internal/database" + "gopkg.in/yaml.v3" +) + +// mcpYAMLConfig mirrors the MCP server's Config struct for YAML generation. +// Only fields the CP needs to set are included. +type mcpYAMLConfig struct { + HTTP mcpHTTPConfig `yaml:"http"` + Databases []mcpDatabaseConfig `yaml:"databases"` + LLM mcpLLMConfig `yaml:"llm"` + Embedding *mcpEmbeddingConfig `yaml:"embedding,omitempty"` + Builtins mcpBuiltinsConfig `yaml:"builtins"` +} + +type mcpHTTPConfig struct { + Enabled bool `yaml:"enabled"` + Address string `yaml:"address"` + Auth mcpAuthConfig `yaml:"auth"` +} + +type mcpAuthConfig struct { + Enabled bool `yaml:"enabled"` + TokenFile string `yaml:"token_file"` + UserFile string `yaml:"user_file"` +} + +type mcpDatabaseConfig struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Database string `yaml:"database"` + User string `yaml:"user"` + Password string `yaml:"password"` + SSLMode string `yaml:"sslmode"` + AllowWrites bool `yaml:"allow_writes"` + Pool mcpPoolConfig `yaml:"pool"` +} + +type mcpPoolConfig struct { + MaxConns int `yaml:"max_conns"` +} + +type mcpLLMConfig struct { + Enabled bool `yaml:"enabled"` + Provider string `yaml:"provider"` + Model string `yaml:"model"` + AnthropicAPIKey string `yaml:"anthropic_api_key,omitempty"` + OpenAIAPIKey string `yaml:"openai_api_key,omitempty"` + OllamaURL string `yaml:"ollama_url,omitempty"` + Temperature float64 `yaml:"temperature"` + MaxTokens int `yaml:"max_tokens"` +} + +type mcpEmbeddingConfig struct { + Enabled bool `yaml:"enabled"` + Provider string `yaml:"provider"` + Model string `yaml:"model"` + VoyageAPIKey string `yaml:"voyage_api_key,omitempty"` + OpenAIAPIKey string `yaml:"openai_api_key,omitempty"` + OllamaURL string `yaml:"ollama_url,omitempty"` +} + +type mcpBuiltinsConfig struct { + Tools mcpToolsConfig `yaml:"tools"` +} + +type mcpToolsConfig struct { + QueryDatabase *bool `yaml:"query_database,omitempty"` + GetSchemaInfo *bool `yaml:"get_schema_info,omitempty"` + SimilaritySearch *bool `yaml:"similarity_search,omitempty"` + ExecuteExplain *bool `yaml:"execute_explain,omitempty"` + GenerateEmbedding *bool `yaml:"generate_embedding,omitempty"` + SearchKnowledgebase *bool `yaml:"search_knowledgebase,omitempty"` + CountRows *bool `yaml:"count_rows,omitempty"` + // Always disabled — CP owns the DB connection + LLMConnectionSelection *bool `yaml:"llm_connection_selection"` +} + +// MCPConfigParams holds all inputs needed to generate a config.yaml for the MCP server. +type MCPConfigParams struct { + Config *database.MCPServiceConfig + DatabaseName string + DatabaseHost string + DatabasePort int + Username string + Password string +} + +// GenerateMCPConfig generates the YAML config file content for the MCP server. +func GenerateMCPConfig(params *MCPConfigParams) ([]byte, error) { + cfg := params.Config + + // Apply defaults for overridable fields + temperature := 0.7 + if cfg.LLMTemperature != nil { + temperature = *cfg.LLMTemperature + } + maxTokens := 4096 + if cfg.LLMMaxTokens != nil { + maxTokens = *cfg.LLMMaxTokens + } + poolMaxConns := 4 + if cfg.PoolMaxConns != nil { + poolMaxConns = *cfg.PoolMaxConns + } + allowWrites := false + if cfg.AllowWrites != nil { + allowWrites = *cfg.AllowWrites + } + + // Build LLM config + llm := mcpLLMConfig{ + Enabled: true, + Provider: cfg.LLMProvider, + Model: cfg.LLMModel, + Temperature: temperature, + MaxTokens: maxTokens, + } + switch cfg.LLMProvider { + case "anthropic": + if cfg.AnthropicAPIKey != nil { + llm.AnthropicAPIKey = *cfg.AnthropicAPIKey + } + case "openai": + if cfg.OpenAIAPIKey != nil { + llm.OpenAIAPIKey = *cfg.OpenAIAPIKey + } + case "ollama": + if cfg.OllamaURL != nil { + llm.OllamaURL = *cfg.OllamaURL + } + } + + // Build embedding config (only if provider is set) + var embedding *mcpEmbeddingConfig + if cfg.EmbeddingProvider != nil { + emb := &mcpEmbeddingConfig{ + Enabled: true, + Provider: *cfg.EmbeddingProvider, + } + if cfg.EmbeddingModel != nil { + emb.Model = *cfg.EmbeddingModel + } + if cfg.EmbeddingAPIKey != nil { + switch *cfg.EmbeddingProvider { + case "voyage": + emb.VoyageAPIKey = *cfg.EmbeddingAPIKey + case "openai": + emb.OpenAIAPIKey = *cfg.EmbeddingAPIKey + } + } + if *cfg.EmbeddingProvider == "ollama" && cfg.OllamaURL != nil { + emb.OllamaURL = *cfg.OllamaURL + } + embedding = emb + } + + // Build tool toggles + falseVal := false + tools := mcpToolsConfig{ + LLMConnectionSelection: &falseVal, // Always disabled + } + if cfg.DisableQueryDatabase != nil && *cfg.DisableQueryDatabase { + tools.QueryDatabase = boolPtr(false) + } + if cfg.DisableGetSchemaInfo != nil && *cfg.DisableGetSchemaInfo { + tools.GetSchemaInfo = boolPtr(false) + } + if cfg.DisableSimilaritySearch != nil && *cfg.DisableSimilaritySearch { + tools.SimilaritySearch = boolPtr(false) + } + if cfg.DisableExecuteExplain != nil && *cfg.DisableExecuteExplain { + tools.ExecuteExplain = boolPtr(false) + } + if cfg.DisableGenerateEmbedding != nil && *cfg.DisableGenerateEmbedding { + tools.GenerateEmbedding = boolPtr(false) + } + if cfg.DisableSearchKnowledgebase != nil && *cfg.DisableSearchKnowledgebase { + tools.SearchKnowledgebase = boolPtr(false) + } + if cfg.DisableCountRows != nil && *cfg.DisableCountRows { + tools.CountRows = boolPtr(false) + } + + yamlCfg := &mcpYAMLConfig{ + HTTP: mcpHTTPConfig{ + Enabled: true, + Address: ":8080", + Auth: mcpAuthConfig{ + Enabled: true, + TokenFile: "/app/data/tokens.yaml", + UserFile: "/app/data/users.yaml", + }, + }, + Databases: []mcpDatabaseConfig{ + { + Name: params.DatabaseName, + Host: params.DatabaseHost, + Port: params.DatabasePort, + Database: params.DatabaseName, + User: params.Username, + Password: params.Password, + SSLMode: "prefer", + AllowWrites: allowWrites, + Pool: mcpPoolConfig{ + MaxConns: poolMaxConns, + }, + }, + }, + LLM: llm, + Embedding: embedding, + Builtins: mcpBuiltinsConfig{ + Tools: tools, + }, + } + + data, err := yaml.Marshal(yamlCfg) + if err != nil { + return nil, fmt.Errorf("failed to marshal MCP config to YAML: %w", err) + } + return data, nil +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/server/internal/orchestrator/swarm/mcp_config_resource.go b/server/internal/orchestrator/swarm/mcp_config_resource.go new file mode 100644 index 00000000..759799db --- /dev/null +++ b/server/internal/orchestrator/swarm/mcp_config_resource.go @@ -0,0 +1,264 @@ +package swarm + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/samber/do" + "github.com/spf13/afero" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/filesystem" + "github.com/pgEdge/control-plane/server/internal/resource" +) + +var _ resource.Resource = (*MCPConfigResource)(nil) + +const ResourceTypeMCPConfig resource.Type = "swarm.mcp_config" + +func MCPConfigResourceIdentifier(serviceInstanceID string) resource.Identifier { + return resource.Identifier{ + ID: serviceInstanceID, + Type: ResourceTypeMCPConfig, + } +} + +// MCPConfigResource manages the MCP server config files on the host filesystem. +// It follows the same pattern as PatroniConfig: generates config files and writes +// them to a host-side directory that is bind-mounted into the container. +// +// Files managed: +// - config.yaml: CP-owned, overwritten on every Create/Update +// - tokens.yaml: Application-owned, written only on first Create if init_token is set +// - users.yaml: Application-owned, written only on first Create if init_users is set +type MCPConfigResource struct { + ServiceInstanceID string `json:"service_instance_id"` + HostID string `json:"host_id"` + DirResourceID string `json:"dir_resource_id"` + Config *database.MCPServiceConfig `json:"config"` + DatabaseName string `json:"database_name"` + DatabaseHost string `json:"database_host"` + DatabasePort int `json:"database_port"` + Username string `json:"username"` + Password string `json:"password"` +} + +func (r *MCPConfigResource) ResourceVersion() string { + return "1" +} + +func (r *MCPConfigResource) DiffIgnore() []string { + return []string{ + // Credentials are populated from ServiceUserRole during refresh. + "/username", + "/password", + } +} + +func (r *MCPConfigResource) Identifier() resource.Identifier { + return MCPConfigResourceIdentifier(r.ServiceInstanceID) +} + +func (r *MCPConfigResource) Executor() resource.Executor { + return resource.HostExecutor(r.HostID) +} + +func (r *MCPConfigResource) Dependencies() []resource.Identifier { + return []resource.Identifier{ + filesystem.DirResourceIdentifier(r.DirResourceID), + ServiceUserRoleIdentifier(r.ServiceInstanceID), + } +} + +func (r *MCPConfigResource) Refresh(ctx context.Context, rc *resource.Context) error { + fs, err := do.Invoke[afero.Fs](rc.Injector) + if err != nil { + return err + } + + dirPath, err := filesystem.DirResourceFullPath(rc, r.DirResourceID) + if err != nil { + return fmt.Errorf("failed to get service data dir path: %w", err) + } + + // Populate credentials from ServiceUserRole + if err := r.populateCredentials(rc); err != nil { + return err + } + + // Check if config.yaml exists + _, err = readResourceFile(fs, filepath.Join(dirPath, "config.yaml")) + if err != nil { + return fmt.Errorf("failed to read MCP config: %w", err) + } + + return nil +} + +func (r *MCPConfigResource) Create(ctx context.Context, rc *resource.Context) error { + fs, err := do.Invoke[afero.Fs](rc.Injector) + if err != nil { + return err + } + + dirPath, err := filesystem.DirResourceFullPath(rc, r.DirResourceID) + if err != nil { + return fmt.Errorf("failed to get service data dir path: %w", err) + } + + // Populate credentials from ServiceUserRole + if err := r.populateCredentials(rc); err != nil { + return err + } + + // Generate and write config.yaml (always) + if err := r.writeConfigFile(fs, dirPath); err != nil { + return err + } + + // Write token file (only if it doesn't exist yet) + tokensPath := filepath.Join(dirPath, "tokens.yaml") + if err := r.writeTokenFileIfNeeded(fs, tokensPath); err != nil { + return err + } + + // Write user file (only if it doesn't exist yet) + usersPath := filepath.Join(dirPath, "users.yaml") + if err := r.writeUserFileIfNeeded(fs, usersPath); err != nil { + return err + } + + return nil +} + +func (r *MCPConfigResource) Update(ctx context.Context, rc *resource.Context) error { + fs, err := do.Invoke[afero.Fs](rc.Injector) + if err != nil { + return err + } + + dirPath, err := filesystem.DirResourceFullPath(rc, r.DirResourceID) + if err != nil { + return fmt.Errorf("failed to get service data dir path: %w", err) + } + + // Populate credentials from ServiceUserRole + if err := r.populateCredentials(rc); err != nil { + return err + } + + // Overwrite config.yaml (CP-owned, always regenerated) + if err := r.writeConfigFile(fs, dirPath); err != nil { + return err + } + + // Do NOT touch tokens.yaml or users.yaml — they are application-owned + + return nil +} + +func (r *MCPConfigResource) Delete(ctx context.Context, rc *resource.Context) error { + // Cleanup is handled by the parent directory resource deletion + return nil +} + +// writeConfigFile generates and writes the config.yaml file. +func (r *MCPConfigResource) writeConfigFile(fs afero.Fs, dirPath string) error { + content, err := GenerateMCPConfig(&MCPConfigParams{ + Config: r.Config, + DatabaseName: r.DatabaseName, + DatabaseHost: r.DatabaseHost, + DatabasePort: r.DatabasePort, + Username: r.Username, + Password: r.Password, + }) + if err != nil { + return fmt.Errorf("failed to generate MCP config: %w", err) + } + + configPath := filepath.Join(dirPath, "config.yaml") + if err := afero.WriteFile(fs, configPath, content, 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", configPath, err) + } + // Chown to MCP user + if err := fs.Chown(configPath, mcpContainerUID, mcpContainerUID); err != nil { + return fmt.Errorf("failed to change ownership for %s: %w", configPath, err) + } + return nil +} + +// writeTokenFileIfNeeded writes tokens.yaml only if the file doesn't exist yet. +func (r *MCPConfigResource) writeTokenFileIfNeeded(fs afero.Fs, tokensPath string) error { + exists, err := afero.Exists(fs, tokensPath) + if err != nil { + return fmt.Errorf("failed to check if tokens.yaml exists: %w", err) + } + if exists { + return nil // Preserve existing application-owned token store + } + + var content []byte + if r.Config.InitToken != nil { + content, err = GenerateTokenFile(*r.Config.InitToken) + if err != nil { + return fmt.Errorf("failed to generate token file: %w", err) + } + } else { + content, err = GenerateEmptyTokenFile() + if err != nil { + return fmt.Errorf("failed to generate empty token file: %w", err) + } + } + + if err := afero.WriteFile(fs, tokensPath, content, 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", tokensPath, err) + } + if err := fs.Chown(tokensPath, mcpContainerUID, mcpContainerUID); err != nil { + return fmt.Errorf("failed to change ownership for %s: %w", tokensPath, err) + } + return nil +} + +// writeUserFileIfNeeded writes users.yaml only if the file doesn't exist yet. +func (r *MCPConfigResource) writeUserFileIfNeeded(fs afero.Fs, usersPath string) error { + exists, err := afero.Exists(fs, usersPath) + if err != nil { + return fmt.Errorf("failed to check if users.yaml exists: %w", err) + } + if exists { + return nil // Preserve existing application-owned user store + } + + var content []byte + if len(r.Config.InitUsers) > 0 { + content, err = GenerateUserFile(r.Config.InitUsers) + if err != nil { + return fmt.Errorf("failed to generate user file: %w", err) + } + } else { + content, err = GenerateEmptyUserFile() + if err != nil { + return fmt.Errorf("failed to generate empty user file: %w", err) + } + } + + if err := afero.WriteFile(fs, usersPath, content, 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", usersPath, err) + } + if err := fs.Chown(usersPath, mcpContainerUID, mcpContainerUID); err != nil { + return fmt.Errorf("failed to change ownership for %s: %w", usersPath, err) + } + return nil +} + +// populateCredentials fetches the username/password from the ServiceUserRole resource. +func (r *MCPConfigResource) populateCredentials(rc *resource.Context) error { + userRole, err := resource.FromContext[*ServiceUserRole](rc, ServiceUserRoleIdentifier(r.ServiceInstanceID)) + if err != nil { + return fmt.Errorf("failed to get service user role from state: %w", err) + } + r.Username = userRole.Username + r.Password = userRole.Password + return nil +} diff --git a/server/internal/orchestrator/swarm/mcp_config_test.go b/server/internal/orchestrator/swarm/mcp_config_test.go new file mode 100644 index 00000000..70936fd1 --- /dev/null +++ b/server/internal/orchestrator/swarm/mcp_config_test.go @@ -0,0 +1,595 @@ +package swarm + +import ( + "testing" + + "github.com/pgEdge/control-plane/server/internal/database" + "gopkg.in/yaml.v3" +) + +func strPtr(s string) *string { return &s } +func float64Ptr(f float64) *float64 { return &f } +func intPtrMCP(i int) *int { return &i } +func boolPtrMCP(b bool) *bool { return &b } + +// parseYAML unmarshals GenerateMCPConfig output into mcpYAMLConfig for assertion. +func parseYAML(t *testing.T, data []byte) *mcpYAMLConfig { + t.Helper() + var cfg mcpYAMLConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to unmarshal YAML: %v\nYAML:\n%s", err, string(data)) + } + return &cfg +} + +func TestGenerateMCPConfig_MinimalConfig(t *testing.T) { + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + // http section + if !cfg.HTTP.Enabled { + t.Error("http.enabled should be true") + } + if cfg.HTTP.Address != ":8080" { + t.Errorf("http.address = %q, want %q", cfg.HTTP.Address, ":8080") + } + if !cfg.HTTP.Auth.Enabled { + t.Error("http.auth.enabled should be true") + } + if cfg.HTTP.Auth.TokenFile != "/app/data/tokens.yaml" { + t.Errorf("http.auth.token_file = %q, want %q", cfg.HTTP.Auth.TokenFile, "/app/data/tokens.yaml") + } + if cfg.HTTP.Auth.UserFile != "/app/data/users.yaml" { + t.Errorf("http.auth.user_file = %q, want %q", cfg.HTTP.Auth.UserFile, "/app/data/users.yaml") + } + + // databases section + if len(cfg.Databases) != 1 { + t.Fatalf("databases len = %d, want 1", len(cfg.Databases)) + } + + // llm section + if !cfg.LLM.Enabled { + t.Error("llm.enabled should be true") + } + if cfg.LLM.Provider != "anthropic" { + t.Errorf("llm.provider = %q, want %q", cfg.LLM.Provider, "anthropic") + } + + // builtins.tools.llm_connection_selection must be false + if cfg.Builtins.Tools.LLMConnectionSelection == nil { + t.Fatal("builtins.tools.llm_connection_selection is nil, want false") + } + if *cfg.Builtins.Tools.LLMConnectionSelection { + t.Error("builtins.tools.llm_connection_selection should be false") + } +} + +func TestGenerateMCPConfig_DefaultValues(t *testing.T) { + // No optional fields set — defaults should apply. + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + // LLMTemperature, LLMMaxTokens, PoolMaxConns, AllowWrites all nil + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.LLM.Temperature != 0.7 { + t.Errorf("llm.temperature = %v, want 0.7", cfg.LLM.Temperature) + } + if cfg.LLM.MaxTokens != 4096 { + t.Errorf("llm.max_tokens = %d, want 4096", cfg.LLM.MaxTokens) + } + if len(cfg.Databases) != 1 { + t.Fatalf("databases len = %d, want 1", len(cfg.Databases)) + } + if cfg.Databases[0].Pool.MaxConns != 4 { + t.Errorf("databases[0].pool.max_conns = %d, want 4", cfg.Databases[0].Pool.MaxConns) + } + if cfg.Databases[0].AllowWrites { + t.Error("databases[0].allow_writes should default to false") + } +} + +func TestGenerateMCPConfig_CustomValues(t *testing.T) { + temp := 1.2 + maxTok := 8192 + poolMax := 10 + allowW := true + + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-opus-4-6", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + LLMTemperature: &temp, + LLMMaxTokens: &maxTok, + PoolMaxConns: &poolMax, + AllowWrites: &allowW, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.LLM.Temperature != 1.2 { + t.Errorf("llm.temperature = %v, want 1.2", cfg.LLM.Temperature) + } + if cfg.LLM.MaxTokens != 8192 { + t.Errorf("llm.max_tokens = %d, want 8192", cfg.LLM.MaxTokens) + } + if len(cfg.Databases) != 1 { + t.Fatalf("databases len = %d, want 1", len(cfg.Databases)) + } + if cfg.Databases[0].Pool.MaxConns != 10 { + t.Errorf("databases[0].pool.max_conns = %d, want 10", cfg.Databases[0].Pool.MaxConns) + } + if !cfg.Databases[0].AllowWrites { + t.Error("databases[0].allow_writes should be true") + } +} + +func TestGenerateMCPConfig_ProviderKeys_Anthropic(t *testing.T) { + apiKey := "sk-ant-api03-test" + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: &apiKey, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.LLM.AnthropicAPIKey != apiKey { + t.Errorf("llm.anthropic_api_key = %q, want %q", cfg.LLM.AnthropicAPIKey, apiKey) + } + if cfg.LLM.OpenAIAPIKey != "" { + t.Errorf("llm.openai_api_key should be empty for anthropic provider, got %q", cfg.LLM.OpenAIAPIKey) + } + if cfg.LLM.OllamaURL != "" { + t.Errorf("llm.ollama_url should be empty for anthropic provider, got %q", cfg.LLM.OllamaURL) + } +} + +func TestGenerateMCPConfig_ProviderKeys_OpenAI(t *testing.T) { + apiKey := "sk-openai-test" + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "openai", + LLMModel: "gpt-4", + OpenAIAPIKey: &apiKey, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.LLM.OpenAIAPIKey != apiKey { + t.Errorf("llm.openai_api_key = %q, want %q", cfg.LLM.OpenAIAPIKey, apiKey) + } + if cfg.LLM.AnthropicAPIKey != "" { + t.Errorf("llm.anthropic_api_key should be empty for openai provider, got %q", cfg.LLM.AnthropicAPIKey) + } + if cfg.LLM.OllamaURL != "" { + t.Errorf("llm.ollama_url should be empty for openai provider, got %q", cfg.LLM.OllamaURL) + } +} + +func TestGenerateMCPConfig_ProviderKeys_Ollama(t *testing.T) { + ollamaURL := "http://localhost:11434" + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "ollama", + LLMModel: "llama3", + OllamaURL: &ollamaURL, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.LLM.OllamaURL != ollamaURL { + t.Errorf("llm.ollama_url = %q, want %q", cfg.LLM.OllamaURL, ollamaURL) + } + if cfg.LLM.AnthropicAPIKey != "" { + t.Errorf("llm.anthropic_api_key should be empty for ollama provider, got %q", cfg.LLM.AnthropicAPIKey) + } + if cfg.LLM.OpenAIAPIKey != "" { + t.Errorf("llm.openai_api_key should be empty for ollama provider, got %q", cfg.LLM.OpenAIAPIKey) + } +} + +func TestGenerateMCPConfig_EmbeddingPresent(t *testing.T) { + embProvider := "voyage" + embModel := "voyage-3" + embAPIKey := "pa-voyage-key" + + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + EmbeddingProvider: &embProvider, + EmbeddingModel: &embModel, + EmbeddingAPIKey: &embAPIKey, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.Embedding == nil { + t.Fatal("embedding section should be present when embedding_provider is set") + } + if !cfg.Embedding.Enabled { + t.Error("embedding.enabled should be true") + } + if cfg.Embedding.Provider != "voyage" { + t.Errorf("embedding.provider = %q, want %q", cfg.Embedding.Provider, "voyage") + } + if cfg.Embedding.Model != "voyage-3" { + t.Errorf("embedding.model = %q, want %q", cfg.Embedding.Model, "voyage-3") + } + if cfg.Embedding.VoyageAPIKey != "pa-voyage-key" { + t.Errorf("embedding.voyage_api_key = %q, want %q", cfg.Embedding.VoyageAPIKey, "pa-voyage-key") + } +} + +func TestGenerateMCPConfig_EmbeddingAbsent(t *testing.T) { + // No embedding_provider set — embedding section must be omitted. + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.Embedding != nil { + t.Errorf("embedding section should be absent when embedding_provider is not set, got %+v", cfg.Embedding) + } +} + +func TestGenerateMCPConfig_EmbeddingOpenAI(t *testing.T) { + embProvider := "openai" + embModel := "text-embedding-3-small" + embAPIKey := "sk-openai-embed" + + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "openai", + LLMModel: "gpt-4", + OpenAIAPIKey: strPtr("sk-openai-llm"), + EmbeddingProvider: &embProvider, + EmbeddingModel: &embModel, + EmbeddingAPIKey: &embAPIKey, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.Embedding == nil { + t.Fatal("embedding section should be present") + } + if cfg.Embedding.OpenAIAPIKey != "sk-openai-embed" { + t.Errorf("embedding.openai_api_key = %q, want %q", cfg.Embedding.OpenAIAPIKey, "sk-openai-embed") + } + if cfg.Embedding.VoyageAPIKey != "" { + t.Errorf("embedding.voyage_api_key should be empty for openai embedding, got %q", cfg.Embedding.VoyageAPIKey) + } +} + +func TestGenerateMCPConfig_EmbeddingOllama(t *testing.T) { + embProvider := "ollama" + embModel := "nomic-embed-text" + ollamaURL := "http://localhost:11434" + + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "ollama", + LLMModel: "llama3", + OllamaURL: &ollamaURL, + EmbeddingProvider: &embProvider, + EmbeddingModel: &embModel, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if cfg.Embedding == nil { + t.Fatal("embedding section should be present") + } + if cfg.Embedding.OllamaURL != ollamaURL { + t.Errorf("embedding.ollama_url = %q, want %q", cfg.Embedding.OllamaURL, ollamaURL) + } +} + +func TestGenerateMCPConfig_ToolToggles_AllDisabled(t *testing.T) { + trueVal := true + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + DisableQueryDatabase: &trueVal, + DisableGetSchemaInfo: &trueVal, + DisableSimilaritySearch: &trueVal, + DisableExecuteExplain: &trueVal, + DisableGenerateEmbedding: &trueVal, + DisableSearchKnowledgebase: &trueVal, + DisableCountRows: &trueVal, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + tools := cfg.Builtins.Tools + + assertBoolPtrFalse := func(name string, ptr *bool) { + t.Helper() + if ptr == nil { + t.Errorf("%s: expected *false, got nil", name) + return + } + if *ptr { + t.Errorf("%s: expected false, got true", name) + } + } + + assertBoolPtrFalse("query_database", tools.QueryDatabase) + assertBoolPtrFalse("get_schema_info", tools.GetSchemaInfo) + assertBoolPtrFalse("similarity_search", tools.SimilaritySearch) + assertBoolPtrFalse("execute_explain", tools.ExecuteExplain) + assertBoolPtrFalse("generate_embedding", tools.GenerateEmbedding) + assertBoolPtrFalse("search_knowledgebase", tools.SearchKnowledgebase) + assertBoolPtrFalse("count_rows", tools.CountRows) + + // llm_connection_selection always false + assertBoolPtrFalse("llm_connection_selection", tools.LLMConnectionSelection) +} + +func TestGenerateMCPConfig_ToolToggles_NoneDisabled(t *testing.T) { + // No disable_* flags set — all tool fields should be omitted (nil), except + // llm_connection_selection which is always explicitly false. + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + tools := cfg.Builtins.Tools + + assertNil := func(name string, ptr *bool) { + t.Helper() + if ptr != nil { + t.Errorf("%s: expected nil (omitted), got %v", name, *ptr) + } + } + + assertNil("query_database", tools.QueryDatabase) + assertNil("get_schema_info", tools.GetSchemaInfo) + assertNil("similarity_search", tools.SimilaritySearch) + assertNil("execute_explain", tools.ExecuteExplain) + assertNil("generate_embedding", tools.GenerateEmbedding) + assertNil("search_knowledgebase", tools.SearchKnowledgebase) + assertNil("count_rows", tools.CountRows) + + // llm_connection_selection always present as false + if tools.LLMConnectionSelection == nil { + t.Fatal("llm_connection_selection should always be present") + } + if *tools.LLMConnectionSelection { + t.Error("llm_connection_selection should always be false") + } +} + +func TestGenerateMCPConfig_ToolToggles_DisableFalseIsNoop(t *testing.T) { + // Setting disable_* to false should NOT write the field (field stays nil/omitted). + falseVal := false + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + DisableQueryDatabase: &falseVal, + }, + DatabaseName: "mydb", + DatabaseHost: "db-host", + DatabasePort: 5432, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + // query_database: disable flag is false → not explicitly disabled → field should be nil + if cfg.Builtins.Tools.QueryDatabase != nil { + t.Errorf("query_database: expected nil when disable flag is false, got %v", *cfg.Builtins.Tools.QueryDatabase) + } +} + +func TestGenerateMCPConfig_DatabaseConfig(t *testing.T) { + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + }, + DatabaseName: "myspecialdb", + DatabaseHost: "pg-primary.internal", + DatabasePort: 5433, + Username: "svc_myspecialdb", + Password: "supersecret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + if len(cfg.Databases) != 1 { + t.Fatalf("databases len = %d, want 1", len(cfg.Databases)) + } + db := cfg.Databases[0] + + if db.Name != "myspecialdb" { + t.Errorf("databases[0].name = %q, want %q", db.Name, "myspecialdb") + } + if db.Database != "myspecialdb" { + t.Errorf("databases[0].database = %q, want %q", db.Database, "myspecialdb") + } + if db.Host != "pg-primary.internal" { + t.Errorf("databases[0].host = %q, want %q", db.Host, "pg-primary.internal") + } + if db.Port != 5433 { + t.Errorf("databases[0].port = %d, want 5433", db.Port) + } + if db.User != "svc_myspecialdb" { + t.Errorf("databases[0].user = %q, want %q", db.User, "svc_myspecialdb") + } + if db.Password != "supersecret" { + t.Errorf("databases[0].password = %q, want %q", db.Password, "supersecret") + } + if db.SSLMode != "prefer" { + t.Errorf("databases[0].sslmode = %q, want %q", db.SSLMode, "prefer") + } +} diff --git a/server/internal/orchestrator/swarm/orchestrator.go b/server/internal/orchestrator/swarm/orchestrator.go index ecea139d..a8583b9f 100644 --- a/server/internal/orchestrator/swarm/orchestrator.go +++ b/server/internal/orchestrator/swarm/orchestrator.go @@ -413,6 +413,12 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn } } + // Parse the MCP service config from the untyped config map + mcpConfig, errs := database.ParseMCPServiceConfig(spec.ServiceSpec.Config, false) + if len(errs) > 0 { + return nil, fmt.Errorf("failed to parse MCP service config: %w", errors.Join(errs...)) + } + // Database network (shared with postgres instances) databaseNetwork := &Network{ Scope: "swarm", @@ -438,6 +444,31 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn serviceUserRole.Password = spec.Credentials.Password } + // Service data directory resource (host-side bind mount directory) + dataDirID := spec.ServiceInstanceID + "-data" + dataDir := &filesystem.DirResource{ + ID: dataDirID, + HostID: spec.HostID, + Path: filepath.Join(o.cfg.DataDir, "services", spec.ServiceInstanceID), + OwnerUID: mcpContainerUID, + OwnerGID: mcpContainerUID, + } + + // MCP config resource (generates config.yaml, tokens.yaml, users.yaml) + mcpConfigResource := &MCPConfigResource{ + ServiceInstanceID: spec.ServiceInstanceID, + HostID: spec.HostID, + DirResourceID: dataDirID, + Config: mcpConfig, + DatabaseName: spec.DatabaseName, + DatabaseHost: spec.DatabaseHost, + DatabasePort: spec.DatabasePort, + } + if spec.Credentials != nil { + mcpConfigResource.Username = spec.Credentials.Username + mcpConfigResource.Password = spec.Credentials.Password + } + // Service instance spec resource serviceName := ServiceInstanceName(spec.ServiceSpec.ServiceType, spec.DatabaseID, spec.ServiceSpec.ServiceID, spec.HostID) serviceInstanceSpec := &ServiceInstanceSpecResource{ @@ -448,13 +479,14 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn HostID: spec.HostID, ServiceName: serviceName, Hostname: serviceName, - CohortMemberID: o.swarmNodeID, // Use orchestrator's swarm node ID (same as Postgres instances) + CohortMemberID: o.swarmNodeID, ServiceImage: serviceImage, Credentials: spec.Credentials, DatabaseNetworkID: databaseNetwork.Name, DatabaseHost: spec.DatabaseHost, DatabasePort: spec.DatabasePort, Port: spec.Port, + DataDirID: dataDirID, } // Service instance resource (actual Docker service) @@ -466,9 +498,12 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn HostID: spec.HostID, } + // Resource chain: Network → ServiceUserRole → DirResource → MCPConfigResource → ServiceInstanceSpec → ServiceInstance orchestratorResources := []resource.Resource{ databaseNetwork, serviceUserRole, + dataDir, + mcpConfigResource, serviceInstanceSpec, serviceInstance, } diff --git a/server/internal/orchestrator/swarm/resources.go b/server/internal/orchestrator/swarm/resources.go index 6f51e1d0..4878137f 100644 --- a/server/internal/orchestrator/swarm/resources.go +++ b/server/internal/orchestrator/swarm/resources.go @@ -20,4 +20,5 @@ func RegisterResourceTypes(registry *resource.Registry) { resource.RegisterResourceType[*CheckWillRestart](registry, ResourceTypeCheckWillRestart) resource.RegisterResourceType[*Switchover](registry, ResourceTypeSwitchover) resource.RegisterResourceType[*ScaleService](registry, ResourceTypeScaleService) + resource.RegisterResourceType[*MCPConfigResource](registry, ResourceTypeMCPConfig) } diff --git a/server/internal/orchestrator/swarm/service_instance_spec.go b/server/internal/orchestrator/swarm/service_instance_spec.go index 8b0192f7..22a1524c 100644 --- a/server/internal/orchestrator/swarm/service_instance_spec.go +++ b/server/internal/orchestrator/swarm/service_instance_spec.go @@ -7,6 +7,7 @@ import ( "github.com/docker/docker/api/types/swarm" "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/filesystem" "github.com/pgEdge/control-plane/server/internal/resource" ) @@ -36,6 +37,7 @@ type ServiceInstanceSpecResource struct { DatabaseHost string `json:"database_host"` // Postgres instance hostname DatabasePort int `json:"database_port"` // Postgres instance port Port *int `json:"port"` // Service published port (optional, 0 = random) + DataDirID string `json:"data_dir_id"` // DirResource ID for the service data directory Spec swarm.ServiceSpec `json:"spec"` } @@ -58,10 +60,11 @@ func (s *ServiceInstanceSpecResource) Executor() resource.Executor { } func (s *ServiceInstanceSpecResource) Dependencies() []resource.Identifier { - // Service instances depend on the database network and service user role + // Service instances depend on the database network, service user role, and MCP config return []resource.Identifier{ NetworkResourceIdentifier(s.DatabaseNetworkID), ServiceUserRoleIdentifier(s.ServiceInstanceID), + MCPConfigResourceIdentifier(s.ServiceInstanceID), } } @@ -89,6 +92,12 @@ func (s *ServiceInstanceSpecResource) Refresh(ctx context.Context, rc *resource. return err } + // Resolve the data directory path from the DirResource + dataPath, err := filesystem.DirResourceFullPath(rc, s.DataDirID) + if err != nil { + return fmt.Errorf("failed to get service data dir path: %w", err) + } + spec, err := ServiceContainerSpec(&ServiceContainerSpecOptions{ ServiceSpec: s.ServiceSpec, ServiceInstanceID: s.ServiceInstanceID, @@ -104,6 +113,7 @@ func (s *ServiceInstanceSpecResource) Refresh(ctx context.Context, rc *resource. DatabaseHost: s.DatabaseHost, DatabasePort: s.DatabasePort, Port: s.Port, + DataPath: dataPath, }) if err != nil { return fmt.Errorf("failed to generate service container spec: %w", err) diff --git a/server/internal/orchestrator/swarm/service_spec.go b/server/internal/orchestrator/swarm/service_spec.go index b5b1ba35..4c7034f5 100644 --- a/server/internal/orchestrator/swarm/service_spec.go +++ b/server/internal/orchestrator/swarm/service_spec.go @@ -9,8 +9,12 @@ import ( "github.com/docker/docker/api/types/swarm" "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/docker" ) +// mcpContainerUID is the UID of the MCP container user. +const mcpContainerUID = 1001 + // ServiceContainerSpecOptions contains all parameters needed to build a service container spec. type ServiceContainerSpecOptions struct { ServiceSpec *database.ServiceSpec @@ -29,6 +33,8 @@ type ServiceContainerSpecOptions struct { DatabasePort int // Service port configuration Port *int + // DataPath is the host-side directory path for the bind mount + DataPath string } // ServiceContainerSpec builds a Docker Swarm service spec for a service instance. @@ -65,9 +71,6 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec, }, } - // Build environment variables for database connection and LLM config - env := buildServiceEnvVars(opts) - // Get container image (already resolved in ServiceImage) image := opts.ServiceImage.Tag @@ -88,13 +91,21 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec, } } + // Build bind mount for config/auth files + mounts := []mount.Mount{ + docker.BuildMount(opts.DataPath, "/app/data", false), + } + return swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Image: image, Labels: labels, Hostname: opts.Hostname, - Env: env, + User: fmt.Sprintf("%d", mcpContainerUID), + // override the default container entrypoint so we can specify path to config on bind mount + Command: []string{"/app/pgedge-postgres-mcp"}, + Args: []string{"-config", "/app/data/config.yaml"}, Healthcheck: &container.HealthConfig{ Test: []string{"CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"}, StartPeriod: time.Second * 30, @@ -102,7 +113,7 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec, Timeout: time.Second * 5, Retries: 3, }, - Mounts: []mount.Mount{}, // No persistent volumes for services in Phase 1 + Mounts: mounts, }, Networks: networks, Placement: &swarm.Placement{ @@ -123,57 +134,6 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec, }, nil } -// buildServiceEnvVars constructs environment variables for the service container. -func buildServiceEnvVars(opts *ServiceContainerSpecOptions) []string { - env := []string{ - // Database connection - fmt.Sprintf("PGHOST=%s", opts.DatabaseHost), - fmt.Sprintf("PGPORT=%d", opts.DatabasePort), - fmt.Sprintf("PGDATABASE=%s", opts.DatabaseName), - "PGSSLMODE=prefer", - - // Service metadata - fmt.Sprintf("PGEDGE_SERVICE_ID=%s", opts.ServiceSpec.ServiceID), - fmt.Sprintf("PGEDGE_DATABASE_ID=%s", opts.DatabaseID), - } - - // Add credentials if provided - if opts.Credentials != nil { - env = append(env, - fmt.Sprintf("PGUSER=%s", opts.Credentials.Username), - fmt.Sprintf("PGPASSWORD=%s", opts.Credentials.Password), - ) - } - - // LLM configuration from serviceSpec.Config - if provider, ok := opts.ServiceSpec.Config["llm_provider"].(string); ok { - env = append(env, fmt.Sprintf("PGEDGE_LLM_PROVIDER=%s", provider)) - } - if model, ok := opts.ServiceSpec.Config["llm_model"].(string); ok { - env = append(env, fmt.Sprintf("PGEDGE_LLM_MODEL=%s", model)) - } - - // Provider-specific API keys - if provider, ok := opts.ServiceSpec.Config["llm_provider"].(string); ok { - switch provider { - case "anthropic": - if key, ok := opts.ServiceSpec.Config["anthropic_api_key"].(string); ok { - env = append(env, fmt.Sprintf("PGEDGE_ANTHROPIC_API_KEY=%s", key)) - } - case "openai": - if key, ok := opts.ServiceSpec.Config["openai_api_key"].(string); ok { - env = append(env, fmt.Sprintf("PGEDGE_OPENAI_API_KEY=%s", key)) - } - case "ollama": - if url, ok := opts.ServiceSpec.Config["ollama_url"].(string); ok { - env = append(env, fmt.Sprintf("PGEDGE_OLLAMA_URL=%s", url)) - } - } - } - - return env -} - // buildServicePortConfig builds port configuration for service containers. // Exposes port 8080 for the HTTP API. // If port is nil, no port is published. diff --git a/server/internal/orchestrator/swarm/service_user_role.go b/server/internal/orchestrator/swarm/service_user_role.go index 9b62bde1..67b216a4 100644 --- a/server/internal/orchestrator/swarm/service_user_role.go +++ b/server/internal/orchestrator/swarm/service_user_role.go @@ -118,12 +118,17 @@ func (r *ServiceUserRole) Create(ctx context.Context, rc *resource.Context) erro } defer conn.Close(ctx) + // Create the role with LOGIN but no inherited roles. We grant permissions + // directly rather than using pgedge_application_read_only because that role + // includes read access to the spock schema (replication internals) which the + // MCP service should not expose. + // https://github.com/pgEdge/pgedge-postgres-mcp/blob/main/docs/guide/security_mgmt.md statements, err := postgres.CreateUserRole(postgres.UserRoleOptions{ - Name: r.Username, - Password: r.Password, - DBName: r.DatabaseName, - DBOwner: false, - Roles: []string{"pgedge_application_read_only"}, + Name: r.Username, + Password: r.Password, + DBName: r.DatabaseName, + DBOwner: false, + Attributes: []string{"LOGIN"}, }) if err != nil { return fmt.Errorf("failed to generate create user role statements: %w", err) @@ -133,6 +138,21 @@ func (r *ServiceUserRole) Create(ctx context.Context, rc *resource.Context) erro return fmt.Errorf("failed to create service user: %w", err) } + // grants based on MCP doc guidelines, but open to change as needed + grants := postgres.Statements{ + // Database-level connect permission + postgres.Statement{SQL: fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;", sanitizeIdentifier(r.DatabaseName), sanitizeIdentifier(r.Username))}, + // Read-only access to the public schema (application tables) + postgres.Statement{SQL: fmt.Sprintf("GRANT USAGE ON SCHEMA public TO %s;", sanitizeIdentifier(r.Username))}, + postgres.Statement{SQL: fmt.Sprintf("GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;", sanitizeIdentifier(r.Username))}, + postgres.Statement{SQL: fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;", sanitizeIdentifier(r.Username))}, + // Allow viewing PostgreSQL configuration via diagnostic tools + postgres.Statement{SQL: fmt.Sprintf("GRANT pg_read_all_settings TO %s;", sanitizeIdentifier(r.Username))}, + } + if err := grants.Exec(ctx, conn); err != nil { + return fmt.Errorf("failed to grant service user permissions: %w", err) + } + logger.Info().Str("username", r.Username).Msg("service user role created successfully") return nil } diff --git a/server/internal/workflows/activities/generate_service_instance_resources.go b/server/internal/workflows/activities/generate_service_instance_resources.go index be762d28..21c55744 100644 --- a/server/internal/workflows/activities/generate_service_instance_resources.go +++ b/server/internal/workflows/activities/generate_service_instance_resources.go @@ -24,7 +24,7 @@ func (a *Activities) ExecuteGenerateServiceInstanceResources( input *GenerateServiceInstanceResourcesInput, ) workflow.Future[*GenerateServiceInstanceResourcesOutput] { options := workflow.ActivityOptions{ - Queue: utils.ManagerQueue(), + Queue: utils.HostQueue(input.Spec.HostID), RetryOptions: workflow.RetryOptions{ MaxAttempts: 1, }, diff --git a/server/internal/workflows/plan_update.go b/server/internal/workflows/plan_update.go index ef5cfe35..fab83acc 100644 --- a/server/internal/workflows/plan_update.go +++ b/server/internal/workflows/plan_update.go @@ -148,15 +148,10 @@ func (w *Workflows) getServiceResources( // container from the database spec. It prefers a co-located instance (same host // as the service) for lower latency, falling back to any instance in the database. // The hostname follows the swarm orchestrator convention: "postgres-{instanceID}". +// The returned port is always the internal container port (5432), not the published +// host port, because service containers connect via the overlay network. func findPostgresInstance(nodeInstances []*database.NodeInstances, serviceHostID string) (string, int, error) { - const defaultPort = 5432 - - instancePort := func(inst *database.InstanceSpec) int { - if inst.Port != nil { - return *inst.Port - } - return defaultPort - } + const internalPort = 5432 var fallback *database.InstanceSpec for _, node := range nodeInstances { @@ -165,13 +160,13 @@ func findPostgresInstance(nodeInstances []*database.NodeInstances, serviceHostID fallback = inst } if inst.HostID == serviceHostID { - return fmt.Sprintf("postgres-%s", inst.InstanceID), instancePort(inst), nil + return fmt.Sprintf("postgres-%s", inst.InstanceID), internalPort, nil } } } if fallback != nil { - return fmt.Sprintf("postgres-%s", fallback.InstanceID), instancePort(fallback), nil + return fmt.Sprintf("postgres-%s", fallback.InstanceID), internalPort, nil } return "", 0, fmt.Errorf("no postgres instances found for service host %s", serviceHostID) From 5fe53cf3cdad08929972f322f933059b0eadd5fc Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 2 Mar 2026 15:05:46 -0500 Subject: [PATCH 2/6] CircleCI failures --- server/internal/orchestrator/swarm/mcp_config_test.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/internal/orchestrator/swarm/mcp_config_test.go b/server/internal/orchestrator/swarm/mcp_config_test.go index 70936fd1..6429de75 100644 --- a/server/internal/orchestrator/swarm/mcp_config_test.go +++ b/server/internal/orchestrator/swarm/mcp_config_test.go @@ -7,10 +7,7 @@ import ( "gopkg.in/yaml.v3" ) -func strPtr(s string) *string { return &s } -func float64Ptr(f float64) *float64 { return &f } -func intPtrMCP(i int) *int { return &i } -func boolPtrMCP(b bool) *bool { return &b } +func strPtr(s string) *string { return &s } // parseYAML unmarshals GenerateMCPConfig output into mcpYAMLConfig for assertion. func parseYAML(t *testing.T, data []byte) *mcpYAMLConfig { From bc2f9e85f16ad1273257c638980e6a094ffc49c6 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 2 Mar 2026 15:17:49 -0500 Subject: [PATCH 3/6] Coderabbit fixes --- e2e/service_provisioning_test.go | 19 +++++++++++++++++-- .../internal/database/mcp_service_config.go | 8 ++++++++ .../database/mcp_service_config_test.go | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/e2e/service_provisioning_test.go b/e2e/service_provisioning_test.go index 56e6f118..157524f6 100644 --- a/e2e/service_provisioning_test.go +++ b/e2e/service_provisioning_test.go @@ -868,9 +868,24 @@ func TestUpdateMCPServiceConfig(t *testing.T) { t.Log("Database updated, verifying service instance was updated in-place") - // Verify the database is available and service is running + // Verify the service instance still exists require.Len(t, db.ServiceInstances, 1, "Should still have 1 service instance") - assert.Equal(t, "running", db.ServiceInstances[0].State, "Service should still be running") + + // The service instance may briefly show "creating" after the update before + // the monitor converges to "running". Poll until it settles. + if db.ServiceInstances[0].State != "running" { + t.Logf("Service state is %q, waiting for running...", db.ServiceInstances[0].State) + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(5 * time.Second) + err = db.Refresh(ctx) + require.NoError(t, err, "Failed to refresh database") + if len(db.ServiceInstances) > 0 && db.ServiceInstances[0].State == "running" { + break + } + } + } + require.Equal(t, "running", db.ServiceInstances[0].State, "Service should be running after update") // The key assertions: ServiceInstanceID and CreatedAt should be unchanged, // proving the service was updated in-place (config.yaml regenerated) rather diff --git a/server/internal/database/mcp_service_config.go b/server/internal/database/mcp_service_config.go index f28f0c62..c6d53a91 100644 --- a/server/internal/database/mcp_service_config.go +++ b/server/internal/database/mcp_service_config.go @@ -196,6 +196,14 @@ func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceCon } // Embedding config cross-validation + if embeddingProvider == nil { + if embeddingModel != nil { + errs = append(errs, fmt.Errorf("embedding_model must not be set without embedding_provider")) + } + if embeddingAPIKey != nil { + errs = append(errs, fmt.Errorf("embedding_api_key must not be set without embedding_provider")) + } + } if embeddingProvider != nil { if !slices.Contains(validEmbeddingProviders, *embeddingProvider) { errs = append(errs, fmt.Errorf("embedding_provider must be one of: %s", strings.Join(validEmbeddingProviders, ", "))) diff --git a/server/internal/database/mcp_service_config_test.go b/server/internal/database/mcp_service_config_test.go index 64edbe2d..de7fb87a 100644 --- a/server/internal/database/mcp_service_config_test.go +++ b/server/internal/database/mcp_service_config_test.go @@ -508,6 +508,20 @@ func TestParseMCPServiceConfig(t *testing.T) { require.NotEmpty(t, errs) assert.Contains(t, joinedErr(errs).Error(), "embedding_provider must be one of") }) + t.Run("embedding_model without embedding_provider", func(t *testing.T) { + config := anthropicBase() + config["embedding_model"] = "voyage-3" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "embedding_model must not be set without embedding_provider") + }) + t.Run("embedding_api_key without embedding_provider", func(t *testing.T) { + config := anthropicBase() + config["embedding_api_key"] = "voy-key" + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "embedding_api_key must not be set without embedding_provider") + }) }) t.Run("init_users", func(t *testing.T) { From 849b0de02c21a04f3f014286b735795e8a0ff182 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Thu, 5 Mar 2026 13:13:55 -0500 Subject: [PATCH 4/6] fix: switched to github.com/goccy/go-yaml library ran "go mod tidy" && "make licenses" --- NOTICE.txt | 31 +++++++++++++++++++ go.mod | 2 +- go.sum | 20 ++++++++++++ .../orchestrator/swarm/mcp_auth_files.go | 2 +- .../orchestrator/swarm/mcp_auth_files_test.go | 2 +- .../internal/orchestrator/swarm/mcp_config.go | 2 +- .../orchestrator/swarm/mcp_config_test.go | 2 +- 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/NOTICE.txt b/NOTICE.txt index e74284d9..375eb7da 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -3009,6 +3009,37 @@ THE SOFTWARE. ``` +## github.com/goccy/go-yaml + +* Name: github.com/goccy/go-yaml +* Version: v1.18.0 +* License: [MIT](https://github.com/goccy/go-yaml/blob/v1.18.0/LICENSE) + +``` +MIT License + +Copyright (c) 2019 Masaaki Goshima + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + ## github.com/gogo/protobuf * Name: github.com/gogo/protobuf diff --git a/go.mod b/go.mod index 1cfb818b..9fff7cc2 100644 --- a/go.mod +++ b/go.mod @@ -147,7 +147,7 @@ require ( go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.46.0 golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index d49441ea..557371fb 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= @@ -129,6 +131,10 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -235,6 +241,8 @@ github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHT github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -273,6 +281,8 @@ github.com/mackerelio/go-osstat v0.2.5 h1:+MqTbZUhoIt4m8qzkVoXUJg1EuifwlAJSk4Yl2 github.com/mackerelio/go-osstat v0.2.5/go.mod h1:atxwWF+POUZcdtR1wnsUcQxTytoHG4uhl2AKKzrOajY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d h1:Zj+PHjnhRYWBK6RqCDBcAhLXoi3TzC27Zad/Vn+gnVQ= github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d/go.mod h1:WZy8Q5coAB1zhY9AOBJP0O6J4BuDfbupUDavKY+I3+s= github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqVQycPnaVviZdBLbizFhU49mtbe4= @@ -300,14 +310,22 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -400,6 +418,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7 github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/wI2L/jsondiff v0.6.1 h1:ISZb9oNWbP64LHnu4AUhsMF5W0FIj5Ok3Krip9Shqpw= github.com/wI2L/jsondiff v0.6.1/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/server/internal/orchestrator/swarm/mcp_auth_files.go b/server/internal/orchestrator/swarm/mcp_auth_files.go index cb93aba3..1ddaea1f 100644 --- a/server/internal/orchestrator/swarm/mcp_auth_files.go +++ b/server/internal/orchestrator/swarm/mcp_auth_files.go @@ -6,9 +6,9 @@ import ( "fmt" "time" + "github.com/goccy/go-yaml" "github.com/pgEdge/control-plane/server/internal/database" "golang.org/x/crypto/bcrypt" - "gopkg.in/yaml.v3" ) // mcpTokenStore mirrors the MCP server's TokenStore YAML format. diff --git a/server/internal/orchestrator/swarm/mcp_auth_files_test.go b/server/internal/orchestrator/swarm/mcp_auth_files_test.go index 38dca829..8b2184fa 100644 --- a/server/internal/orchestrator/swarm/mcp_auth_files_test.go +++ b/server/internal/orchestrator/swarm/mcp_auth_files_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" + "github.com/goccy/go-yaml" "github.com/pgEdge/control-plane/server/internal/database" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" - "gopkg.in/yaml.v3" ) func TestGenerateTokenFile(t *testing.T) { diff --git a/server/internal/orchestrator/swarm/mcp_config.go b/server/internal/orchestrator/swarm/mcp_config.go index 1e428b92..194a483d 100644 --- a/server/internal/orchestrator/swarm/mcp_config.go +++ b/server/internal/orchestrator/swarm/mcp_config.go @@ -3,8 +3,8 @@ package swarm import ( "fmt" + "github.com/goccy/go-yaml" "github.com/pgEdge/control-plane/server/internal/database" - "gopkg.in/yaml.v3" ) // mcpYAMLConfig mirrors the MCP server's Config struct for YAML generation. diff --git a/server/internal/orchestrator/swarm/mcp_config_test.go b/server/internal/orchestrator/swarm/mcp_config_test.go index 6429de75..d449bf8d 100644 --- a/server/internal/orchestrator/swarm/mcp_config_test.go +++ b/server/internal/orchestrator/swarm/mcp_config_test.go @@ -3,8 +3,8 @@ package swarm import ( "testing" + "github.com/goccy/go-yaml" "github.com/pgEdge/control-plane/server/internal/database" - "gopkg.in/yaml.v3" ) func strPtr(s string) *string { return &s } From 0986d73911a025666a531b60107587f2be4913b7 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Thu, 5 Mar 2026 13:51:10 -0500 Subject: [PATCH 5/6] fix: coderabbit --- server/internal/orchestrator/swarm/mcp_auth_files_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/internal/orchestrator/swarm/mcp_auth_files_test.go b/server/internal/orchestrator/swarm/mcp_auth_files_test.go index 8b2184fa..37859331 100644 --- a/server/internal/orchestrator/swarm/mcp_auth_files_test.go +++ b/server/internal/orchestrator/swarm/mcp_auth_files_test.go @@ -287,6 +287,7 @@ func TestGenerateEmptyTokenFile(t *testing.T) { var store mcpTokenStore require.NoError(t, yaml.Unmarshal(data, &store)) + require.NotNil(t, store.Tokens) assert.Empty(t, store.Tokens) }) } @@ -313,6 +314,7 @@ func TestGenerateEmptyUserFile(t *testing.T) { var store mcpUserStore require.NoError(t, yaml.Unmarshal(data, &store)) + require.NotNil(t, store.Users) assert.Empty(t, store.Users) }) } From 94393bc88167aa3132483840005b1023b40528a6 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Thu, 5 Mar 2026 14:34:53 -0500 Subject: [PATCH 6/6] fix: rebase error --- .../orchestrator/swarm/service_spec_test.go | 476 ++---------------- 1 file changed, 42 insertions(+), 434 deletions(-) diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go index b324d5c0..f1d4869c 100644 --- a/server/internal/orchestrator/swarm/service_spec_test.go +++ b/server/internal/orchestrator/swarm/service_spec_test.go @@ -1,7 +1,7 @@ package swarm import ( - "strings" + "fmt" "testing" "github.com/docker/docker/api/types/swarm" @@ -18,16 +18,15 @@ func TestServiceContainerSpec(t *testing.T) { opts *ServiceContainerSpecOptions wantErr bool // Validation functions - checkLabels func(t *testing.T, labels map[string]string) - checkNetworks func(t *testing.T, networks []swarm.NetworkAttachmentConfig) - checkEnv func(t *testing.T, env []string) - checkPlacement func(t *testing.T, placement *swarm.Placement) - checkResources func(t *testing.T, resources *swarm.ResourceRequirements) - checkHealthcheck func(t *testing.T, healthcheck *swarm.ContainerSpec) - checkPorts func(t *testing.T, ports []swarm.PortConfig) + checkLabels func(t *testing.T, labels map[string]string) + checkNetworks func(t *testing.T, networks []swarm.NetworkAttachmentConfig) + checkContainer func(t *testing.T, spec *swarm.ContainerSpec) + checkPlacement func(t *testing.T, placement *swarm.Placement) + checkResources func(t *testing.T, resources *swarm.ResourceRequirements) + checkPorts func(t *testing.T, ports []swarm.PortConfig) }{ { - name: "basic MCP service", + name: "basic MCP service with bind mount and entrypoint", opts: &ServiceContainerSpecOptions{ ServiceSpec: &database.ServiceSpec{ ServiceID: "mcp-server", @@ -58,6 +57,7 @@ func TestServiceContainerSpec(t *testing.T) { DatabaseHost: "postgres-instance-1", DatabasePort: 5432, Port: intPtr(8080), + DataPath: "/var/lib/pgedge/services/db1-mcp-server-host1", }, wantErr: false, checkLabels: func(t *testing.T, labels map[string]string) { @@ -79,43 +79,44 @@ func TestServiceContainerSpec(t *testing.T) { t.Errorf("got %d networks, want 2", len(networks)) return } - // First network should be bridge if networks[0].Target != "bridge" { t.Errorf("first network = %v, want bridge", networks[0].Target) } - // Second network should be database overlay if networks[1].Target != "db1-database" { t.Errorf("second network = %v, want db1-database", networks[1].Target) } }, - checkEnv: func(t *testing.T, env []string) { - expectedEnv := []string{ - "PGHOST=postgres-instance-1", - "PGPORT=5432", - "PGDATABASE=testdb", - "PGSSLMODE=prefer", - "PGEDGE_SERVICE_ID=mcp-server", - "PGEDGE_DATABASE_ID=db1", - "PGUSER=svc_db1mcp", - "PGPASSWORD=testpassword", - "PGEDGE_LLM_PROVIDER=anthropic", - "PGEDGE_LLM_MODEL=claude-sonnet-4-5", - "PGEDGE_ANTHROPIC_API_KEY=sk-ant-api03-test", + checkContainer: func(t *testing.T, spec *swarm.ContainerSpec) { + // User should be mcpContainerUID + if spec.User != fmt.Sprintf("%d", mcpContainerUID) { + t.Errorf("User = %v, want %d", spec.User, mcpContainerUID) } - if len(env) != len(expectedEnv) { - t.Errorf("got %d env vars, want %d", len(env), len(expectedEnv)) + // Command should override entrypoint + if len(spec.Command) != 1 || spec.Command[0] != "/app/pgedge-postgres-mcp" { + t.Errorf("Command = %v, want [/app/pgedge-postgres-mcp]", spec.Command) } - for _, e := range expectedEnv { - found := false - for _, got := range env { - if got == e { - found = true - break - } - } - if !found { - t.Errorf("missing env var: %s", e) - } + // Args should pass config file path + if len(spec.Args) != 2 || spec.Args[0] != "-config" || spec.Args[1] != "/app/data/config.yaml" { + t.Errorf("Args = %v, want [-config /app/data/config.yaml]", spec.Args) + } + // Should have bind mount + if len(spec.Mounts) != 1 { + t.Fatalf("got %d mounts, want 1", len(spec.Mounts)) + } + m := spec.Mounts[0] + if m.Source != "/var/lib/pgedge/services/db1-mcp-server-host1" { + t.Errorf("mount source = %v, want /var/lib/pgedge/services/db1-mcp-server-host1", m.Source) + } + if m.Target != "/app/data" { + t.Errorf("mount target = %v, want /app/data", m.Target) + } + // No env vars for config (config is via file) + if len(spec.Env) > 0 { + t.Errorf("expected no env vars, got %d: %v", len(spec.Env), spec.Env) + } + // Healthcheck should be set + if spec.Healthcheck == nil { + t.Error("healthcheck is nil") } }, checkPlacement: func(t *testing.T, placement *swarm.Placement) { @@ -131,14 +132,6 @@ func TestServiceContainerSpec(t *testing.T) { t.Errorf("expected no resource limits, got %+v", resources) } }, - checkHealthcheck: func(t *testing.T, containerSpec *swarm.ContainerSpec) { - if containerSpec.Healthcheck == nil { - t.Fatal("healthcheck is nil") - } - if len(containerSpec.Healthcheck.Test) == 0 { - t.Error("healthcheck test is empty") - } - }, checkPorts: func(t *testing.T, ports []swarm.PortConfig) { if len(ports) != 1 { t.Errorf("got %d ports, want 1", len(ports)) @@ -180,6 +173,7 @@ func TestServiceContainerSpec(t *testing.T) { DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", DatabasePort: 5432, + DataPath: "/var/lib/pgedge/services/db1-mcp-server-host1", }, wantErr: false, checkResources: func(t *testing.T, resources *swarm.ResourceRequirements) { @@ -199,243 +193,6 @@ func TestServiceContainerSpec(t *testing.T) { } }, }, - { - name: "service with OpenAI provider", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - ServiceType: "mcp", - Version: "1.0.0", - Config: map[string]interface{}{ - "llm_provider": "openai", - "llm_model": "gpt-4", - "openai_api_key": "sk-openai-test", - }, - }, - ServiceInstanceID: "db1-mcp-server-host1", - DatabaseID: "db1", - DatabaseName: "testdb", - HostID: "host1", - ServiceName: "db1-mcp-server-host1", - Hostname: "mcp-server-host1", - CohortMemberID: "swarm-node-123", - ServiceImage: &ServiceImage{ - Tag: "ghcr.io/pgedge/postgres-mcp:latest", - }, - DatabaseNetworkID: "db1-database", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - wantErr: false, - checkEnv: func(t *testing.T, env []string) { - expectedEnv := []string{ - "PGEDGE_LLM_PROVIDER=openai", - "PGEDGE_LLM_MODEL=gpt-4", - "PGEDGE_OPENAI_API_KEY=sk-openai-test", - } - for _, e := range expectedEnv { - found := false - for _, got := range env { - if got == e { - found = true - break - } - } - if !found { - t.Errorf("missing env var: %s", e) - } - } - }, - }, - { - name: "service with Ollama provider", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - ServiceType: "mcp", - Version: "1.0.0", - Config: map[string]interface{}{ - "llm_provider": "ollama", - "llm_model": "llama2", - "ollama_url": "http://localhost:11434", - }, - }, - ServiceInstanceID: "db1-mcp-server-host1", - DatabaseID: "db1", - DatabaseName: "testdb", - HostID: "host1", - ServiceName: "db1-mcp-server-host1", - Hostname: "mcp-server-host1", - CohortMemberID: "swarm-node-123", - ServiceImage: &ServiceImage{ - Tag: "ghcr.io/pgedge/postgres-mcp:latest", - }, - DatabaseNetworkID: "db1-database", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - wantErr: false, - checkEnv: func(t *testing.T, env []string) { - expectedEnv := []string{ - "PGEDGE_LLM_PROVIDER=ollama", - "PGEDGE_LLM_MODEL=llama2", - "PGEDGE_OLLAMA_URL=http://localhost:11434", - } - for _, e := range expectedEnv { - found := false - for _, got := range env { - if got == e { - found = true - break - } - } - if !found { - t.Errorf("missing env var: %s", e) - } - } - }, - }, - { - name: "service without credentials", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - ServiceType: "mcp", - Version: "1.0.0", - Config: map[string]interface{}{ - "llm_provider": "anthropic", - "llm_model": "claude-sonnet-4-5", - "anthropic_api_key": "sk-ant-test", - }, - }, - ServiceInstanceID: "db1-mcp-server-host1", - DatabaseID: "db1", - DatabaseName: "testdb", - HostID: "host1", - ServiceName: "db1-mcp-server-host1", - Hostname: "mcp-server-host1", - CohortMemberID: "swarm-node-123", - ServiceImage: &ServiceImage{ - Tag: "ghcr.io/pgedge/postgres-mcp:latest", - }, - Credentials: nil, // No credentials - DatabaseNetworkID: "db1-database", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - wantErr: false, - checkEnv: func(t *testing.T, env []string) { - // Should not have PGUSER or PGPASSWORD - for _, e := range env { - if strings.HasPrefix(e, "PGUSER=") || strings.HasPrefix(e, "PGPASSWORD=") { - t.Errorf("unexpected credential env var: %s", e) - } - } - }, - }, - { - name: "service with extra labels for Traefik", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - ServiceType: "mcp", - Version: "latest", - Config: map[string]interface{}{ - "llm_provider": "anthropic", - "llm_model": "claude-sonnet-4-5", - "anthropic_api_key": "sk-ant-test", - }, - OrchestratorOpts: &database.OrchestratorOpts{ - Swarm: &database.SwarmOpts{ - ExtraLabels: map[string]string{ - "traefik.enable": "true", - "traefik.http.routers.mcp.rule": "Host(`mcp.example.com`)", - "traefik.http.services.mcp.loadbalancer.server.port": "8080", - }, - }, - }, - }, - ServiceInstanceID: "db1-mcp-server-host1", - DatabaseID: "db1", - DatabaseName: "testdb", - HostID: "host1", - ServiceName: "db1-mcp-server-host1", - Hostname: "mcp-server-host1", - CohortMemberID: "swarm-node-123", - ServiceImage: &ServiceImage{ - Tag: "ghcr.io/pgedge/postgres-mcp:latest", - }, - DatabaseNetworkID: "db1-database", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - wantErr: false, - checkLabels: func(t *testing.T, labels map[string]string) { - // System labels must still be present - expectedSystem := map[string]string{ - "pgedge.component": "service", - "pgedge.service.instance.id": "db1-mcp-server-host1", - "pgedge.service.id": "mcp-server", - "pgedge.database.id": "db1", - "pgedge.host.id": "host1", - } - for k, v := range expectedSystem { - if labels[k] != v { - t.Errorf("system label %s = %q, want %q", k, labels[k], v) - } - } - // Extra labels must be merged in - expectedExtra := map[string]string{ - "traefik.enable": "true", - "traefik.http.routers.mcp.rule": "Host(`mcp.example.com`)", - "traefik.http.services.mcp.loadbalancer.server.port": "8080", - } - for k, v := range expectedExtra { - if labels[k] != v { - t.Errorf("extra label %s = %q, want %q", k, labels[k], v) - } - } - // Total should be system + extra - if len(labels) != len(expectedSystem)+len(expectedExtra) { - t.Errorf("got %d labels, want %d", len(labels), len(expectedSystem)+len(expectedExtra)) - } - }, - }, - { - name: "service with nil orchestrator opts (backward compat)", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - ServiceType: "mcp", - Version: "latest", - Config: map[string]interface{}{"llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test"}, - OrchestratorOpts: nil, - }, - ServiceInstanceID: "db1-mcp-server-host1", - DatabaseID: "db1", - DatabaseName: "testdb", - HostID: "host1", - ServiceName: "db1-mcp-server-host1", - Hostname: "mcp-server-host1", - CohortMemberID: "swarm-node-123", - ServiceImage: &ServiceImage{ - Tag: "ghcr.io/pgedge/postgres-mcp:latest", - }, - DatabaseNetworkID: "db1-database", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - wantErr: false, - checkLabels: func(t *testing.T, labels map[string]string) { - // Only system labels, no extras - if len(labels) != 5 { - t.Errorf("got %d labels, want 5 (system only)", len(labels)) - } - if labels["pgedge.component"] != "service" { - t.Errorf("pgedge.component = %q, want %q", labels["pgedge.component"], "service") - } - }, - }, } for _, tt := range tests { @@ -450,16 +207,14 @@ func TestServiceContainerSpec(t *testing.T) { return } - // Verify labels are applied to both ContainerSpec and Annotations if tt.checkLabels != nil { tt.checkLabels(t, got.TaskTemplate.ContainerSpec.Labels) - tt.checkLabels(t, got.Labels) } if tt.checkNetworks != nil { tt.checkNetworks(t, got.TaskTemplate.Networks) } - if tt.checkEnv != nil { - tt.checkEnv(t, got.TaskTemplate.ContainerSpec.Env) + if tt.checkContainer != nil { + tt.checkContainer(t, got.TaskTemplate.ContainerSpec) } if tt.checkPlacement != nil { tt.checkPlacement(t, got.TaskTemplate.Placement) @@ -467,9 +222,6 @@ func TestServiceContainerSpec(t *testing.T) { if tt.checkResources != nil { tt.checkResources(t, got.TaskTemplate.Resources) } - if tt.checkHealthcheck != nil { - tt.checkHealthcheck(t, got.TaskTemplate.ContainerSpec) - } if tt.checkPorts != nil { tt.checkPorts(t, got.EndpointSpec.Ports) } @@ -492,150 +244,6 @@ func TestServiceContainerSpec(t *testing.T) { } } -func TestBuildServiceEnvVars(t *testing.T) { - tests := []struct { - name string - opts *ServiceContainerSpecOptions - expected []string - }{ - { - name: "anthropic provider with credentials", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - Config: map[string]interface{}{ - "llm_provider": "anthropic", - "llm_model": "claude-sonnet-4-5", - "anthropic_api_key": "sk-ant-test", - }, - }, - DatabaseID: "db1", - DatabaseName: "testdb", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - Credentials: &database.ServiceUser{ - Username: "svc_test", - Password: "testpass", - }, - }, - expected: []string{ - "PGHOST=postgres-instance-1", - "PGPORT=5432", - "PGDATABASE=testdb", - "PGSSLMODE=prefer", - "PGEDGE_SERVICE_ID=mcp-server", - "PGEDGE_DATABASE_ID=db1", - "PGUSER=svc_test", - "PGPASSWORD=testpass", - "PGEDGE_LLM_PROVIDER=anthropic", - "PGEDGE_LLM_MODEL=claude-sonnet-4-5", - "PGEDGE_ANTHROPIC_API_KEY=sk-ant-test", - }, - }, - { - name: "openai provider without credentials", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - Config: map[string]interface{}{ - "llm_provider": "openai", - "llm_model": "gpt-4", - "openai_api_key": "sk-openai-test", - }, - }, - DatabaseID: "db1", - DatabaseName: "testdb", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - Credentials: nil, - }, - expected: []string{ - "PGHOST=postgres-instance-1", - "PGPORT=5432", - "PGDATABASE=testdb", - "PGSSLMODE=prefer", - "PGEDGE_SERVICE_ID=mcp-server", - "PGEDGE_DATABASE_ID=db1", - "PGEDGE_LLM_PROVIDER=openai", - "PGEDGE_LLM_MODEL=gpt-4", - "PGEDGE_OPENAI_API_KEY=sk-openai-test", - }, - }, - { - name: "ollama provider", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - Config: map[string]interface{}{ - "llm_provider": "ollama", - "llm_model": "llama2", - "ollama_url": "http://localhost:11434", - }, - }, - DatabaseID: "db1", - DatabaseName: "testdb", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - expected: []string{ - "PGHOST=postgres-instance-1", - "PGPORT=5432", - "PGDATABASE=testdb", - "PGSSLMODE=prefer", - "PGEDGE_SERVICE_ID=mcp-server", - "PGEDGE_DATABASE_ID=db1", - "PGEDGE_LLM_PROVIDER=ollama", - "PGEDGE_LLM_MODEL=llama2", - "PGEDGE_OLLAMA_URL=http://localhost:11434", - }, - }, - { - name: "minimal config without LLM settings", - opts: &ServiceContainerSpecOptions{ - ServiceSpec: &database.ServiceSpec{ - ServiceID: "mcp-server", - Config: map[string]interface{}{}, - }, - DatabaseID: "db1", - DatabaseName: "testdb", - DatabaseHost: "postgres-instance-1", - DatabasePort: 5432, - }, - expected: []string{ - "PGHOST=postgres-instance-1", - "PGPORT=5432", - "PGDATABASE=testdb", - "PGSSLMODE=prefer", - "PGEDGE_SERVICE_ID=mcp-server", - "PGEDGE_DATABASE_ID=db1", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildServiceEnvVars(tt.opts) - - if len(got) != len(tt.expected) { - t.Errorf("got %d env vars, want %d", len(got), len(tt.expected)) - } - - for _, e := range tt.expected { - found := false - for _, g := range got { - if g == e { - found = true - break - } - } - if !found { - t.Errorf("missing expected env var: %s", e) - } - } - }) - } -} - func TestBuildServicePortConfig(t *testing.T) { tests := []struct { name string @@ -677,7 +285,7 @@ func TestBuildServicePortConfig(t *testing.T) { } if tt.wantPortCount == 0 { - return // No port config expected + return } port := ports[0]