Skip to content

Commit 8145482

Browse files
committed
feat(config): auto-extract and deduplicate named volumes from services and dependencies
1 parent e640d63 commit 8145482

File tree

2 files changed

+175
-0
lines changed

2 files changed

+175
-0
lines changed

pkg/config/config.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ func ParseConfig(data []byte) (*Config, error) {
217217

218218
validate := validator.New()
219219

220+
// Register custom validations
220221
_ = validate.RegisterValidation("volume_reference", func(fl validator.FieldLevel) bool {
221222
value := fl.Field().String()
222223
parts := strings.Split(value, ":")
@@ -232,9 +233,66 @@ func ParseConfig(data []byte) (*Config, error) {
232233
return nil, fmt.Errorf("validation error: %v", err)
233234
}
234235

236+
// Collect all named volumes from config.Services and config.Dependencies,
237+
// plus any that were explicitly listed in config.Volumes, deduplicating them.
238+
uniqueVolNames := make(map[string]struct{})
239+
240+
// First, preserve any volumes already defined in config.Volumes
241+
for _, volName := range config.Volumes {
242+
uniqueVolNames[volName] = struct{}{}
243+
}
244+
245+
// Check volumes in each service
246+
for _, svc := range config.Services {
247+
for _, volRef := range svc.Volumes {
248+
if volName := extractNamedVolume(volRef); volName != "" {
249+
uniqueVolNames[volName] = struct{}{}
250+
}
251+
}
252+
}
253+
254+
// Check volumes in each dependency
255+
for _, dep := range config.Dependencies {
256+
for _, volRef := range dep.Volumes {
257+
if volName := extractNamedVolume(volRef); volName != "" {
258+
uniqueVolNames[volName] = struct{}{}
259+
}
260+
}
261+
}
262+
263+
// Convert to a sorted slice
264+
finalVols := make([]string, 0, len(uniqueVolNames))
265+
for name := range uniqueVolNames {
266+
finalVols = append(finalVols, name)
267+
}
268+
sort.Strings(finalVols)
269+
config.Volumes = finalVols
270+
235271
return &config, nil
236272
}
237273

274+
// extractNamedVolume checks if volRef is in the form "NAME:/some/path"
275+
// and if NAME starts with a letter. If so, it returns NAME; otherwise "".
276+
func extractNamedVolume(volRef string) string {
277+
parts := strings.SplitN(volRef, ":", 2)
278+
if len(parts) < 2 {
279+
return ""
280+
}
281+
namePart := parts[0]
282+
if len(namePart) == 0 {
283+
return ""
284+
}
285+
286+
// Check if first character is a letter [a-zA-Z].
287+
first := namePart[0]
288+
if (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') {
289+
return namePart
290+
}
291+
292+
// If it starts with '/', '.', or anything else, we treat it as a path, not a named volume
293+
return ""
294+
}
295+
238296
func (s *Service) Hash() (string, error) {
239297
sortedService := s.sortServiceFields()
240298
bytes, err := json.Marshal(sortedService)

pkg/config/config_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,120 @@ dependencies:
341341
assert.Equal(suite.T(), "elasticsearch", config.Dependencies[2].Name)
342342
assert.Equal(suite.T(), "elasticsearch:latest", config.Dependencies[2].Image)
343343
}
344+
345+
func (suite *ConfigTestSuite) TestParseConfig_VolumesExtraction() {
346+
yamlData := []byte(`
347+
project:
348+
name: "test-project"
349+
domain: "example.com"
350+
email: "test@example.com"
351+
352+
server:
353+
host: "example.com"
354+
port: 22
355+
user: "user"
356+
ssh_key: "~/.ssh/id_rsa"
357+
358+
services:
359+
- name: "web"
360+
image: "nginx:latest"
361+
port: 80
362+
routes:
363+
- path: "/"
364+
volumes:
365+
- "my-vol:/app/data"
366+
- "/host/data:/container/data" # not a named volume
367+
- name: "worker"
368+
image: "golang:latest"
369+
routes:
370+
- path: "/worker"
371+
port: 9000
372+
volumes:
373+
- "my-other-vol:/srv"
374+
375+
dependencies:
376+
- name: "some-db"
377+
image: "postgres:15"
378+
volumes:
379+
- "third-vol:/some/dep/path"
380+
- "123bad:/skip"
381+
- "logs:/var/log"
382+
383+
volumes:
384+
- "predefined-volume"
385+
`)
386+
387+
config, err := ParseConfig(yamlData)
388+
assert.NoError(suite.T(), err)
389+
assert.NotNil(suite.T(), config)
390+
391+
expected := []string{"logs", "my-other-vol", "my-vol", "predefined-volume", "third-vol"}
392+
assert.Equal(suite.T(), expected, config.Volumes)
393+
}
394+
395+
func (suite *ConfigTestSuite) TestParseConfig_VolumesExtraction_NoNamedVolumes() {
396+
yamlData := []byte(`
397+
project:
398+
name: "path-volumes-only"
399+
domain: "example.com"
400+
email: "test@example.com"
401+
402+
server:
403+
host: "example.com"
404+
port: 22
405+
user: "user"
406+
ssh_key: "~/.ssh/id_rsa"
407+
408+
services:
409+
- name: "web"
410+
image: "nginx:latest"
411+
port: 80
412+
volumes:
413+
- "/absolute/path:/container/path"
414+
- "./relative:/opt/app"
415+
dependencies:
416+
- name: "redis"
417+
image: "redis:latest"
418+
volumes:
419+
- "/redis/logs:/var/log/redis"
420+
- "./some-local-dir:/data"
421+
volumes: []
422+
`)
423+
424+
config, err := ParseConfig(yamlData)
425+
assert.NoError(suite.T(), err)
426+
assert.NotNil(suite.T(), config)
427+
428+
assert.Empty(suite.T(), config.Volumes)
429+
}
430+
431+
func (suite *ConfigTestSuite) TestParseConfig_VolumesExtraction_DefaultConfigs() {
432+
yamlData := []byte(`
433+
project:
434+
name: "default-configs-only"
435+
domain: "example.com"
436+
email: "test@example.com"
437+
438+
server:
439+
host: "example.com"
440+
port: 22
441+
user: "user"
442+
ssh_key: "~/.ssh/id_rsa"
443+
444+
services:
445+
- name: "web"
446+
image: "nginx:latest"
447+
routes:
448+
- path: "/"
449+
port: 80
450+
dependencies:
451+
- "redis:6"
452+
`)
453+
454+
config, err := ParseConfig(yamlData)
455+
assert.NoError(suite.T(), err)
456+
assert.NotNil(suite.T(), config)
457+
458+
expected := []string{"redis_data"}
459+
assert.Equal(suite.T(), expected, config.Volumes)
460+
}

0 commit comments

Comments
 (0)