Repository: compose-spec/compose-go
Pull request #414 (2023-06-02 13:31:02)

introduce ResolveRelativePaths as a distinct operation vs Normalization
`Normalization` is used both to define implicit resources _and_ to resolve relative paths, so it brings a parameter so apply load option `ResolvePaths`
this PR introduces and explicit `ResolveRelativePaths` to manage this option as an explicit load operation
this also allows to avoid duplicating the code when loading service `extends` and resolving basefile-relative paths


Patch:
diff --git a/go.mod b/go.mod
index eca6d13..011938f 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/opencontainers/go-digest v1.0.0
 	github.com/pkg/errors v0.9.1
 	github.com/sirupsen/logrus v1.9.0
-	github.com/stretchr/testify v1.8.4
+	github.com/stretchr/testify v1.8.2
 	github.com/xeipuuv/gojsonschema v1.2.0
 	golang.org/x/sync v0.2.0
 	gopkg.in/yaml.v3 v3.0.1
diff --git a/go.sum b/go.sum
index fa7b156..b8c71d5 100644
--- a/go.sum
+++ b/go.sum
@@ -27,10 +27,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
 github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
diff --git a/loader/loader.go b/loader/loader.go
index c8e5f24..adddf69 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -185,6 +185,7 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 			LookupValue:     configDetails.LookupEnv,
 			TypeCastMapping: interpolateTypeCastMapping,
 		},
+		ResolvePaths: true,
 	}
 
 	for _, op := range options {
@@ -248,14 +249,6 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		model = merged
 	}
 
-	for _, s := range model.Services {
-		var newEnvFiles types.StringList
-		for _, ef := range s.EnvFile {
-			newEnvFiles = append(newEnvFiles, absPath(configDetails.WorkingDir, ef))
-		}
-		s.EnvFile = newEnvFiles
-	}
-
 	project := &types.Project{
 		Name:        projectName,
 		WorkingDir:  configDetails.WorkingDir,
@@ -268,8 +261,24 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		Extensions:  model.Extensions,
 	}
 
+	if opts.ResolvePaths {
+		err = ResolveRelativePaths(project)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if opts.ConvertWindowsPaths {
+		for i, service := range project.Services {
+			for j, volume := range service.Volumes {
+				service.Volumes[j] = convertVolumePath(volume)
+			}
+			project.Services[i] = service
+		}
+	}
+
 	if !opts.SkipNormalization {
-		err = Normalize(project, opts.ResolvePaths)
+		err = Normalize(project)
 		if err != nil {
 			return nil, err
 		}
@@ -428,11 +437,11 @@ func loadSections(filename string, config map[string]interface{}, configDetails
 	if err != nil {
 		return nil, err
 	}
-	cfg.Secrets, err = LoadSecrets(getSection(config, "secrets"), configDetails, opts.ResolvePaths)
+	cfg.Secrets, err = LoadSecrets(getSection(config, "secrets"))
 	if err != nil {
 		return nil, err
 	}
-	cfg.Configs, err = LoadConfigObjs(getSection(config, "configs"), configDetails, opts.ResolvePaths)
+	cfg.Configs, err = LoadConfigObjs(getSection(config, "configs"))
 	if err != nil {
 		return nil, err
 	}
@@ -634,7 +643,7 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 		target = map[string]interface{}{}
 	}
 
-	serviceConfig, err := LoadService(name, target.(map[string]interface{}), workingDir, lookupEnv, opts.ResolvePaths, opts.ConvertWindowsPaths)
+	serviceConfig, err := LoadService(name, target.(map[string]interface{}))
 	if err != nil {
 		return nil, err
 	}
@@ -672,21 +681,9 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 			// make the paths relative to `file` rather than `baseFilePath` so
 			// that the resulting paths won't be absolute if `file` isn't an
 			// absolute path.
-			baseFileParent := filepath.Dir(file)
-			if baseService.Build != nil {
-				baseService.Build.Context = resolveBuildContextPath(baseFileParent, baseService.Build.Context)
-			}
 
-			for i, vol := range baseService.Volumes {
-				if vol.Type != types.VolumeTypeBind {
-					continue
-				}
-				baseService.Volumes[i].Source = resolveMaybeUnixPath(vol.Source, baseFileParent, lookupEnv)
-			}
-
-			for i, envFile := range baseService.EnvFile {
-				baseService.EnvFile[i] = resolveMaybeUnixPath(envFile, baseFileParent, lookupEnv)
-			}
+			baseFileParent := filepath.Dir(file)
+			ResolveServiceRelativePaths(baseFileParent, baseService)
 		}
 
 		serviceConfig, err = _merge(baseService, serviceConfig)
@@ -699,22 +696,9 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 	return serviceConfig, nil
 }
 
-func resolveBuildContextPath(baseFileParent string, context string) string {
-	// Checks if the context is an HTTP(S) URL or a remote git repository URL
-	for _, prefix := range []string{"https://", "http://", "git://", "github.com/", "git@"} {
-		if strings.HasPrefix(context, prefix) {
-			return context
-		}
-	}
-
-	// Note that the Dockerfile is always defined relative to the
-	// build context, so there's no need to update the Dockerfile field.
-	return absPath(baseFileParent, context)
-}
-
 // LoadService produces a single ServiceConfig from a compose file Dict
 // the serviceDict is not validated if directly used. Use Load() to enable validation
-func LoadService(name string, serviceDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, resolvePaths bool, convertPaths bool) (*types.ServiceConfig, error) {
+func LoadService(name string, serviceDict map[string]interface{}) (*types.ServiceConfig, error) {
 	serviceConfig := &types.ServiceConfig{
 		Scale: 1,
 	}
@@ -731,13 +715,6 @@ func LoadService(name string, serviceDict map[string]interface{}, workingDir str
 			return nil, errors.New(`invalid mount config for type "bind": field Source must not be empty`)
 		}
 
-		if resolvePaths || convertPaths {
-			volume = resolveVolumePath(volume, workingDir, lookupEnv)
-		}
-
-		if convertPaths {
-			volume = convertVolumePath(volume)
-		}
 		serviceConfig.Volumes[i] = volume
 	}
 
@@ -759,8 +736,8 @@ func convertVolumePath(volume types.ServiceVolumeConfig) types.ServiceVolumeConf
 	return volume
 }
 
-func resolveMaybeUnixPath(path string, workingDir string, lookupEnv template.Mapping) string {
-	filePath := expandUser(path, lookupEnv)
+func resolveMaybeUnixPath(workingDir string, path string) string {
+	filePath := expandUser(path)
 	// Check if source is an absolute path (either Unix or Windows), to
 	// handle a Windows client with a Unix daemon or vice-versa.
 	//
@@ -773,20 +750,15 @@ func resolveMaybeUnixPath(path string, workingDir string, lookupEnv template.Map
 	return filePath
 }
 
-func resolveVolumePath(volume types.ServiceVolumeConfig, workingDir string, lookupEnv template.Mapping) types.ServiceVolumeConfig {
-	volume.Source = resolveMaybeUnixPath(volume.Source, workingDir, lookupEnv)
-	return volume
-}
-
 func resolveSecretsPath(secret types.SecretConfig, workingDir string, lookupEnv template.Mapping) types.SecretConfig {
 	if !secret.External.External && secret.File != "" {
-		secret.File = resolveMaybeUnixPath(secret.File, workingDir, lookupEnv)
+		secret.File = resolveMaybeUnixPath(workingDir, secret.File)
 	}
 	return secret
 }
 
 // TODO: make this more robust
-func expandUser(path string, lookupEnv template.Mapping) string {
+func expandUser(path string) string {
 	if strings.HasPrefix(path, "~") {
 		home, err := os.UserHomeDir()
 		if err != nil {
@@ -886,44 +858,39 @@ func LoadVolumes(source map[string]interface{}) (map[string]types.VolumeConfig,
 
 // LoadSecrets produces a SecretConfig map from a compose file Dict
 // the source Dict is not validated if directly used. Use Load() to enable validation
-func LoadSecrets(source map[string]interface{}, details types.ConfigDetails, resolvePaths bool) (map[string]types.SecretConfig, error) {
+func LoadSecrets(source map[string]interface{}) (map[string]types.SecretConfig, error) {
 	secrets := make(map[string]types.SecretConfig)
 	if err := Transform(source, &secrets); err != nil {
 		return secrets, err
 	}
 	for name, secret := range secrets {
-		obj, err := loadFileObjectConfig(name, "secret", types.FileObjectConfig(secret), details, false)
+		obj, err := loadFileObjectConfig(name, "secret", types.FileObjectConfig(secret))
 		if err != nil {
 			return nil, err
 		}
-		secretConfig := types.SecretConfig(obj)
-		if resolvePaths {
-			secretConfig = resolveSecretsPath(secretConfig, details.WorkingDir, details.LookupEnv)
-		}
-		secrets[name] = secretConfig
+		secrets[name] = types.SecretConfig(obj)
 	}
 	return secrets, nil
 }
 
 // LoadConfigObjs produces a ConfigObjConfig map from a compose file Dict
 // the source Dict is not validated if directly used. Use Load() to enable validation
-func LoadConfigObjs(source map[string]interface{}, details types.ConfigDetails, resolvePaths bool) (map[string]types.ConfigObjConfig, error) {
+func LoadConfigObjs(source map[string]interface{}) (map[string]types.ConfigObjConfig, error) {
 	configs := make(map[string]types.ConfigObjConfig)
 	if err := Transform(source, &configs); err != nil {
 		return configs, err
 	}
 	for name, config := range configs {
-		obj, err := loadFileObjectConfig(name, "config", types.FileObjectConfig(config), details, resolvePaths)
+		obj, err := loadFileObjectConfig(name, "config", types.FileObjectConfig(config))
 		if err != nil {
 			return nil, err
 		}
-		configConfig := types.ConfigObjConfig(obj)
-		configs[name] = configConfig
+		configs[name] = types.ConfigObjConfig(obj)
 	}
 	return configs, nil
 }
 
-func loadFileObjectConfig(name string, objType string, obj types.FileObjectConfig, details types.ConfigDetails, resolvePaths bool) (types.FileObjectConfig, error) {
+func loadFileObjectConfig(name string, objType string, obj types.FileObjectConfig) (types.FileObjectConfig, error) {
 	// if "external: true"
 	switch {
 	case obj.External.External:
@@ -943,26 +910,11 @@ func loadFileObjectConfig(name string, objType string, obj types.FileObjectConfi
 		if obj.File != "" {
 			return obj, errors.Errorf("%[1]s %[2]s: %[1]s.driver and %[1]s.file conflict; only use %[1]s.driver", objType, name)
 		}
-	default:
-		if obj.File != "" && resolvePaths {
-			obj.File = absPath(details.WorkingDir, obj.File)
-		}
 	}
 
 	return obj, nil
 }
 
-func absPath(workingDir string, filePath string) string {
-	if strings.HasPrefix(filePath, "~") {
-		home, _ := os.UserHomeDir()
-		return filepath.Join(home, filePath[1:])
-	}
-	if filepath.IsAbs(filePath) {
-		return filePath
-	}
-	return filepath.Join(workingDir, filePath)
-}
-
 var transformMapStringString TransformerFunc = func(data interface{}) (interface{}, error) {
 	switch value := data.(type) {
 	case map[string]interface{}:
diff --git a/loader/normalize.go b/loader/normalize.go
index f6610b1..ee11cf4 100644
--- a/loader/normalize.go
+++ b/loader/normalize.go
@@ -18,7 +18,6 @@ package loader
 
 import (
 	"fmt"
-	"os"
 	"path/filepath"
 	"strings"
 
@@ -29,19 +28,13 @@ import (
 )
 
 // Normalize compose project by moving deprecated attributes to their canonical position and injecting implicit defaults
-func Normalize(project *types.Project, resolvePaths bool) error {
+func Normalize(project *types.Project) error {
 	absWorkingDir, err := filepath.Abs(project.WorkingDir)
 	if err != nil {
 		return err
 	}
 	project.WorkingDir = absWorkingDir
 
-	absComposeFiles, err := absComposeFiles(project.ComposeFiles)
-	if err != nil {
-		return err
-	}
-	project.ComposeFiles = absComposeFiles
-
 	if project.Networks == nil {
 		project.Networks = make(map[string]types.NetworkConfig)
 	}
@@ -75,35 +68,10 @@ func Normalize(project *types.Project, resolvePaths bool) error {
 			if s.Build.Dockerfile == "" && s.Build.DockerfileInline == "" {
 				s.Build.Dockerfile = "Dockerfile"
 			}
-			if resolvePaths {
-				// Build context might be a remote http/git context. Unfortunately supported "remote"
-				// syntax is highly ambiguous in moby/moby and not defined by compose-spec,
-				// so let's assume runtime will check
-				localContext := absPath(project.WorkingDir, s.Build.Context)
-				if _, err := os.Stat(localContext); err == nil {
-					s.Build.Context = localContext
-				}
-				for name, path := range s.Build.AdditionalContexts {
-					if strings.Contains(path, "://") { // `docker-image://` or any builder specific context type
-						continue
-					}
-					path = absPath(project.WorkingDir, path)
-					if _, err := os.Stat(path); err == nil {
-						s.Build.AdditionalContexts[name] = path
-					}
-				}
-			}
 			s.Build.Args = s.Build.Args.Resolve(fn)
 		}
-		for j, f := range s.EnvFile {
-			s.EnvFile[j] = absPath(project.WorkingDir, f)
-		}
 		s.Environment = s.Environment.Resolve(fn)
 
-		if s.Extends != nil && s.Extends.File != "" {
-			s.Extends.File = absPath(project.WorkingDir, s.Extends.File)
-		}
-
 		for _, link := range s.Links {
 			parts := strings.Split(link, ":")
 			if len(parts) == 2 {
@@ -254,18 +222,6 @@ func relocateScale(s *types.ServiceConfig) error {
 	return nil
 }
 
-func absComposeFiles(composeFiles []string) ([]string, error) {
-	absComposeFiles := make([]string, len(composeFiles))
-	for i, composeFile := range composeFiles {
-		absComposefile, err := filepath.Abs(composeFile)
-		if err != nil {
-			return nil, err
-		}
-		absComposeFiles[i] = absComposefile
-	}
-	return absComposeFiles, nil
-}
-
 // Resources with no explicit name are actually named by their key in map
 func setNameFromKey(project *types.Project) {
 	for i, n := range project.Networks {
diff --git a/loader/paths.go b/loader/paths.go
new file mode 100644
index 0000000..8314485
--- /dev/null
+++ b/loader/paths.go
@@ -0,0 +1,131 @@
+/*
+   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 loader
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/compose-spec/compose-go/types"
+)
+
+// ResolveRelativePaths resolves relative paths based on project WorkingDirectory
+func ResolveRelativePaths(project *types.Project) error {
+	absComposeFiles, err := absComposeFiles(project.ComposeFiles)
+	if err != nil {
+		return err
+	}
+	project.ComposeFiles = absComposeFiles
+
+	for i, s := range project.Services {
+		ResolveServiceRelativePaths(project.WorkingDir, &s)
+		project.Services[i] = s
+	}
+
+	for i, obj := range project.Configs {
+		if obj.File != "" {
+			obj.File = absPath(project.WorkingDir, obj.File)
+			project.Configs[i] = obj
+		}
+	}
+
+	for i, obj := range project.Secrets {
+		if obj.File != "" {
+			obj.File = resolveMaybeUnixPath(project.WorkingDir, obj.File)
+			project.Secrets[i] = obj
+		}
+	}
+
+	for name, config := range project.Volumes {
+		if config.Driver == "local" && config.DriverOpts["o"] == "bind" {
+			// This is actually a bind mount
+			config.DriverOpts["device"] = resolveMaybeUnixPath(project.WorkingDir, config.DriverOpts["device"])
+			project.Volumes[name] = config
+		}
+	}
+	return nil
+}
+
+func ResolveServiceRelativePaths(workingDir string, s *types.ServiceConfig) {
+	if s.Build != nil && s.Build.Context != "" && !isRemoteContext(s.Build.Context) {
+		// Build context might be a remote http/git context. Unfortunately supported "remote"
+		// syntax is highly ambiguous in moby/moby and not defined by compose-spec,
+		// so let's assume runtime will check
+		localContext := absPath(workingDir, s.Build.Context)
+		if _, err := os.Stat(localContext); err == nil {
+			s.Build.Context = localContext
+		}
+		for name, path := range s.Build.AdditionalContexts {
+			if strings.Contains(path, "://") { // `docker-image://` or any builder specific context type
+				continue
+			}
+			if isRemoteContext(path) {
+				continue
+			}
+			path = absPath(workingDir, path)
+			if _, err := os.Stat(path); err == nil {
+				s.Build.AdditionalContexts[name] = path
+			}
+		}
+	}
+	for j, f := range s.EnvFile {
+		s.EnvFile[j] = absPath(workingDir, f)
+	}
+
+	if s.Extends != nil && s.Extends.File != "" {
+		s.Extends.File = absPath(workingDir, s.Extends.File)
+	}
+
+	for i, vol := range s.Volumes {
+		if vol.Type != types.VolumeTypeBind {
+			continue
+		}
+		s.Volumes[i].Source = resolveMaybeUnixPath(workingDir, vol.Source)
+	}
+}
+
+func absPath(workingDir string, filePath string) string {
+	if strings.HasPrefix(filePath, "~") {
+		home, _ := os.UserHomeDir()
+		return filepath.Join(home, filePath[1:])
+	}
+	if filepath.IsAbs(filePath) {
+		return filePath
+	}
+	return filepath.Join(workingDir, filePath)
+}
+
+func absComposeFiles(composeFiles []string) ([]string, error) {
+	for i, composeFile := range composeFiles {
+		absComposefile, err := filepath.Abs(composeFile)
+		if err != nil {
+			return nil, err
+		}
+		composeFiles[i] = absComposefile
+	}
+	return composeFiles, nil
+}
+
+func isRemoteContext(maybeURL string) bool {
+	for _, prefix := range []string{"https://", "http://", "git://", "github.com/", "git@"} {
+		if strings.HasPrefix(maybeURL, prefix) {
+			return true
+		}
+	}
+	return false
+}


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 82924dd..aa7a41c 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -177,8 +177,8 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 				"ENV_WITH_UNDERSCORE": strPtr("ok"),
 			},
 			EnvFile: []string{
-				"./example1.env",
-				"./example2.env",
+				filepath.Join(workingDir, "example1.env"),
+				filepath.Join(workingDir, "example2.env"),
 			},
 			Expose: []string{"3000", "8000"},
 			ExternalLinks: []string{
@@ -728,8 +728,8 @@ services:
       FOO: foo_from_env_file
       QUX: qux_from_environment
     env_file:
-      - ./example1.env
-      - ./example2.env
+      - %s
+      - %s
     expose:
       - "3000"
       - "8000"
@@ -1035,6 +1035,8 @@ x-nested:
   bar: baz
   foo: bar
 `,
+		filepath.Join(workingDir, "example1.env"),
+		filepath.Join(workingDir, "example2.env"),
 		workingDir,
 		filepath.Join(workingDir, "static"),
 		filepath.Join(homeDir, "configs"),
@@ -1327,8 +1329,8 @@ func fullExampleJSON(workingDir, homeDir string) string {
         "QUX": "qux_from_environment"
       },
       "env_file": [
-        "./example1.env",
-        "./example2.env"
+        "%s",
+        "%s"
       ],
       "expose": [
         "3000",
@@ -1677,6 +1679,8 @@ func fullExampleJSON(workingDir, homeDir string) string {
 		toPath(workingDir, "config_data"),
 		toPath(homeDir, "config_data"),
 		toPath(workingDir, "secret_data"),
+		toPath(workingDir, "example1.env"),
+		toPath(workingDir, "example2.env"),
 		toPath(workingDir),
 		toPath(workingDir, "static"),
 		toPath(homeDir, "configs"),
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 6a3133b..71692cc 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -843,6 +843,7 @@ services:
 	// Default behavior keeps the `env_file` entries
 	configWithEnvFiles, err := Load(configDetails, func(options *Options) {
 		options.SkipNormalization = true
+		options.ResolvePaths = false
 	})
 	assert.NilError(t, err)
 	assert.DeepEqual(t, configWithEnvFiles.Services[0].EnvFile, types.StringList{"example1.env",
@@ -1453,8 +1454,7 @@ func TestLoadSecretsWarnOnDeprecatedExternalNameVersion35(t *testing.T) {
 			},
 		},
 	}
-	details := types.ConfigDetails{}
-	secrets, err := LoadSecrets(source, details, true)
+	secrets, err := LoadSecrets(source)
 	assert.NilError(t, err)
 	expected := map[string]types.SecretConfig{
 		"foo": {
@@ -1929,8 +1929,7 @@ func TestLoadWithExtends(t *testing.T) {
 	actual, err := Load(configDetails)
 	assert.NilError(t, err)
 
-	extendsDir, err := filepath.Abs(filepath.Join("testdata", "subdir"))
-	assert.NilError(t, err)
+	extendsDir := filepath.Join("testdata", "subdir")
 
 	expectedEnvFilePath := filepath.Join(extendsDir, "extra.env")
 
@@ -2089,7 +2088,7 @@ func TestLoadServiceWithVolumes(t *testing.T) {
 			},
 		},
 	}
-	s, err := LoadService("Test Name", m, ".", nil, true, false)
+	s, err := LoadService("Test Name", m)
 	assert.NilError(t, err)
 	assert.Equal(t, len(s.Volumes), 2)
 	assert.Equal(t, "/path 1", s.Volumes[0].Target)
diff --git a/loader/normalize_test.go b/loader/normalize_test.go
index 2c6097e..f5b1af1 100644
--- a/loader/normalize_test.go
+++ b/loader/normalize_test.go
@@ -17,7 +17,6 @@
 package loader
 
 import (
-	"fmt"
 	"os"
 	"path/filepath"
 	"testing"
@@ -81,71 +80,13 @@ networks:
   mynet:
     name: myProject_mynet
 `
-	err := Normalize(&project, false)
+	err := Normalize(&project)
 	assert.NilError(t, err)
 	marshal, err := project.MarshalYAML()
 	assert.NilError(t, err)
 	assert.Equal(t, expected, string(marshal))
 }
 
-func TestNormalizeResolvePathsBuildContextPaths(t *testing.T) {
-	wd, _ := os.Getwd()
-	project := types.Project{
-		Name:       "myProject",
-		WorkingDir: wd,
-		Services: []types.ServiceConfig{
-			{
-				Name: "foo",
-				Build: &types.BuildConfig{
-					Context:    "./testdata",
-					Dockerfile: "Dockerfile-sample",
-				},
-				Scale: 1,
-			},
-		},
-	}
-
-	expected := fmt.Sprintf(`name: myProject
-services:
-  foo:
-    build:
-      context: %s
-      dockerfile: Dockerfile-sample
-    networks:
-      default: null
-networks:
-  default:
-    name: myProject_default
-`, filepath.Join(wd, "testdata"))
-	err := Normalize(&project, true)
-	assert.NilError(t, err)
-	marshal, err := project.MarshalYAML()
-	assert.NilError(t, err)
-	assert.Equal(t, expected, string(marshal))
-}
-
-func TestNormalizeAbsolutePaths(t *testing.T) {
-	project := types.Project{
-		Name:         "myProject",
-		WorkingDir:   "testdata",
-		Networks:     types.Networks{},
-		ComposeFiles: []string{filepath.Join("testdata", "simple", "compose.yaml"), filepath.Join("testdata", "simple", "compose-with-overrides.yaml")},
-	}
-	absWorkingDir, _ := filepath.Abs("testdata")
-	absComposeFile, _ := filepath.Abs(filepath.Join("testdata", "simple", "compose.yaml"))
-	absOverrideFile, _ := filepath.Abs(filepath.Join("testdata", "simple", "compose-with-overrides.yaml"))
-
-	expected := types.Project{
-		Name:         "myProject",
-		Networks:     types.Networks{"default": {Name: "myProject_default"}},
-		WorkingDir:   absWorkingDir,
-		ComposeFiles: []string{absComposeFile, absOverrideFile},
-	}
-	err := Normalize(&project, false)
-	assert.NilError(t, err)
-	assert.DeepEqual(t, expected, project)
-}
-
 func TestNormalizeVolumes(t *testing.T) {
 	project := types.Project{
 		Name:     "myProject",
@@ -176,10 +117,9 @@ func TestNormalizeVolumes(t *testing.T) {
 				Name: "CustomName",
 			},
 		},
-		WorkingDir:   absCwd,
-		ComposeFiles: []string{},
+		WorkingDir: absCwd,
 	}
-	err := Normalize(&project, false)
+	err := Normalize(&project)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, expected, project)
 }
@@ -239,65 +179,13 @@ networks:
   default:
     name: myProject_default
 `
-	err := Normalize(&project, true)
+	err := Normalize(&project)
 	assert.NilError(t, err)
 	marshal, err := project.MarshalYAML()
 	assert.NilError(t, err)
 	assert.Equal(t, expected, string(marshal))
 }
 
-func TestNormalizeAdditionalContexts(t *testing.T) {
-	project := types.Project{
-		Name: "myProject",
-		Services: types.Services{
-			types.ServiceConfig{
-				Name: "test",
-				Build: &types.BuildConfig{
-					Context:    ".",
-					Dockerfile: "Dockerfile",
-					AdditionalContexts: map[string]string{
-						"image":    "docker-image://foo",
-						"oci":      "oci-layout://foo",
-						"abs_path": "/tmp",
-						"rel_path": "./testdata",
-					},
-				},
-			},
-		},
-	}
-
-	absCwd, _ := filepath.Abs(".")
-	expected := types.Project{
-		Name: "myProject",
-		Services: types.Services{
-			types.ServiceConfig{
-				Name: "test",
-				Build: &types.BuildConfig{
-					Context:    absCwd,
-					Dockerfile: "Dockerfile",
-					AdditionalContexts: map[string]string{
-						"image":    "docker-image://foo",
-						"oci":      "oci-layout://foo",
-						"abs_path": "/tmp",
-						"rel_path": filepath.Join(absCwd, "testdata"),
-					},
-				},
-				Networks: map[string]*types.ServiceNetworkConfig{
-					"default": nil,
-				},
-			},
-		},
-		Networks: types.Networks{"default": types.NetworkConfig{
-			Name: "myProject_default",
-		}},
-		WorkingDir:   absCwd,
-		ComposeFiles: []string{},
-	}
-	err := Normalize(&project, true)
-	assert.NilError(t, err)
-	assert.DeepEqual(t, expected, project)
-}
-
 func TestNormalizeImplicitDependencies(t *testing.T) {
 	project := types.Project{
 		Name: "myProject",
@@ -326,7 +214,7 @@ func TestNormalizeImplicitDependencies(t *testing.T) {
 		"quux":  {Condition: types.ServiceConditionStarted},
 		"corge": {Condition: types.ServiceConditionStarted, Restart: true},
 	}
-	err := Normalize(&project, true)
+	err := Normalize(&project)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, expected, project.Services[0].DependsOn)
 }
diff --git a/loader/paths_test.go b/loader/paths_test.go
new file mode 100644
index 0000000..42abd89
--- /dev/null
+++ b/loader/paths_test.go
@@ -0,0 +1,131 @@
+/*
+   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 loader
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/compose-spec/compose-go/types"
+	"gotest.tools/v3/assert"
+)
+
+func TestResolveComposeFilePaths(t *testing.T) {
+	absWorkingDir, _ := filepath.Abs("testdata")
+	absComposeFile, _ := filepath.Abs(filepath.Join("testdata", "simple", "compose.yaml"))
+	absOverrideFile, _ := filepath.Abs(filepath.Join("testdata", "simple", "compose-with-overrides.yaml"))
+
+	project := types.Project{
+		Name:         "myProject",
+		WorkingDir:   absWorkingDir,
+		ComposeFiles: []string{filepath.Join("testdata", "simple", "compose.yaml"), filepath.Join("testdata", "simple", "compose-with-overrides.yaml")},
+	}
+
+	expected := types.Project{
+		Name:         "myProject",
+		WorkingDir:   absWorkingDir,
+		ComposeFiles: []string{absComposeFile, absOverrideFile},
+	}
+	err := ResolveRelativePaths(&project)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, expected, project)
+}
+
+func TestResolveBuildContextPaths(t *testing.T) {
+	wd, _ := os.Getwd()
+	project := types.Project{
+		Name:       "myProject",
+		WorkingDir: wd,
+		Services: []types.ServiceConfig{
+			{
+				Name: "foo",
+				Build: &types.BuildConfig{
+					Context:    "./testdata",
+					Dockerfile: "Dockerfile-sample",
+				},
+				Scale: 1,
+			},
+		},
+	}
+
+	expected := types.Project{
+		Name:       "myProject",
+		WorkingDir: wd,
+		Services: []types.ServiceConfig{
+			{
+				Name: "foo",
+				Build: &types.BuildConfig{
+					Context:    filepath.Join(wd, "testdata"),
+					Dockerfile: "Dockerfile-sample",
+				},
+				Scale: 1,
+			},
+		},
+	}
+	err := ResolveRelativePaths(&project)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, expected, project)
+}
+
+func TestResolveAdditionalContexts(t *testing.T) {
+	wd, _ := filepath.Abs(".")
+	project := types.Project{
+		Name:       "myProject",
+		WorkingDir: wd,
+		Services: types.Services{
+			types.ServiceConfig{
+				Name: "test",
+				Build: &types.BuildConfig{
+					Context:    ".",
+					Dockerfile: "Dockerfile",
+					AdditionalContexts: map[string]string{
+						"image":    "docker-image://foo",
+						"oci":      "oci-layout://foo",
+						"abs_path": "/tmp",
+						"github":   "github.com/compose-spec/compose-go",
+						"rel_path": "./testdata",
+					},
+				},
+			},
+		},
+	}
+
+	expected := types.Project{
+		Name:       "myProject",
+		WorkingDir: wd,
+		Services: types.Services{
+			types.ServiceConfig{
+				Name: "test",
+				Build: &types.BuildConfig{
+					Context:    wd,
+					Dockerfile: "Dockerfile",
+					AdditionalContexts: map[string]string{
+						"image":    "docker-image://foo",
+						"oci":      "oci-layout://foo",
+						"abs_path": "/tmp",
+						"github":   "github.com/compose-spec/compose-go",
+						"rel_path": filepath.Join(wd, "testdata"),
+					},
+				},
+			},
+		},
+	}
+	err := ResolveRelativePaths(&project)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, expected, project)
+}