carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package instanceexec
4
5import (
6 "bufio"
7 "errors"
8 "fmt"
9 "io"
10 "net"
11 "os"
12 "os/exec"
13 "os/signal"
14 "strconv"
15 "strings"
16 "syscall"
17 "testing"
18 "time"
19
20 "golang.org/x/sys/unix"
21)
22
23// The init tests run the real runInit in a child process: this test
24// binary, re-executed with ieHelperEnv naming a role. The "init" role
25// runs runInit with the binary itself as the tenant, in the tenant role
26// named by ieTenantEnv; every other role is a tenant.
27const (
28 ieHelperEnv = "CARLOS_INSTANCEEXEC_HELPER"
29 ieTenantEnv = "CARLOS_INSTANCEEXEC_TENANT"
30 ieScopeEnv = "CARLOS_INSTANCEEXEC_SCOPE"
31 ieArgEnv = "CARLOS_INSTANCEEXEC_ARG"
32)
33
34func TestMain(m *testing.M) {
35 if role := os.Getenv(ieHelperEnv); role != "" {
36 os.Exit(runHelper(role))
37 }
38 os.Exit(m.Run())
39}
40
41// withRole is the environment with the helper role replaced, not
42// appended: a Go child keeps the FIRST of duplicate keys (design §6.2).
43func withRole(role string) []string {
44 env := []string{ieHelperEnv + "=" + role}
45 for _, kv := range os.Environ() {
46 if !strings.HasPrefix(kv, ieHelperEnv+"=") {
47 env = append(env, kv)
48 }
49 }
50 return env
51}
52
53func runHelper(role string) int {
54 self, err := os.Executable()
55 if err != nil {
56 fmt.Println("executable:", err)
57 return 70
58 }
59 switch role {
60 case "init":
61 env := withRole(os.Getenv(ieTenantEnv))
62 scope := func() error { return nil }
63 if os.Getenv(ieScopeEnv) == "1" {
64 scope = ScopeAbstractUnix
65 }
66 err := runInit(self, []string{self}, env, scope)
67 fmt.Println("init:", err)
68 return 71
69 case "no-handler":
70 // A tenant that never asks for SIGTERM, like most Go servers.
71 fmt.Printf("ready pid=%d ppid=%d\n", os.Getpid(), os.Getppid())
72 time.Sleep(30 * time.Second)
73 return 0
74 case "echo-signals":
75 // Reports every forwarded signal; leaves on SIGTERM.
76 got := make(chan os.Signal, 8)
77 signal.Notify(got, forwarded[:]...)
78 fmt.Println("ready")
79 for s := range got {
80 fmt.Println("got", s)
81 if s == syscall.SIGTERM {
82 return 0
83 }
84 }
85 return 0
86 case "exit":
87 n, _ := strconv.Atoi(os.Getenv(ieArgEnv))
88 return n
89 case "kill-self":
90 _ = syscall.Kill(os.Getpid(), syscall.SIGKILL)
91 time.Sleep(30 * time.Second)
92 return 0
93 case "orphan":
94 // Leave a grandchild behind: the intermediate starts it and exits
95 // at once, so it is reparented to the nearest reaper.
96 cmd := exec.Command(self)
97 cmd.Env = withRole("intermediate")
98 cmd.Stdout = os.Stdout
99 if err := cmd.Run(); err != nil {
100 fmt.Println("intermediate:", err)
101 return 72
102 }
103 time.Sleep(30 * time.Second)
104 return 0
105 case "intermediate":
106 cmd := exec.Command(self)
107 cmd.Env = withRole("short-lived")
108 if err := cmd.Start(); err != nil {
109 fmt.Println("grandchild:", err)
110 return 73
111 }
112 fmt.Printf("grandchild %d\n", cmd.Process.Pid)
113 return 0
114 case "short-lived":
115 time.Sleep(1500 * time.Millisecond)
116 return 0
117 case "inherit":
118 cwd, _ := os.Getwd()
119 sid, _ := unix.Getsid(0)
120 fmt.Printf("cwd=%s arg=%s sid-is-pid=%t\n", cwd, os.Getenv(ieArgEnv), sid == os.Getpid())
121 f := os.NewFile(3, "fd3")
122 if _, err := f.Write([]byte("fd3 reached\n")); err != nil {
123 fmt.Println("fd3:", err)
124 }
125 return 0
126 case "dial":
127 c, err := net.Dial("unix", os.Getenv(ieArgEnv))
128 switch {
129 case err == nil:
130 c.Close()
131 fmt.Println("dial: connected")
132 case errors.Is(unwrapErrno(err), syscall.EPERM):
133 fmt.Println("dial: EPERM")
134 default:
135 fmt.Println("dial:", err)
136 }
137 return 0
138 }
139 fmt.Println("unknown helper role", role)
140 return 74
141}
142
143// startInit starts the init helper with the given tenant role and extra
144// environment, and returns it with a line reader on its stdout (which is
145// the tenant's stdout too: the init passes fd 1 through).
146func startInit(t *testing.T, tenant string, attr *syscall.SysProcAttr, extra ...string) (*exec.Cmd, *bufio.Reader) {
147 t.Helper()
148 self, err := os.Executable()
149 if err != nil {
150 t.Fatal(err)
151 }
152 cmd := exec.Command(self)
153 cmd.Env = append(os.Environ(), append([]string{ieHelperEnv + "=init", ieTenantEnv + "=" + tenant}, extra...)...)
154 cmd.Stderr = os.Stderr
155 cmd.SysProcAttr = attr
156 out, err := cmd.StdoutPipe()
157 if err != nil {
158 t.Fatal(err)
159 }
160 if err := cmd.Start(); err != nil {
161 if attr != nil {
162 t.Skipf("no unprivileged user and PID namespace here: %v", err)
163 }
164 t.Fatal(err)
165 }
166 t.Cleanup(func() {
167 // Through the init first, so the tenant stops with it outside a
168 // PID namespace too; the SIGKILL is for a test that failed early.
169 _ = cmd.Process.Signal(syscall.SIGTERM)
170 time.Sleep(100 * time.Millisecond)
171 _ = cmd.Process.Kill()
172 _ = cmd.Wait()
173 })
174 return cmd, bufio.NewReader(out)
175}
176
177// readLine reads one line within d, failing the test on a timeout.
178func readLine(t *testing.T, r *bufio.Reader, d time.Duration) string {
179 t.Helper()
180 got := make(chan string, 1)
181 go func() {
182 line, err := r.ReadString('\n')
183 if err != nil && line == "" {
184 line = "read: " + err.Error()
185 }
186 got <- strings.TrimSpace(line)
187 }()
188 select {
189 case line := <-got:
190 return line
191 case <-time.After(d):
192 t.Fatalf("no line from the helper within %v", d)
193 return ""
194 }
195}
196
197// waitExit waits up to d for cmd to exit and returns its exit code, or -1
198// with the signal if it died by one.
199func waitExit(t *testing.T, cmd *exec.Cmd, d time.Duration) (int, syscall.Signal) {
200 t.Helper()
201 done := make(chan error, 1)
202 go func() { done <- cmd.Wait() }()
203 select {
204 case <-done:
205 case <-time.After(d):
206 t.Fatalf("helper still running %v after the signal", d)
207 }
208 ws := cmd.ProcessState.Sys().(syscall.WaitStatus)
209 if ws.Signaled() {
210 return -1, ws.Signal()
211 }
212 return ws.ExitStatus(), 0
213}
214
215func TestInitExitStatusMapping(t *testing.T) {
216 for _, c := range []struct {
217 ws syscall.WaitStatus
218 want int
219 }{
220 {0, 0},
221 {1 << 8, 1},
222 {2 << 8, 2},
223 {255 << 8, 255},
224 {syscall.WaitStatus(syscall.SIGTERM), 143},
225 {syscall.WaitStatus(syscall.SIGKILL), 137},
226 {syscall.WaitStatus(syscall.SIGINT), 130},
227 {syscall.WaitStatus(syscall.SIGSEGV) | 0x80, 139}, // core dumped
228 } {
229 if got := initExitCode(c.ws); got != c.want {
230 t.Errorf("initExitCode(%#x) = %d, want %d", uint32(c.ws), got, c.want)
231 }
232 }
233}
234
235// End to end through the init, outside any namespace: the tenant's own
236// exit code passes through, and a death by signal is 128+n.
237func TestInitExitsWithTheTenantsStatus(t *testing.T) {
238 cmd, _ := startInit(t, "exit", nil, ieArgEnv+"=7")
239 if code, sig := waitExit(t, cmd, 10*time.Second); code != 7 {
240 t.Errorf("init exit = %d (signal %v), want 7", code, sig)
241 }
242 cmd, _ = startInit(t, "kill-self", nil)
243 if code, sig := waitExit(t, cmd, 10*time.Second); code != 128+int(syscall.SIGKILL) {
244 t.Errorf("init exit = %d (signal %v), want 137", code, sig)
245 }
246}
247
248// Every forwarded signal reaches the tenant.
249func TestInitForwardsEachSignal(t *testing.T) {
250 cmd, out := startInit(t, "echo-signals", nil)
251 if line := readLine(t, out, 10*time.Second); line != "ready" {
252 t.Fatalf("tenant said %q", line)
253 }
254 // SIGTERM last: the tenant leaves on it.
255 for _, sig := range []syscall.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGTERM} {
256 if err := cmd.Process.Signal(sig); err != nil {
257 t.Fatal(err)
258 }
259 if line := readLine(t, out, 5*time.Second); line != "got "+sig.String() {
260 t.Errorf("sent %v, tenant said %q", sig, line)
261 }
262 }
263 if code, sig := waitExit(t, cmd, 5*time.Second); code != 0 {
264 t.Errorf("init exit = %d (signal %v), want the tenant's 0", code, sig)
265 }
266}
267
268// A tenant with no handlers acts on a forwarded signal by its default
269// disposition, as the main process did before PrivatePIDs: it dies by
270// the signal, and the init reports 128+n.
271func TestInitStopsATenantWithNoHandler(t *testing.T) {
272 for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP} {
273 t.Run(sig.String(), func(t *testing.T) {
274 cmd, out := startInit(t, "no-handler", nil)
275 if line := readLine(t, out, 10*time.Second); !strings.HasPrefix(line, "ready ") {
276 t.Fatalf("tenant said %q", line)
277 }
278 if err := cmd.Process.Signal(sig); err != nil {
279 t.Fatal(err)
280 }
281 if code, got := waitExit(t, cmd, 5*time.Second); code != 128+int(sig) {
282 t.Errorf("init exit = %d (signal %v), want %d", code, got, 128+int(sig))
283 }
284 })
285 }
286}
287
288// The tenant gets the init's cwd, the launch env, a session of its own
289// (as the unit's main process had) and any inherited fd beyond stdio.
290func TestInitPassesTheTenantItsEnvironment(t *testing.T) {
291 r, w, err := os.Pipe()
292 if err != nil {
293 t.Fatal(err)
294 }
295 defer r.Close()
296 self, _ := os.Executable()
297 cmd := exec.Command(self)
298 cmd.Dir = t.TempDir()
299 cmd.Env = append(os.Environ(), ieHelperEnv+"=init", ieTenantEnv+"=inherit", ieArgEnv+"=launch-value")
300 cmd.ExtraFiles = []*os.File{w}
301 var stdout strings.Builder
302 cmd.Stdout = &stdout
303 cmd.Stderr = os.Stderr
304 if err := cmd.Run(); err != nil {
305 t.Fatalf("init: %v; output %q", err, stdout.String())
306 }
307 w.Close()
308 want := fmt.Sprintf("cwd=%s arg=launch-value sid-is-pid=true", cmd.Dir)
309 if got := strings.TrimSpace(stdout.String()); got != want {
310 t.Errorf("tenant said %q, want %q", got, want)
311 }
312 b, _ := io.ReadAll(r)
313 if string(b) != "fd3 reached\n" {
314 t.Errorf("fd 3 carried %q", b)
315 }
316}
317
318// A process orphaned inside the namespace is the init's to reap. The
319// grandchild must be reparented to the init (it is a subreaper, and PID 1
320// in production) and must not stay a zombie after it exits.
321func TestInitReapsOrphans(t *testing.T) {
322 cmd, out := startInit(t, "orphan", nil)
323 line := readLine(t, out, 10*time.Second)
324 var gpid int
325 if _, err := fmt.Sscanf(line, "grandchild %d", &gpid); err != nil {
326 t.Fatalf("tenant said %q", line)
327 }
328 initPid := cmd.Process.Pid
329 adopted := false
330 deadline := time.Now().Add(10 * time.Second)
331 for time.Now().Before(deadline) {
332 state, ppid, ok := procStat(gpid)
333 if !ok {
334 break // reaped
335 }
336 if ppid == initPid {
337 adopted = true
338 }
339 if state == "Z" && time.Now().After(deadline.Add(-7*time.Second)) {
340 t.Fatalf("grandchild %d is a zombie of %d and nobody reaps it", gpid, ppid)
341 }
342 time.Sleep(20 * time.Millisecond)
343 }
344 if _, _, ok := procStat(gpid); ok {
345 t.Fatalf("grandchild %d still present", gpid)
346 }
347 if !adopted {
348 t.Fatalf("grandchild %d was never the init's (%d) child", gpid, initPid)
349 }
350 // The tenant itself is still running: reaping an orphan is not the
351 // tenant's exit.
352 if err := cmd.Process.Signal(syscall.Signal(0)); err != nil {
353 t.Fatalf("init gone after reaping an orphan: %v", err)
354 }
355}
356
357// procStat returns the state and parent of pid from /proc/<pid>/stat.
358func procStat(pid int) (state string, ppid int, ok bool) {
359 b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
360 if err != nil {
361 return "", 0, false
362 }
363 s := string(b)
364 i := strings.LastIndexByte(s, ')')
365 if i < 0 {
366 return "", 0, false
367 }
368 f := strings.Fields(s[i+1:])
369 if len(f) < 2 {
370 return "", 0, false
371 }
372 ppid, _ = strconv.Atoi(f[1])
373 return f[0], ppid, true
374}
375
376// The Landlock scope applied by the init holds in the tenant: the scope is
377// on the thread that forks, and a domain is inherited across fork and
378// exec. The unscoped run is the control, proving the dial can succeed.
379func TestInitTenantKeepsTheAbstractSocketScope(t *testing.T) {
380 abi, _, e := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, 0, 0, unix.LANDLOCK_CREATE_RULESET_VERSION)
381 if e != 0 || abi < landlockScopeABI {
382 t.Skipf("no Landlock scope on this kernel (abi %d, %v)", abi, e)
383 }
384 name := "@carlos-init-test-" + randomName()
385 ln, err := net.Listen("unix", name)
386 if err != nil {
387 t.Skipf("abstract listen: %v", err)
388 }
389 defer ln.Close()
390 go func() {
391 for {
392 c, err := ln.Accept()
393 if err != nil {
394 return
395 }
396 c.Close()
397 }
398 }()
399 for scope, want := range map[string]string{"0": "dial: connected", "1": "dial: EPERM"} {
400 cmd, out := startInit(t, "dial", nil, ieArgEnv+"="+name, ieScopeEnv+"="+scope)
401 if got := readLine(t, out, 10*time.Second); got != want {
402 t.Errorf("scope=%s: tenant said %q, want %q", scope, got, want)
403 }
404 if code, sig := waitExit(t, cmd, 10*time.Second); code != 0 {
405 t.Errorf("scope=%s: init exit = %d (signal %v)", scope, code, sig)
406 }
407 }
408}
409
410// The finding itself (#126, dev fleet): in a PID namespace, as under
411// PrivatePIDs=yes, the first process is init and has no default signal
412// actions. A Go tenant with no SIGTERM handler in that slot does not die
413// by SIGTERM. Through the init it is the init's child and does, promptly. Needs an
414// unprivileged user namespace; skipped where there is none.
415func TestInitStopsATenantWithNoHandlerInAPIDNamespace(t *testing.T) {
416 userPIDNS := func() *syscall.SysProcAttr {
417 return &syscall.SysProcAttr{
418 Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWPID,
419 UidMappings: []syscall.SysProcIDMap{{ContainerID: os.Getuid(), HostID: os.Getuid(), Size: 1}},
420 GidMappings: []syscall.SysProcIDMap{{ContainerID: os.Getgid(), HostID: os.Getgid(), Size: 1}},
421 }
422 }
423
424 t.Run("tenant as PID 1", func(t *testing.T) {
425 self, _ := os.Executable()
426 cmd := exec.Command(self)
427 cmd.Env = append(os.Environ(), ieHelperEnv+"=no-handler")
428 cmd.SysProcAttr = userPIDNS()
429 cmd.Stderr = io.Discard // the runtime's own complaint, if any
430 out, err := cmd.StdoutPipe()
431 if err != nil {
432 t.Fatal(err)
433 }
434 if err := cmd.Start(); err != nil {
435 t.Skipf("no unprivileged user and PID namespace here: %v", err)
436 }
437 defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
438 if line := readLine(t, bufio.NewReader(out), 10*time.Second); !strings.HasPrefix(line, "ready pid=1 ") {
439 t.Fatalf("tenant said %q, want it at PID 1", line)
440 }
441 _ = cmd.Process.Signal(syscall.SIGTERM)
442 done := make(chan struct{})
443 go func() { _ = cmd.Wait(); close(done) }()
444 select {
445 case <-done:
446 ws := cmd.ProcessState.Sys().(syscall.WaitStatus)
447 if ws.Signaled() && ws.Signal() == syscall.SIGTERM {
448 t.Skip("this kernel delivered SIGTERM's default action to a namespace init")
449 }
450 t.Logf("as PID 1 the tenant did not die by SIGTERM: %v (the bug the init fixes)", cmd.ProcessState)
451 case <-time.After(2 * time.Second):
452 t.Logf("as PID 1 the tenant ignored SIGTERM (the bug the init fixes)")
453 }
454 })
455
456 t.Run("through the init", func(t *testing.T) {
457 cmd, out := startInit(t, "no-handler", userPIDNS())
458 // Not PID 2 as such: the init's own threads take PIDs in the
459 // namespace too. What matters is that the tenant is not PID 1.
460 line := readLine(t, out, 10*time.Second)
461 var pid, ppid int
462 if _, err := fmt.Sscanf(line, "ready pid=%d ppid=%d", &pid, &ppid); err != nil || pid == 1 || ppid != 1 {
463 t.Fatalf("tenant said %q, want a child of the init at PID 1", line)
464 }
465 start := time.Now()
466 if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
467 t.Fatal(err)
468 }
469 code, sig := waitExit(t, cmd, 5*time.Second)
470 if code != 128+int(syscall.SIGTERM) {
471 t.Errorf("init exit = %d (signal %v), want 143", code, sig)
472 }
473 t.Logf("stopped in %v", time.Since(start))
474 })
475}
476