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 }