Repository: compose-spec/compose-go
Pull request #272 (2022-06-03 22:36:08)

interpolate COMPOSE_PROJECT_NAME with name from config file
Use name defined in configuration files to interpolate `${COMPOSE_PROJECT_NAME}`

fix https://github.com/docker/compose/issues/9530


Patch:
diff --git a/loader/full-example.yml b/loader/full-example.yml
index 1969527..2687b05 100644
--- a/loader/full-example.yml
+++ b/loader/full-example.yml
@@ -25,6 +25,7 @@ services:
       tags:
         - foo:v1.0.0
         - docker.io/username/foo:my-other-tag
+        - ${COMPOSE_PROJECT_NAME}:1.0.0
 
 
     cap_add:
diff --git a/loader/loader.go b/loader/loader.go
index 01c0c5d..2c115e3 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -160,6 +160,8 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		op(opts)
 	}
 
+	projectName := projectName(configDetails, opts)
+
 	var configs []*types.Config
 	for i, file := range configDetails.ConfigFiles {
 		configDict := file.Config
@@ -207,15 +209,6 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		s.EnvFile = newEnvFiles
 	}
 
-	projectName, projectNameImperativelySet := opts.GetProjectName()
-	model.Name = NormalizeProjectName(model.Name)
-	if !projectNameImperativelySet && model.Name != "" {
-		projectName = model.Name
-	}
-
-	if projectName != "" {
-		configDetails.Environment[consts.ComposeProjectName] = projectName
-	}
 	project := &types.Project{
 		Name:        projectName,
 		WorkingDir:  configDetails.WorkingDir,
@@ -245,6 +238,30 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 	return project, nil
 }
 
+func projectName(details types.ConfigDetails, opts *Options) string {
+	projectName, projectNameImperativelySet := opts.GetProjectName()
+	var pjNameFromConfigFile string
+
+	for _, configFile := range details.ConfigFiles {
+		yml, err := ParseYAML(configFile.Content)
+		if err != nil {
+			return ""
+		}
+		if val, ok := yml["name"]; ok && val != "" {
+			pjNameFromConfigFile = yml["name"].(string)
+		}
+	}
+	pjNameFromConfigFile = NormalizeProjectName(pjNameFromConfigFile)
+	if !projectNameImperativelySet && pjNameFromConfigFile != "" {
+		projectName = pjNameFromConfigFile
+	}
+
+	if _, ok := details.Environment[consts.ComposeProjectName]; !ok && projectName != "" {
+		details.Environment[consts.ComposeProjectName] = projectName
+	}
+	return projectName
+}
+
 func NormalizeProjectName(s string) string {
 	r := regexp.MustCompile("[a-z0-9_-]")
 	s = strings.ToLower(s)


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 9bdb637..66b3380 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -70,7 +70,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 						Mode:   uint32Ptr(0440),
 					},
 				},
-				Tags: []string{"foo:v1.0.0", "docker.io/username/foo:my-other-tag"},
+				Tags: []string{"foo:v1.0.0", "docker.io/username/foo:my-other-tag", "full_example_project_name:1.0.0"},
 			},
 			CapAdd:       []string{"ALL"},
 			CapDrop:      []string{"NET_ADMIN", "SYS_ADMIN"},
@@ -602,6 +602,7 @@ services:
       tags:
       - foo:v1.0.0
       - docker.io/username/foo:my-other-tag
+      - full_example_project_name:1.0.0
     cap_add:
     - ALL
     cap_drop:
@@ -1133,7 +1134,8 @@ func fullExampleJSON(workingDir, homeDir string) string {
         ],
         "tags": [
           "foo:v1.0.0",
-          "docker.io/username/foo:my-other-tag"
+          "docker.io/username/foo:my-other-tag",
+          "full_example_project_name:1.0.0"
         ]
       },
       "cap_add": [
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 60569b2..7e3684a 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -34,20 +34,32 @@ import (
 )
 
 func buildConfigDetails(yaml string, env map[string]string) types.ConfigDetails {
+	return buildConfigDetailsMultipleFiles(env, yaml)
+}
+
+func buildConfigDetailsMultipleFiles(env map[string]string, yamls ...string) types.ConfigDetails {
 	workingDir, err := os.Getwd()
 	if err != nil {
 		panic(err)
 	}
 
 	return types.ConfigDetails{
-		WorkingDir: workingDir,
-		ConfigFiles: []types.ConfigFile{
-			{Filename: "filename.yml", Content: []byte(yaml)},
-		},
+		WorkingDir:  workingDir,
+		ConfigFiles: buildConfigFiles(yamls),
 		Environment: env,
 	}
 }
 
+func buildConfigFiles(yamls []string) []types.ConfigFile {
+	configFiles := []types.ConfigFile{}
+	for i, yaml := range yamls {
+		configFiles = append(configFiles, types.ConfigFile{
+			Filename: fmt.Sprintf("filename%d.yml", i),
+			Content:  []byte(yaml)})
+	}
+	return configFiles
+}
+
 func loadYAML(yaml string) (*types.Project, error) {
 	return loadYAMLWithEnv(yaml, nil)
 }
@@ -1951,3 +1963,76 @@ services:
 	assert.NilError(t, err)
 	assert.Equal(t, "value2", sshValue)
 }
+
+func TestProjectNameInterpolation(t *testing.T) {
+	t.Run("project name simple interpolation", func(t *testing.T) {
+		yaml := `
+name: interpolated
+services:
+  web:
+    image: web
+    container_name: ${COMPOSE_PROJECT_NAME}-web
+`
+		configDetails := buildConfigDetails(yaml, map[string]string{})
+
+		actual, err := Load(configDetails)
+		assert.NilError(t, err)
+		svc, err := actual.GetService("web")
+		assert.NilError(t, err)
+		assert.Equal(t, "interpolated-web", svc.ContainerName)
+	})
+
+	t.Run("project name interpolation with override", func(t *testing.T) {
+		yaml1 := `
+name: interpolated
+services:
+  web:
+    image: web
+    container_name: ${COMPOSE_PROJECT_NAME}-web
+`
+		yaml2 := `
+name: overrided
+services:
+  db:
+    image: db
+    container_name: ${COMPOSE_PROJECT_NAME}-db
+`
+		yaml3 := `
+services:
+  proxy:
+    image: proxy
+    container_name: ${COMPOSE_PROJECT_NAME}-proxy
+`
+		configDetails := buildConfigDetailsMultipleFiles(map[string]string{}, yaml1, yaml2, yaml3)
+
+		actual, err := Load(configDetails)
+		assert.NilError(t, err)
+		svc, err := actual.GetService("web")
+		assert.NilError(t, err)
+		assert.Equal(t, "overrided-web", svc.ContainerName)
+
+		svc, err = actual.GetService("db")
+		assert.NilError(t, err)
+		assert.Equal(t, "overrided-db", svc.ContainerName)
+
+		svc, err = actual.GetService("proxy")
+		assert.NilError(t, err)
+		assert.Equal(t, "overrided-proxy", svc.ContainerName)
+	})
+
+	t.Run("project name env variable interpolation", func(t *testing.T) {
+		yaml := `
+name: interpolated
+services:
+  web:
+    image: web
+    container_name: ${COMPOSE_PROJECT_NAME}-web
+`
+		configDetails := buildConfigDetails(yaml, map[string]string{"COMPOSE_PROJECT_NAME": "env-var"})
+		actual, err := Load(configDetails)
+		assert.NilError(t, err)
+		svc, err := actual.GetService("web")
+		assert.NilError(t, err)
+		assert.Equal(t, "env-var-web", svc.ContainerName)
+	})
+}