mirror of https://github.com/docker/cli.git
Merge pull request #5229 from thaJeztah/exit_error
cmd/docker: split handling exit-code to a separate utility
This commit is contained in:
commit
61c6ff2d4a
|
@ -3,6 +3,7 @@ package plugin
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -34,7 +35,7 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager
|
||||||
|
|
||||||
var persistentPreRunOnce sync.Once
|
var persistentPreRunOnce sync.Once
|
||||||
PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
|
PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
|
||||||
var err error
|
var retErr error
|
||||||
persistentPreRunOnce.Do(func() {
|
persistentPreRunOnce.Do(func() {
|
||||||
ctx, cancel := context.WithCancel(cmd.Context())
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
cmd.SetContext(ctx)
|
cmd.SetContext(ctx)
|
||||||
|
@ -46,7 +47,7 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager
|
||||||
opts = append(opts, withPluginClientConn(plugin.Name()))
|
opts = append(opts, withPluginClientConn(plugin.Name()))
|
||||||
}
|
}
|
||||||
opts = append(opts, command.WithEnableGlobalMeterProvider(), command.WithEnableGlobalTracerProvider())
|
opts = append(opts, command.WithEnableGlobalMeterProvider(), command.WithEnableGlobalTracerProvider())
|
||||||
err = tcmd.Initialize(opts...)
|
retErr = tcmd.Initialize(opts...)
|
||||||
ogRunE := cmd.RunE
|
ogRunE := cmd.RunE
|
||||||
if ogRunE == nil {
|
if ogRunE == nil {
|
||||||
ogRun := cmd.Run
|
ogRun := cmd.Run
|
||||||
|
@ -66,7 +67,7 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return err
|
return retErr
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd, args, err := tcmd.HandleGlobalFlags()
|
cmd, args, err := tcmd.HandleGlobalFlags()
|
||||||
|
@ -92,18 +93,17 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
|
||||||
plugin := makeCmd(dockerCli)
|
plugin := makeCmd(dockerCli)
|
||||||
|
|
||||||
if err := RunPlugin(dockerCli, plugin, meta); err != nil {
|
if err := RunPlugin(dockerCli, plugin, meta); err != nil {
|
||||||
if sterr, ok := err.(cli.StatusError); ok {
|
var stErr cli.StatusError
|
||||||
if sterr.Status != "" {
|
if errors.As(err, &stErr) {
|
||||||
fmt.Fprintln(dockerCli.Err(), sterr.Status)
|
|
||||||
}
|
|
||||||
// StatusError should only be used for errors, and all errors should
|
// StatusError should only be used for errors, and all errors should
|
||||||
// have a non-zero exit status, so never exit with 0
|
// have a non-zero exit status, so never exit with 0
|
||||||
if sterr.StatusCode == 0 {
|
if stErr.StatusCode == 0 { // FIXME(thaJeztah): this should never be used with a zero status-code. Check if we do this anywhere.
|
||||||
os.Exit(1)
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,43 +29,41 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
statusCode := dockerMain()
|
err := dockerMain(context.Background())
|
||||||
if statusCode != 0 {
|
if err != nil && !errdefs.IsCancelled(err) {
|
||||||
os.Exit(statusCode)
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(getExitCode(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dockerMain() int {
|
func dockerMain(ctx context.Context) error {
|
||||||
ctx, cancelNotify := signal.NotifyContext(context.Background(), platformsignals.TerminationSignals...)
|
ctx, cancelNotify := signal.NotifyContext(ctx, platformsignals.TerminationSignals...)
|
||||||
defer cancelNotify()
|
defer cancelNotify()
|
||||||
|
|
||||||
dockerCli, err := command.NewDockerCli(command.WithBaseContext(ctx))
|
dockerCli, err := command.NewDockerCli(command.WithBaseContext(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
return err
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
logrus.SetOutput(dockerCli.Err())
|
logrus.SetOutput(dockerCli.Err())
|
||||||
otel.SetErrorHandler(debug.OTELErrorHandler)
|
otel.SetErrorHandler(debug.OTELErrorHandler)
|
||||||
|
|
||||||
if err := runDocker(ctx, dockerCli); err != nil {
|
return runDocker(ctx, dockerCli)
|
||||||
if sterr, ok := err.(cli.StatusError); ok {
|
}
|
||||||
if sterr.Status != "" {
|
|
||||||
fmt.Fprintln(dockerCli.Err(), sterr.Status)
|
// 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
|
||||||
// StatusError should only be used for errors, and all errors should
|
// status-code from it, otherwise it returns "1" for any error.
|
||||||
// have a non-zero exit status, so never exit with 0
|
func getExitCode(err error) int {
|
||||||
if sterr.StatusCode == 0 {
|
if err == nil {
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return sterr.StatusCode
|
|
||||||
}
|
|
||||||
if errdefs.IsCancelled(err) {
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
fmt.Fprintln(dockerCli.Err(), err)
|
var stErr cli.StatusError
|
||||||
return 1
|
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
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
|
// No status-code provided; all errors should have a non-zero exit code.
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {
|
func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {
|
||||||
|
|
|
@ -39,18 +39,18 @@ func RootCmd(dockerCli command.Cli) *cobra.Command {
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
go func() {
|
go func() {
|
||||||
<-cmd.Context().Done()
|
<-cmd.Context().Done()
|
||||||
fmt.Fprintln(dockerCli.Out(), "context cancelled")
|
_, _ = fmt.Fprintln(dockerCli.Out(), "test-no-socket: exiting after context was done")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}()
|
}()
|
||||||
signalCh := make(chan os.Signal, 10)
|
signalCh := make(chan os.Signal, 10)
|
||||||
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
for range signalCh {
|
for range signalCh {
|
||||||
fmt.Fprintln(dockerCli.Out(), "received SIGINT")
|
_, _ = fmt.Fprintln(dockerCli.Out(), "received SIGINT")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
<-time.After(3 * time.Second)
|
<-time.After(3 * time.Second)
|
||||||
fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds")
|
_, _ = fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -64,18 +64,18 @@ func RootCmd(dockerCli command.Cli) *cobra.Command {
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
go func() {
|
go func() {
|
||||||
<-cmd.Context().Done()
|
<-cmd.Context().Done()
|
||||||
fmt.Fprintln(dockerCli.Out(), "context cancelled")
|
_, _ = fmt.Fprintln(dockerCli.Out(), "test-socket: exiting after context was done")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}()
|
}()
|
||||||
signalCh := make(chan os.Signal, 10)
|
signalCh := make(chan os.Signal, 10)
|
||||||
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
for range signalCh {
|
for range signalCh {
|
||||||
fmt.Fprintln(dockerCli.Out(), "received SIGINT")
|
_, _ = fmt.Fprintln(dockerCli.Out(), "received SIGINT")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
<-time.After(3 * time.Second)
|
<-time.After(3 * time.Second)
|
||||||
fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds")
|
_, _ = fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -91,11 +91,11 @@ func RootCmd(dockerCli command.Cli) *cobra.Command {
|
||||||
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
for range signalCh {
|
for range signalCh {
|
||||||
fmt.Fprintln(dockerCli.Out(), "received SIGINT")
|
_, _ = fmt.Fprintln(dockerCli.Out(), "received SIGINT")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
<-time.After(3 * time.Second)
|
<-time.After(3 * time.Second)
|
||||||
fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds")
|
_, _ = fmt.Fprintln(dockerCli.Err(), "exit after 3 seconds")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -113,7 +113,7 @@ func RootCmd(dockerCli command.Cli) *cobra.Command {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
fmt.Fprint(dockerCli.Err(), "timeout after 2 seconds")
|
_, _ = fmt.Fprint(dockerCli.Err(), "timeout after 2 seconds")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package cliplugins
|
package cliplugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -11,6 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
|
is "gotest.tools/v3/assert/cmp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestPluginSocketBackwardsCompatible executes a plugin binary
|
// TestPluginSocketBackwardsCompatible executes a plugin binary
|
||||||
|
@ -37,7 +38,7 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) {
|
||||||
err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
|
err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
|
||||||
assert.NilError(t, err, "failed to signal process group")
|
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") {
|
if err != nil && !strings.Contains(err.Error(), "input/output error") {
|
||||||
t.Fatal("failed to get command output")
|
t.Fatal("failed to get command output")
|
||||||
}
|
}
|
||||||
|
@ -45,7 +46,7 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) {
|
||||||
// the plugin is attached to the TTY, so the parent process
|
// the plugin is attached to the TTY, so the parent process
|
||||||
// ignores the received signal, and the plugin receives a SIGINT
|
// ignores the received signal, and the plugin receives a SIGINT
|
||||||
// as well
|
// 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
|
// ensure that we don't break plugins that attempt to read from the TTY
|
||||||
|
@ -95,13 +96,13 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) {
|
||||||
err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
||||||
assert.NilError(t, err, "failed to signal process group")
|
assert.NilError(t, err, "failed to signal process group")
|
||||||
}()
|
}()
|
||||||
bytes, err := command.CombinedOutput()
|
out, err := command.CombinedOutput()
|
||||||
t.Log("command output: " + string(bytes))
|
t.Log("command output: " + string(out))
|
||||||
assert.NilError(t, err, "failed to run command")
|
assert.NilError(t, err, "failed to run command")
|
||||||
|
|
||||||
// the plugin process does not receive a SIGINT
|
// the plugin process does not receive a SIGINT
|
||||||
// so it exits after 3 seconds and prints this message
|
// 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) {
|
t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
|
||||||
|
@ -130,13 +131,18 @@ func TestPluginSocketBackwardsCompatible(t *testing.T) {
|
||||||
err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
||||||
assert.NilError(t, err, "failed to signal process group")
|
assert.NilError(t, err, "failed to signal process group")
|
||||||
}()
|
}()
|
||||||
bytes, err := command.CombinedOutput()
|
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 plugin process does not receive a SIGINT and does
|
||||||
// the CLI cannot cancel it over the socket, so it kills
|
// the CLI cannot cancel it over the socket, so it kills
|
||||||
// the plugin process and forcefully exits
|
// 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 +167,7 @@ func TestPluginSocketCommunication(t *testing.T) {
|
||||||
err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
|
err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
|
||||||
assert.NilError(t, err, "failed to signal process group")
|
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") {
|
if err != nil && !strings.Contains(err.Error(), "input/output error") {
|
||||||
t.Fatal("failed to get command output")
|
t.Fatal("failed to get command output")
|
||||||
}
|
}
|
||||||
|
@ -169,7 +175,7 @@ func TestPluginSocketCommunication(t *testing.T) {
|
||||||
// the plugin is attached to the TTY, so the parent process
|
// the plugin is attached to the TTY, so the parent process
|
||||||
// ignores the received signal, and the plugin receives a SIGINT
|
// ignores the received signal, and the plugin receives a SIGINT
|
||||||
// as well
|
// 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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -177,9 +183,6 @@ func TestPluginSocketCommunication(t *testing.T) {
|
||||||
t.Run("the plugin does not get signalled", func(t *testing.T) {
|
t.Run("the plugin does not get signalled", func(t *testing.T) {
|
||||||
cmd := run("presocket", "test-socket")
|
cmd := run("presocket", "test-socket")
|
||||||
command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
|
command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
|
||||||
outB := bytes.Buffer{}
|
|
||||||
command.Stdout = &outB
|
|
||||||
command.Stderr = &outB
|
|
||||||
command.SysProcAttr = &syscall.SysProcAttr{
|
command.SysProcAttr = &syscall.SysProcAttr{
|
||||||
Setpgid: true,
|
Setpgid: true,
|
||||||
}
|
}
|
||||||
|
@ -190,13 +193,19 @@ func TestPluginSocketCommunication(t *testing.T) {
|
||||||
err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
||||||
assert.NilError(t, err, "failed to signal CLI process")
|
assert.NilError(t, err, "failed to signal CLI process")
|
||||||
}()
|
}()
|
||||||
err := command.Run()
|
out, err := command.CombinedOutput()
|
||||||
t.Log(outB.String())
|
|
||||||
assert.ErrorContains(t, err, "exit status 2")
|
|
||||||
|
|
||||||
// the plugin does not get signalled, but it does get it's
|
var exitError *exec.ExitError
|
||||||
// context cancelled by the CLI through the socket
|
assert.Assert(t, errors.As(err, &exitError))
|
||||||
assert.Equal(t, outB.String(), "context cancelled\n")
|
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
|
||||||
|
const expected = "test-socket: exiting after context was done\nexit status 2"
|
||||||
|
actual := strings.TrimSpace(string(out))
|
||||||
|
assert.Check(t, is.Equal(actual, expected))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
|
t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
|
||||||
|
@ -223,13 +232,18 @@ func TestPluginSocketCommunication(t *testing.T) {
|
||||||
err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
|
||||||
assert.NilError(t, err, "failed to signal CLI process§")
|
assert.NilError(t, err, "failed to signal CLI process§")
|
||||||
}()
|
}()
|
||||||
bytes, err := command.CombinedOutput()
|
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 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
|
// 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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue