Repository: compose-spec/compose-go
Pull request #153 (2021-07-01 08:39:56)

adopt goyaml/v3
Adopting go.yaml/v3 will bring support for yaml 1.2 schema

compatibility considerations:
- go.yaml/v3 uses 4 spaces for indentation by default. `project.MarshalYAML()` is configured to use only 2
- go.yaml/v3 force indentation on sequences (so the updated expected yaml in this PR). See https://github.com/go-yaml/yaml/pull/924

benefits: 
- v3 branch is the active development branch for go.yaml
- [nvd.nist.gov/vuln/detail/CVE-2019-11254](https://nvd.nist.gov/vuln/detail/CVE-2019-11254)
- go.yaml/v3 adds support for yaml.Node processing, which we could use to transform the model before applying to go structs. Typically can be used to introduce custom tags, like `!inline` as proposed on https://github.com/compose-spec/compose-spec/issues/298



AkihiroSuda: What's current status of this?
I saw v3.0.0 was released recently.

ndeloof: I didn't noticed the v3.0.0 release, this is something we should consider for next release, wdyt @glours @ulyssessouza ?
AkihiroSuda: One of the reasons to adopt v3 would be that v2 has a vulnerability https://nvd.nist.gov/vuln/detail/CVE-2019-11254 .
Very unlikely to affect compose-go, but scanners are noisy about this CVE.

AkihiroSuda: Is there any blocker to get this merged? 🙏 

ndeloof: updated with recent v3.0.1 release, still missing https://github.com/go-yaml/yaml/pull/753

Patch:
diff --git a/go.mod b/go.mod
index 11596d2..54eee1c 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
 	github.com/stretchr/testify v1.8.2
 	github.com/xeipuuv/gojsonschema v1.2.0
 	golang.org/x/sync v0.1.0
-	gopkg.in/yaml.v2 v2.4.0
+	gopkg.in/yaml.v3 v3.0.1
 	gotest.tools/v3 v3.4.0
 )
 
@@ -26,5 +26,4 @@ require (
 	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	golang.org/x/sys v0.3.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index b4c003c..0d46448 100644
--- a/go.sum
+++ b/go.sum
@@ -71,8 +71,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/loader/loader.go b/loader/loader.go
index eb09b31..3843dc5 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -37,7 +37,7 @@ import (
 	"github.com/mitchellh/mapstructure"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
-	"gopkg.in/yaml.v2"
+	"gopkg.in/yaml.v3"
 )
 
 // Options supported by Load
@@ -138,6 +138,14 @@ func ParseYAML(source []byte) (map[string]interface{}, error) {
 	if err := yaml.Unmarshal(source, &cfg); err != nil {
 		return nil, err
 	}
+	stringMap, ok := cfg.(map[string]interface{})
+	if ok {
+		converted, err := convertToStringKeysRecursive(stringMap, "")
+		if err != nil {
+			return nil, err
+		}
+		return converted.(map[string]interface{}), nil
+	}
 	cfgMap, ok := cfg.(map[interface{}]interface{})
 	if !ok {
 		return nil, errors.Errorf("Top-level object must be a mapping")
@@ -451,6 +459,22 @@ func createTransformHook(additionalTransformers ...Transformer) mapstructure.Dec
 
 // keys need to be converted to strings for jsonschema
 func convertToStringKeysRecursive(value interface{}, keyPrefix string) (interface{}, error) {
+	if mapping, ok := value.(map[string]interface{}); ok {
+		for key, entry := range mapping {
+			var newKeyPrefix string
+			if keyPrefix == "" {
+				newKeyPrefix = key
+			} else {
+				newKeyPrefix = fmt.Sprintf("%s.%s", keyPrefix, key)
+			}
+			convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
+			if err != nil {
+				return nil, err
+			}
+			mapping[key] = convertedEntry
+		}
+		return mapping, nil
+	}
 	if mapping, ok := value.(map[interface{}]interface{}); ok {
 		dict := make(map[string]interface{})
 		for key, entry := range mapping {
diff --git a/types/project.go b/types/project.go
index 59c06b4..b4882ab 100644
--- a/types/project.go
+++ b/types/project.go
@@ -18,6 +18,7 @@ package types
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -28,6 +29,7 @@ import (
 	godigest "github.com/opencontainers/go-digest"
 	"github.com/pkg/errors"
 	"golang.org/x/sync/errgroup"
+	"gopkg.in/yaml.v3"
 )
 
 // Project is the result of loading a set of compose files
@@ -439,6 +441,43 @@ func (p *Project) ResolveImages(resolver func(named reference.Named) (godigest.D
 	return eg.Wait()
 }
 
+// MarshalYAML marshal Project into a yaml tree
+func (p *Project) MarshalYAML() ([]byte, error) {
+	buf := bytes.NewBuffer([]byte{})
+	encoder := yaml.NewEncoder(buf)
+	encoder.SetIndent(2)
+	// encoder.CompactSeqIndent() FIXME https://github.com/go-yaml/yaml/pull/753
+	err := encoder.Encode(p)
+	if err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+// MarshalJSON makes Config implement json.Marshaler
+func (p *Project) MarshalJSON() ([]byte, error) {
+	m := map[string]interface{}{
+		"services": p.Services,
+	}
+
+	if len(p.Networks) > 0 {
+		m["networks"] = p.Networks
+	}
+	if len(p.Volumes) > 0 {
+		m["volumes"] = p.Volumes
+	}
+	if len(p.Secrets) > 0 {
+		m["secrets"] = p.Secrets
+	}
+	if len(p.Configs) > 0 {
+		m["configs"] = p.Configs
+	}
+	for k, v := range p.Extensions {
+		m[k] = v
+	}
+	return json.Marshal(m)
+}
+
 // ResolveServicesEnvironment parse env_files set for services to resolve the actual environment map for services
 func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error {
 	for i, service := range p.Services {
diff --git a/types/types.go b/types/types.go
index 9d64f2c..fa1a138 100644
--- a/types/types.go
+++ b/types/types.go
@@ -359,7 +359,7 @@ type ThrottleDevice struct {
 // preserved so that it can override any base value (e.g. container entrypoint).
 //
 // The different semantics between YAML and JSON are due to limitations with
-// JSON marshaling + `omitempty` in the Go stdlib, while gopkg.in/yaml.v2 gives
+// JSON marshaling + `omitempty` in the Go stdlib, while gopkg.in/yaml.v3 gives
 // us more flexibility via the yaml.IsZeroer interface.
 //
 // In the future, it might make sense to make fields of this type be
@@ -383,7 +383,7 @@ func (s ShellCommand) IsZero() bool {
 // accurately if the `omitempty` struct tag is omitted/forgotten.
 //
 // A similar MarshalJSON() implementation is not needed because the Go stdlib
-// already serializes nil slices to `null`, whereas gopkg.in/yaml.v2 by default
+// already serializes nil slices to `null`, whereas gopkg.in/yaml.v3 by default
 // serializes nil slices to `[]`.
 func (s ShellCommand) MarshalYAML() (interface{}, error) {
 	if s == nil {
@@ -883,7 +883,13 @@ func (u *UlimitsConfig) MarshalYAML() (interface{}, error) {
 	if u.Single != 0 {
 		return u.Single, nil
 	}
-	return u, nil
+	return struct {
+		Soft int
+		Hard int
+	}{
+		Soft: u.Soft,
+		Hard: u.Hard,
+	}, nil
 }
 
 // MarshalJSON makes UlimitsConfig implement json.Marshaller


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index d98c256..add2500 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -25,8 +25,8 @@ import (
 	"github.com/compose-spec/compose-go/types"
 )
 
-func fullExampleConfig(workingDir, homeDir string) *types.Config {
-	return &types.Config{
+func fullExampleProject(workingDir, homeDir string) *types.Project {
+	return &types.Project{
 		Name:     "full_example_project_name",
 		Services: services(workingDir, homeDir),
 		Networks: networks(),
@@ -591,47 +591,47 @@ services:
       args:
         foo: bar
       ssh:
-      - default
+        - default
       labels:
         FOO: BAR
       cache_from:
-      - foo
-      - bar
+        - foo
+        - bar
       network: foo
       target: foo
       secrets:
-      - source: secret1
-      - source: secret2
-        target: my_secret
-        uid: "103"
-        gid: "103"
-        mode: 288
+        - source: secret1
+        - source: secret2
+          target: my_secret
+          uid: "103"
+          gid: "103"
+          mode: 288
       tags:
-      - foo:v1.0.0
-      - docker.io/username/foo:my-other-tag
-      - full_example_project_name:1.0.0
+        - foo:v1.0.0
+        - docker.io/username/foo:my-other-tag
+        - full_example_project_name:1.0.0
       platforms:
-      - linux/amd64
-      - linux/arm64
+        - linux/amd64
+        - linux/arm64
     cap_add:
-    - ALL
+      - ALL
     cap_drop:
-    - NET_ADMIN
-    - SYS_ADMIN
+      - NET_ADMIN
+      - SYS_ADMIN
     cgroup_parent: m-executor-abcd
     command:
-    - bundle
-    - exec
-    - thin
-    - -p
-    - "3000"
+      - bundle
+      - exec
+      - thin
+      - -p
+      - "3000"
     configs:
-    - source: config1
-    - source: config2
-      target: /my_config
-      uid: "103"
-      gid: "103"
-      mode: 288
+      - source: config1
+      - source: config2
+        target: /my_config
+        uid: "103"
+        gid: "103"
+        mode: 288
     container_name: my-web-container
     depends_on:
       db:
@@ -665,12 +665,12 @@ services:
           cpus: "0.0001"
           memory: "20971520"
           generic_resources:
-          - discrete_resource_spec:
-              kind: gpu
-              value: 2
-          - discrete_resource_spec:
-              kind: ssd
-              value: 1
+            - discrete_resource_spec:
+                kind: gpu
+                value: 2
+            - discrete_resource_spec:
+                kind: ssd
+                value: 1
       restart_policy:
         condition: on-failure
         delay: 5s
@@ -678,27 +678,27 @@ services:
         window: 2m0s
       placement:
         constraints:
-        - node=foo
+          - node=foo
         preferences:
-        - spread: node.labels.az
+          - spread: node.labels.az
         max_replicas_per_node: 5
       endpoint_mode: dnsrr
     device_cgroup_rules:
-    - c 1:3 mr
-    - a 7:* rmw
+      - c 1:3 mr
+      - a 7:* rmw
     devices:
-    - /dev/ttyUSB0:/dev/ttyUSB0
+      - /dev/ttyUSB0:/dev/ttyUSB0
     dns:
-    - 8.8.8.8
-    - 9.9.9.9
+      - 8.8.8.8
+      - 9.9.9.9
     dns_search:
-    - dc1.example.com
-    - dc2.example.com
+      - dc1.example.com
+      - dc2.example.com
     domainname: foo.com
     entrypoint:
-    - /code/entrypoint.sh
-    - -p
-    - "3000"
+      - /code/entrypoint.sh
+      - -p
+      - "3000"
     environment:
       BAR: bar_from_env_file_2
       BAZ: baz_from_service_def
@@ -707,23 +707,23 @@ services:
       FOO: foo_from_env_file
       QUX: qux_from_environment
     env_file:
-    - ./example1.env
-    - ./example2.env
+      - ./example1.env
+      - ./example2.env
     expose:
-    - "3000"
-    - "8000"
+      - "3000"
+      - "8000"
     external_links:
-    - redis_1
-    - project_db_1:mysql
-    - project_db_1:postgresql
+      - redis_1
+      - project_db_1:mysql
+      - project_db_1:postgresql
     extra_hosts:
-    - otherhost:50.31.209.229
-    - somehost:162.242.195.82
+      - otherhost:50.31.209.229
+      - somehost:162.242.195.82
     hostname: foo
     healthcheck:
       test:
-      - CMD-SHELL
-      - echo "hello world"
+        - CMD-SHELL
+        - echo "hello world"
       timeout: 1s
       interval: 10s
       retries: 5
@@ -735,9 +735,9 @@ services:
       com.example.empty-label: ""
       com.example.number: "42"
     links:
-    - db
-    - db:database
-    - redis
+      - db
+      - db:database
+      - redis
     logging:
       driver: syslog
       options:
@@ -751,117 +751,117 @@ services:
       other-other-network: null
       some-network:
         aliases:
-        - alias1
-        - alias3
+          - alias1
+          - alias3
     pid: host
     ports:
-    - mode: ingress
-      target: 3000
-      protocol: tcp
-    - mode: ingress
-      target: 3001
-      protocol: tcp
-    - mode: ingress
-      target: 3002
-      protocol: tcp
-    - mode: ingress
-      target: 3003
-      protocol: tcp
-    - mode: ingress
-      target: 3004
-      protocol: tcp
-    - mode: ingress
-      target: 3005
-      protocol: tcp
-    - mode: ingress
-      target: 8000
-      published: "8000"
-      protocol: tcp
-    - mode: ingress
-      target: 8080
-      published: "9090"
-      protocol: tcp
-    - mode: ingress
-      target: 8081
-      published: "9091"
-      protocol: tcp
-    - mode: ingress
-      target: 22
-      published: "49100"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 8001
-      published: "8001"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5000
-      published: "5000"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5001
-      published: "5001"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5002
-      published: "5002"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5003
-      published: "5003"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5004
-      published: "5004"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5005
-      published: "5005"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5006
-      published: "5006"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5007
-      published: "5007"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5008
-      published: "5008"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5009
-      published: "5009"
-      protocol: tcp
-    - mode: ingress
-      host_ip: 127.0.0.1
-      target: 5010
-      published: "5010"
-      protocol: tcp
+      - mode: ingress
+        target: 3000
+        protocol: tcp
+      - mode: ingress
+        target: 3001
+        protocol: tcp
+      - mode: ingress
+        target: 3002
+        protocol: tcp
+      - mode: ingress
+        target: 3003
+        protocol: tcp
+      - mode: ingress
+        target: 3004
+        protocol: tcp
+      - mode: ingress
+        target: 3005
+        protocol: tcp
+      - mode: ingress
+        target: 8000
+        published: "8000"
+        protocol: tcp
+      - mode: ingress
+        target: 8080
+        published: "9090"
+        protocol: tcp
+      - mode: ingress
+        target: 8081
+        published: "9091"
+        protocol: tcp
+      - mode: ingress
+        target: 22
+        published: "49100"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 8001
+        published: "8001"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5000
+        published: "5000"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5001
+        published: "5001"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5002
+        published: "5002"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5003
+        published: "5003"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5004
+        published: "5004"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5005
+        published: "5005"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5006
+        published: "5006"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5007
+        published: "5007"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5008
+        published: "5008"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5009
+        published: "5009"
+        protocol: tcp
+      - mode: ingress
+        host_ip: 127.0.0.1
+        target: 5010
+        published: "5010"
+        protocol: tcp
     privileged: true
     read_only: true
     restart: always
     secrets:
-    - source: secret1
-    - source: secret2
-      target: my_secret
-      uid: "103"
-      gid: "103"
-      mode: 288
+      - source: secret1
+      - source: secret2
+        target: my_secret
+        uid: "103"
+        gid: "103"
+        mode: 288
     security_opt:
-    - label=level:s0:c100,c200
-    - label=type:svirt_apache_t
+      - label=level:s0:c100,c200
+      - label=type:svirt_apache_t
     stdin_open: true
     stop_grace_period: 20s
     stop_signal: SIGUSR1
@@ -869,8 +869,8 @@ services:
       net.core.somaxconn: "1024"
       net.ipv4.tcp_syncookies: "0"
     tmpfs:
-    - /run
-    - /tmp
+      - /run
+      - /tmp
     tty: true
     ulimits:
       nofile:
@@ -880,42 +880,42 @@ services:
     user: someone
     uts: host
     volumes:
-    - type: volume
-      target: /var/lib/mysql
-      volume: {}
-    - type: bind
-      source: /opt/data
-      target: /var/lib/mysql
-      bind:
-        create_host_path: true
-    - type: bind
-      source: %s
-      target: /code
-      bind:
-        create_host_path: true
-    - type: bind
-      source: %s
-      target: /var/www/html
-      bind:
-        create_host_path: true
-    - type: bind
-      source: %s
-      target: /etc/configs
-      read_only: true
-      bind:
-        create_host_path: true
-    - type: volume
-      source: datavolume
-      target: /var/lib/mysql
-      volume: {}
-    - type: bind
-      source: %s
-      target: /opt
-      consistency: cached
-    - type: tmpfs
-      target: /opt
-      tmpfs:
-        size: "10000"
+      - type: volume
+        target: /var/lib/mysql
+        volume: {}
+      - type: bind
+        source: /opt/data
+        target: /var/lib/mysql
+        bind:
+          create_host_path: true
+      - type: bind
+        source: %s
+        target: /code
+        bind:
+          create_host_path: true
+      - type: bind
+        source: %s
+        target: /var/www/html
+        bind:
+          create_host_path: true
+      - type: bind
+        source: %s
+        target: /etc/configs
+        read_only: true
+        bind:
+          create_host_path: true
+      - type: volume
+        source: datavolume
+        target: /var/lib/mysql
+        volume: {}
+      - type: bind
+        source: %s
+        target: /opt
+        consistency: cached
+      - type: tmpfs
+        target: /opt
+        tmpfs:
+          size: "10000"
     working_dir: /code
     x-bar: baz
     x-foo: bar
@@ -936,15 +936,15 @@ networks:
     ipam:
       driver: overlay
       config:
-      - subnet: 172.28.0.0/16
-        gateway: 172.28.5.254
-        ip_range: 172.28.5.0/24
-        aux_addresses:
-          host1: 172.28.1.5
-          host2: 172.28.1.6
-          host3: 172.28.1.7
-      - subnet: 2001:3984:3989::/64
-        gateway: 2001:3984:3989::1
+        - subnet: 172.28.0.0/16
+          gateway: 172.28.5.254
+          ip_range: 172.28.5.0/24
+          aux_addresses:
+            host1: 172.28.1.5
+            host2: 172.28.1.6
+            host3: 172.28.1.7
+        - subnet: 2001:3984:3989::/64
+          gateway: 2001:3984:3989::1
     labels:
       foo: bar
   some-network: {}
diff --git a/loader/loader_test.go b/loader/loader_test.go
index d5511f3..4199d37 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -971,7 +971,7 @@ func TestFullExample(t *testing.T) {
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
 
-	expectedConfig := fullExampleConfig(workingDir, homeDir)
+	expectedConfig := fullExampleProject(workingDir, homeDir)
 
 	assert.Check(t, is.DeepEqual(expectedConfig.Name, config.Name))
 	assert.Check(t, is.DeepEqual(expectedConfig.Services, config.Services))
diff --git a/loader/normalize_test.go b/loader/normalize_test.go
index 93e8e71..1066a53 100644
--- a/loader/normalize_test.go
+++ b/loader/normalize_test.go
@@ -23,7 +23,6 @@ import (
 	"testing"
 
 	"github.com/compose-spec/compose-go/types"
-	"gopkg.in/yaml.v2"
 	"gotest.tools/v3/assert"
 )
 
@@ -84,9 +83,9 @@ networks:
 `
 	err := Normalize(&project, false)
 	assert.NilError(t, err)
-	marshal, err := yaml.Marshal(project)
+	marshal, err := project.MarshalYAML()
 	assert.NilError(t, err)
-	assert.DeepEqual(t, expected, string(marshal))
+	assert.Equal(t, expected, string(marshal))
 }
 
 func TestNormalizeResolvePathsBuildContextPaths(t *testing.T) {
@@ -120,9 +119,9 @@ networks:
 `, filepath.Join(wd, "testdata"))
 	err := Normalize(&project, true)
 	assert.NilError(t, err)
-	marshal, err := yaml.Marshal(project)
+	marshal, err := project.MarshalYAML()
 	assert.NilError(t, err)
-	assert.DeepEqual(t, expected, string(marshal))
+	assert.Equal(t, expected, string(marshal))
 }
 
 func TestNormalizeAbsolutePaths(t *testing.T) {
@@ -220,7 +219,7 @@ services:
     networks:
       default: null
     volumes_from:
-    - zot
+      - zot
   foo:
     depends_on:
       bar:
@@ -238,7 +237,7 @@ networks:
 `
 	err := Normalize(&project, true)
 	assert.NilError(t, err)
-	marshal, err := yaml.Marshal(project)
+	marshal, err := project.MarshalYAML()
 	assert.NilError(t, err)
-	assert.DeepEqual(t, expected, string(marshal))
+	assert.Equal(t, expected, string(marshal))
 }
diff --git a/loader/types_test.go b/loader/types_test.go
index 3f466d0..59628ef 100644
--- a/loader/types_test.go
+++ b/loader/types_test.go
@@ -21,20 +21,19 @@ import (
 	"os"
 	"testing"
 
-	yaml "gopkg.in/yaml.v2"
 	"gotest.tools/v3/assert"
 	is "gotest.tools/v3/assert/cmp"
 )
 
-func TestMarshallConfig(t *testing.T) {
+func TestMarshallProject(t *testing.T) {
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
 	homeDir, err := os.UserHomeDir()
 	assert.NilError(t, err)
-	cfg := fullExampleConfig(workingDir, homeDir)
+	project := fullExampleProject(workingDir, homeDir)
 	expected := fullExampleYAML(workingDir, homeDir)
 
-	actual, err := yaml.Marshal(cfg)
+	actual, err := project.MarshalYAML()
 	assert.NilError(t, err)
 	assert.Check(t, is.Equal(expected, string(actual)))
 
@@ -46,16 +45,16 @@ func TestMarshallConfig(t *testing.T) {
 	assert.NilError(t, err)
 }
 
-func TestJSONMarshallConfig(t *testing.T) {
+func TestJSONMarshallProject(t *testing.T) {
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
 	homeDir, err := os.UserHomeDir()
 	assert.NilError(t, err)
 
-	cfg := fullExampleConfig(workingDir, homeDir)
+	project := fullExampleProject(workingDir, homeDir)
 	expected := fullExampleJSON(workingDir, homeDir)
 
-	actual, err := json.MarshalIndent(cfg, "", "  ")
+	actual, err := json.MarshalIndent(project, "", "  ")
 	assert.NilError(t, err)
 	assert.Check(t, is.Equal(expected, string(actual)))
 
diff --git a/types/types_test.go b/types/types_test.go
index 3d9a741..2ec5476 100644
--- a/types/types_test.go
+++ b/types/types_test.go
@@ -21,7 +21,7 @@ import (
 	"strings"
 	"testing"
 
-	"gopkg.in/yaml.v2"
+	"gopkg.in/yaml.v3"
 
 	"gotest.tools/v3/assert"
 	is "gotest.tools/v3/assert/cmp"
@@ -300,7 +300,7 @@ func TestMarshalServiceEntrypoint(t *testing.T) {
 		{
 			name:         "value",
 			entrypoint:   ShellCommand{"ls", "/"},
-			expectedYAML: "entrypoint:\n- ls\n- /",
+			expectedYAML: "entrypoint:\n    - ls\n    - /",
 			expectedJSON: `{"command":null,"entrypoint":["ls","/"]}`,
 		},
 	}