diff --git a/cli/options.go b/cli/options.go index bde6a770..06446e6a 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/cli/options_test.go b/cli/options_test.go index 3fca7507..7f31f0aa 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/consts/consts.go b/consts/consts.go new file mode 100644 index 00000000..bf5cc9f1 --- /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 c13c58d5..71e7820f 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/full-struct_test.go b/loader/full-struct_test.go index 25419a24..8a1623e4 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.go b/loader/loader.go index 1c0394f1..83942875 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/loader_test.go b/loader/loader_test.go index 3e3dd614..78625be3 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)) diff --git a/loader/merge.go b/loader/merge.go index 3af10a1a..f6138ca2 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 21127f49..b395363b 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"`