| package cliplugins |
| |
| import ( |
| "errors" |
| "io" |
| "os/exec" |
| "strings" |
| "syscall" |
| "testing" |
| "time" |
| |
| "github.com/creack/pty" |
| "gotest.tools/v3/assert" |
| is "gotest.tools/v3/assert/cmp" |
| ) |
| |
| // TestPluginSocketBackwardsCompatible executes a plugin binary |
| // that does not connect to the CLI plugin socket, simulating |
| // a plugin compiled against an older version of the CLI, and |
| // ensures that backwards compatibility is maintained. |
| func TestPluginSocketBackwardsCompatible(t *testing.T) { |
| run, _, cleanup := prepare(t) |
| defer cleanup() |
| |
| t.Run("attached", func(t *testing.T) { |
| t.Run("the plugin gets signalled if attached to a TTY", func(t *testing.T) { |
| cmd := run("presocket", "test-no-socket") |
| command := exec.Command(cmd.Command[0], cmd.Command[1:]...) |
| |
| ptmx, err := pty.Start(command) |
| assert.NilError(t, err, "failed to launch command with fake TTY") |
| |
| // send a SIGINT to the process group after 1 second, since |
| // we're simulating an "attached TTY" scenario and a TTY would |
| // send a signal to the process group |
| go func() { |
| <-time.After(time.Second) |
| err := syscall.Kill(-command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal process group") |
| }() |
| out, err := io.ReadAll(ptmx) |
| if err != nil && !strings.Contains(err.Error(), "input/output error") { |
| t.Fatal("failed to get command output") |
| } |
| |
| // 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(out), "received SIGINT\r\nexit after 3 seconds\r\n") |
| }) |
| |
| // ensure that we don't break plugins that attempt to read from the TTY |
| // (see: https://github.com/moby/moby/issues/47073) |
| // (remove me if/when we decide to break compatibility here) |
| t.Run("the plugin can read from the TTY", func(t *testing.T) { |
| cmd := run("presocket", "tty") |
| command := exec.Command(cmd.Command[0], cmd.Command[1:]...) |
| |
| ptmx, err := pty.Start(command) |
| assert.NilError(t, err, "failed to launch command with fake TTY") |
| _, _ = ptmx.WriteString("hello!") |
| |
| done := make(chan error) |
| go func() { |
| <-time.After(time.Second) |
| _, err := io.ReadAll(ptmx) |
| done <- err |
| }() |
| |
| select { |
| case cmdErr := <-done: |
| if cmdErr != nil && !strings.Contains(cmdErr.Error(), "input/output error") { |
| t.Fatal("failed to get command output") |
| } |
| case <-time.After(5 * time.Second): |
| t.Fatal("timed out! plugin process probably stuck") |
| } |
| }) |
| }) |
| |
| t.Run("detached", func(t *testing.T) { |
| t.Run("the plugin does not get signalled", func(t *testing.T) { |
| cmd := run("presocket", "test-no-socket") |
| command := exec.Command(cmd.Command[0], cmd.Command[1:]...) |
| t.Log(strings.Join(command.Args, " ")) |
| command.SysProcAttr = &syscall.SysProcAttr{ |
| Setpgid: true, |
| } |
| |
| go func() { |
| <-time.After(time.Second) |
| // we're signalling the parent process directly and not |
| // the process group, since we're testing the case where |
| // the process is detached and not simulating a CTRL-C |
| // from a TTY |
| err := syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal process group") |
| }() |
| 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(out), "exit after 3 seconds\n") |
| }) |
| |
| t.Run("the main CLI exits after 3 signals", func(t *testing.T) { |
| cmd := run("presocket", "test-no-socket") |
| command := exec.Command(cmd.Command[0], cmd.Command[1:]...) |
| t.Log(strings.Join(command.Args, " ")) |
| command.SysProcAttr = &syscall.SysProcAttr{ |
| Setpgid: true, |
| } |
| |
| go func() { |
| <-time.After(time.Second) |
| // we're signalling the parent process directly and not |
| // the process group, since we're testing the case where |
| // the process is detached and not simulating a CTRL-C |
| // from a TTY |
| err := syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal process group") |
| // TODO: look into CLI signal handling, it's currently necessary |
| // to add a short delay between each signal in order for the CLI |
| // process to consistently pick them all up. |
| time.Sleep(50 * time.Millisecond) |
| err = syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal process group") |
| time.Sleep(50 * time.Millisecond) |
| err = syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal process group") |
| }() |
| out, err := command.CombinedOutput() |
| |
| var exitError *exec.ExitError |
| assert.Assert(t, errors.As(err, &exitError)) |
| assert.Check(t, exitError.Exited()) |
| assert.Check(t, is.Equal(exitError.ExitCode(), 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(out), "got 3 SIGTERM/SIGINTs, forcefully exiting\n") |
| }) |
| }) |
| } |
| |
| func TestPluginSocketCommunication(t *testing.T) { |
| run, _, cleanup := prepare(t) |
| defer cleanup() |
| |
| t.Run("attached", func(t *testing.T) { |
| t.Run("the socket is not closed + the plugin receives a signal due to pgid", func(t *testing.T) { |
| cmd := run("presocket", "test-socket") |
| command := exec.Command(cmd.Command[0], cmd.Command[1:]...) |
| |
| ptmx, err := pty.Start(command) |
| assert.NilError(t, err, "failed to launch command with fake TTY") |
| |
| // send a SIGINT to the process group after 1 second, since |
| // we're simulating an "attached TTY" scenario and a TTY would |
| // send a signal to the process group |
| go func() { |
| <-time.After(time.Second) |
| err := syscall.Kill(-command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal process group") |
| }() |
| out, err := io.ReadAll(ptmx) |
| if err != nil && !strings.Contains(err.Error(), "input/output error") { |
| t.Fatal("failed to get command output") |
| } |
| |
| // 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(out), "received SIGINT\r\nexit after 3 seconds\r\n") |
| }) |
| }) |
| |
| t.Run("detached", func(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:]...) |
| command.SysProcAttr = &syscall.SysProcAttr{ |
| Setpgid: true, |
| } |
| |
| // send a SIGINT to the process group after 1 second |
| go func() { |
| <-time.After(time.Second) |
| err := syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal CLI process") |
| }() |
| out, err := command.CombinedOutput() |
| |
| var exitError *exec.ExitError |
| assert.Assert(t, errors.As(err, &exitError)) |
| assert.Check(t, exitError.Exited()) |
| assert.Check(t, is.Equal(exitError.ExitCode(), 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(string(out)) |
| assert.Check(t, is.Equal(actual, expected)) |
| }) |
| |
| t.Run("the main CLI exits after 3 signals", func(t *testing.T) { |
| cmd := run("presocket", "test-socket-ignore-context") |
| command := exec.Command(cmd.Command[0], cmd.Command[1:]...) |
| command.SysProcAttr = &syscall.SysProcAttr{ |
| Setpgid: true, |
| } |
| |
| go func() { |
| <-time.After(time.Second) |
| // we're signalling the parent process directly and not |
| // the process group, since we're testing the case where |
| // the process is detached and not simulating a CTRL-C |
| // from a TTY |
| err := syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal CLI process") |
| // TODO: same as above TODO, CLI signal handling is not consistent |
| // with multiple signals without intervals |
| time.Sleep(50 * time.Millisecond) |
| err = syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal CLI process") |
| time.Sleep(50 * time.Millisecond) |
| err = syscall.Kill(command.Process.Pid, syscall.SIGINT) |
| assert.NilError(t, err, "failed to signal CLI process§") |
| }() |
| out, err := command.CombinedOutput() |
| |
| var exitError *exec.ExitError |
| assert.Assert(t, errors.As(err, &exitError)) |
| assert.Check(t, exitError.Exited()) |
| assert.Check(t, is.Equal(exitError.ExitCode(), 1)) |
| |
| // the plugin process does not receive a SIGINT and does |
| // not exit after having it's context canceled, so the CLI |
| // kills the plugin process and forcefully exits |
| assert.Equal(t, string(out), "got 3 SIGTERM/SIGINTs, forcefully exiting\n") |
| }) |
| }) |
| } |