Skip to content

Commit dce1987

Browse files
committed
feat(config)!: add deep merge for drop-in configs and SIGHUP reload tests
Refactors drop-in configuration loading to use deep merge strategy: - Add DefaultDropInConfigDir constant ("conf.d") for auto-discovery - Implement deepMerge() for recursive merging of nested config maps - Merge raw TOML maps before parsing to preserve extended configs (cluster_provider_configs, toolset_configs) across multiple drop-ins - Simplify Read() signature to (configPath, dropInConfigDir string) - Auto-resolve relative drop-in paths against config file directory Add comprehensive test coverage: - Unit tests for deepMerge() function (flat, nested, type mismatch, arrays) - Tests for default conf.d auto-discovery and resolution - Tests for standalone --config-dir mode without main config - Tests for extended config merging across multiple drop-in files - SIGHUP signal handler integration tests verifying: - Config reload from file triggers tool updates - Drop-in directory changes are reloaded - Invalid config gracefully continues with old config - Config-dir only mode works with SIGHUP Signed-off-by: Marc Nuri <marc@marcnuri.com>
1 parent 2bed46b commit dce1987

File tree

5 files changed

+1060
-72
lines changed

5 files changed

+1060
-72
lines changed

pkg/config/config.go

Lines changed: 87 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import (
1313
"k8s.io/klog/v2"
1414
)
1515

16+
const (
17+
DefaultDropInConfigDir = "conf.d"
18+
)
19+
1620
const (
1721
ClusterProviderKubeConfig = "kubeconfig"
1822
ClusterProviderInCluster = "in-cluster"
@@ -102,110 +106,77 @@ func WithDirPath(path string) ReadConfigOpt {
102106
// Read reads the toml file, applies drop-in configs from configDir (if provided),
103107
// and returns the StaticConfig with any opts applied.
104108
// Loading order: defaults → main config file → drop-in files (lexically sorted)
105-
func Read(configPath string, configDir string, opts ...ReadConfigOpt) (*StaticConfig, error) {
106-
// Start with defaults
107-
cfg := Default()
109+
func Read(configPath, dropInConfigDir string) (*StaticConfig, error) {
110+
var configFiles []string
111+
var configDir string
108112

109-
// Get the absolute dir path for the main config file
110-
var dirPath string
113+
// Main config file
111114
if configPath != "" {
115+
klog.V(2).Infof("Loading main config from: %s", configPath)
116+
configFiles = append(configFiles, configPath)
117+
112118
// get and save the absolute dir path to the config file, so that other config parsers can use it
113119
absPath, err := filepath.Abs(configPath)
114120
if err != nil {
115121
return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
116122
}
117-
dirPath = filepath.Dir(absPath)
118-
119-
// Load main config file
120-
klog.V(2).Infof("Loading main config from: %s", configPath)
121-
if err := mergeConfigFile(cfg, configPath, append(opts, WithDirPath(dirPath))...); err != nil {
122-
return nil, fmt.Errorf("failed to load main config file %s: %w", configPath, err)
123-
}
123+
configDir = filepath.Dir(absPath)
124124
}
125125

126-
// Load drop-in config files if directory is specified
127-
if configDir != "" {
128-
if err := loadDropInConfigs(cfg, configDir, append(opts, WithDirPath(dirPath))...); err != nil {
129-
return nil, fmt.Errorf("failed to load drop-in configs from %s: %w", configDir, err)
130-
}
126+
// Drop-in config files
127+
if dropInConfigDir == "" {
128+
dropInConfigDir = DefaultDropInConfigDir
131129
}
132130

133-
return cfg, nil
134-
}
135-
136-
// mergeConfigFile reads a config file and merges its values into the target config.
137-
// Values present in the file will overwrite existing values in cfg.
138-
// Values not present in the file will remain unchanged in cfg.
139-
func mergeConfigFile(cfg *StaticConfig, filePath string, opts ...ReadConfigOpt) error {
140-
configData, err := os.ReadFile(filePath)
141-
if err != nil {
142-
return err
131+
// Resolve drop-in config directory path (relative paths are resolved against config directory)
132+
if configDir != "" && !filepath.IsAbs(dropInConfigDir) {
133+
dropInConfigDir = filepath.Join(configDir, dropInConfigDir)
143134
}
144135

145-
md, err := toml.NewDecoder(bytes.NewReader(configData)).Decode(cfg)
146-
if err != nil {
147-
return fmt.Errorf("failed to decode TOML: %w", err)
148-
}
149-
150-
for _, opt := range opts {
151-
opt(cfg)
136+
if configDir == "" {
137+
configDir = dropInConfigDir
152138
}
153139

154-
ctx := withConfigDirPath(context.Background(), cfg.configDirPath)
155-
156-
cfg.parsedClusterProviderConfigs, err = providerConfigRegistry.parse(ctx, md, cfg.ClusterProviderConfigs)
140+
dropInFiles, err := loadDropInConfigs(dropInConfigDir)
157141
if err != nil {
158-
return err
142+
return nil, fmt.Errorf("failed to load drop-in configs from %s: %w", dropInConfigDir, err)
143+
}
144+
if len(dropInFiles) == 0 {
145+
klog.V(2).Infof("No drop-in config files found in: %s", dropInConfigDir)
146+
} else {
147+
klog.V(2).Infof("Loading %d drop-in config file(s) from: %s", len(dropInFiles), dropInConfigDir)
159148
}
149+
configFiles = append(configFiles, dropInFiles...)
160150

161-
cfg.parsedToolsetConfigs, err = toolsetConfigRegistry.parse(ctx, md, cfg.ToolsetConfigs)
151+
// Read and merge all config files
152+
configData, err := readAndMergeFiles(configFiles)
162153
if err != nil {
163-
return err
154+
return nil, fmt.Errorf("failed to read and merge config files: %w", err)
164155
}
165156

166-
return nil
157+
return ReadToml(configData, WithDirPath(configDir))
167158
}
168159

169160
// loadDropInConfigs loads and merges config files from a drop-in directory.
170161
// Files are processed in lexical (alphabetical) order.
171162
// Only files with .toml extension are processed; dotfiles are ignored.
172-
func loadDropInConfigs(cfg *StaticConfig, dropInDir string, opts ...ReadConfigOpt) error {
163+
func loadDropInConfigs(dropInConfigDir string) ([]string, error) {
173164
// Check if directory exists
174-
info, err := os.Stat(dropInDir)
165+
info, err := os.Stat(dropInConfigDir)
175166
if err != nil {
176167
if os.IsNotExist(err) {
177-
klog.V(2).Infof("Drop-in config directory does not exist, skipping: %s", dropInDir)
178-
return nil
168+
klog.V(2).Infof("Drop-in config directory does not exist, skipping: %s", dropInConfigDir)
169+
return nil, nil
179170
}
180-
return fmt.Errorf("failed to stat drop-in directory: %w", err)
171+
return nil, fmt.Errorf("failed to stat drop-in directory: %w", err)
181172
}
182173

183174
if !info.IsDir() {
184-
return fmt.Errorf("drop-in config path is not a directory: %s", dropInDir)
175+
return nil, fmt.Errorf("drop-in config path is not a directory: %s", dropInConfigDir)
185176
}
186177

187178
// Get all .toml files in the directory
188-
files, err := getSortedConfigFiles(dropInDir)
189-
if err != nil {
190-
return err
191-
}
192-
193-
if len(files) == 0 {
194-
klog.V(2).Infof("No drop-in config files found in: %s", dropInDir)
195-
return nil
196-
}
197-
198-
klog.V(2).Infof("Loading %d drop-in config file(s) from: %s", len(files), dropInDir)
199-
200-
// Merge each file in order
201-
for _, file := range files {
202-
klog.V(3).Infof(" - Merging drop-in config: %s", filepath.Base(file))
203-
if err := mergeConfigFile(cfg, file, opts...); err != nil {
204-
return fmt.Errorf("failed to merge drop-in config %s: %w", file, err)
205-
}
206-
}
207-
208-
return nil
179+
return getSortedConfigFiles(dropInConfigDir)
209180
}
210181

211182
// getSortedConfigFiles returns a sorted list of .toml files in the specified directory.
@@ -247,7 +218,54 @@ func getSortedConfigFiles(dir string) ([]string, error) {
247218
return files, nil
248219
}
249220

250-
// ReadToml reads the toml data and returns the StaticConfig, with any opts applied
221+
// readAndMergeFiles reads and merges multiple TOML config files into a single byte slice.
222+
// Files are merged in the order provided, with later files overriding earlier ones.
223+
func readAndMergeFiles(files []string) ([]byte, error) {
224+
rawConfig := map[string]interface{}{}
225+
// Merge each file in order using deep merge
226+
for _, file := range files {
227+
klog.V(3).Infof(" - Merging config: %s", filepath.Base(file))
228+
configData, err := os.ReadFile(file)
229+
if err != nil {
230+
return nil, fmt.Errorf("failed to read config %s: %w", file, err)
231+
}
232+
233+
dropInConfig := make(map[string]interface{})
234+
if _, err = toml.NewDecoder(bytes.NewReader(configData)).Decode(&dropInConfig); err != nil {
235+
return nil, fmt.Errorf("failed to decode config %s: %w", file, err)
236+
}
237+
238+
deepMerge(rawConfig, dropInConfig)
239+
}
240+
241+
bufferedConfig := new(bytes.Buffer)
242+
if err := toml.NewEncoder(bufferedConfig).Encode(rawConfig); err != nil {
243+
return nil, fmt.Errorf("failed to encode merged config: %w", err)
244+
}
245+
return bufferedConfig.Bytes(), nil
246+
}
247+
248+
// deepMerge recursively merges src into dst.
249+
// For nested maps, it merges recursively. For other types, src overwrites dst.
250+
func deepMerge(dst, src map[string]interface{}) {
251+
for key, srcVal := range src {
252+
if dstVal, exists := dst[key]; exists {
253+
// Both have this key - check if both are maps for recursive merge
254+
srcMap, srcIsMap := srcVal.(map[string]interface{})
255+
dstMap, dstIsMap := dstVal.(map[string]interface{})
256+
if srcIsMap && dstIsMap {
257+
deepMerge(dstMap, srcMap)
258+
continue
259+
}
260+
}
261+
// Either key doesn't exist in dst, or values aren't both maps - overwrite
262+
dst[key] = srcVal
263+
}
264+
}
265+
266+
// ReadToml reads the toml data, loads and applies drop-in configs from configDir (if provided),
267+
// and returns the StaticConfig with any opts applied.
268+
// Loading order: defaults → main config file → drop-in files (lexically sorted)
251269
func ReadToml(configData []byte, opts ...ReadConfigOpt) (*StaticConfig, error) {
252270
config := Default()
253271
md, err := toml.NewDecoder(bytes.NewReader(configData)).Decode(config)

0 commit comments

Comments
 (0)