Repository: compose-spec/compose-go
Pull request #231 (2022-02-23 15:56:44)

Update spec and implement project name
[Implement project name](https://github.com/compose-spec/compose-spec/pull/206)


Patch:
diff --git a/cli/options.go b/cli/options.go
index bde6a77..06446e6 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -21,9 +21,9 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
-	"regexp"
 	"strings"
 
+	"github.com/compose-spec/compose-go/consts"
 	"github.com/compose-spec/compose-go/dotenv"
 	"github.com/compose-spec/compose-go/errdefs"
 	"github.com/compose-spec/compose-go/loader"
@@ -87,11 +87,11 @@ func WithConfigFileEnv(o *ProjectOptions) error {
 	if len(o.ConfigPaths) > 0 {
 		return nil
 	}
-	sep := o.Environment[ComposePathSeparator]
+	sep := o.Environment[consts.ComposePathSeparator]
 	if sep == "" {
 		sep = string(os.PathListSeparator)
 	}
-	f, ok := o.Environment[ComposeFilePath]
+	f, ok := o.Environment[consts.ComposeFilePath]
 	if ok {
 		paths, err := absolutePaths(strings.Split(f, sep))
 		o.ConfigPaths = paths
@@ -276,12 +276,6 @@ var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.y
 // DefaultOverrideFileNames defines the Compose override file names for auto-discovery (in order of preference)
 var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"}
 
-const (
-	ComposeProjectName   = "COMPOSE_PROJECT_NAME"
-	ComposePathSeparator = "COMPOSE_PATH_SEPARATOR"
-	ComposeFilePath      = "COMPOSE_FILE"
-)
-
 func (o ProjectOptions) GetWorkingDir() (string, error) {
 	if o.WorkingDir != "" {
 		return o.WorkingDir, nil
@@ -338,17 +332,7 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 		return nil, err
 	}
 
-	var nameLoadOpt = func(opts *loader.Options) {
-		if options.Name != "" {
-			opts.Name = options.Name
-		} else if nameFromEnv, ok := options.Environment[ComposeProjectName]; ok && nameFromEnv != "" {
-			opts.Name = nameFromEnv
-		} else {
-			opts.Name = filepath.Base(absWorkingDir)
-		}
-		opts.Name = normalizeName(opts.Name)
-	}
-	options.loadOptions = append(options.loadOptions, nameLoadOpt)
+	options.loadOptions = append(options.loadOptions, withNamePrecedenceLoad(absWorkingDir, options))
 
 	project, err := loader.Load(types.ConfigDetails{
 		ConfigFiles: configs,
@@ -363,11 +347,16 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 	return project, nil
 }
 
-func normalizeName(s string) string {
-	r := regexp.MustCompile("[a-z0-9_-]")
-	s = strings.ToLower(s)
-	s = strings.Join(r.FindAllString(s, -1), "")
-	return strings.TrimLeft(s, "_-")
+func withNamePrecedenceLoad(absWorkingDir string, options *ProjectOptions) func(*loader.Options) {
+	return func(opts *loader.Options) {
+		if options.Name != "" {
+			opts.SetProjectName(options.Name, true)
+		} else if nameFromEnv, ok := options.Environment[consts.ComposeProjectName]; ok && nameFromEnv != "" {
+			opts.SetProjectName(nameFromEnv, true)
+		} else {
+			opts.SetProjectName(filepath.Base(absWorkingDir), false)
+		}
+	}
 }
 
 // getConfigPathsFromOptions retrieves the config files for project based on project options
diff --git a/consts/consts.go b/consts/consts.go
new file mode 100644
index 0000000..bf5cc9f
--- /dev/null
+++ b/consts/consts.go
@@ -0,0 +1,23 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package consts
+
+const (
+	ComposeProjectName   = "COMPOSE_PROJECT_NAME"
+	ComposePathSeparator = "COMPOSE_PATH_SEPARATOR"
+	ComposeFilePath      = "COMPOSE_FILE"
+)
diff --git a/loader/full-example.yml b/loader/full-example.yml
index c13c58d..71e7820 100644
--- a/loader/full-example.yml
+++ b/loader/full-example.yml
@@ -1,3 +1,4 @@
+name: Full_Example_project_name
 services:
   foo:
 
diff --git a/loader/loader.go b/loader/loader.go
index 1c0394f..8394287 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -23,11 +23,13 @@ import (
 	"path"
 	"path/filepath"
 	"reflect"
+	"regexp"
 	"sort"
 	"strconv"
 	"strings"
 	"time"
 
+	"github.com/compose-spec/compose-go/consts"
 	"github.com/compose-spec/compose-go/dotenv"
 	interp "github.com/compose-spec/compose-go/interpolation"
 	"github.com/compose-spec/compose-go/schema"
@@ -59,8 +61,19 @@ type Options struct {
 	Interpolate *interp.Options
 	// Discard 'env_file' entries after resolving to 'environment' section
 	discardEnvFiles bool
-	// Set project name
-	Name string
+	// Set project projectName
+	projectName string
+	// Indicates when the projectName was imperatively set or guessed from path
+	projectNameImperativelySet bool
+}
+
+func (o *Options) SetProjectName(name string, imperativelySet bool) {
+	o.projectName = normalizeProjectName(name)
+	o.projectNameImperativelySet = imperativelySet
+}
+
+func (o Options) GetProjectName() (string, bool) {
+	return o.projectName, o.projectNameImperativelySet
 }
 
 // serviceRef identifies a reference to a service. It's used to detect cyclic
@@ -193,8 +206,17 @@ 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:        opts.Name,
+		Name:        projectName,
 		WorkingDir:  configDetails.WorkingDir,
 		Services:    model.Services,
 		Networks:    model.Networks,
@@ -222,6 +244,13 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 	return project, nil
 }
 
+func normalizeProjectName(s string) string {
+	r := regexp.MustCompile("[a-z0-9_-]")
+	s = strings.ToLower(s)
+	s = strings.Join(r.FindAllString(s, -1), "")
+	return strings.TrimLeft(s, "_-")
+}
+
 func parseConfig(b []byte, opts *Options) (map[string]interface{}, error) {
 	yml, err := ParseYAML(b)
 	if err != nil {
@@ -255,7 +284,14 @@ func loadSections(filename string, config map[string]interface{}, configDetails
 	cfg := types.Config{
 		Filename: filename,
 	}
-
+	name := ""
+	if n, ok := config["name"]; ok {
+		name, ok = n.(string)
+		if !ok {
+			return nil, errors.New("project name must be a string")
+		}
+	}
+	cfg.Name = name
 	cfg.Services, err = LoadServices(filename, getSection(config, "services"), configDetails.WorkingDir, configDetails.LookupEnv, opts)
 	if err != nil {
 		return nil, err
diff --git a/loader/merge.go b/loader/merge.go
index 3af10a1..f6138ca 100644
--- a/loader/merge.go
+++ b/loader/merge.go
@@ -53,6 +53,7 @@ func merge(configs []*types.Config) (*types.Config, error) {
 	base := configs[0]
 	for _, override := range configs[1:] {
 		var err error
+		base.Name = mergeNames(base.Name, override.Name)
 		base.Services, err = mergeServices(base.Services, override.Services)
 		if err != nil {
 			return base, errors.Wrapf(err, "cannot merge services from %s", override.Filename)
@@ -81,6 +82,13 @@ func merge(configs []*types.Config) (*types.Config, error) {
 	return base, nil
 }
 
+func mergeNames(base, override string) string {
+	if override != "" {
+		return override
+	}
+	return base
+}
+
 func mergeServices(base, override []types.ServiceConfig) ([]types.ServiceConfig, error) {
 	baseServices := mapByName(base)
 	overrideServices := mapByName(override)
@@ -291,7 +299,7 @@ func mergeLoggingConfig(dst, src reflect.Value) error {
 	return nil
 }
 
-//nolint: unparam
+// nolint: unparam
 func mergeUlimitsConfig(dst, src reflect.Value) error {
 	if src.Interface() != reflect.Zero(reflect.TypeOf(src.Interface())).Interface() {
 		dst.Elem().Set(src.Elem())
@@ -299,7 +307,7 @@ func mergeUlimitsConfig(dst, src reflect.Value) error {
 	return nil
 }
 
-//nolint: unparam
+// nolint: unparam
 func mergeServiceNetworkConfig(dst, src reflect.Value) error {
 	if src.Interface() != reflect.Zero(reflect.TypeOf(src.Interface())).Interface() {
 		dst.Elem().FieldByName("Aliases").Set(src.Elem().FieldByName("Aliases"))
diff --git a/types/config.go b/types/config.go
index 21127f4..b395363 100644
--- a/types/config.go
+++ b/types/config.go
@@ -49,6 +49,7 @@ type ConfigFile struct {
 // Config is a full compose file configuration and model
 type Config struct {
 	Filename   string     `yaml:"-" json:"-"`
+	Name       string     `yaml:",omitempty" json:"name,omitempty"`
 	Services   Services   `json:"services"`
 	Networks   Networks   `yaml:",omitempty" json:"networks,omitempty"`
 	Volumes    Volumes    `yaml:",omitempty" json:"volumes,omitempty"`


Test patch:
diff --git a/cli/options_test.go b/cli/options_test.go
index 3fca750..7f31f0a 100644
--- a/cli/options_test.go
+++ b/cli/options_test.go
@@ -22,6 +22,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/compose-spec/compose-go/consts"
 	"gotest.tools/v3/assert"
 )
 
@@ -42,7 +43,7 @@ func TestProjectName(t *testing.T) {
 		assert.Equal(t, p.Name, "42my_project_num")
 
 		opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithEnv([]string{
-			fmt.Sprintf("%s=%s", ComposeProjectName, "42my_project_env"),
+			fmt.Sprintf("%s=%s", consts.ComposeProjectName, "42my_project_env"),
 		}))
 		assert.NilError(t, err)
 		p, err = ProjectFromOptions(opts)
@@ -58,7 +59,7 @@ func TestProjectName(t *testing.T) {
 		assert.Equal(t, p.Name, "my_project")
 
 		opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithEnv([]string{
-			fmt.Sprintf("%s=%s", ComposeProjectName, "-my_project"),
+			fmt.Sprintf("%s=%s", consts.ComposeProjectName, "-my_project"),
 		}))
 		assert.NilError(t, err)
 		p, err = ProjectFromOptions(opts)
@@ -74,7 +75,7 @@ func TestProjectName(t *testing.T) {
 		assert.Equal(t, p.Name, "my_project")
 
 		opts, err = NewProjectOptions([]string{"testdata/simple/compose.yaml"}, WithEnv([]string{
-			fmt.Sprintf("%s=%s", ComposeProjectName, "_my_project"),
+			fmt.Sprintf("%s=%s", consts.ComposeProjectName, "_my_project"),
 		}))
 		assert.NilError(t, err)
 		p, err = ProjectFromOptions(opts)
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 25419a2..8a1623e 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -27,6 +27,7 @@ import (
 
 func fullExampleConfig(workingDir, homeDir string) *types.Config {
 	return &types.Config{
+		Name:     "full_example_project_name",
 		Services: services(workingDir, homeDir),
 		Networks: networks(),
 		Volumes:  volumes(),
@@ -214,7 +215,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 			},
 			Pid: "host",
 			Ports: []types.ServicePortConfig{
-				//"3000",
+				// "3000",
 				{
 					Mode:     "ingress",
 					Target:   3000,
@@ -245,14 +246,14 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 					Target:   3005,
 					Protocol: "tcp",
 				},
-				//"8000:8000",
+				// "8000:8000",
 				{
 					Mode:      "ingress",
 					Target:    8000,
 					Published: "8000",
 					Protocol:  "tcp",
 				},
-				//"9090-9091:8080-8081",
+				// "9090-9091:8080-8081",
 				{
 					Mode:      "ingress",
 					Target:    8080,
@@ -265,14 +266,14 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 					Published: "9091",
 					Protocol:  "tcp",
 				},
-				//"49100:22",
+				// "49100:22",
 				{
 					Mode:      "ingress",
 					Target:    22,
 					Published: "49100",
 					Protocol:  "tcp",
 				},
-				//"127.0.0.1:8001:8001",
+				// "127.0.0.1:8001:8001",
 				{
 					Mode:      "ingress",
 					HostIP:    "127.0.0.1",
@@ -280,7 +281,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 					Published: "8001",
 					Protocol:  "tcp",
 				},
-				//"127.0.0.1:5000-5010:5000-5010",
+				// "127.0.0.1:5000-5010:5000-5010",
 				{
 					Mode:      "ingress",
 					HostIP:    "127.0.0.1",
@@ -560,7 +561,8 @@ func secrets(workingDir string) map[string]types.SecretConfig {
 }
 
 func fullExampleYAML(workingDir, homeDir string) string {
-	return fmt.Sprintf(`services:
+	return fmt.Sprintf(`name: full_example_project_name
+services:
   foo:
     build:
       context: ./dir
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 3e3dd61..78625be 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -912,13 +912,13 @@ func uint32Ptr(value uint32) *uint32 {
 }
 
 func TestFullExample(t *testing.T) {
-	bytes, err := ioutil.ReadFile("full-example.yml")
+	b, err := ioutil.ReadFile("full-example.yml")
 	assert.NilError(t, err)
 
 	homeDir, err := os.UserHomeDir()
 	assert.NilError(t, err)
 	env := map[string]string{"HOME": homeDir, "QUX": "qux_from_environment"}
-	config, err := loadYAMLWithEnv(string(bytes), env)
+	config, err := loadYAMLWithEnv(string(b), env)
 	assert.NilError(t, err)
 
 	workingDir, err := os.Getwd()
@@ -926,6 +926,7 @@ func TestFullExample(t *testing.T) {
 
 	expectedConfig := fullExampleConfig(workingDir, homeDir)
 
+	assert.Check(t, is.DeepEqual(expectedConfig.Name, config.Name))
 	assert.Check(t, is.DeepEqual(expectedConfig.Services, config.Services))
 	assert.Check(t, is.DeepEqual(expectedConfig.Networks, config.Networks))
 	assert.Check(t, is.DeepEqual(expectedConfig.Volumes, config.Volumes))