From 5e7948ec838ed3df5b4a11721131b446260f8fbb Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 17:36:41 +0200 Subject: [PATCH 1/9] e2e/cli-plugins: rename var that shadowed import Signed-off-by: Sebastiaan van Stijn --- e2e/cli-plugins/socket_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/e2e/cli-plugins/socket_test.go b/e2e/cli-plugins/socket_test.go index 5e0b1cbb7c..b34982c32b 100644 --- a/e2e/cli-plugins/socket_test.go +++ b/e2e/cli-plugins/socket_test.go @@ -37,7 +37,7 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) { err := syscall.Kill(-command.Process.Pid, syscall.SIGINT) assert.NilError(t, err, "failed to signal process group") }() - bytes, err := io.ReadAll(ptmx) + out, err := io.ReadAll(ptmx) if err != nil && !strings.Contains(err.Error(), "input/output error") { t.Fatal("failed to get command output") } @@ -45,7 +45,7 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) { // the plugin is attached to the TTY, so the parent process // ignores the received signal, and the plugin receives a SIGINT // as well - assert.Equal(t, string(bytes), "received SIGINT\r\nexit after 3 seconds\r\n") + assert.Equal(t, string(out), "received SIGINT\r\nexit after 3 seconds\r\n") }) // ensure that we don't break plugins that attempt to read from the TTY @@ -95,13 +95,13 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) { err := syscall.Kill(command.Process.Pid, syscall.SIGINT) assert.NilError(t, err, "failed to signal process group") }() - bytes, err := command.CombinedOutput() - t.Log("command output: " + string(bytes)) + out, err := command.CombinedOutput() + t.Log("command output: " + string(out)) assert.NilError(t, err, "failed to run command") // the plugin process does not receive a SIGINT // so it exits after 3 seconds and prints this message - assert.Equal(t, string(bytes), "exit after 3 seconds\n") + assert.Equal(t, string(out), "exit after 3 seconds\n") }) t.Run("the main CLI exits after 3 signals", func(t *testing.T) { @@ -130,13 +130,13 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) { err = syscall.Kill(command.Process.Pid, syscall.SIGINT) assert.NilError(t, err, "failed to signal process group") }() - bytes, err := command.CombinedOutput() + out, err := command.CombinedOutput() assert.ErrorContains(t, err, "exit status 1") // the plugin process does not receive a SIGINT and does // the CLI cannot cancel it over the socket, so it kills // the plugin process and forcefully exits - assert.Equal(t, string(bytes), "got 3 SIGTERM/SIGINTs, forcefully exiting\n") + assert.Equal(t, string(out), "got 3 SIGTERM/SIGINTs, forcefully exiting\n") }) }) } @@ -161,7 +161,7 @@ func TestPluginSocketCommunication(t *testing.T) { err := syscall.Kill(-command.Process.Pid, syscall.SIGINT) assert.NilError(t, err, "failed to signal process group") }() - bytes, err := io.ReadAll(ptmx) + out, err := io.ReadAll(ptmx) if err != nil && !strings.Contains(err.Error(), "input/output error") { t.Fatal("failed to get command output") } @@ -169,7 +169,7 @@ func TestPluginSocketCommunication(t *testing.T) { // the plugin is attached to the TTY, so the parent process // ignores the received signal, and the plugin receives a SIGINT // as well - assert.Equal(t, string(bytes), "received SIGINT\r\nexit after 3 seconds\r\n") + assert.Equal(t, string(out), "received SIGINT\r\nexit after 3 seconds\r\n") }) }) From e9f32edac5aff80e174e95cd952f3a3a6fcc611c Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 5 Jul 2024 10:54:08 +0200 Subject: [PATCH 2/9] e2e/cli-plugins: explicitly ignore fmt.Printxx errors To keep some linters happier, and my IDE to be less noisy. Signed-off-by: Sebastiaan van Stijn --- e2e/cli-plugins/plugins/presocket/main.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/e2e/cli-plugins/plugins/presocket/main.go b/e2e/cli-plugins/plugins/presocket/main.go index 6cdf87a424..d0ec79dcf4 100644 --- a/e2e/cli-plugins/plugins/presocket/main.go +++ b/e2e/cli-plugins/plugins/presocket/main.go @@ -39,18 +39,18 @@ func RootCmd(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { go func() { <-cmd.Context().Done() - fmt.Fprintln(dockerCli.Out(), "context cancelled") + _, _ = fmt.Fprintln(dockerCli.Out(), "context cancelled") os.Exit(2) }() signalCh := make(chan os.Signal, 10) signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) go func() { for range signalCh { - fmt.Fprintln(dockerCli.Out(), "received SIGINT") + _, _ = fmt.Fprintln(dockerCli.Out(), "received SIGINT") } }() <-time.After(3 * time.Second) - fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds") + _, _ = fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds") return nil }, }) @@ -64,18 +64,18 @@ func RootCmd(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { go func() { <-cmd.Context().Done() - fmt.Fprintln(dockerCli.Out(), "context cancelled") + _, _ = fmt.Fprintln(dockerCli.Out(), "context cancelled") os.Exit(2) }() signalCh := make(chan os.Signal, 10) signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) go func() { for range signalCh { - fmt.Fprintln(dockerCli.Out(), "received SIGINT") + _, _ = fmt.Fprintln(dockerCli.Out(), "received SIGINT") } }() <-time.After(3 * time.Second) - fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds") + _, _ = fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds") return nil }, }) @@ -91,11 +91,11 @@ func RootCmd(dockerCli command.Cli) *cobra.Command { signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) go func() { for range signalCh { - fmt.Fprintln(dockerCli.Out(), "received SIGINT") + _, _ = fmt.Fprintln(dockerCli.Out(), "received SIGINT") } }() <-time.After(3 * time.Second) - fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds") + _, _ = fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds") return nil }, }) @@ -113,7 +113,7 @@ func RootCmd(dockerCli command.Cli) *cobra.Command { select { case <-done: case <-time.After(2 * time.Second): - fmt.Fprint(dockerCli.Err(), "timeout after 2 seconds") + _, _ = fmt.Fprint(dockerCli.Err(), "timeout after 2 seconds") } return nil }, From c6b40640ccf1bd1b691f32cd1f22dd0a05bb05eb Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 17:08:14 +0200 Subject: [PATCH 3/9] e2e/cli-plugins: use identifiable output for test This confused me fore a bit, because I thought the test was checking for an actual `context.Canceled` error (which is spelled "context canceled" with a single "l". But then I found that this was a string that's printed as part of a test-utility, just looking very similar but with the British spelling ("cancelled"). Let's change this to a message that's unique for the test, also to make it more grep'able. Signed-off-by: Sebastiaan van Stijn --- e2e/cli-plugins/plugins/presocket/main.go | 4 ++-- e2e/cli-plugins/socket_test.go | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/e2e/cli-plugins/plugins/presocket/main.go b/e2e/cli-plugins/plugins/presocket/main.go index d0ec79dcf4..8c8ad6df66 100644 --- a/e2e/cli-plugins/plugins/presocket/main.go +++ b/e2e/cli-plugins/plugins/presocket/main.go @@ -39,7 +39,7 @@ func RootCmd(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { go func() { <-cmd.Context().Done() - _, _ = fmt.Fprintln(dockerCli.Out(), "context cancelled") + _, _ = fmt.Fprintln(dockerCli.Out(), "test-no-socket: exiting after context was done") os.Exit(2) }() signalCh := make(chan os.Signal, 10) @@ -64,7 +64,7 @@ func RootCmd(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { go func() { <-cmd.Context().Done() - _, _ = fmt.Fprintln(dockerCli.Out(), "context cancelled") + _, _ = fmt.Fprintln(dockerCli.Out(), "test-socket: exiting after context was done") os.Exit(2) }() signalCh := make(chan os.Signal, 10) diff --git a/e2e/cli-plugins/socket_test.go b/e2e/cli-plugins/socket_test.go index b34982c32b..4c2539b73e 100644 --- a/e2e/cli-plugins/socket_test.go +++ b/e2e/cli-plugins/socket_test.go @@ -11,6 +11,7 @@ import ( "github.com/creack/pty" "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" ) // TestPluginSocketBackwardsCompatible executes a plugin binary @@ -194,9 +195,11 @@ func TestPluginSocketCommunication(t *testing.T) { t.Log(outB.String()) assert.ErrorContains(t, err, "exit status 2") - // the plugin does not get signalled, but it does get it's - // context cancelled by the CLI through the socket - assert.Equal(t, outB.String(), "context cancelled\n") + // the plugin does not get signalled, but it does get its + // context canceled by the CLI through the socket + const expected = "test-socket: exiting after context was done" + actual := strings.TrimSpace(outB.String()) + assert.Check(t, is.Equal(actual, expected)) }) t.Run("the main CLI exits after 3 signals", func(t *testing.T) { @@ -223,13 +226,13 @@ func TestPluginSocketCommunication(t *testing.T) { err = syscall.Kill(command.Process.Pid, syscall.SIGINT) assert.NilError(t, err, "failed to signal CLI process§") }() - bytes, err := command.CombinedOutput() + out, err := command.CombinedOutput() assert.ErrorContains(t, err, "exit status 1") // the plugin process does not receive a SIGINT and does - // not exit after having it's context cancelled, so the CLI + // not exit after having it's context canceled, so the CLI // kills the plugin process and forcefully exits - assert.Equal(t, string(bytes), "got 3 SIGTERM/SIGINTs, forcefully exiting\n") + assert.Equal(t, string(out), "got 3 SIGTERM/SIGINTs, forcefully exiting\n") }) }) } From baf35da40104175a837a7001285abf1e05711c9b Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 17:42:09 +0200 Subject: [PATCH 4/9] e2e/cli-plugins: use cmd.CombinedOutput() instead of custom buffer Also remove a debug-log, as the output would already be shown if the test would fail. Signed-off-by: Sebastiaan van Stijn --- e2e/cli-plugins/socket_test.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/e2e/cli-plugins/socket_test.go b/e2e/cli-plugins/socket_test.go index 4c2539b73e..8d80824d5c 100644 --- a/e2e/cli-plugins/socket_test.go +++ b/e2e/cli-plugins/socket_test.go @@ -1,7 +1,6 @@ package cliplugins import ( - "bytes" "io" "os/exec" "strings" @@ -178,9 +177,6 @@ func TestPluginSocketCommunication(t *testing.T) { t.Run("the plugin does not get signalled", func(t *testing.T) { cmd := run("presocket", "test-socket") command := exec.Command(cmd.Command[0], cmd.Command[1:]...) - outB := bytes.Buffer{} - command.Stdout = &outB - command.Stderr = &outB command.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, } @@ -191,14 +187,13 @@ func TestPluginSocketCommunication(t *testing.T) { err := syscall.Kill(command.Process.Pid, syscall.SIGINT) assert.NilError(t, err, "failed to signal CLI process") }() - err := command.Run() - t.Log(outB.String()) + out, err := command.CombinedOutput() assert.ErrorContains(t, err, "exit status 2") // the plugin does not get signalled, but it does get its // context canceled by the CLI through the socket const expected = "test-socket: exiting after context was done" - actual := strings.TrimSpace(outB.String()) + actual := strings.TrimSpace(string(out)) assert.Check(t, is.Equal(actual, expected)) }) From 2f83064ec49fb5e7181c42d969d1bcb9b09a2463 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 17:50:02 +0200 Subject: [PATCH 5/9] e2e/cli-plugins: check for exit-errors in tests Verify that we get the expected exit-code, not just the message. Signed-off-by: Sebastiaan van Stijn --- e2e/cli-plugins/socket_test.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/e2e/cli-plugins/socket_test.go b/e2e/cli-plugins/socket_test.go index 8d80824d5c..be9b2c67f6 100644 --- a/e2e/cli-plugins/socket_test.go +++ b/e2e/cli-plugins/socket_test.go @@ -1,6 +1,7 @@ package cliplugins import ( + "errors" "io" "os/exec" "strings" @@ -131,7 +132,12 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) { assert.NilError(t, err, "failed to signal process group") }() out, err := command.CombinedOutput() - assert.ErrorContains(t, err, "exit status 1") + + var exitError *exec.ExitError + assert.Assert(t, errors.As(err, &exitError)) + assert.Check(t, exitError.Exited()) + assert.Check(t, is.Equal(exitError.ExitCode(), 1)) + assert.Check(t, is.ErrorContains(err, "exit status 1")) // the plugin process does not receive a SIGINT and does // the CLI cannot cancel it over the socket, so it kills @@ -188,7 +194,12 @@ func TestPluginSocketCommunication(t *testing.T) { assert.NilError(t, err, "failed to signal CLI process") }() out, err := command.CombinedOutput() - assert.ErrorContains(t, err, "exit status 2") + + var exitError *exec.ExitError + assert.Assert(t, errors.As(err, &exitError)) + assert.Check(t, exitError.Exited()) + assert.Check(t, is.Equal(exitError.ExitCode(), 2)) + assert.Check(t, is.ErrorContains(err, "exit status 2")) // the plugin does not get signalled, but it does get its // context canceled by the CLI through the socket @@ -222,7 +233,12 @@ func TestPluginSocketCommunication(t *testing.T) { assert.NilError(t, err, "failed to signal CLI process§") }() out, err := command.CombinedOutput() - assert.ErrorContains(t, err, "exit status 1") + + var exitError *exec.ExitError + assert.Assert(t, errors.As(err, &exitError)) + assert.Check(t, exitError.Exited()) + assert.Check(t, is.Equal(exitError.ExitCode(), 1)) + assert.Check(t, is.ErrorContains(err, "exit status 1")) // the plugin process does not receive a SIGINT and does // not exit after having it's context canceled, so the CLI From 3dd6fc365d853e21f0e11f9e6ab62c4f8ae438e7 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 19:20:59 +0200 Subject: [PATCH 6/9] cmd/docker: don't discard cli.StatusError errors without custom message Signed-off-by: Sebastiaan van Stijn --- cmd/docker/docker.go | 13 ++++++------- e2e/cli-plugins/socket_test.go | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 7825c7d354..ca52039df6 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -48,16 +48,15 @@ func dockerMain() int { otel.SetErrorHandler(debug.OTELErrorHandler) if err := runDocker(ctx, dockerCli); err != nil { - if sterr, ok := err.(cli.StatusError); ok { - if sterr.Status != "" { - fmt.Fprintln(dockerCli.Err(), sterr.Status) - } + var stErr cli.StatusError + if errors.As(err, &stErr) { // StatusError should only be used for errors, and all errors should // have a non-zero exit status, so never exit with 0 - if sterr.StatusCode == 0 { - return 1 + if stErr.StatusCode == 0 { // FIXME(thaJeztah): StatusCode should never be used with a zero status-code. Check if we do this anywhere. + stErr.StatusCode = 1 } - return sterr.StatusCode + _, _ = fmt.Fprintln(dockerCli.Err(), stErr) + return stErr.StatusCode } if errdefs.IsCancelled(err) { return 0 diff --git a/e2e/cli-plugins/socket_test.go b/e2e/cli-plugins/socket_test.go index be9b2c67f6..c641f0b7da 100644 --- a/e2e/cli-plugins/socket_test.go +++ b/e2e/cli-plugins/socket_test.go @@ -203,7 +203,7 @@ func TestPluginSocketCommunication(t *testing.T) { // the plugin does not get signalled, but it does get its // context canceled by the CLI through the socket - const expected = "test-socket: exiting after context was done" + const expected = "test-socket: exiting after context was done\nexit status 2" actual := strings.TrimSpace(string(out)) assert.Check(t, is.Equal(actual, expected)) }) From 350a0b68a9584ec9ae712b6eca906c1018ba6dac Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 19:31:33 +0200 Subject: [PATCH 7/9] cli-plugins: Run(): don't discard cli.StatusError errors without message Signed-off-by: Sebastiaan van Stijn --- cli-plugins/plugin/plugin.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cli-plugins/plugin/plugin.go b/cli-plugins/plugin/plugin.go index 8cce73d9ac..780d476cdc 100644 --- a/cli-plugins/plugin/plugin.go +++ b/cli-plugins/plugin/plugin.go @@ -3,6 +3,7 @@ package plugin import ( "context" "encoding/json" + "errors" "fmt" "os" "sync" @@ -92,18 +93,17 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) { plugin := makeCmd(dockerCli) if err := RunPlugin(dockerCli, plugin, meta); err != nil { - if sterr, ok := err.(cli.StatusError); ok { - if sterr.Status != "" { - fmt.Fprintln(dockerCli.Err(), sterr.Status) - } + var stErr cli.StatusError + if errors.As(err, &stErr) { // StatusError should only be used for errors, and all errors should // have a non-zero exit status, so never exit with 0 - if sterr.StatusCode == 0 { - os.Exit(1) + if stErr.StatusCode == 0 { // FIXME(thaJeztah): this should never be used with a zero status-code. Check if we do this anywhere. + stErr.StatusCode = 1 } - os.Exit(sterr.StatusCode) + _, _ = fmt.Fprintln(dockerCli.Err(), stErr) + os.Exit(stErr.StatusCode) } - fmt.Fprintln(dockerCli.Err(), err) + _, _ = fmt.Fprintln(dockerCli.Err(), err) os.Exit(1) } } From b7695d6c793498f3458b98e4752577b5a700cba2 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 19:36:46 +0200 Subject: [PATCH 8/9] cli-plugins: RunPlugin(): rename error-variable that's possibly shadowed The logic in this function is confusing; let's start make it obvious where the error that is returned is produced, Signed-off-by: Sebastiaan van Stijn --- cli-plugins/plugin/plugin.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli-plugins/plugin/plugin.go b/cli-plugins/plugin/plugin.go index 780d476cdc..4ae85dbce4 100644 --- a/cli-plugins/plugin/plugin.go +++ b/cli-plugins/plugin/plugin.go @@ -35,7 +35,7 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager var persistentPreRunOnce sync.Once PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { - var err error + var retErr error persistentPreRunOnce.Do(func() { ctx, cancel := context.WithCancel(cmd.Context()) cmd.SetContext(ctx) @@ -47,7 +47,7 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager opts = append(opts, withPluginClientConn(plugin.Name())) } opts = append(opts, command.WithEnableGlobalMeterProvider(), command.WithEnableGlobalTracerProvider()) - err = tcmd.Initialize(opts...) + retErr = tcmd.Initialize(opts...) ogRunE := cmd.RunE if ogRunE == nil { ogRun := cmd.Run @@ -67,7 +67,7 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager return err } }) - return err + return retErr } cmd, args, err := tcmd.HandleGlobalFlags() From eae75092a0e4b7d3cbb5d02145a75c01f1bd6bbe Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 4 Jul 2024 13:45:43 +0200 Subject: [PATCH 9/9] cmd/docker: split handling exit-code to a separate utility This allows dockerMain() to return an error "as usual", and puts the responsibility for turning that into an appropriate exit-code in main() (which also sets the exit-code when terminating). We could consider putting this utility in the cli package and exporting it if would be useful for doing a similar handling in plugins. Signed-off-by: Sebastiaan van Stijn --- cmd/docker/docker.go | 47 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index ca52039df6..a8596388fa 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -29,42 +29,41 @@ import ( ) func main() { - statusCode := dockerMain() - if statusCode != 0 { - os.Exit(statusCode) + err := dockerMain(context.Background()) + if err != nil && !errdefs.IsCancelled(err) { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(getExitCode(err)) } } -func dockerMain() int { - ctx, cancelNotify := signal.NotifyContext(context.Background(), platformsignals.TerminationSignals...) +func dockerMain(ctx context.Context) error { + ctx, cancelNotify := signal.NotifyContext(ctx, platformsignals.TerminationSignals...) defer cancelNotify() dockerCli, err := command.NewDockerCli(command.WithBaseContext(ctx)) if err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 + return err } logrus.SetOutput(dockerCli.Err()) otel.SetErrorHandler(debug.OTELErrorHandler) - if err := runDocker(ctx, dockerCli); err != nil { - var stErr cli.StatusError - if errors.As(err, &stErr) { - // StatusError should only be used for errors, and all errors should - // have a non-zero exit status, so never exit with 0 - if stErr.StatusCode == 0 { // FIXME(thaJeztah): StatusCode should never be used with a zero status-code. Check if we do this anywhere. - stErr.StatusCode = 1 - } - _, _ = fmt.Fprintln(dockerCli.Err(), stErr) - return stErr.StatusCode - } - if errdefs.IsCancelled(err) { - return 0 - } - fmt.Fprintln(dockerCli.Err(), err) - return 1 + return runDocker(ctx, dockerCli) +} + +// getExitCode returns the exit-code to use for the given error. +// If err is a [cli.StatusError] and has a StatusCode set, it uses the +// status-code from it, otherwise it returns "1" for any error. +func getExitCode(err error) int { + if err == nil { + return 0 } - return 0 + var stErr cli.StatusError + if errors.As(err, &stErr) && stErr.StatusCode != 0 { // FIXME(thaJeztah): StatusCode should never be used with a zero status-code. Check if we do this anywhere. + return stErr.StatusCode + } + + // No status-code provided; all errors should have a non-zero exit code. + return 1 } func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {