diff --git a/pkg/config/toolset_config_test.go b/pkg/config/toolset_config_test.go index 5b0c236cf..aff0b2979 100644 --- a/pkg/config/toolset_config_test.go +++ b/pkg/config/toolset_config_test.go @@ -3,6 +3,8 @@ package config import ( "context" "errors" + "os" + "path/filepath" "testing" "github.com/BurntSushi/toml" @@ -123,6 +125,210 @@ func (s *ToolsetConfigSuite) TestReadConfigUnregisteredToolsetConfig() { }) } +func (s *ToolsetConfigSuite) TestConfigDirPathInContext() { + var capturedDirPath string + RegisterToolsetConfig("test-toolset", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) { + capturedDirPath = ConfigDirPathFromContext(ctx) + var toolsetConfigForTest ToolsetConfigForTest + if err := md.PrimitiveDecode(primitive, &toolsetConfigForTest); err != nil { + return nil, err + } + return &toolsetConfigForTest, nil + }) + configPath := s.writeConfig(` + [toolset_configs.test-toolset] + enabled = true + endpoint = "https://example.com" + timeout = 30 + `) + + absConfigPath, err := filepath.Abs(configPath) + s.Require().NoError(err, "test error: getting the absConfigPath should not fail") + + _, err = Read(configPath, "") + s.Run("provides config directory path in context to parser", func() { + s.Require().NoError(err, "Expected no error reading config") + s.NotEmpty(capturedDirPath, "Expected non-empty directory path in context") + s.Equal(filepath.Dir(absConfigPath), capturedDirPath, "Expected directory path to match config file directory") + }) +} + +func (s *ToolsetConfigSuite) TestExtendedConfigMergingAcrossDropIns() { + // Test that extended configs (toolset_configs) are properly merged + // when scattered across multiple drop-in files + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + + tempDir := s.T().TempDir() + + // Create main config with initial toolset config + mainConfigPath := filepath.Join(tempDir, "config.toml") + err := os.WriteFile(mainConfigPath, []byte(` + [toolset_configs.test-toolset] + enabled = false + endpoint = "from-main" + timeout = 1 + `), 0644) + s.Require().NoError(err) + + // Create drop-in directory + dropInDir := filepath.Join(tempDir, "conf.d") + s.Require().NoError(os.Mkdir(dropInDir, 0755)) + + // First drop-in overrides some fields + err = os.WriteFile(filepath.Join(dropInDir, "10-override.toml"), []byte(` + [toolset_configs.test-toolset] + enabled = true + timeout = 10 + `), 0644) + s.Require().NoError(err) + + // Second drop-in overrides other fields + err = os.WriteFile(filepath.Join(dropInDir, "20-final.toml"), []byte(` + [toolset_configs.test-toolset] + endpoint = "from-drop-in" + timeout = 42 + `), 0644) + s.Require().NoError(err) + + config, err := Read(mainConfigPath, "") + s.Require().NoError(err) + s.Require().NotNil(config) + + toolsetConfig, ok := config.GetToolsetConfig("test-toolset") + s.Require().True(ok, "Expected to find toolset config") + + testConfig, ok := toolsetConfig.(*ToolsetConfigForTest) + s.Require().True(ok, "Expected toolset config to be *ToolsetConfigForTest") + + s.Run("merges enabled from first drop-in", func() { + s.True(testConfig.Enabled, "enabled should be true from 10-override.toml") + }) + + s.Run("merges endpoint from second drop-in", func() { + s.Equal("from-drop-in", testConfig.Endpoint, "endpoint should be from 20-final.toml") + }) + + s.Run("last drop-in wins for timeout", func() { + s.Equal(42, testConfig.Timeout, "timeout should be 42 from 20-final.toml") + }) +} + +func (s *ToolsetConfigSuite) TestExtendedConfigFromDropInOnly() { + // Test that extended configs work when defined only in drop-in files (not in main config) + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + + tempDir := s.T().TempDir() + + // Create main config WITHOUT toolset config + mainConfigPath := filepath.Join(tempDir, "config.toml") + err := os.WriteFile(mainConfigPath, []byte(` + log_level = 1 + `), 0644) + s.Require().NoError(err) + + // Create drop-in directory + dropInDir := filepath.Join(tempDir, "conf.d") + s.Require().NoError(os.Mkdir(dropInDir, 0755)) + + // Drop-in defines the toolset config + err = os.WriteFile(filepath.Join(dropInDir, "10-toolset.toml"), []byte(` + [toolset_configs.test-toolset] + enabled = true + endpoint = "from-drop-in-only" + timeout = 99 + `), 0644) + s.Require().NoError(err) + + config, err := Read(mainConfigPath, "") + s.Require().NoError(err) + s.Require().NotNil(config) + + toolsetConfig, ok := config.GetToolsetConfig("test-toolset") + s.Require().True(ok, "Expected to find toolset config from drop-in") + + testConfig, ok := toolsetConfig.(*ToolsetConfigForTest) + s.Require().True(ok) + + s.Run("loads extended config from drop-in only", func() { + s.True(testConfig.Enabled) + s.Equal("from-drop-in-only", testConfig.Endpoint) + s.Equal(99, testConfig.Timeout) + }) +} + +func (s *ToolsetConfigSuite) TestStandaloneConfigDirWithExtendedConfig() { + // Test that extended configs work with standalone --config-dir (no main config) + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + + tempDir := s.T().TempDir() + + // Create drop-in files only (no main config) + err := os.WriteFile(filepath.Join(tempDir, "10-base.toml"), []byte(` + [toolset_configs.test-toolset] + enabled = false + endpoint = "base" + timeout = 1 + `), 0644) + s.Require().NoError(err) + + err = os.WriteFile(filepath.Join(tempDir, "20-override.toml"), []byte(` + [toolset_configs.test-toolset] + enabled = true + timeout = 100 + `), 0644) + s.Require().NoError(err) + + // Read with standalone config-dir (empty config path) + config, err := Read("", tempDir) + s.Require().NoError(err) + s.Require().NotNil(config) + + toolsetConfig, ok := config.GetToolsetConfig("test-toolset") + s.Require().True(ok, "Expected to find toolset config in standalone mode") + + testConfig, ok := toolsetConfig.(*ToolsetConfigForTest) + s.Require().True(ok) + + s.Run("merges extended config in standalone mode", func() { + s.True(testConfig.Enabled, "enabled should be true from 20-override.toml") + s.Equal("base", testConfig.Endpoint, "endpoint should be 'base' from 10-base.toml") + s.Equal(100, testConfig.Timeout, "timeout should be 100 from 20-override.toml") + }) +} + +func (s *ToolsetConfigSuite) TestConfigDirPathInContextStandalone() { + // Test that configDirPath is correctly set in context for standalone --config-dir + var capturedDirPath string + RegisterToolsetConfig("test-toolset", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) { + capturedDirPath = ConfigDirPathFromContext(ctx) + var toolsetConfigForTest ToolsetConfigForTest + if err := md.PrimitiveDecode(primitive, &toolsetConfigForTest); err != nil { + return nil, err + } + return &toolsetConfigForTest, nil + }) + + tempDir := s.T().TempDir() + + err := os.WriteFile(filepath.Join(tempDir, "10-config.toml"), []byte(` + [toolset_configs.test-toolset] + enabled = true + endpoint = "test" + timeout = 1 + `), 0644) + s.Require().NoError(err) + + absTempDir, err := filepath.Abs(tempDir) + s.Require().NoError(err) + + _, err = Read("", tempDir) + s.Run("provides config directory path in context for standalone mode", func() { + s.Require().NoError(err) + s.NotEmpty(capturedDirPath, "Expected non-empty directory path in context") + s.Equal(absTempDir, capturedDirPath, "Expected directory path to match config-dir") + }) +} + func TestToolsetConfig(t *testing.T) { suite.Run(t, new(ToolsetConfigSuite)) } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index c1db51ab9..07c07dce8 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" "k8s.io/cli-runtime/pkg/genericiooptions" ) @@ -131,6 +132,117 @@ func TestConfig(t *testing.T) { }) } +type CmdSuite struct { + suite.Suite + testDataDir string +} + +func (s *CmdSuite) SetupSuite() { + _, file, _, _ := runtime.Caller(0) + s.testDataDir = filepath.Join(filepath.Dir(file), "testdata") +} + +func (s *CmdSuite) TestConfigDir() { + s.Run("set with --config-dir standalone", func() { + dropInDir := s.T().TempDir() + s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-config.toml"), []byte(` + list_output = "yaml" + read_only = true + disable_destructive = true + `), 0644)) + + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config-dir", dropInDir}) + s.Require().NoError(rootCmd.Execute()) + s.Contains(out.String(), "ListOutput: yaml") + s.Contains(out.String(), "Read-only mode: true") + s.Contains(out.String(), "Disable destructive tools: true") + }) + s.Run("--config-dir path is a file throws error", func() { + tempDir := s.T().TempDir() + filePath := filepath.Join(tempDir, "not-a-directory.toml") + s.Require().NoError(os.WriteFile(filePath, []byte("log_level = 1"), 0644)) + + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config-dir", filePath}) + err := rootCmd.Execute() + s.Require().Error(err) + s.Contains(err.Error(), "drop-in config path is not a directory") + }) + s.Run("nonexistent --config-dir is silently skipped", func() { + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config-dir", "/nonexistent/path/to/config-dir"}) + err := rootCmd.Execute() + s.Require().NoError(err, "Nonexistent directories should be gracefully skipped") + s.Contains(out.String(), "ListOutput: table", "Default values should be used") + }) + s.Run("--config with --config-dir merges configs", func() { + tempDir := s.T().TempDir() + mainConfigPath := filepath.Join(tempDir, "config.toml") + s.Require().NoError(os.WriteFile(mainConfigPath, []byte(` + list_output = "table" + read_only = false + `), 0644)) + + dropInDir := filepath.Join(tempDir, "conf.d") + s.Require().NoError(os.Mkdir(dropInDir, 0755)) + s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-override.toml"), []byte(` + read_only = true + disable_destructive = true + `), 0644)) + + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", mainConfigPath, "--config-dir", dropInDir}) + s.Require().NoError(rootCmd.Execute()) + s.Contains(out.String(), "ListOutput: table", "list_output from main config") + s.Contains(out.String(), "Read-only mode: true", "read_only overridden by drop-in") + s.Contains(out.String(), "Disable destructive tools: true", "disable_destructive from drop-in") + }) + s.Run("multiple drop-in files are merged in order", func() { + dropInDir := s.T().TempDir() + s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-first.toml"), []byte(` + list_output = "yaml" + read_only = true + `), 0644)) + s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "20-second.toml"), []byte(` + list_output = "table" + disable_destructive = true + `), 0644)) + + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config-dir", dropInDir}) + s.Require().NoError(rootCmd.Execute()) + s.Contains(out.String(), "ListOutput: table", "list_output from 20-second.toml (last wins)") + s.Contains(out.String(), "Read-only mode: true", "read_only from 10-first.toml") + s.Contains(out.String(), "Disable destructive tools: true", "disable_destructive from 20-second.toml") + }) + s.Run("flags take precedence over --config-dir", func() { + dropInDir := s.T().TempDir() + s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-config.toml"), []byte(` + list_output = "yaml" + read_only = true + disable_destructive = true + `), 0644)) + + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--list-output=table", "--read-only=false", "--disable-destructive=false", "--config-dir", dropInDir}) + s.Require().NoError(rootCmd.Execute()) + s.Contains(out.String(), "ListOutput: table", "flag takes precedence") + s.Contains(out.String(), "Read-only mode: false", "flag takes precedence") + s.Contains(out.String(), "Disable destructive tools: false", "flag takes precedence") + }) +} + +func TestCmd(t *testing.T) { + suite.Run(t, new(CmdSuite)) +} + func TestToolsets(t *testing.T) { t.Run("available", func(t *testing.T) { ioStreams, _ := testStream()