github.com/khulnasoft/cli@v0.0.0-20240402070845-01bcad7beefa/e2e/cli-plugins/socket_test.go (about)

     1  package cliplugins
     2  
     3  import (
     4  	"bytes"
     5  	"io"
     6  	"os/exec"
     7  	"strings"
     8  	"syscall"
     9  	"testing"
    10  	"time"
    11  
    12  	"github.com/creack/pty"
    13  	"gotest.tools/v3/assert"
    14  )
    15  
    16  // TestPluginSocketBackwardsCompatible executes a plugin binary
    17  // that does not connect to the CLI plugin socket, simulating
    18  // a plugin compiled against an older version of the CLI, and
    19  // ensures that backwards compatibility is maintained.
    20  func TestPluginSocketBackwardsCompatible(t *testing.T) {
    21  	run, _, cleanup := prepare(t)
    22  	defer cleanup()
    23  
    24  	t.Run("attached", func(t *testing.T) {
    25  		t.Run("the plugin gets signalled if attached to a TTY", func(t *testing.T) {
    26  			cmd := run("presocket", "test-no-socket")
    27  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
    28  
    29  			ptmx, err := pty.Start(command)
    30  			assert.NilError(t, err, "failed to launch command with fake TTY")
    31  
    32  			// send a SIGINT to the process group after 1 second, since
    33  			// we're simulating an "attached TTY" scenario and a TTY would
    34  			// send a signal to the process group
    35  			go func() {
    36  				<-time.After(time.Second)
    37  				err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
    38  				assert.NilError(t, err, "failed to signal process group")
    39  			}()
    40  			bytes, err := io.ReadAll(ptmx)
    41  			if err != nil && !strings.Contains(err.Error(), "input/output error") {
    42  				t.Fatal("failed to get command output")
    43  			}
    44  
    45  			// the plugin is attached to the TTY, so the parent process
    46  			// ignores the received signal, and the plugin receives a SIGINT
    47  			// as well
    48  			assert.Equal(t, string(bytes), "received SIGINT\r\nexit after 3 seconds\r\n")
    49  		})
    50  
    51  		// ensure that we don't break plugins that attempt to read from the TTY
    52  		// (see: https://github.com/moby/moby/issues/47073)
    53  		// (remove me if/when we decide to break compatibility here)
    54  		t.Run("the plugin can read from the TTY", func(t *testing.T) {
    55  			cmd := run("presocket", "tty")
    56  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
    57  
    58  			ptmx, err := pty.Start(command)
    59  			assert.NilError(t, err, "failed to launch command with fake TTY")
    60  			_, _ = ptmx.Write([]byte("hello!"))
    61  
    62  			done := make(chan error)
    63  			go func() {
    64  				<-time.After(time.Second)
    65  				_, err := io.ReadAll(ptmx)
    66  				done <- err
    67  			}()
    68  
    69  			select {
    70  			case cmdErr := <-done:
    71  				if cmdErr != nil && !strings.Contains(cmdErr.Error(), "input/output error") {
    72  					t.Fatal("failed to get command output")
    73  				}
    74  			case <-time.After(5 * time.Second):
    75  				t.Fatal("timed out! plugin process probably stuck")
    76  			}
    77  		})
    78  	})
    79  
    80  	t.Run("detached", func(t *testing.T) {
    81  		t.Run("the plugin does not get signalled", func(t *testing.T) {
    82  			cmd := run("presocket", "test-no-socket")
    83  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
    84  			t.Log(strings.Join(command.Args, " "))
    85  			command.SysProcAttr = &syscall.SysProcAttr{
    86  				Setpgid: true,
    87  			}
    88  
    89  			go func() {
    90  				<-time.After(time.Second)
    91  				// we're signalling the parent process directly and not
    92  				// the process group, since we're testing the case where
    93  				// the process is detached and not simulating a CTRL-C
    94  				// from a TTY
    95  				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
    96  				assert.NilError(t, err, "failed to signal process group")
    97  			}()
    98  			bytes, err := command.CombinedOutput()
    99  			t.Log("command output: " + string(bytes))
   100  			assert.NilError(t, err, "failed to run command")
   101  
   102  			// the plugin process does not receive a SIGINT
   103  			// so it exits after 3 seconds and prints this message
   104  			assert.Equal(t, string(bytes), "exit after 3 seconds\n")
   105  		})
   106  
   107  		t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
   108  			cmd := run("presocket", "test-no-socket")
   109  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
   110  			t.Log(strings.Join(command.Args, " "))
   111  			command.SysProcAttr = &syscall.SysProcAttr{
   112  				Setpgid: true,
   113  			}
   114  
   115  			go func() {
   116  				<-time.After(time.Second)
   117  				// we're signalling the parent process directly and not
   118  				// the process group, since we're testing the case where
   119  				// the process is detached and not simulating a CTRL-C
   120  				// from a TTY
   121  				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
   122  				assert.NilError(t, err, "failed to signal process group")
   123  				// TODO: look into CLI signal handling, it's currently necessary
   124  				// to add a short delay between each signal in order for the CLI
   125  				// process to consistently pick them all up.
   126  				time.Sleep(50 * time.Millisecond)
   127  				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
   128  				assert.NilError(t, err, "failed to signal process group")
   129  				time.Sleep(50 * time.Millisecond)
   130  				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
   131  				assert.NilError(t, err, "failed to signal process group")
   132  			}()
   133  			bytes, err := command.CombinedOutput()
   134  			assert.ErrorContains(t, err, "exit status 1")
   135  
   136  			// the plugin process does not receive a SIGINT and does
   137  			// the CLI cannot cancel it over the socket, so it kills
   138  			// the plugin process and forcefully exits
   139  			assert.Equal(t, string(bytes), "got 3 SIGTERM/SIGINTs, forcefully exiting\n")
   140  		})
   141  	})
   142  }
   143  
   144  func TestPluginSocketCommunication(t *testing.T) {
   145  	run, _, cleanup := prepare(t)
   146  	defer cleanup()
   147  
   148  	t.Run("attached", func(t *testing.T) {
   149  		t.Run("the socket is not closed + the plugin receives a signal due to pgid", func(t *testing.T) {
   150  			cmd := run("presocket", "test-socket")
   151  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
   152  
   153  			ptmx, err := pty.Start(command)
   154  			assert.NilError(t, err, "failed to launch command with fake TTY")
   155  
   156  			// send a SIGINT to the process group after 1 second, since
   157  			// we're simulating an "attached TTY" scenario and a TTY would
   158  			// send a signal to the process group
   159  			go func() {
   160  				<-time.After(time.Second)
   161  				err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
   162  				assert.NilError(t, err, "failed to signal process group")
   163  			}()
   164  			bytes, err := io.ReadAll(ptmx)
   165  			if err != nil && !strings.Contains(err.Error(), "input/output error") {
   166  				t.Fatal("failed to get command output")
   167  			}
   168  
   169  			// the plugin is attached to the TTY, so the parent process
   170  			// ignores the received signal, and the plugin receives a SIGINT
   171  			// as well
   172  			assert.Equal(t, string(bytes), "received SIGINT\r\nexit after 3 seconds\r\n")
   173  		})
   174  	})
   175  
   176  	t.Run("detached", func(t *testing.T) {
   177  		t.Run("the plugin does not get signalled", func(t *testing.T) {
   178  			cmd := run("presocket", "test-socket")
   179  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
   180  			outB := bytes.Buffer{}
   181  			command.Stdout = &outB
   182  			command.Stderr = &outB
   183  			command.SysProcAttr = &syscall.SysProcAttr{
   184  				Setpgid: true,
   185  			}
   186  
   187  			// send a SIGINT to the process group after 1 second
   188  			go func() {
   189  				<-time.After(time.Second)
   190  				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
   191  				assert.NilError(t, err, "failed to signal CLI process")
   192  			}()
   193  			err := command.Run()
   194  			t.Log(outB.String())
   195  			assert.ErrorContains(t, err, "exit status 2")
   196  
   197  			// the plugin does not get signalled, but it does get it's
   198  			// context cancelled by the CLI through the socket
   199  			assert.Equal(t, outB.String(), "context cancelled\n")
   200  		})
   201  
   202  		t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
   203  			cmd := run("presocket", "test-socket-ignore-context")
   204  			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
   205  			command.SysProcAttr = &syscall.SysProcAttr{
   206  				Setpgid: true,
   207  			}
   208  
   209  			go func() {
   210  				<-time.After(time.Second)
   211  				// we're signalling the parent process directly and not
   212  				// the process group, since we're testing the case where
   213  				// the process is detached and not simulating a CTRL-C
   214  				// from a TTY
   215  				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
   216  				assert.NilError(t, err, "failed to signal CLI process")
   217  				// TODO: same as above TODO, CLI signal handling is not consistent
   218  				// with multiple signals without intervals
   219  				time.Sleep(50 * time.Millisecond)
   220  				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
   221  				assert.NilError(t, err, "failed to signal CLI process")
   222  				time.Sleep(50 * time.Millisecond)
   223  				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
   224  				assert.NilError(t, err, "failed to signal CLI processĀ§")
   225  			}()
   226  			bytes, err := command.CombinedOutput()
   227  			assert.ErrorContains(t, err, "exit status 1")
   228  
   229  			// the plugin process does not receive a SIGINT and does
   230  			// not exit after having it's context cancelled, so the CLI
   231  			// kills the plugin process and forcefully exits
   232  			assert.Equal(t, string(bytes), "got 3 SIGTERM/SIGINTs, forcefully exiting\n")
   233  		})
   234  	})
   235  }