Repository: score-spec/score-compose (go)

Issue:
[Bug] Duplicate error message shown
### How to reproduce

This issue can be reproduced by triggering any error message. For example:

run `score-compose run -f ./score.yaml -o ./compose.yaml`  with the following `score.yaml` file:

```
apiVersion: score.dev/v1b1

metadata:
  name: sample-service

containers:
  hello:
    image: busybox
    command: "/bin/sh"
```

An error is thrown as `command` should be set to `["/bin/sh"]`

### Actual behaviour

The error output on score-compose is a little messy. It starts with the error message, then the help text and then the error message again.

```
Error: validating workload spec: 1 error(s) decoding:

* 'containers[hello].command': source data must be an array or slice, got string
Usage:
  score-compose run [flags]

Flags:
      --build string       Replaces 'image' name with compose 'build' instruction
      --env-file string    Location to store sample .env file
  -f, --file string        Source SCORE file (default "./score.yaml")
  -h, --help               help for run
  -o, --output string      Output file
      --overrides string   Overrides SCORE file (default "./overrides.score.yaml")
      --verbose            Enable diagnostic messages (written to STDERR)

validating workload spec: 1 error(s) decoding:

* 'containers[hello].command': source data must be an array or slice, got string
```
### Expected Behaviour

* Remove the "help" message on application errors, and only show it if there is a usage error.
* Remove the second error message

```
Error: validating workload spec: 1 error(s) decoding:

* 'containers[hello].command': source data must be an array or slice, got string
```

### Scope
All application errors in score-compose and score-helm


Pull request:
fix: remove double printing of errors and supress usage text when errors are present after parsing
This PR fixes #30 and prevents the double printing of errors along with usage messages that aren't appropriate.

The sample giving in #30 now prints just the single error without usage string:

```
$ score-compose run -f ./score.yaml -o ./compose.yaml
Error: validating workload spec: jsonschema: '/containers/hello/command' does not validate with https://score.dev/schemas/score#/properties/containers/additionalProperties/$ref/properties/command/type: expected array, but got string
```

#### Types of changes
- [X] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] New chore (expected functionality to be implemented)

#### Checklist:
- [ ] My change requires a change to the documentation.
  - [ ] I have updated the documentation accordingly.
- [X] I've signed off with an email address that matches the commit author.


Patch:
diff --git a/README.md b/README.md
index c76f13c..fa65bc8 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,25 @@ score-compose run -f /tmp/score.yaml -o /tmp/compose.yaml
 - `-f` is the path to the Score file.
 - `-o` specifies the path to the output file.
 
-If you're just getting started, follow [this guide](https://docs.score.dev/docs/get-started/score-compose-hello-world/) to run your first Hello World program with `score-compose`.
+If you're just getting started, follow [this guide](https://docs.score.dev/docs/get-started/score-compose-hello-world/) to run your first Hello World program with `score-compose`. The full usage of the `run` command is:
+
+```
+Translate the SCORE file to docker-compose configuration
+
+Usage:
+  score-compose run [--file=score.yaml] [--output=compose.yaml] [flags]
+
+Flags:
+      --build string           Replaces 'image' name with compose 'build' instruction
+      --env-file string        Location to store sample .env file
+  -f, --file string            Source SCORE file (default "./score.yaml")
+  -h, --help                   help for run
+  -o, --output string          Output file
+      --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)
+```
 
 ## ![Get involved](docs/images/get-involved.svg) Get involved
 
diff --git a/cmd/score-compose/main.go b/cmd/score-compose/main.go
index a07048a..99408d1 100644
--- a/cmd/score-compose/main.go
+++ b/cmd/score-compose/main.go
@@ -16,7 +16,7 @@ import (
 
 func main() {
 	if err := command.Execute(); err != nil {
-		fmt.Fprintln(os.Stderr, err)
+		_, _ = fmt.Fprintln(os.Stderr, "Error: "+err.Error())
 		os.Exit(1)
 	}
 }
diff --git a/internal/command/root.go b/internal/command/root.go
index 21e9059..de2c746 100644
--- a/internal/command/root.go
+++ b/internal/command/root.go
@@ -23,6 +23,8 @@ var (
 This tool produces a docker-compose configuration file from the SCORE specification.
 Complete documentation is available at https://score.dev`,
 		Version: fmt.Sprintf("%s (build: %s; sha: %s)", version.Version, version.BuildTime, version.GitSHA),
+		// don't print the errors - we print these ourselves in main()
+		SilenceErrors: true,
 	}
 )
 
diff --git a/internal/command/run.go b/internal/command/run.go
index 2c412cb..4b872a7 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -63,12 +63,17 @@ func init() {
 }
 
 var runCmd = &cobra.Command{
-	Use:   "run",
+	Use:   "run [--file=score.yaml] [--output=compose.yaml]",
 	Short: "Translate the SCORE file to docker-compose configuration",
 	RunE:  run,
+	// don't print the errors - we print these ourselves in main()
+	SilenceErrors: true,
 }
 
 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)
 	}
@@ -185,7 +190,7 @@ func run(cmd *cobra.Command, args []string) error {
 
 	// Open output file (optional)
 	//
-	var dest = io.Writer(os.Stdout)
+	var dest = cmd.OutOrStdout()
 	if outFile != "" {
 		log.Printf("Creating '%s'...\n", outFile)
 		destFile, err := os.Create(outFile)


Test patch:
diff --git a/e2e-tests/resources/outputs/run --help-output.txt b/e2e-tests/resources/outputs/run --help-output.txt
index 7c1a526..f168c89 100644
--- a/e2e-tests/resources/outputs/run --help-output.txt
+++ b/e2e-tests/resources/outputs/run --help-output.txt
@@ -1,7 +1,7 @@
 Translate the SCORE file to docker-compose configuration
 
 Usage:
-  score-compose run [flags]
+  score-compose run [--file=score.yaml] [--output=compose.yaml] [flags]
 
 Flags:
       --build string           Replaces 'image' name with compose 'build' instruction
diff --git a/e2e-tests/resources/outputs/run --verbose-output.txt b/e2e-tests/resources/outputs/run --verbose-output.txt
index ed7891e..45c6c4e 100644
--- a/e2e-tests/resources/outputs/run --verbose-output.txt
+++ b/e2e-tests/resources/outputs/run --verbose-output.txt
@@ -1,17 +1,1 @@
-'./score.yaml'...
-Error: open ./score.yaml: no such file or directory
-Usage:
-  score-compose run [flags]
-
-Flags:
-      --build string           Replaces 'image' name with compose 'build' instruction
-      --env-file string        Location to store sample .env file
-  -f, --file string            Source SCORE file (default "./score.yaml")
-  -h, --help                   help for run
-  -o, --output string          Output file
-      --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)
-
-open ./score.yaml: no such file or directory
\ No newline at end of file
+Error: open ./score.yaml: no such file or directory
\ No newline at end of file
diff --git a/e2e-tests/resources/outputs/run-output.txt b/e2e-tests/resources/outputs/run-output.txt
index eb7a0e2..45c6c4e 100644
--- a/e2e-tests/resources/outputs/run-output.txt
+++ b/e2e-tests/resources/outputs/run-output.txt
@@ -1,16 +1,1 @@
-Error: open ./score.yaml: no such file or directory
-Usage:
-  score-compose run [flags]
-
-Flags:
-      --build string           Replaces 'image' name with compose 'build' instruction
-      --env-file string        Location to store sample .env file
-  -f, --file string            Source SCORE file (default "./score.yaml")
-  -h, --help                   help for run
-  -o, --output string          Output file
-      --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)
-
-open ./score.yaml: no such file or directory
\ No newline at end of file
+Error: open ./score.yaml: no such file or directory
\ No newline at end of file
diff --git a/e2e-tests/resources/outputs/unknown-output.txt b/e2e-tests/resources/outputs/unknown-output.txt
index 04a2e28..006fe2c 100644
--- a/e2e-tests/resources/outputs/unknown-output.txt
+++ b/e2e-tests/resources/outputs/unknown-output.txt
@@ -1,3 +1,1 @@
-Error: unknown command "unknown" for "score-compose"
-Run 'score-compose --help' for usage.
-unknown command "unknown" for "score-compose"
\ No newline at end of file
+Error: unknown command "unknown" for "score-compose"
\ No newline at end of file
diff --git a/internal/command/run_test.go b/internal/command/run_test.go
new file mode 100644
index 0000000..f996cde
--- /dev/null
+++ b/internal/command/run_test.go
@@ -0,0 +1,148 @@
+package command
+
+import (
+	"bytes"
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+// executeAndResetCommand is a test helper that runs and then resets a command for executing in another test.
+func executeAndResetCommand(ctx context.Context, cmd *cobra.Command, args []string) (string, string, error) {
+	beforeOut, beforeErr := cmd.OutOrStderr(), cmd.ErrOrStderr()
+	defer func() {
+		cmd.SetOut(beforeOut)
+		cmd.SetErr(beforeErr)
+	}()
+
+	nowOut, nowErr := new(bytes.Buffer), new(bytes.Buffer)
+	cmd.SetOut(nowOut)
+	cmd.SetErr(nowErr)
+	cmd.SetArgs(args)
+	subCmd, err := cmd.ExecuteContextC(ctx)
+	if subCmd != nil {
+		subCmd.SetContext(nil)
+		subCmd.SilenceUsage = false
+		subCmd.SilenceErrors = false
+		subCmd.Flags().VisitAll(func(f *pflag.Flag) {
+			_ = f.Value.Set(f.DefValue)
+		})
+	}
+	return nowOut.String(), nowErr.String(), err
+}
+
+func TestExample(t *testing.T) {
+	td := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example-workload-name123
+  extra-key: extra-value
+service:
+  ports:
+    port-one:
+      port: 1000
+      protocol: TCP
+      targetPort: 10000
+    port-two2:
+      port: 8000
+containers:
+  container-one1:
+    image: localhost:4000/repo/my-image:tag
+    command: ["/bin/sh", "-c"]
+    args: ["hello", "world"]
+    resources:
+      requests:
+        cpu: 1000m
+        memory: 10Gi
+      limits:
+        cpu: "0.24"
+        memory: 128M
+    variables:
+      SOME_VAR: some content here
+    volumes:
+    - source: volume-name
+      target: /mnt/something
+      readOnly: false
+    - source: volume-two
+      target: /mnt/something-else
+    livenessProbe:
+      httpGet:
+        port: 8080
+        path: /livez
+    readinessProbe:
+      httpGet:
+        host: 127.0.0.1
+        port: 80
+        scheme: HTTP
+        path: /readyz
+        httpHeaders:
+        - name: SOME_HEADER
+          value: some-value-here
+  container-two2:
+    image: localhost:4000/repo/my-image:tag
+resources:
+  resource-one1:
+    metadata:
+      annotations:
+        Default-Annotation: this is my annotation
+        prefix.com/Another-Key_Annotation.2: something else
+      extra-key: extra-value
+    type: Resource-One
+    class: default
+    params:
+      extra:
+        data: here
+  resource-two2:
+    type: Resource-Two
+`), 0600))
+	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)
+	rawComposeContent, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
+	require.NoError(t, err)
+	var actualComposeContent map[string]interface{}
+	assert.NoError(t, yaml.Unmarshal(rawComposeContent, &actualComposeContent))
+	assert.Equal(t, map[string]interface{}{
+		"services": map[string]interface{}{
+			"example-workload-name123-container-one1": map[string]interface{}{
+				"image":      "localhost:4000/repo/my-image:tag",
+				"entrypoint": []interface{}{"/bin/sh", "-c"},
+				"command":    []interface{}{"hello", "world"},
+				"environment": map[string]interface{}{
+					"SOME_VAR": "some content here",
+				},
+				"ports": []interface{}{
+					map[string]interface{}{"target": 10000, "published": "1000", "protocol": "TCP"},
+					map[string]interface{}{"target": 8000, "published": "8000"},
+				},
+				"volumes": []interface{}{
+					map[string]interface{}{"type": "volume", "source": "volume-name", "target": "/mnt/something"},
+					map[string]interface{}{"type": "volume", "source": "volume-two", "target": "/mnt/something-else"},
+				},
+			},
+			"example-workload-name123-container-two2": map[string]interface{}{
+				"image":        "localhost:4000/repo/my-image:tag",
+				"network_mode": "service:example-workload-name123-container-one1",
+			},
+		},
+	}, actualComposeContent)
+}
+
+func TestExample_invalid_spec(t *testing.T) {
+	td := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+{}`), 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, "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)
+}