Repository: compose-spec/compose-go
Pull request #385 (2023-04-07 07:07:02)

expand additional context relative paths
With introduction for  `additional_contexts` a user can set context to a local path, and as we support relative paths in many places it also make sense to expand relative paths for such additional contexts. Challenge here is that such a context can be a path or URL (just like context) but also builder specific string. I'm assuming here such a custom string will ALWAYS use a `<type>://` prefix as buildx does

see https://github.com/docker/compose/pull/10369

ndeloof: @crazymax does it sound reasonable to assume a custom context type will always include some `xxx://` prefix ?
crazy-max: > @crazy-max does it sound reasonable to assume a custom context type will always include some `xxx://` prefix ?

Named context target can also be a path. Such as with buildx:

```console
docker buildx build --build-context project=path/to/project/source .
```

Otherwise yes it's either `docker-image://`, `oci-layout://`, `http://` `https://`, or Git URL.

More info: https://docs.docker.com/engine/reference/commandline/buildx_build/#build-context
ndeloof: yes, that's my point: when not prefix is given, we can check this is a path or git url (just like for `context` attribute)
My point here is to ensure we don't introduce in the future some new context types without a prefix, which might confuse compose file parsing. Typically, being able to distinguish path vs git url is already somehow acrobatic
EronWright: Thanks for fixing this issue!
https://github.com/docker/compose/issues/10448

Patch:
diff --git a/loader/normalize.go b/loader/normalize.go
index d915604..a8b8015 100644
--- a/loader/normalize.go
+++ b/loader/normalize.go
@@ -75,14 +75,23 @@ func Normalize(project *types.Project, resolvePaths bool) error {
 			if s.Build.Dockerfile == "" && s.Build.DockerfileInline == "" {
 				s.Build.Dockerfile = "Dockerfile"
 			}
-			localContext := absPath(project.WorkingDir, s.Build.Context)
-			if _, err := os.Stat(localContext); err == nil {
-				if resolvePaths {
+			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
 				}
-				// } else {
-				// 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
+				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)
 		}
@@ -117,7 +126,7 @@ func Normalize(project *types.Project, resolvePaths bool) error {
 		}
 
 		for _, vol := range s.VolumesFrom {
-			if !strings.HasPrefix(vol, types.ContainerPrefix) {
+			if !strings.HasPrefix(s.Pid, types.ContainerPrefix) {
 				spec := strings.Split(vol, ":")
 				s.DependsOn = setIfMissing(s.DependsOn, spec[0], types.ServiceDependency{
 					Condition: types.ServiceConditionStarted,
diff --git a/types/types.go b/types/types.go
index 226ddc8..a56d62c 100644
--- a/types/types.go
+++ b/types/types.go
@@ -296,14 +296,14 @@ func (s set) toSlice() []string {
 type BuildConfig struct {
 	Context            string                `yaml:",omitempty" json:"context,omitempty"`
 	Dockerfile         string                `yaml:",omitempty" json:"dockerfile,omitempty"`
-	DockerfileInline   string                `yaml:"dockerfile_inline,omitempty" json:"dockerfile_inline,omitempty"`
+	DockerfileInline   string                `yaml:",omitempty" json:"dockerfile_inline,omitempty"`
 	Args               MappingWithEquals     `yaml:",omitempty" json:"args,omitempty"`
 	SSH                SSHConfig             `yaml:"ssh,omitempty" json:"ssh,omitempty"`
 	Labels             Labels                `yaml:",omitempty" json:"labels,omitempty"`
 	CacheFrom          StringList            `mapstructure:"cache_from" yaml:"cache_from,omitempty" json:"cache_from,omitempty"`
 	CacheTo            StringList            `mapstructure:"cache_to" yaml:"cache_to,omitempty" json:"cache_to,omitempty"`
 	NoCache            bool                  `mapstructure:"no_cache" yaml:"no_cache,omitempty" json:"no_cache,omitempty"`
-	AdditionalContexts MappingWithEquals     `mapstructure:"additional_contexts" yaml:"additional_contexts,omitempty" json:"additional_contexts,omitempty"`
+	AdditionalContexts Mapping               `mapstructure:"additional_contexts" yaml:"additional_contexts,omitempty" json:"additional_contexts,omitempty"`
 	Pull               bool                  `mapstructure:"pull" yaml:"pull,omitempty" json:"pull,omitempty"`
 	ExtraHosts         HostsList             `mapstructure:"extra_hosts" yaml:"extra_hosts,omitempty" json:"extra_hosts,omitempty"`
 	Isolation          string                `yaml:",omitempty" json:"isolation,omitempty"`


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 75cc4fb..2071156 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -57,7 +57,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 				Target:             "foo",
 				Network:            "foo",
 				CacheFrom:          []string{"foo", "bar"},
-				AdditionalContexts: map[string]*string{"foo": strPtr("/bar")},
+				AdditionalContexts: types.Mapping{"foo": "/bar"},
 				Labels:             map[string]string{"FOO": "BAR"},
 				Secrets: []types.ServiceSecretConfig{
 					{
diff --git a/loader/normalize_test.go b/loader/normalize_test.go
index 807e286..10c4580 100644
--- a/loader/normalize_test.go
+++ b/loader/normalize_test.go
@@ -201,11 +201,8 @@ func TestNormalizeDependsOn(t *testing.T) {
 				NetworkMode: "service:zot",
 			},
 			{
-				Name: "bar",
-				VolumesFrom: []string{
-					"zot",
-					"container:xxx",
-				},
+				Name:        "bar",
+				VolumesFrom: []string{"zot"},
 			},
 			{
 				Name: "zot",
@@ -223,7 +220,6 @@ services:
       default: null
     volumes_from:
       - zot
-      - container:xxx
   foo:
     depends_on:
       bar:
@@ -245,3 +241,55 @@ networks:
 	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)
+}
diff --git a/types/types_test.go b/types/types_test.go
index e4e7a13..2ec5476 100644
--- a/types/types_test.go
+++ b/types/types_test.go
@@ -17,9 +17,7 @@
 package types
 
 import (
-	"bytes"
 	"encoding/json"
-	"fmt"
 	"strings"
 	"testing"
 
@@ -331,40 +329,3 @@ func TestMarshalServiceEntrypoint(t *testing.T) {
 	}
 
 }
-
-func TestMarshalBuild_DockerfileInline(t *testing.T) {
-	b := BuildConfig{
-		DockerfileInline: "FROM alpine\n\n# echo the env\nRUN env\n\nENTRYPOINT /bin/echo\n",
-	}
-	out, err := yaml.Marshal(b)
-	assert.NilError(t, err)
-
-	const expected = `
-dockerfile_inline: |
-    FROM alpine
-
-    # echo the env
-    RUN env
-
-    ENTRYPOINT /bin/echo
-`
-	assert.Check(t, equalTrimSpace(out, expected))
-
-	// round-trip
-	var b2 BuildConfig
-	assert.NilError(t, yaml.Unmarshal(out, &b2))
-	assert.Check(t, equalTrimSpace(b.DockerfileInline, b2.DockerfileInline))
-}
-
-func equalTrimSpace(x interface{}, y interface{}) is.Comparison {
-	trim := func(v interface{}) interface{} {
-		switch vv := v.(type) {
-		case string:
-			return strings.TrimSpace(vv)
-		case []byte:
-			return string(bytes.TrimSpace(vv))
-		}
-		panic(fmt.Errorf("invalid type %T (value: %+v)", v, v))
-	}
-	return is.DeepEqual(trim(x), trim(y))
-}