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