Skip to content

Commit 8cba970

Browse files
feat: deep merge imports to combine local and catalog configs
Signed-off-by: JordanGoasdoue <[email protected]>
1 parent c29cdf5 commit 8cba970

File tree

2 files changed

+318
-7
lines changed

2 files changed

+318
-7
lines changed

pkg/devspace/config/loader/imports.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,7 @@ func ResolveImports(ctx context.Context, resolver variable.Resolver, basePath st
118118
if mergedMap[section] == nil {
119119
mergedMap[section] = map[string]interface{}{}
120120
}
121-
122-
for key, value := range sectionMap {
123-
_, ok := mergedMap[section].(map[string]interface{})[key]
124-
if !ok {
125-
mergedMap[section].(map[string]interface{})[key] = value
126-
}
127-
}
121+
deepMerge(mergedMap[section].(map[string]interface{}), sectionMap)
128122
}
129123

130124
// resolve the import imports
@@ -143,3 +137,39 @@ func ResolveImports(ctx context.Context, resolver variable.Resolver, basePath st
143137

144138
return mergedMap, nil
145139
}
140+
141+
// deepMerge recursively merges src into dst.
142+
// Maps are deep merged, other types (arrays, scalars) in dst take precedence.
143+
func deepMerge(dst, src map[string]interface{}) {
144+
for key, srcVal := range src {
145+
// Skip nil source values
146+
if srcVal == nil {
147+
continue
148+
}
149+
150+
dstVal, exists := dst[key]
151+
152+
// Key doesn't exist in dst - add from src
153+
if !exists {
154+
dst[key] = srcVal
155+
continue
156+
}
157+
158+
// Dst value is nil - use src value
159+
if dstVal == nil {
160+
dst[key] = srcVal
161+
continue
162+
}
163+
164+
// Both exist and are non-nil - check if both are maps
165+
srcMap, srcIsMap := srcVal.(map[string]interface{})
166+
dstMap, dstIsMap := dstVal.(map[string]interface{})
167+
168+
if srcIsMap && dstIsMap {
169+
// Both are maps - merge recursively
170+
deepMerge(dstMap, srcMap)
171+
}
172+
173+
// For other types (arrays, scalars), dst takes precedence (no action needed)
174+
}
175+
}

pkg/devspace/config/loader/loader_test.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,6 +1964,287 @@ deployments:
19641964
}
19651965
}
19661966

1967+
// TestImportsDeepMerge validates that imports are deep merged correctly.
1968+
// Maps should be recursively merged while arrays and scalars are replaced.
1969+
func TestImportsDeepMerge(t *testing.T) {
1970+
dir := t.TempDir()
1971+
1972+
wdBackup, err := os.Getwd()
1973+
if err != nil {
1974+
t.Fatalf("Error getting current working directory: %v", err)
1975+
}
1976+
err = os.Chdir(dir)
1977+
if err != nil {
1978+
t.Fatalf("Error changing working directory: %v", err)
1979+
}
1980+
defer func() {
1981+
err = os.Chdir(wdBackup)
1982+
if err != nil {
1983+
t.Fatalf("Error changing dir back: %v", err)
1984+
}
1985+
}()
1986+
1987+
testCases := []struct {
1988+
name string
1989+
catalogYaml string
1990+
mainYaml string
1991+
verify func(t *testing.T, dev map[string]*latest.DevPod)
1992+
}{
1993+
{
1994+
name: "Catalog provides base config, project adds minimal customization",
1995+
catalogYaml: `
1996+
version: v2beta1
1997+
name: catalog
1998+
dev:
1999+
api:
2000+
command: ["/bin/bash"]
2001+
ports:
2002+
- port: "8080:8080"
2003+
sync:
2004+
- path: ./src:/app/src
2005+
excludePaths:
2006+
- "**/__pycache__/"
2007+
- "**/*.pyc"
2008+
onUpload:
2009+
restartContainer: true
2010+
logs:
2011+
enabled: true
2012+
`,
2013+
mainYaml: `
2014+
version: v2beta1
2015+
name: my-project
2016+
imports:
2017+
- path: catalog.yaml
2018+
dev:
2019+
api:
2020+
labelSelector:
2021+
app.kubernetes.io/name: my-app
2022+
`,
2023+
verify: func(t *testing.T, dev map[string]*latest.DevPod) {
2024+
assert.Assert(t, dev["api"] != nil, "api dev config should exist")
2025+
2026+
// Project customization
2027+
assert.Equal(t, dev["api"].LabelSelector["app.kubernetes.io/name"], "my-app", "labelSelector from project")
2028+
2029+
// Catalog base config preserved
2030+
assert.Equal(t, len(dev["api"].Command), 1, "command from catalog")
2031+
assert.Equal(t, dev["api"].Command[0], "/bin/bash", "command value from catalog")
2032+
assert.Assert(t, len(dev["api"].Ports) >= 1, "ports from catalog")
2033+
assert.Assert(t, len(dev["api"].Sync) >= 1, "sync from catalog")
2034+
},
2035+
},
2036+
{
2037+
name: "Project replaces arrays but deep merges maps",
2038+
catalogYaml: `
2039+
version: v2beta1
2040+
name: catalog
2041+
dev:
2042+
api:
2043+
labelSelector:
2044+
app: catalog-app
2045+
version: v1
2046+
command: ["/bin/bash"]
2047+
ports:
2048+
- port: "8080:8080"
2049+
- port: "8443:8443"
2050+
`,
2051+
mainYaml: `
2052+
version: v2beta1
2053+
name: my-project
2054+
imports:
2055+
- path: catalog.yaml
2056+
dev:
2057+
api:
2058+
labelSelector:
2059+
app: project-app
2060+
tier: frontend
2061+
ports:
2062+
- port: "3000:3000"
2063+
`,
2064+
verify: func(t *testing.T, dev map[string]*latest.DevPod) {
2065+
assert.Assert(t, dev["api"] != nil, "api dev config should exist")
2066+
2067+
// Maps are deep merged
2068+
assert.Equal(t, dev["api"].LabelSelector["app"], "project-app", "project overrides 'app' in map")
2069+
assert.Equal(t, dev["api"].LabelSelector["version"], "v1", "catalog 'version' preserved in map")
2070+
assert.Equal(t, dev["api"].LabelSelector["tier"], "frontend", "project adds 'tier' in map")
2071+
2072+
// Arrays are replaced entirely
2073+
assert.Equal(t, len(dev["api"].Ports), 1, "project ports replace catalog ports (array replaced)")
2074+
2075+
// Catalog scalar values preserved
2076+
assert.Equal(t, len(dev["api"].Command), 1, "command from catalog")
2077+
assert.Equal(t, dev["api"].Command[0], "/bin/bash", "command value")
2078+
},
2079+
},
2080+
{
2081+
name: "Catalog with nested labelSelector, project adds and overrides",
2082+
catalogYaml: `
2083+
version: v2beta1
2084+
name: catalog
2085+
dev:
2086+
api:
2087+
labelSelector:
2088+
app: base-app
2089+
version: v1
2090+
environment: production
2091+
command: ["/bin/bash"]
2092+
ports:
2093+
- port: "8080:8080"
2094+
`,
2095+
mainYaml: `
2096+
version: v2beta1
2097+
name: my-project
2098+
imports:
2099+
- path: catalog.yaml
2100+
dev:
2101+
api:
2102+
labelSelector:
2103+
app: my-custom-app
2104+
tier: frontend
2105+
`,
2106+
verify: func(t *testing.T, dev map[string]*latest.DevPod) {
2107+
assert.Assert(t, dev["api"] != nil, "api dev config should exist")
2108+
2109+
// Project overrides specific label
2110+
assert.Equal(t, dev["api"].LabelSelector["app"], "my-custom-app", "project overrides 'app' label")
2111+
2112+
// Project adds new label
2113+
assert.Equal(t, dev["api"].LabelSelector["tier"], "frontend", "project adds 'tier' label")
2114+
2115+
// Catalog labels preserved when not overridden
2116+
assert.Equal(t, dev["api"].LabelSelector["version"], "v1", "catalog 'version' label preserved")
2117+
assert.Equal(t, dev["api"].LabelSelector["environment"], "production", "catalog 'environment' label preserved")
2118+
2119+
// Catalog command/ports preserved
2120+
assert.Equal(t, len(dev["api"].Command), 1, "command from catalog")
2121+
assert.Assert(t, len(dev["api"].Ports) >= 1, "ports from catalog")
2122+
},
2123+
},
2124+
{
2125+
name: "Multiple services with independent merges",
2126+
catalogYaml: `
2127+
version: v2beta1
2128+
name: catalog
2129+
dev:
2130+
api:
2131+
command: ["/bin/bash"]
2132+
ports:
2133+
- port: "8080:8080"
2134+
sync:
2135+
- path: ./api:/app
2136+
worker:
2137+
command: ["/bin/sh"]
2138+
ports:
2139+
- port: "9090:9090"
2140+
sync:
2141+
- path: ./worker:/app
2142+
`,
2143+
mainYaml: `
2144+
version: v2beta1
2145+
name: my-project
2146+
imports:
2147+
- path: catalog.yaml
2148+
dev:
2149+
api:
2150+
labelSelector:
2151+
app.kubernetes.io/name: my-api
2152+
tier: backend
2153+
worker:
2154+
labelSelector:
2155+
app.kubernetes.io/name: my-worker
2156+
tier: jobs
2157+
`,
2158+
verify: func(t *testing.T, dev map[string]*latest.DevPod) {
2159+
// Verify api service
2160+
assert.Assert(t, dev["api"] != nil, "api dev config should exist")
2161+
assert.Equal(t, dev["api"].LabelSelector["app.kubernetes.io/name"], "my-api", "api labelSelector")
2162+
assert.Equal(t, dev["api"].LabelSelector["tier"], "backend", "api tier label")
2163+
assert.Equal(t, len(dev["api"].Command), 1, "api command from catalog")
2164+
assert.Equal(t, dev["api"].Command[0], "/bin/bash", "api command value")
2165+
assert.Assert(t, len(dev["api"].Ports) >= 1, "api ports from catalog")
2166+
assert.Assert(t, len(dev["api"].Sync) >= 1, "api sync from catalog")
2167+
2168+
// Verify worker service
2169+
assert.Assert(t, dev["worker"] != nil, "worker dev config should exist")
2170+
assert.Equal(t, dev["worker"].LabelSelector["app.kubernetes.io/name"], "my-worker", "worker labelSelector")
2171+
assert.Equal(t, dev["worker"].LabelSelector["tier"], "jobs", "worker tier label")
2172+
assert.Equal(t, len(dev["worker"].Command), 1, "worker command from catalog")
2173+
assert.Equal(t, dev["worker"].Command[0], "/bin/sh", "worker command value")
2174+
assert.Assert(t, len(dev["worker"].Ports) >= 1, "worker ports from catalog")
2175+
assert.Assert(t, len(dev["worker"].Sync) >= 1, "worker sync from catalog")
2176+
},
2177+
},
2178+
{
2179+
name: "Project only defines labelSelector, everything else from catalog",
2180+
catalogYaml: `
2181+
version: v2beta1
2182+
name: catalog
2183+
dev:
2184+
api:
2185+
command: ["/bin/bash"]
2186+
ports:
2187+
- port: "8080:8080"
2188+
- port: "8443:8443"
2189+
sync:
2190+
- path: ./src:/usr/src/app/src
2191+
excludePaths:
2192+
- "**/__pycache__/"
2193+
- "**/*.pyc"
2194+
- "**/node_modules/"
2195+
onUpload:
2196+
restartContainer: true
2197+
logs:
2198+
enabled: true
2199+
`,
2200+
mainYaml: `
2201+
version: v2beta1
2202+
name: my-project
2203+
imports:
2204+
- path: catalog.yaml
2205+
dev:
2206+
api:
2207+
labelSelector:
2208+
app.kubernetes.io/name: my-minimal-app
2209+
`,
2210+
verify: func(t *testing.T, dev map[string]*latest.DevPod) {
2211+
assert.Assert(t, dev["api"] != nil, "api dev config should exist")
2212+
2213+
// Only labelSelector from project
2214+
assert.Equal(t, dev["api"].LabelSelector["app.kubernetes.io/name"], "my-minimal-app", "labelSelector from project")
2215+
2216+
// Everything else from catalog
2217+
assert.Equal(t, len(dev["api"].Command), 1, "command from catalog")
2218+
assert.Equal(t, dev["api"].Command[0], "/bin/bash", "command value")
2219+
assert.Equal(t, len(dev["api"].Ports), 2, "all ports from catalog")
2220+
assert.Assert(t, len(dev["api"].Sync) >= 1, "sync config from catalog")
2221+
},
2222+
},
2223+
}
2224+
2225+
for _, tc := range testCases {
2226+
t.Run(tc.name, func(t *testing.T) {
2227+
// Create catalog file
2228+
err := fsutil.WriteToFile([]byte(tc.catalogYaml), filepath.Join(dir, "catalog.yaml"))
2229+
assert.NilError(t, err, "Error writing catalog.yaml")
2230+
2231+
// Create main config file
2232+
err = fsutil.WriteToFile([]byte(tc.mainYaml), filepath.Join(dir, "devspace.yaml"))
2233+
assert.NilError(t, err, "Error writing devspace.yaml")
2234+
2235+
// Load config
2236+
loader, err := NewConfigLoader(filepath.Join(dir, "devspace.yaml"))
2237+
assert.NilError(t, err, "Error creating config loader")
2238+
2239+
config, err := loader.Load(context.TODO(), nil, &ConfigOptions{Dry: true}, log.Discard)
2240+
assert.NilError(t, err, "Error loading config in test case %s", tc.name)
2241+
2242+
// Verify using custom verification function
2243+
tc.verify(t, config.Config().Dev)
2244+
})
2245+
}
2246+
}
2247+
19672248
func stripNames(config *latest.Config) {
19682249
for k := range config.Images {
19692250
config.Images[k].Name = ""

0 commit comments

Comments
 (0)