Repository: compose-spec/compose-go
Pull request #714 (2024-11-22 09:19:13)

introduce marhsaller options and ability to include secrets content
This allows to export secret content in yaml output, to be consumed by third-party tools which rely on canonical compose model and need all data resolved. Still, default marshalling output does **not** include content as this would introduce risk to leak sensitive data


Patch:
diff --git a/types/project.go b/types/project.go
index 19d6e32..fa4acc7 100644
--- a/types/project.go
+++ b/types/project.go
@@ -560,39 +560,69 @@ func (p *Project) WithImagesResolved(resolver func(named reference.Named) (godig
 	})
 }
 
+type marshallOptions struct {
+	secretsContent bool
+}
+
+func WithSecretContent(o *marshallOptions) {
+	o.secretsContent = true
+}
+
+func (opt *marshallOptions) apply(p *Project) *Project {
+	if opt.secretsContent {
+		p = p.deepCopy()
+		for name, config := range p.Secrets {
+			config.marshallContent = true
+			p.Secrets[name] = config
+		}
+	}
+	return p
+}
+
+func applyMarshallOptions(p *Project, options ...func(*marshallOptions)) *Project {
+	opts := &marshallOptions{}
+	for _, option := range options {
+		option(opts)
+	}
+	p = opts.apply(p)
+	return p
+}
+
 // MarshalYAML marshal Project into a yaml tree
-func (p *Project) MarshalYAML() ([]byte, error) {
+func (p *Project) MarshalYAML(options ...func(*marshallOptions)) ([]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)
+	src := applyMarshallOptions(p, options...)
+	err := encoder.Encode(src)
 	if err != nil {
 		return nil, err
 	}
 	return buf.Bytes(), nil
 }
 
-// MarshalJSON makes Config implement json.Marshaler
-func (p *Project) MarshalJSON() ([]byte, error) {
+// MarshalJSON marshal Project into a json document
+func (p *Project) MarshalJSON(options ...func(*marshallOptions)) ([]byte, error) {
+	src := applyMarshallOptions(p, options...)
 	m := map[string]interface{}{
-		"name":     p.Name,
-		"services": p.Services,
+		"name":     src.Name,
+		"services": src.Services,
 	}
 
-	if len(p.Networks) > 0 {
-		m["networks"] = p.Networks
+	if len(src.Networks) > 0 {
+		m["networks"] = src.Networks
 	}
-	if len(p.Volumes) > 0 {
-		m["volumes"] = p.Volumes
+	if len(src.Volumes) > 0 {
+		m["volumes"] = src.Volumes
 	}
-	if len(p.Secrets) > 0 {
-		m["secrets"] = p.Secrets
+	if len(src.Secrets) > 0 {
+		m["secrets"] = src.Secrets
 	}
-	if len(p.Configs) > 0 {
-		m["configs"] = p.Configs
+	if len(src.Configs) > 0 {
+		m["configs"] = src.Configs
 	}
-	for k, v := range p.Extensions {
+	for k, v := range src.Extensions {
 		m[k] = v
 	}
 	return json.MarshalIndent(m, "", "  ")
diff --git a/types/types.go b/types/types.go
index 3cae539..3774945 100644
--- a/types/types.go
+++ b/types/types.go
@@ -732,16 +732,18 @@ type CredentialSpecConfig struct {
 
 // FileObjectConfig is a config type for a file used by a service
 type FileObjectConfig struct {
-	Name           string            `yaml:"name,omitempty" json:"name,omitempty"`
-	File           string            `yaml:"file,omitempty" json:"file,omitempty"`
-	Environment    string            `yaml:"environment,omitempty" json:"environment,omitempty"`
-	Content        string            `yaml:"content,omitempty" json:"content,omitempty"`
-	External       External          `yaml:"external,omitempty" json:"external,omitempty"`
-	Labels         Labels            `yaml:"labels,omitempty" json:"labels,omitempty"`
-	Driver         string            `yaml:"driver,omitempty" json:"driver,omitempty"`
-	DriverOpts     map[string]string `yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
-	TemplateDriver string            `yaml:"template_driver,omitempty" json:"template_driver,omitempty"`
-	Extensions     Extensions        `yaml:"#extensions,inline,omitempty" json:"-"`
+	Name        string `yaml:"name,omitempty" json:"name,omitempty"`
+	File        string `yaml:"file,omitempty" json:"file,omitempty"`
+	Environment string `yaml:"environment,omitempty" json:"environment,omitempty"`
+	Content     string `yaml:"content,omitempty" json:"content,omitempty"`
+	// configure marshalling to include Content - excluded by default to prevent sensitive data leaks
+	marshallContent bool
+	External        External          `yaml:"external,omitempty" json:"external,omitempty"`
+	Labels          Labels            `yaml:"labels,omitempty" json:"labels,omitempty"`
+	Driver          string            `yaml:"driver,omitempty" json:"driver,omitempty"`
+	DriverOpts      map[string]string `yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
+	TemplateDriver  string            `yaml:"template_driver,omitempty" json:"template_driver,omitempty"`
+	Extensions      Extensions        `yaml:"#extensions,inline,omitempty" json:"-"`
 }
 
 const (
@@ -775,14 +777,18 @@ type SecretConfig FileObjectConfig
 // MarshalYAML makes SecretConfig implement yaml.Marshaller
 func (s SecretConfig) MarshalYAML() (interface{}, error) {
 	// secret content is set while loading model. Never marshall it
-	s.Content = ""
+	if !s.marshallContent {
+		s.Content = ""
+	}
 	return FileObjectConfig(s), nil
 }
 
 // MarshalJSON makes SecretConfig implement json.Marshaller
 func (s SecretConfig) MarshalJSON() ([]byte, error) {
 	// secret content is set while loading model. Never marshall it
-	s.Content = ""
+	if !s.marshallContent {
+		s.Content = ""
+	}
 	return json.Marshal(FileObjectConfig(s))
 }
 


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 5318636..b223e21 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -912,7 +912,7 @@ networks:
 		},
 	}
 
-	assert.DeepEqual(t, expected, config)
+	assertEqual(t, expected, config)
 }
 
 func TestUnsupportedProperties(t *testing.T) {
@@ -1158,8 +1158,8 @@ func TestFullExample(t *testing.T) {
 	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))
-	assert.Check(t, is.DeepEqual(expectedConfig.Secrets, config.Secrets))
-	assert.Check(t, is.DeepEqual(expectedConfig.Configs, config.Configs))
+	assert.Check(t, is.DeepEqual(expectedConfig.Secrets, config.Secrets, cmpopts.IgnoreUnexported(types.SecretConfig{})))
+	assert.Check(t, is.DeepEqual(expectedConfig.Configs, config.Configs, cmpopts.IgnoreUnexported(types.ConfigObjConfig{})))
 	assert.Check(t, is.DeepEqual(expectedConfig.Extensions, config.Extensions))
 }
 
@@ -1592,7 +1592,7 @@ secrets:
 			External: true,
 		},
 	}
-	assert.Check(t, is.DeepEqual(expected, project.Secrets))
+	assert.Check(t, is.DeepEqual(expected, project.Secrets, cmpopts.IgnoreUnexported(types.SecretConfig{})))
 	assert.Check(t, is.Contains(buf.String(), "secrets.foo: external.name is deprecated. Please set name and external: true"))
 }
 
@@ -1940,7 +1940,7 @@ secrets:
 			"COMPOSE_PROJECT_NAME": "load-template-driver",
 		},
 	}
-	assert.DeepEqual(t, config, expected, cmpopts.EquateEmpty())
+	assertEqual(t, expected, config)
 }
 
 func TestLoadSecretDriver(t *testing.T) {
@@ -2012,7 +2012,11 @@ secrets:
 			"COMPOSE_PROJECT_NAME": "load-secret-driver",
 		},
 	}
-	assert.DeepEqual(t, config, expected, cmpopts.EquateEmpty())
+	assertEqual(t, config, expected)
+}
+
+func assertEqual(t *testing.T, config *types.Project, expected *types.Project) {
+	assert.DeepEqual(t, config, expected, cmpopts.EquateEmpty(), cmpopts.IgnoreUnexported(types.SecretConfig{}), cmpopts.IgnoreUnexported(types.ConfigObjConfig{}))
 }
 
 func TestComposeFileWithVersion(t *testing.T) {
@@ -3399,12 +3403,12 @@ secrets:
 		"config": {
 			Environment: "GA",
 			Content:     "BU",
-		}})
+		}}, cmpopts.IgnoreUnexported(types.ConfigObjConfig{}))
 	assert.DeepEqual(t, config.Secrets, types.Secrets{
 		"secret": {
 			Environment: "MEU",
 			Content:     "Shadoks",
-		}})
+		}}, cmpopts.IgnoreUnexported(types.SecretConfig{}))
 }
 
 func TestLoadDeviceMapping(t *testing.T) {
diff --git a/loader/types_test.go b/loader/types_test.go
index 8917ca1..3828726 100644
--- a/loader/types_test.go
+++ b/loader/types_test.go
@@ -17,7 +17,6 @@
 package loader
 
 import (
-	"encoding/json"
 	"os"
 	"testing"
 
@@ -54,7 +53,7 @@ func TestJSONMarshalProject(t *testing.T) {
 	project := fullExampleProject(workingDir, homeDir)
 	expected := fullExampleJSON(workingDir, homeDir)
 
-	actual, err := json.MarshalIndent(project, "", "  ")
+	actual, err := project.MarshalJSON()
 	assert.NilError(t, err)
 	assert.Check(t, is.Equal(expected, string(actual)))
 
diff --git a/types/project_test.go b/types/project_test.go
index c2c0fbf..040f936 100644
--- a/types/project_test.go
+++ b/types/project_test.go
@@ -20,6 +20,7 @@ import (
 	_ "crypto/sha256"
 	"errors"
 	"fmt"
+	"strings"
 	"testing"
 
 	"github.com/compose-spec/compose-go/v2/utils"
@@ -410,3 +411,25 @@ func TestServicesWithCapabilities(t *testing.T) {
 	assert.DeepEqual(t, []string{"service_1"}, gpu)
 	assert.DeepEqual(t, []string{"service_1", "service_2"}, tpu)
 }
+
+func TestMarshallOptions(t *testing.T) {
+	p := &Project{
+		Secrets: map[string]SecretConfig{
+			"test": {
+				Name:    "test",
+				Content: "SECRET",
+				File:    "~/.secret",
+			},
+		},
+	}
+	yaml, err := p.MarshalYAML(WithSecretContent)
+	assert.NilError(t, err)
+	expected := `
+services: {}
+secrets:
+  test:
+    name: test
+    file: ~/.secret
+    content: SECRET`
+	assert.Equal(t, strings.TrimSpace(string(yaml)), strings.TrimSpace(expected))
+}