Repository: score-spec/score-compose
Pull request #56 (2024-03-04 14:49:33)

fix: reject unsupported features and log ignored ones
Duplicate of https://github.com/score-spec/score-compose/pull/55 to merge into #54 by mistake. This was previously approved by @delca85 


Patch:
diff --git a/compose.yaml b/compose.yaml
index 8e31e23..a0fa5c1 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -10,10 +10,8 @@ services:
         published: "80"
     environment:
       FOO: bar
-  my-project-worker:
-    command:
-      - /bin/sh
-      - -c
-      - sleep 2600
-    image: debian
-    network_mode: service:my-project-api
+
+    deploy:
+      resources:
+        limits:
+          cpus: '0.5M'
\ No newline at end of file
diff --git a/internal/command/root.go b/internal/command/root.go
index a319aac..369bca9 100644
--- a/internal/command/root.go
+++ b/internal/command/root.go
@@ -8,8 +8,12 @@ The Apache Software Foundation (http://www.apache.org/).
 package command
 
 import (
+	"io"
+	"log/slog"
+
 	"github.com/spf13/cobra"
 
+	"github.com/score-spec/score-compose/internal/logging"
 	"github.com/score-spec/score-compose/internal/version"
 )
 
@@ -22,6 +26,22 @@ This tool produces a docker-compose configuration file from the SCORE specificat
 Complete documentation is available at https://score.dev`,
 		// don't print the errors - we print these ourselves in main()
 		SilenceErrors: true,
+
+		// This function always runs for all subcommands
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			if q, _ := cmd.Flags().GetBool("quiet"); q {
+				slog.SetDefault(slog.New(&logging.SimpleHandler{Level: slog.LevelError, Writer: io.Discard}))
+			} else if v, _ := cmd.Flags().GetCount("verbose"); v == 0 {
+				slog.SetDefault(slog.New(&logging.SimpleHandler{Level: slog.LevelInfo, Writer: cmd.ErrOrStderr()}))
+			} else if v == 1 {
+				slog.SetDefault(slog.New(&logging.SimpleHandler{Level: slog.LevelDebug, Writer: cmd.ErrOrStderr()}))
+			} else if v == 2 {
+				slog.SetDefault(slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{
+					Level: slog.LevelDebug, AddSource: true,
+				})))
+			}
+			return nil
+		},
 	}
 )
 
@@ -29,6 +49,8 @@ func init() {
 	rootCmd.Version = version.BuildVersionString()
 	rootCmd.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s" .Version}}
 `)
+	rootCmd.PersistentFlags().Bool("quiet", false, "Mute any logging output")
+	rootCmd.PersistentFlags().CountP("verbose", "v", "Increase log verbosity and detail by specifying this flag one or more times")
 }
 
 func Execute() error {
diff --git a/internal/command/run.go b/internal/command/run.go
index fe02055..aecd3d4 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -11,7 +11,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
-	"log"
+	"log/slog"
 	"os"
 	"sort"
 	"strings"
@@ -22,11 +22,11 @@ import (
 	"github.com/tidwall/sjson"
 	"gopkg.in/yaml.v3"
 
-	"github.com/score-spec/score-compose/internal/compose"
-
 	loader "github.com/score-spec/score-go/loader"
 	schema "github.com/score-spec/score-go/schema"
 	score "github.com/score-spec/score-go/types"
+
+	"github.com/score-spec/score-compose/internal/compose"
 )
 
 const (
@@ -44,7 +44,6 @@ var (
 	overrideParams []string
 
 	skipValidation bool
-	verbose        bool
 )
 
 func init() {
@@ -57,13 +56,13 @@ func init() {
 	runCmd.Flags().StringArrayVarP(&overrideParams, "property", "p", nil, "Overrides selected property value")
 
 	runCmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "DEPRECATED: Disables Score file schema validation")
-	runCmd.Flags().BoolVar(&verbose, "verbose", false, "Enable diagnostic messages (written to STDERR)")
 
 	rootCmd.AddCommand(runCmd)
 }
 
 var runCmd = &cobra.Command{
 	Use:   "run [--file=score.yaml] [--output=compose.yaml]",
+	Args:  cobra.NoArgs,
 	Short: "Translate the SCORE file to docker-compose configuration",
 	RunE:  run,
 	// don't print the errors - we print these ourselves in main()
@@ -74,13 +73,9 @@ func run(cmd *cobra.Command, args []string) error {
 	// Silence usage message if args are parsed correctly
 	cmd.SilenceUsage = true
 
-	if !verbose {
-		log.SetOutput(io.Discard)
-	}
-
 	// Open source file
 	//
-	log.Printf("Reading '%s'...\n", scoreFile)
+	slog.Info(fmt.Sprintf("Reading Score file '%s'", scoreFile))
 	var err error
 	var src *os.File
 	if src, err = os.Open(scoreFile); err != nil {
@@ -90,7 +85,7 @@ func run(cmd *cobra.Command, args []string) error {
 
 	// Parse SCORE spec
 	//
-	log.Print("Parsing SCORE spec...\n")
+	slog.Info("Parsing Score specification")
 	var srcMap map[string]interface{}
 	if err = loader.ParseYAML(&srcMap, src); err != nil {
 		return err
@@ -99,15 +94,15 @@ func run(cmd *cobra.Command, args []string) error {
 	// Apply overrides from file (optional)
 	//
 	if overridesFile != "" {
-		log.Printf("Checking '%s'...\n", overridesFile)
 		if ovr, err := os.Open(overridesFile); err == nil {
 			defer ovr.Close()
 
-			log.Print("Applying SCORE overrides...\n")
+			slog.Info(fmt.Sprintf("Loading Score overrides file '%s'", overridesFile))
 			var ovrMap map[string]interface{}
 			if err = loader.ParseYAML(&ovrMap, ovr); err != nil {
 				return err
 			}
+			slog.Info("Applying Score overrides")
 			if err := mergo.MergeWithOverwrite(&srcMap, ovrMap); err != nil {
 				return fmt.Errorf("applying overrides fom '%s': %w", overridesFile, err)
 			}
@@ -119,8 +114,6 @@ func run(cmd *cobra.Command, args []string) error {
 	// Apply overrides from command line (optional)
 	//
 	for _, pstr := range overrideParams {
-		log.Print("Applying SCORE properties overrides...\n")
-
 		jsonBytes, err := json.Marshal(srcMap)
 		if err != nil {
 			return fmt.Errorf("marshalling score spec: %w", err)
@@ -129,7 +122,7 @@ func run(cmd *cobra.Command, args []string) error {
 		pmap := strings.SplitN(pstr, "=", 2)
 		if len(pmap) <= 1 {
 			var path = pmap[0]
-			log.Printf("removing '%s'", path)
+			slog.Info(fmt.Sprintf("Applying Score properties override: removing '%s'", path))
 			if jsonBytes, err = sjson.DeleteBytes(jsonBytes, path); err != nil {
 				return fmt.Errorf("removing '%s': %w", path, err)
 			}
@@ -140,7 +133,7 @@ func run(cmd *cobra.Command, args []string) error {
 				val = pmap[1]
 			}
 
-			log.Printf("overriding '%s' = '%s' (%T)", path, val, val)
+			slog.Info(fmt.Sprintf("Applying Score properties override: overriding '%s' = '%s' (%T)", path, val, val))
 			if jsonBytes, err = sjson.SetBytes(jsonBytes, path, val); err != nil {
 				return fmt.Errorf("overriding '%s': %w", path, err)
 			}
@@ -156,14 +149,14 @@ func run(cmd *cobra.Command, args []string) error {
 		return fmt.Errorf("failed to upgrade spec: %w", err)
 	} else if len(changes) > 0 {
 		for _, change := range changes {
-			log.Printf("Applying upgrade to specification: %s\n", change)
+			slog.Info(fmt.Sprintf("Applying upgrade to specification: %s", change))
 		}
 	}
 
 	// Validate SCORE spec
 	//
 	if !skipValidation {
-		log.Print("Validating SCORE spec...\n")
+		slog.Info("Validating final Score specification")
 		if err := schema.Validate(srcMap); err != nil {
 			return fmt.Errorf("validating workload spec: %w", err)
 		}
@@ -178,7 +171,7 @@ func run(cmd *cobra.Command, args []string) error {
 
 	// Build docker-compose configuration
 	//
-	log.Print("Building docker-compose configuration...\n")
+	slog.Info("Building docker-compose configuration")
 	proj, vars, err := compose.ConvertSpec(&spec)
 	if err != nil {
 		return fmt.Errorf("building docker-compose configuration: %w", err)
@@ -187,7 +180,7 @@ func run(cmd *cobra.Command, args []string) error {
 	// Override 'image' reference with 'build' instructions
 	//
 	if buildCtx != "" {
-		log.Printf("Applying build instructions: '%s'...\n", buildCtx)
+		slog.Info(fmt.Sprintf("Applying build context '%s' for service images", buildCtx))
 		// We add the build context to all services and containers here and make a big assumption that all are
 		// using the image we are building here and now. If this is unexpected, users should use a more complex
 		// overrides file.
@@ -201,7 +194,7 @@ func run(cmd *cobra.Command, args []string) error {
 	//
 	var dest = cmd.OutOrStdout()
 	if outFile != "" {
-		log.Printf("Creating '%s'...\n", outFile)
+		slog.Info(fmt.Sprintf("Writing output compose file '%s'", outFile))
 		destFile, err := os.Create(outFile)
 		if err != nil {
 			return err
@@ -213,7 +206,6 @@ func run(cmd *cobra.Command, args []string) error {
 
 	// Write docker-compose spec
 	//
-	log.Print("Writing docker-compose configuration...\n")
 	if err = compose.WriteYAML(dest, proj); err != nil {
 		return err
 	}
@@ -221,7 +213,7 @@ func run(cmd *cobra.Command, args []string) error {
 	if envFile != "" {
 		// Open .env file
 		//
-		log.Printf("Creating '%s'...\n", envFile)
+		slog.Info(fmt.Sprintf("Writing output .env file '%s'", envFile))
 		dest, err := os.Create(envFile)
 		if err != nil {
 			return err
@@ -230,8 +222,6 @@ func run(cmd *cobra.Command, args []string) error {
 
 		// Write .env file
 		//
-		log.Print("Writing .env file template...\n")
-
 		envVars := make([]string, 0)
 		for key, val := range vars.Accessed() {
 			var envVar = fmt.Sprintf("%s=%v\n", key, val)
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index cb9cf31..49395ad 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -10,6 +10,7 @@ package compose
 import (
 	"errors"
 	"fmt"
+	"log/slog"
 	"sort"
 	"strings"
 
@@ -38,14 +39,16 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)
 	// The first thing we must do is validate or create the resources this workload depends on.
 	// NOTE: this will soon be replaced by a much more sophisticated resource provisioning system!
 	for resourceName, resourceSpec := range spec.Resources {
+		resClass := DerefOr(resourceSpec.Class, "default")
 		if resourceSpec.Type == "environment" {
-			if DerefOr(resourceSpec.Class, "default") != "default" {
-				return nil, nil, fmt.Errorf("resources.%s: '%s.%s' is not supported in score-compose", resourceName, resourceSpec.Type, *resourceSpec.Class)
+			if resClass != "default" {
+				return nil, nil, fmt.Errorf("resources.%s: '%s.%s' is not supported in score-compose", resourceName, resourceSpec.Type, resClass)
 			}
 			resources[resourceName] = envVarTracker
-		} else if resourceSpec.Type == "volume" && DerefOr(resourceSpec.Class, "default") == "default" {
+		} else if resourceSpec.Type == "volume" && resClass == "default" {
 			resources[resourceName] = resourceWithStaticOutputs{}
 		} else {
+			slog.Warn(fmt.Sprintf("resources.%s: '%s.%s' is not directly supported in score-compose, references will be converted to environment variables", resourceName, resourceSpec.Type, resClass))
 			// TODO: only enable this if the type.class is in an allow-list or the allow-list is '*' - otherwise return an error
 			resources[resourceName] = envVarTracker.GenerateResource(resourceName)
 		}
@@ -105,13 +108,15 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)
 			volumes = make([]compose.ServiceVolumeConfig, len(cSpec.Volumes))
 			for idx, vol := range cSpec.Volumes {
 				if vol.Path != nil && *vol.Path != "" {
-					return nil, nil, fmt.Errorf("can't mount named volume with sub path '%s': %w", *vol.Path, errors.New("not supported"))
+					return nil, nil, fmt.Errorf("containers.%s.volumes[%d].path: can't mount named volume with sub path '%s': not supported", containerName, idx, *vol.Path)
 				}
 
 				// TODO: deprecate this - volume should be linked directly
 				resolvedVolumeSource, err := ctx.Substitute(vol.Source)
 				if err != nil {
 					return nil, nil, fmt.Errorf("containers.%s.volumes[%d].source: %w", containerName, idx, err)
+				} else if resolvedVolumeSource != vol.Source {
+					slog.Warn(fmt.Sprintf("containers.%s.volumes[%d].source: interpolation will be deprecated in the future", containerName, idx))
 				}
 
 				if res, ok := spec.Resources[resolvedVolumeSource]; !ok {
@@ -134,6 +139,29 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)
 		})
 		// END (NOTE)
 
+		// Files are not supported just yet
+		if len(cSpec.Files) > 0 {
+			return nil, nil, fmt.Errorf("containers.%s.files: not supported", containerName)
+		}
+
+		// Docker compose without swarm/stack mode doesn't really support resource limits. There are optimistic
+		// workarounds but they vary between specific versions of the CLI. Better to just ignore.
+		if cSpec.Resources != nil {
+			if cSpec.Resources.Requests != nil {
+				slog.Warn(fmt.Sprintf("containers.%s.resources.requests: not supported - ignoring", containerName))
+			}
+			if cSpec.Resources.Limits != nil {
+				slog.Warn(fmt.Sprintf("containers.%s.resources.limits: not supported - ignoring", containerName))
+			}
+		}
+
+		if cSpec.ReadinessProbe != nil {
+			slog.Warn(fmt.Sprintf("containers.%s.readinessProbe: not supported - ignoring", containerName))
+		}
+		if cSpec.LivenessProbe != nil {
+			slog.Warn(fmt.Sprintf("containers.%s.livenessProbe: not supported - ignoring", containerName))
+		}
+
 		var svc = compose.ServiceConfig{
 			Name:        workloadName + "-" + containerName,
 			Image:       cSpec.Image,
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
new file mode 100644
index 0000000..b70e55f
--- /dev/null
+++ b/internal/logging/logging.go
@@ -0,0 +1,39 @@
+package logging
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log/slog"
+	"sync"
+)
+
+type SimpleHandler struct {
+	Writer io.Writer
+	Level  slog.Leveler
+
+	mu sync.Mutex
+}
+
+func (h *SimpleHandler) Enabled(ctx context.Context, level slog.Level) bool {
+	return level >= h.Level.Level()
+}
+
+func (h *SimpleHandler) Handle(ctx context.Context, record slog.Record) error {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	_, err := h.Writer.Write([]byte(fmt.Sprintf("%s: %s\n", record.Level.String(), record.Message)))
+	return err
+}
+
+func (h *SimpleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+	// no support for attrs here
+	return h
+}
+
+func (h *SimpleHandler) WithGroup(name string) slog.Handler {
+	// no support for attrs here
+	return h
+}
+
+var _ slog.Handler = (*SimpleHandler)(nil)


Test patch:
diff --git a/internal/command/root_test.go b/internal/command/root_test.go
index 540f3f0..ae8f387 100644
--- a/internal/command/root_test.go
+++ b/internal/command/root_test.go
@@ -24,8 +24,10 @@ Available Commands:
   run         Translate the SCORE file to docker-compose configuration
 
 Flags:
-  -h, --help      help for score-compose
-  -v, --version   version for score-compose
+  -h, --help            help for score-compose
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
+      --version         version for score-compose
 
 Use "score-compose [command] --help" for more information about a command.
 `, stdout)
@@ -58,6 +60,10 @@ Available Commands:
 Flags:
   -h, --help   help for completion
 
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
+
 Use "score-compose completion [command] --help" for more information about a command.
 `, stdout)
 	assert.Equal(t, "", stderr)
@@ -98,6 +104,10 @@ Usage:
 Flags:
   -h, --help              help for bash
       --no-descriptions   disable completion descriptions
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
 `, stdout)
 	assert.Equal(t, "", stderr)
 }
@@ -130,6 +140,10 @@ Usage:
 Flags:
   -h, --help              help for fish
       --no-descriptions   disable completion descriptions
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
 `, stdout)
 	assert.Equal(t, "", stderr)
 }
@@ -173,6 +187,10 @@ Usage:
 Flags:
   -h, --help              help for zsh
       --no-descriptions   disable completion descriptions
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
 `, stdout)
 	assert.Equal(t, "", stderr)
 }
@@ -202,6 +220,10 @@ Usage:
 Flags:
   -h, --help              help for powershell
       --no-descriptions   disable completion descriptions
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
 `, stdout)
 	assert.Equal(t, "", stderr)
 }
diff --git a/internal/command/run_test.go b/internal/command/run_test.go
index 4132aeb..d2966b0 100644
--- a/internal/command/run_test.go
+++ b/internal/command/run_test.go
@@ -6,6 +6,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/spf13/cobra"
@@ -64,7 +65,10 @@ Flags:
       --overrides string       Overrides SCORE file (default "./overrides.score.yaml")
   -p, --property stringArray   Overrides selected property value
       --skip-validation        DEPRECATED: Disables Score file schema validation
-      --verbose                Enable diagnostic messages (written to STDERR)
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
 `, stdout)
 	assert.Equal(t, "", stderr)
 
@@ -146,7 +150,17 @@ resources:
 	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"run", "--file", filepath.Join(td, "score.yaml"), "--output", filepath.Join(td, "compose.yaml")})
 	assert.NoError(t, err)
 	assert.NotEqual(t, "", stdout)
-	assert.Equal(t, "", stderr)
+	for _, l := range []string{
+		"WARN: resources.resource-one1: 'Resource-One.default' is not directly supported in score-compose, references will be converted to environment variables\n",
+		"WARN: resources.resource-two2: 'Resource-Two.default' is not directly supported in score-compose, references will be converted to environment variables\n",
+		"WARN: containers.container-one1.resources.requests: not supported - ignoring\n",
+		"WARN: containers.container-one1.resources.limits: not supported - ignoring\n",
+		"WARN: containers.container-one1.readinessProbe: not supported - ignoring\n",
+		"WARN: containers.container-one1.livenessProbe: not supported - ignoring\n",
+	} {
+		assert.Contains(t, stderr, l)
+	}
+
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	var actualComposeContent map[string]interface{}
@@ -209,7 +223,7 @@ func TestExample_invalid_spec(t *testing.T) {
 	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"run", "--file", filepath.Join(td, "score.yaml"), "--output", filepath.Join(td, "compose.yaml")})
 	assert.EqualError(t, err, "validating workload spec: jsonschema: '' does not validate with https://score.dev/schemas/score#/required: missing properties: 'apiVersion', 'metadata', 'containers'")
 	assert.Equal(t, "", stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 }
 
 func TestVolumeSubPathNotSupported(t *testing.T) {
@@ -227,9 +241,28 @@ containers:
       path: /sub/path
 `), 0600))
 	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"run", "--file", filepath.Join(td, "score.yaml"), "--output", filepath.Join(td, "compose.yaml")})
-	assert.EqualError(t, err, "building docker-compose configuration: can't mount named volume with sub path '/sub/path': not supported")
+	assert.EqualError(t, err, "building docker-compose configuration: containers.container-one1.volumes[0].path: can't mount named volume with sub path '/sub/path': not supported")
 	assert.Equal(t, "", stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+}
+
+func TestFilesNotSupported(t *testing.T) {
+	td := t.TempDir()
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example-workload-name123
+containers:
+  container-one1:
+    image: localhost:4000/repo/my-image:tag
+    files:
+    - target: /mnt/something
+      content: bananas
+`), 0600))
+	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"run", "--file", filepath.Join(td, "score.yaml"), "--output", filepath.Join(td, "compose.yaml")})
+	assert.EqualError(t, err, "building docker-compose configuration: containers.container-one1.files: not supported")
+	assert.Equal(t, "", stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 }
 
 func TestInvalidWorkloadName(t *testing.T) {
@@ -245,7 +278,7 @@ containers:
 	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"run", "--file", filepath.Join(td, "score.yaml")})
 	assert.EqualError(t, err, "validating workload spec: jsonschema: '/metadata/name' does not validate with https://score.dev/schemas/score#/properties/metadata/properties/name/pattern: does not match pattern '^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$'")
 	assert.Equal(t, "", stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 }
 
 func TestRunExample01(t *testing.T) {
@@ -264,7 +297,7 @@ func TestRunExample01(t *testing.T) {
 `
 
 	assert.Equal(t, expectedOutput, stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -304,7 +337,7 @@ func TestRunExample02(t *testing.T) {
 `
 
 	assert.Equal(t, expectedOutput, stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -347,7 +380,7 @@ func TestRunExample03(t *testing.T) {
 `
 
 		assert.Equal(t, expectedOutput, stdout)
-		assert.Equal(t, "", stderr)
+		assert.NotEqual(t, "", strings.TrimSpace(stderr))
 		rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose-a.yaml"))
 		require.NoError(t, err)
 		assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -370,7 +403,7 @@ func TestRunExample03(t *testing.T) {
 `
 
 		assert.Equal(t, expectedOutput, stdout)
-		assert.Equal(t, "", stderr)
+		assert.NotEqual(t, "", strings.TrimSpace(stderr))
 		rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose-b.yaml"))
 		require.NoError(t, err)
 		assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -417,7 +450,7 @@ func TestRunExample04(t *testing.T) {
 `
 
 	assert.Equal(t, expectedOutput, stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -467,7 +500,7 @@ containers:
 `
 
 	assert.Equal(t, expectedOutput, stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -502,7 +535,7 @@ containers:
 `
 
 	assert.Equal(t, expectedOutput, stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	assert.Equal(t, expectedOutput, string(rawComposeContent))
@@ -531,7 +564,7 @@ containers:
 `
 
 	assert.Equal(t, expectedOutput, stdout)
-	assert.Equal(t, "", stderr)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
 	require.NoError(t, err)
 	assert.Equal(t, expectedOutput, string(rawComposeContent))