github.com/rohankumardubey/nomad@v0.11.8/plugins/drivers/testutils/exec_testing.go (about)

     1  package testutils
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"io/ioutil"
     8  	"os"
     9  	"reflect"
    10  	"regexp"
    11  	"runtime"
    12  	"strings"
    13  	"sync"
    14  	"testing"
    15  	"time"
    16  
    17  	"github.com/hashicorp/nomad/plugins/drivers"
    18  	dproto "github.com/hashicorp/nomad/plugins/drivers/proto"
    19  	"github.com/stretchr/testify/require"
    20  )
    21  
    22  func ExecTaskStreamingConformanceTests(t *testing.T, driver *DriverHarness, taskID string) {
    23  	t.Helper()
    24  
    25  	if runtime.GOOS == "windows" {
    26  		// tests assume unix-ism now
    27  		t.Skip("test assume unix tasks")
    28  	}
    29  
    30  	TestExecTaskStreamingBasicResponses(t, driver, taskID)
    31  	TestExecFSIsolation(t, driver, taskID)
    32  }
    33  
    34  var ExecTaskStreamingBasicCases = []struct {
    35  	Name     string
    36  	Command  string
    37  	Tty      bool
    38  	Stdin    string
    39  	Stdout   interface{}
    40  	Stderr   interface{}
    41  	ExitCode int
    42  }{
    43  	{
    44  		Name:     "notty: basic",
    45  		Command:  "echo hello stdout; echo hello stderr >&2; exit 43",
    46  		Tty:      false,
    47  		Stdout:   "hello stdout\n",
    48  		Stderr:   "hello stderr\n",
    49  		ExitCode: 43,
    50  	},
    51  	{
    52  		Name:     "notty: streaming",
    53  		Command:  "for n in 1 2 3; do echo $n; sleep 1; done",
    54  		Tty:      false,
    55  		Stdout:   "1\n2\n3\n",
    56  		ExitCode: 0,
    57  	},
    58  	{
    59  		Name:     "notty: stty check",
    60  		Command:  "stty size",
    61  		Tty:      false,
    62  		Stderr:   regexp.MustCompile("stty: .?standard input.?: Inappropriate ioctl for device\n"),
    63  		ExitCode: 1,
    64  	},
    65  	{
    66  		Name:     "notty: stdin passing",
    67  		Command:  "echo hello from command; head -n1",
    68  		Tty:      false,
    69  		Stdin:    "hello from stdin\n",
    70  		Stdout:   "hello from command\nhello from stdin\n",
    71  		ExitCode: 0,
    72  	},
    73  	{
    74  		Name:    "notty: children processes",
    75  		Command: "(( sleep 3; echo from background ) & ); echo from main; exec sleep 1",
    76  		Tty:     false,
    77  		// when not using tty; wait for all processes to exit matching behavior of `docker exec`
    78  		Stdout:   "from main\nfrom background\n",
    79  		ExitCode: 0,
    80  	},
    81  
    82  	// TTY cases - difference is new lines add `\r` and child process waiting is different
    83  	{
    84  		Name:     "tty: basic",
    85  		Command:  "echo hello stdout; echo hello stderr >&2; exit 43",
    86  		Tty:      true,
    87  		Stdout:   "hello stdout\r\nhello stderr\r\n",
    88  		ExitCode: 43,
    89  	},
    90  	{
    91  		Name:     "tty: streaming",
    92  		Command:  "for n in 1 2 3; do echo $n; sleep 1; done",
    93  		Tty:      true,
    94  		Stdout:   "1\r\n2\r\n3\r\n",
    95  		ExitCode: 0,
    96  	},
    97  	{
    98  		Name:     "tty: stty check",
    99  		Command:  "sleep 1; stty size",
   100  		Tty:      true,
   101  		Stdout:   "100 100\r\n",
   102  		ExitCode: 0,
   103  	},
   104  	{
   105  		Name:    "tty: stdin passing",
   106  		Command: "head -n1",
   107  		Tty:     true,
   108  		Stdin:   "hello from stdin\n",
   109  		// in tty mode, we emit line twice: once for tty echoing and one for the actual head output
   110  		Stdout:   "hello from stdin\r\nhello from stdin\r\n",
   111  		ExitCode: 0,
   112  	},
   113  	{
   114  		Name:    "tty: children processes",
   115  		Command: "(( sleep 3; echo from background ) & ); echo from main; exec sleep 1",
   116  		Tty:     true,
   117  		// when using tty; wait for lead process only, like `docker exec -it`
   118  		Stdout:   "from main\r\n",
   119  		ExitCode: 0,
   120  	},
   121  }
   122  
   123  func TestExecTaskStreamingBasicResponses(t *testing.T, driver *DriverHarness, taskID string) {
   124  	for _, c := range ExecTaskStreamingBasicCases {
   125  		t.Run("basic: "+c.Name, func(t *testing.T) {
   126  
   127  			result := execTask(t, driver, taskID, c.Command, c.Tty, c.Stdin)
   128  
   129  			require.Equal(t, c.ExitCode, result.exitCode)
   130  
   131  			switch s := c.Stdout.(type) {
   132  			case string:
   133  				require.Equal(t, s, result.stdout)
   134  			case *regexp.Regexp:
   135  				require.Regexp(t, s, result.stdout)
   136  			case nil:
   137  				require.Empty(t, result.stdout)
   138  			default:
   139  				require.Fail(t, "unexpected stdout type", "found %v (%v), but expected string or regexp", s, reflect.TypeOf(s))
   140  			}
   141  
   142  			switch s := c.Stderr.(type) {
   143  			case string:
   144  				require.Equal(t, s, result.stderr)
   145  			case *regexp.Regexp:
   146  				require.Regexp(t, s, result.stderr)
   147  			case nil:
   148  				require.Empty(t, result.stderr)
   149  			default:
   150  				require.Fail(t, "unexpected stderr type", "found %v (%v), but expected string or regexp", s, reflect.TypeOf(s))
   151  			}
   152  
   153  		})
   154  	}
   155  }
   156  
   157  // TestExecFSIsolation asserts that exec occurs inside chroot/isolation environment rather than
   158  // on host
   159  func TestExecFSIsolation(t *testing.T, driver *DriverHarness, taskID string) {
   160  	t.Run("isolation", func(t *testing.T) {
   161  		caps, err := driver.Capabilities()
   162  		require.NoError(t, err)
   163  
   164  		isolated := (caps.FSIsolation != drivers.FSIsolationNone)
   165  
   166  		text := "hello from the other side"
   167  
   168  		// write to a file and check it presence in host
   169  		w := execTask(t, driver, taskID,
   170  			fmt.Sprintf(`FILE=$(mktemp); echo "$FILE"; echo %q >> "${FILE}"`, text),
   171  			false, "")
   172  		require.Zero(t, w.exitCode)
   173  
   174  		tempfile := strings.TrimSpace(w.stdout)
   175  		if !isolated {
   176  			defer os.Remove(tempfile)
   177  		}
   178  
   179  		t.Logf("created file in task: %v", tempfile)
   180  
   181  		// read from host
   182  		b, err := ioutil.ReadFile(tempfile)
   183  		if !isolated {
   184  			require.NoError(t, err)
   185  			require.Equal(t, text, strings.TrimSpace(string(b)))
   186  		} else {
   187  			require.Error(t, err)
   188  			require.True(t, os.IsNotExist(err))
   189  		}
   190  
   191  		// read should succeed from task again
   192  		r := execTask(t, driver, taskID,
   193  			fmt.Sprintf("cat %q", tempfile),
   194  			false, "")
   195  		require.Zero(t, r.exitCode)
   196  		require.Equal(t, text, strings.TrimSpace(r.stdout))
   197  
   198  		// we always run in a cgroup - testing freezer cgroup
   199  		r = execTask(t, driver, taskID,
   200  			fmt.Sprintf("cat /proc/self/cgroup"),
   201  			false, "")
   202  		require.Zero(t, r.exitCode)
   203  
   204  		if !strings.Contains(r.stdout, ":freezer:/nomad") && !strings.Contains(r.stdout, "freezer:/docker") {
   205  			require.Fail(t, "unexpected freezer cgroup", "expected freezer to be /nomad/ or /docker/, but found:\n%s", r.stdout)
   206  
   207  		}
   208  	})
   209  }
   210  
   211  func execTask(t *testing.T, driver *DriverHarness, taskID string, cmd string, tty bool, stdin string) execResult {
   212  	stream := newTestExecStream(t, tty, stdin)
   213  
   214  	ctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second)
   215  	defer cancelFn()
   216  
   217  	command := []string{"/bin/sh", "-c", cmd}
   218  
   219  	isRaw := false
   220  	exitCode := -2
   221  	if raw, ok := driver.impl.(drivers.ExecTaskStreamingRawDriver); ok {
   222  		isRaw = true
   223  		err := raw.ExecTaskStreamingRaw(ctx, taskID,
   224  			command, tty, stream)
   225  		require.NoError(t, err)
   226  	} else if d, ok := driver.impl.(drivers.ExecTaskStreamingDriver); ok {
   227  		execOpts, errCh := drivers.StreamToExecOptions(ctx, command, tty, stream)
   228  
   229  		r, err := d.ExecTaskStreaming(ctx, taskID, execOpts)
   230  		require.NoError(t, err)
   231  
   232  		select {
   233  		case err := <-errCh:
   234  			require.NoError(t, err)
   235  		default:
   236  			// all good
   237  		}
   238  
   239  		exitCode = r.ExitCode
   240  	} else {
   241  		require.Fail(t, "driver does not support exec")
   242  	}
   243  
   244  	result := stream.currentResult()
   245  	require.NoError(t, result.err)
   246  
   247  	if !isRaw {
   248  		result.exitCode = exitCode
   249  	}
   250  
   251  	return result
   252  }
   253  
   254  type execResult struct {
   255  	exitCode int
   256  	stdout   string
   257  	stderr   string
   258  
   259  	err error
   260  }
   261  
   262  func newTestExecStream(t *testing.T, tty bool, stdin string) *testExecStream {
   263  
   264  	return &testExecStream{
   265  		t:      t,
   266  		input:  newInputStream(tty, stdin),
   267  		result: &execResult{exitCode: -2},
   268  	}
   269  }
   270  
   271  func newInputStream(tty bool, stdin string) []*drivers.ExecTaskStreamingRequestMsg {
   272  	input := []*drivers.ExecTaskStreamingRequestMsg{}
   273  	if tty {
   274  		// emit two resize to ensure we honor latest
   275  		input = append(input, &drivers.ExecTaskStreamingRequestMsg{
   276  			TtySize: &dproto.ExecTaskStreamingRequest_TerminalSize{
   277  				Height: 50,
   278  				Width:  40,
   279  			}})
   280  		input = append(input, &drivers.ExecTaskStreamingRequestMsg{
   281  			TtySize: &dproto.ExecTaskStreamingRequest_TerminalSize{
   282  				Height: 100,
   283  				Width:  100,
   284  			}})
   285  
   286  	}
   287  
   288  	input = append(input, &drivers.ExecTaskStreamingRequestMsg{
   289  		Stdin: &dproto.ExecTaskStreamingIOOperation{
   290  			Data: []byte(stdin),
   291  		},
   292  	})
   293  
   294  	if !tty {
   295  		// don't close stream in interactive session and risk closing tty prematurely
   296  		input = append(input, &drivers.ExecTaskStreamingRequestMsg{
   297  			Stdin: &dproto.ExecTaskStreamingIOOperation{
   298  				Close: true,
   299  			},
   300  		})
   301  	}
   302  
   303  	return input
   304  }
   305  
   306  var _ drivers.ExecTaskStream = (*testExecStream)(nil)
   307  
   308  type testExecStream struct {
   309  	t *testing.T
   310  
   311  	// input
   312  	input      []*drivers.ExecTaskStreamingRequestMsg
   313  	recvCalled int
   314  
   315  	// result so far
   316  	resultLock sync.Mutex
   317  	result     *execResult
   318  }
   319  
   320  func (s *testExecStream) currentResult() execResult {
   321  	s.resultLock.Lock()
   322  	defer s.resultLock.Unlock()
   323  
   324  	// make a copy
   325  	return *s.result
   326  }
   327  
   328  func (s *testExecStream) Recv() (*drivers.ExecTaskStreamingRequestMsg, error) {
   329  	if s.recvCalled >= len(s.input) {
   330  		return nil, io.EOF
   331  	}
   332  
   333  	i := s.input[s.recvCalled]
   334  	s.recvCalled++
   335  	return i, nil
   336  }
   337  
   338  func (s *testExecStream) Send(m *drivers.ExecTaskStreamingResponseMsg) error {
   339  	s.resultLock.Lock()
   340  	defer s.resultLock.Unlock()
   341  
   342  	switch {
   343  	case m.Stdout != nil && m.Stdout.Data != nil:
   344  		s.t.Logf("received stdout: %s", string(m.Stdout.Data))
   345  		s.result.stdout += string(m.Stdout.Data)
   346  	case m.Stderr != nil && m.Stderr.Data != nil:
   347  		s.t.Logf("received stderr: %s", string(m.Stderr.Data))
   348  		s.result.stderr += string(m.Stderr.Data)
   349  	case m.Exited && m.Result != nil:
   350  		s.result.exitCode = int(m.Result.ExitCode)
   351  	}
   352  
   353  	return nil
   354  }