Merge branch 'fix-instance-init' into main
Nested instances: a minimal init as PID 1 forwards stop signals and reaps, so apps stop cleanly (#126)
11 files changed,
+646
−24
docs/site/cli.md+6 −3internal/activator/backend_exec.go+2 −2internal/host/units/carlos-instance-seed@.service+3 −0internal/host/units/carlos-instance-sidecar@.service+6 −0internal/host/units/carlos-instance@.service+6 −0internal/host/units_isolation_test.go+9 −2internal/instanceexec/exec.go+5 −4internal/instanceexec/init_linux.go+131 −0internal/instanceexec/init_test.go+475 −0internal/instanceexec/scope_linux.go+2 −12internal/instanceexec/scope_other.go+1 −1
CI passed — run details
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 23fccb7e5..44ec13482 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1920,10 +1920,13 @@ process. Not for operators: there is nothing to type. |
| ### carlos instance-exec |
| The first thing an isolated instance unit runs. It reads the launch file |
| -the agent wrote into the instance's directory and execs the host's live |
| +the agent wrote into the instance's directory and starts the host's live |
| link with exactly the arguments and environment in it, refusing anything |
| -that is not the platform's own live link. `ExecStart=` of the three |
| -instance templates. |
| +that is not the platform's own live link. It then stays on as the |
| +instance's first process: it passes stop signals on to the app, cleans up |
| +exited processes, and exits with the app's status, so an app with no |
| +signal handling still stops cleanly. `ExecStart=` of the three instance |
| +templates. |
| ### carlos instance-exit |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 60fbaa181..7c2caa3ef 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -1186,8 +1186,8 @@ func (b *ExecBackend) instanceEnv(rt registry.Route) (map[string]string, error) |
| // childEnv is the child's complete environment: the agent's own, minus |
| // every CARLOS_* variable, with the instance's file values layered on top. |
| // One entry per key, last value wins: os/exec deduplicates cmd.Env that |
| -// way, but the unit path hands this list to syscall.Exec, which passes |
| -// duplicates through, and a Go child then keeps the FIRST occurrence |
| +// way, but on the unit path the trampoline hands this list to execve as |
| +// is, duplicates and all, and a Go child then keeps the FIRST occurrence |
| // (design §6.2, codex R1-16). Composing it deduplicated here keeps the |
| // two spawn paths identical. |
| func childEnv(fileEnv map[string]string) []string { |
diff --git a/internal/host/units/carlos-instance-seed@.service b/internal/host/units/carlos-instance-seed@.service| index 706bf5723..42facdaae 100644 |
| --- a/internal/host/units/carlos-instance-seed@.service |
| +++ b/internal/host/units/carlos-instance-seed@.service |
| @@ -42,6 +42,9 @@ Slice=carlos-instances-%i.slice |
| # TimeoutStopSec; the activator's stop budget is 20s. |
| KillMode=mixed |
| TimeoutStopSec=20 |
| +# No SuccessExitStatus= here, unlike the run and sidecar templates (#126): |
| +# the trampoline's init reports a seed a signal ended as 128+n, and a seed |
| +# a signal ended has not seeded. |
| # The log stream: stdout and stderr connect to the agent's per-role |
| # listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| # nosymfollow logs mount, at a path no tenant mount contains. |
diff --git a/internal/host/units/carlos-instance-sidecar@.service b/internal/host/units/carlos-instance-sidecar@.service| index 7d34f2f89..18c672e28 100644 |
| --- a/internal/host/units/carlos-instance-sidecar@.service |
| +++ b/internal/host/units/carlos-instance-sidecar@.service |
| @@ -38,6 +38,12 @@ Restart=no |
| # TimeoutStopSec; the activator's stop budget is 20s. |
| KillMode=mixed |
| TimeoutStopSec=20 |
| +# The main process is the trampoline, which stays as the namespace's init |
| +# and runs the app as its child (#126): it forwards the SIGTERM and exits |
| +# 128+n when the app dies by signal n. These are SIGHUP, SIGINT, SIGPIPE |
| +# and SIGTERM, the deaths systemd counts as clean for a main process, so a |
| +# stop still leaves the unit inactive rather than failed. |
| +SuccessExitStatus=129 130 141 143 |
| # The log stream: stdout and stderr connect to the agent's per-role |
| # listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| # nosymfollow logs mount, at a path no tenant mount contains. |
diff --git a/internal/host/units/carlos-instance@.service b/internal/host/units/carlos-instance@.service| index 0f6ca71b8..f979ff7d6 100644 |
| --- a/internal/host/units/carlos-instance@.service |
| +++ b/internal/host/units/carlos-instance@.service |
| @@ -38,6 +38,12 @@ Restart=no |
| # TimeoutStopSec; the activator's stop budget is 20s. |
| KillMode=mixed |
| TimeoutStopSec=20 |
| +# The main process is the trampoline, which stays as the namespace's init |
| +# and runs the app as its child (#126): it forwards the SIGTERM and exits |
| +# 128+n when the app dies by signal n. These are SIGHUP, SIGINT, SIGPIPE |
| +# and SIGTERM, the deaths systemd counts as clean for a main process, so a |
| +# stop still leaves the unit inactive rather than failed. |
| +SuccessExitStatus=129 130 141 143 |
| # The log stream: stdout and stderr connect to the agent's per-role |
| # listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| # nosymfollow logs mount, at a path no tenant mount contains. |
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go| index 527c48f05..9bc3ba317 100644 |
| --- a/internal/host/units_isolation_test.go |
| +++ b/internal/host/units_isolation_test.go |
| @@ -267,12 +267,15 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) { |
| "carlos-instance@.service": {"ExecStart=/opt/carlos/carlos instance-exec run %I", "ExecStopPost=/opt/carlos/carlos instance-exit run %I", |
| "BindPaths=/data/instances/%I /run/carlos/instances/%I", "StandardOutput=file:/run/carlos/logs/%I.run.sock", |
| "Slice=carlos-instances-%i.slice", "Restart=no", "KillMode=mixed", "TimeoutStopSec=20", "BindsTo=carlos-edge.service", |
| - "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple"}, |
| + "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple", |
| + // The trampoline's init exits 128+n for a death by signal n (#126). |
| + "SuccessExitStatus=129 130 141 143"}, |
| "carlos-instance-seed@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=120", "ExecStart=/opt/carlos/carlos instance-exec seed %I", |
| "ExecStopPost=/opt/carlos/carlos instance-exit seed %I", "StandardOutput=file:/run/carlos/logs/%I.seed.sock", |
| "KillMode=mixed", "TimeoutStopSec=20"}, |
| "carlos-instance-sidecar@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec sidecar %I", |
| - "ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I", "StandardOutput=file:/run/carlos/logs/%I.sidecar.sock"}, |
| + "ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I", "StandardOutput=file:/run/carlos/logs/%I.sidecar.sock", |
| + "SuccessExitStatus=129 130 141 143"}, |
| "carlos-instance-probe.service": {"Type=oneshot", "ExecStart=/opt/carlos/carlos instance-probe", |
| "BindPaths=/data/instances/_probe /run/carlos/instances/_probe", "StandardOutput=truncate:/run/carlos-control/probe.out"}, |
| } { |
| @@ -285,6 +288,10 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) { |
| if strings.Contains(u, "ExecStopPost=+") { |
| t.Errorf("%s uses ExecStopPost=+: the plus prefix keeps the unit's tmpfs (codex R3-2)", name) |
| } |
| + // A seed a signal ended has not seeded (#126). |
| + if name == "carlos-instance-seed@.service" && strings.Contains(u, "\nSuccessExitStatus=") { |
| + t.Errorf("%s counts a signal's exit status as success", name) |
| + } |
| if name != "carlos-instance-probe.service" && !strings.Contains(u, "User=carlos") { |
| t.Errorf("%s does not run as carlos", name) |
| } |
diff --git a/internal/instanceexec/exec.go b/internal/instanceexec/exec.go| index ad72521d7..9b2adfb93 100644 |
| --- a/internal/instanceexec/exec.go |
| +++ b/internal/instanceexec/exec.go |
| @@ -51,15 +51,16 @@ func RunExec(args []string) { |
| if !ok { |
| log.Fatalf("instance-exec: role %q is not run, seed or sidecar", fs.Arg(0)) |
| } |
| - // The exec is scoped (#178): see ScopeAbstractUnix. |
| - if err := execLaunch(dataDir(), role, fs.Arg(1), scopedExec); err != nil { |
| + // The tenant is scoped (#178, see ScopeAbstractUnix) and runs under |
| + // the trampoline as its init (#126, see init_linux.go). |
| + if err := execLaunch(dataDir(), role, fs.Arg(1), scopedInit); err != nil { |
| log.Fatalf("instance-exec: %v", err) |
| } |
| } |
| // execLaunch reads launch.<role>.json from the instance directory through |
| -// instancefs (never through a symlink), validates it, and execs. It returns |
| -// only on refusal; a successful exec never returns. |
| +// instancefs (never through a symlink), validates it, and launches. It |
| +// returns only on refusal; a successful launch exits with the tenant. |
| // |
| // The validation is defence in depth: the agent wrote the file, but the |
| // directory is the instance's own, so the instance can rewrite it. What |
diff --git a/internal/instanceexec/init_linux.go b/internal/instanceexec/init_linux.go| new file mode 100644 |
| index 000000000..1d90a8ca5 |
| --- /dev/null |
| +++ b/internal/instanceexec/init_linux.go |
| @@ -0,0 +1,131 @@ |
| +//go:build linux |
| + |
| +package instanceexec |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "os/signal" |
| + "runtime" |
| + "syscall" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// The trampoline stays behind as the namespace's init (#126, found on the |
| +// dev fleet). The templates run with PrivatePIDs=yes, so the unit's main |
| +// process is PID 1 of its own PID namespace, and the kernel gives a |
| +// namespace init no default signal actions: a signal it has no handler |
| +// for is dropped, whoever sends it (only SIGKILL from outside gets |
| +// through). A tenant exec'd into that slot that never asked for SIGTERM, |
| +// which is most Go servers, cannot be stopped by it: Go's runtime tries |
| +// to die by the signal, the kernel drops it, and the runtime exits 2 |
| +// instead; a tenant with no handler at all waits out TimeoutStopSec for |
| +// the SIGKILL. Every hibernate, restart, move and deploy then left the |
| +// unit failed. A flat tenant was never PID 1. |
| +// |
| +// So the trampoline forks the tenant as its child, which is not PID 1 and |
| +// keeps its default actions exactly as on the flat layout, and does the |
| +// two things PID 1 owes the namespace: |
| +// |
| +// - Forward. KillMode=mixed sends the stop's SIGTERM to the main |
| +// process, which is this one; it and the other signals in forwarded |
| +// go on to the tenant, which acts on them as it would have as the |
| +// main process. SIGKILL needs no forwarding: after TimeoutStopSec |
| +// systemd sends it to the whole cgroup, and SIGKILL to a namespace |
| +// init from outside always kills it. |
| +// - Reap. A process whose parent dies is reparented to the namespace's |
| +// init, and stays a zombie until init waits for it. The tenant's own |
| +// children that outlive their parent land here. |
| +// |
| +// When the tenant exits, the init exits with its status: the tenant's |
| +// exit code, or 128+n for a death by signal n (the shell's convention; a |
| +// namespace init cannot die by a signal it sends itself). The run and |
| +// sidecar templates count 129, 130, 141 and 143 as success, the signals |
| +// systemd itself counts as a clean death of a main process. The kernel |
| +// then kills whatever is left in the namespace, as it did when the tenant |
| +// was PID 1. |
| +// |
| +// The init is small on purpose and allocates nothing per signal: one |
| +// buffered channel, one WaitStatus, a kill or a wait4 per signal. |
| + |
| +// forwarded are the signals the init passes on to the tenant. |
| +var forwarded = [...]os.Signal{syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGUSR1, syscall.SIGUSR2} |
| + |
| +// scopedInit is the trampoline's launch: scope, start the tenant under |
| +// the init, and exit with its status. It returns only if the tenant could |
| +// not be started; a scope that cannot be applied refuses the start (the |
| +// probe has already failed readiness on such a box, so no nested wake |
| +// gets here). |
| +func scopedInit(argv0 string, argv, env []string) error { |
| + return runInit(argv0, argv, env, ScopeAbstractUnix) |
| +} |
| + |
| +func runInit(argv0 string, argv, env []string, scope func() error) error { |
| + // Before anything can start the tenant: a stop that arrives early is |
| + // queued and forwarded, not dropped by PID 1's missing defaults. |
| + sigs := make(chan os.Signal, 32) |
| + signal.Notify(sigs, append(forwarded[:], syscall.SIGCHLD)...) |
| + |
| + // Landlock restricts a thread, and a fork copies the forking thread's |
| + // domain, which execve keeps: scoping this locked thread and forking |
| + // from it scopes the tenant. The thread stays locked until exit. |
| + runtime.LockOSThread() |
| + if err := scope(); err != nil { |
| + return fmt.Errorf("not starting without the abstract socket scope: %w", err) |
| + } |
| + // PID 1 in production already reaps orphans; this makes the init |
| + // their reaper wherever it runs. |
| + if err := unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0); err != nil { |
| + return fmt.Errorf("subreaper: %w", err) |
| + } |
| + // Same argv, env, cwd and stdio as the exec this replaces. Fds beyond |
| + // stdio pass as they did there: anything not close-on-exec stays |
| + // open across fork and exec. Setsid gives the tenant the session of |
| + // its own that systemd gave the main process. |
| + tenant, err := syscall.ForkExec(argv0, argv, &syscall.ProcAttr{ |
| + Env: env, |
| + Files: []uintptr{0, 1, 2}, |
| + Sys: &syscall.SysProcAttr{Setsid: true}, |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + os.Exit(superviseTenant(tenant, sigs)) |
| + return nil |
| +} |
| + |
| +// superviseTenant forwards signals to the tenant and reaps every child |
| +// until the tenant itself is reaped, and returns the init's exit code. |
| +func superviseTenant(tenant int, sigs <-chan os.Signal) int { |
| + var ws syscall.WaitStatus |
| + for s := range sigs { |
| + sig := s.(syscall.Signal) |
| + if sig != syscall.SIGCHLD { |
| + _ = syscall.Kill(tenant, sig) |
| + continue |
| + } |
| + // One SIGCHLD can stand for several exits: drain them all. |
| + for { |
| + pid, err := syscall.Wait4(-1, &ws, syscall.WNOHANG, nil) |
| + if err == syscall.EINTR { |
| + continue |
| + } |
| + if err != nil || pid <= 0 { |
| + break |
| + } |
| + if pid == tenant { |
| + return initExitCode(ws) |
| + } |
| + } |
| + } |
| + return 1 // unreachable: signal.Notify never closes the channel |
| +} |
| + |
| +// initExitCode maps the tenant's wait status to the init's exit code. |
| +func initExitCode(ws syscall.WaitStatus) int { |
| + if ws.Signaled() { |
| + return 128 + int(ws.Signal()) |
| + } |
| + return ws.ExitStatus() |
| +} |
diff --git a/internal/instanceexec/init_test.go b/internal/instanceexec/init_test.go| new file mode 100644 |
| index 000000000..ff2f75d38 |
| --- /dev/null |
| +++ b/internal/instanceexec/init_test.go |
| @@ -0,0 +1,475 @@ |
| +//go:build linux |
| + |
| +package instanceexec |
| + |
| +import ( |
| + "bufio" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "os" |
| + "os/exec" |
| + "os/signal" |
| + "strconv" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// The init tests run the real runInit in a child process: this test |
| +// binary, re-executed with ieHelperEnv naming a role. The "init" role |
| +// runs runInit with the binary itself as the tenant, in the tenant role |
| +// named by ieTenantEnv; every other role is a tenant. |
| +const ( |
| + ieHelperEnv = "CARLOS_INSTANCEEXEC_HELPER" |
| + ieTenantEnv = "CARLOS_INSTANCEEXEC_TENANT" |
| + ieScopeEnv = "CARLOS_INSTANCEEXEC_SCOPE" |
| + ieArgEnv = "CARLOS_INSTANCEEXEC_ARG" |
| +) |
| + |
| +func TestMain(m *testing.M) { |
| + if role := os.Getenv(ieHelperEnv); role != "" { |
| + os.Exit(runHelper(role)) |
| + } |
| + os.Exit(m.Run()) |
| +} |
| + |
| +// withRole is the environment with the helper role replaced, not |
| +// appended: a Go child keeps the FIRST of duplicate keys (design §6.2). |
| +func withRole(role string) []string { |
| + env := []string{ieHelperEnv + "=" + role} |
| + for _, kv := range os.Environ() { |
| + if !strings.HasPrefix(kv, ieHelperEnv+"=") { |
| + env = append(env, kv) |
| + } |
| + } |
| + return env |
| +} |
| + |
| +func runHelper(role string) int { |
| + self, err := os.Executable() |
| + if err != nil { |
| + fmt.Println("executable:", err) |
| + return 70 |
| + } |
| + switch role { |
| + case "init": |
| + env := withRole(os.Getenv(ieTenantEnv)) |
| + scope := func() error { return nil } |
| + if os.Getenv(ieScopeEnv) == "1" { |
| + scope = ScopeAbstractUnix |
| + } |
| + err := runInit(self, []string{self}, env, scope) |
| + fmt.Println("init:", err) |
| + return 71 |
| + case "no-handler": |
| + // A tenant that never asks for SIGTERM, like most Go servers. |
| + fmt.Printf("ready pid=%d ppid=%d\n", os.Getpid(), os.Getppid()) |
| + time.Sleep(30 * time.Second) |
| + return 0 |
| + case "echo-signals": |
| + // Reports every forwarded signal; leaves on SIGTERM. |
| + got := make(chan os.Signal, 8) |
| + signal.Notify(got, forwarded[:]...) |
| + fmt.Println("ready") |
| + for s := range got { |
| + fmt.Println("got", s) |
| + if s == syscall.SIGTERM { |
| + return 0 |
| + } |
| + } |
| + return 0 |
| + case "exit": |
| + n, _ := strconv.Atoi(os.Getenv(ieArgEnv)) |
| + return n |
| + case "kill-self": |
| + _ = syscall.Kill(os.Getpid(), syscall.SIGKILL) |
| + time.Sleep(30 * time.Second) |
| + return 0 |
| + case "orphan": |
| + // Leave a grandchild behind: the intermediate starts it and exits |
| + // at once, so it is reparented to the nearest reaper. |
| + cmd := exec.Command(self) |
| + cmd.Env = withRole("intermediate") |
| + cmd.Stdout = os.Stdout |
| + if err := cmd.Run(); err != nil { |
| + fmt.Println("intermediate:", err) |
| + return 72 |
| + } |
| + time.Sleep(30 * time.Second) |
| + return 0 |
| + case "intermediate": |
| + cmd := exec.Command(self) |
| + cmd.Env = withRole("short-lived") |
| + if err := cmd.Start(); err != nil { |
| + fmt.Println("grandchild:", err) |
| + return 73 |
| + } |
| + fmt.Printf("grandchild %d\n", cmd.Process.Pid) |
| + return 0 |
| + case "short-lived": |
| + time.Sleep(1500 * time.Millisecond) |
| + return 0 |
| + case "inherit": |
| + cwd, _ := os.Getwd() |
| + sid, _ := unix.Getsid(0) |
| + fmt.Printf("cwd=%s arg=%s sid-is-pid=%t\n", cwd, os.Getenv(ieArgEnv), sid == os.Getpid()) |
| + f := os.NewFile(3, "fd3") |
| + if _, err := f.Write([]byte("fd3 reached\n")); err != nil { |
| + fmt.Println("fd3:", err) |
| + } |
| + return 0 |
| + case "dial": |
| + c, err := net.Dial("unix", os.Getenv(ieArgEnv)) |
| + switch { |
| + case err == nil: |
| + c.Close() |
| + fmt.Println("dial: connected") |
| + case errors.Is(unwrapErrno(err), syscall.EPERM): |
| + fmt.Println("dial: EPERM") |
| + default: |
| + fmt.Println("dial:", err) |
| + } |
| + return 0 |
| + } |
| + fmt.Println("unknown helper role", role) |
| + return 74 |
| +} |
| + |
| +// startInit starts the init helper with the given tenant role and extra |
| +// environment, and returns it with a line reader on its stdout (which is |
| +// the tenant's stdout too: the init passes fd 1 through). |
| +func startInit(t *testing.T, tenant string, attr *syscall.SysProcAttr, extra ...string) (*exec.Cmd, *bufio.Reader) { |
| + t.Helper() |
| + self, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(self) |
| + cmd.Env = append(os.Environ(), append([]string{ieHelperEnv + "=init", ieTenantEnv + "=" + tenant}, extra...)...) |
| + cmd.Stderr = os.Stderr |
| + cmd.SysProcAttr = attr |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := cmd.Start(); err != nil { |
| + if attr != nil { |
| + t.Skipf("no unprivileged user and PID namespace here: %v", err) |
| + } |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + // Through the init first, so the tenant stops with it outside a |
| + // PID namespace too; the SIGKILL is for a test that failed early. |
| + _ = cmd.Process.Signal(syscall.SIGTERM) |
| + time.Sleep(100 * time.Millisecond) |
| + _ = cmd.Process.Kill() |
| + _ = cmd.Wait() |
| + }) |
| + return cmd, bufio.NewReader(out) |
| +} |
| + |
| +// readLine reads one line within d, failing the test on a timeout. |
| +func readLine(t *testing.T, r *bufio.Reader, d time.Duration) string { |
| + t.Helper() |
| + got := make(chan string, 1) |
| + go func() { |
| + line, err := r.ReadString('\n') |
| + if err != nil && line == "" { |
| + line = "read: " + err.Error() |
| + } |
| + got <- strings.TrimSpace(line) |
| + }() |
| + select { |
| + case line := <-got: |
| + return line |
| + case <-time.After(d): |
| + t.Fatalf("no line from the helper within %v", d) |
| + return "" |
| + } |
| +} |
| + |
| +// waitExit waits up to d for cmd to exit and returns its exit code, or -1 |
| +// with the signal if it died by one. |
| +func waitExit(t *testing.T, cmd *exec.Cmd, d time.Duration) (int, syscall.Signal) { |
| + t.Helper() |
| + done := make(chan error, 1) |
| + go func() { done <- cmd.Wait() }() |
| + select { |
| + case <-done: |
| + case <-time.After(d): |
| + t.Fatalf("helper still running %v after the signal", d) |
| + } |
| + ws := cmd.ProcessState.Sys().(syscall.WaitStatus) |
| + if ws.Signaled() { |
| + return -1, ws.Signal() |
| + } |
| + return ws.ExitStatus(), 0 |
| +} |
| + |
| +func TestInitExitStatusMapping(t *testing.T) { |
| + for _, c := range []struct { |
| + ws syscall.WaitStatus |
| + want int |
| + }{ |
| + {0, 0}, |
| + {1 << 8, 1}, |
| + {2 << 8, 2}, |
| + {255 << 8, 255}, |
| + {syscall.WaitStatus(syscall.SIGTERM), 143}, |
| + {syscall.WaitStatus(syscall.SIGKILL), 137}, |
| + {syscall.WaitStatus(syscall.SIGINT), 130}, |
| + {syscall.WaitStatus(syscall.SIGSEGV) | 0x80, 139}, // core dumped |
| + } { |
| + if got := initExitCode(c.ws); got != c.want { |
| + t.Errorf("initExitCode(%#x) = %d, want %d", uint32(c.ws), got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// End to end through the init, outside any namespace: the tenant's own |
| +// exit code passes through, and a death by signal is 128+n. |
| +func TestInitExitsWithTheTenantsStatus(t *testing.T) { |
| + cmd, _ := startInit(t, "exit", nil, ieArgEnv+"=7") |
| + if code, sig := waitExit(t, cmd, 10*time.Second); code != 7 { |
| + t.Errorf("init exit = %d (signal %v), want 7", code, sig) |
| + } |
| + cmd, _ = startInit(t, "kill-self", nil) |
| + if code, sig := waitExit(t, cmd, 10*time.Second); code != 128+int(syscall.SIGKILL) { |
| + t.Errorf("init exit = %d (signal %v), want 137", code, sig) |
| + } |
| +} |
| + |
| +// Every forwarded signal reaches the tenant. |
| +func TestInitForwardsEachSignal(t *testing.T) { |
| + cmd, out := startInit(t, "echo-signals", nil) |
| + if line := readLine(t, out, 10*time.Second); line != "ready" { |
| + t.Fatalf("tenant said %q", line) |
| + } |
| + // SIGTERM last: the tenant leaves on it. |
| + for _, sig := range []syscall.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGTERM} { |
| + if err := cmd.Process.Signal(sig); err != nil { |
| + t.Fatal(err) |
| + } |
| + if line := readLine(t, out, 5*time.Second); line != "got "+sig.String() { |
| + t.Errorf("sent %v, tenant said %q", sig, line) |
| + } |
| + } |
| + if code, sig := waitExit(t, cmd, 5*time.Second); code != 0 { |
| + t.Errorf("init exit = %d (signal %v), want the tenant's 0", code, sig) |
| + } |
| +} |
| + |
| +// A tenant with no handlers acts on a forwarded signal by its default |
| +// disposition, as the main process did before PrivatePIDs: it dies by |
| +// the signal, and the init reports 128+n. |
| +func TestInitStopsATenantWithNoHandler(t *testing.T) { |
| + for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP} { |
| + t.Run(sig.String(), func(t *testing.T) { |
| + cmd, out := startInit(t, "no-handler", nil) |
| + if line := readLine(t, out, 10*time.Second); !strings.HasPrefix(line, "ready ") { |
| + t.Fatalf("tenant said %q", line) |
| + } |
| + if err := cmd.Process.Signal(sig); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, got := waitExit(t, cmd, 5*time.Second); code != 128+int(sig) { |
| + t.Errorf("init exit = %d (signal %v), want %d", code, got, 128+int(sig)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The tenant gets the init's cwd, the launch env, a session of its own |
| +// (as the unit's main process had) and any inherited fd beyond stdio. |
| +func TestInitPassesTheTenantItsEnvironment(t *testing.T) { |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + self, _ := os.Executable() |
| + cmd := exec.Command(self) |
| + cmd.Dir = t.TempDir() |
| + cmd.Env = append(os.Environ(), ieHelperEnv+"=init", ieTenantEnv+"=inherit", ieArgEnv+"=launch-value") |
| + cmd.ExtraFiles = []*os.File{w} |
| + var stdout strings.Builder |
| + cmd.Stdout = &stdout |
| + cmd.Stderr = os.Stderr |
| + if err := cmd.Run(); err != nil { |
| + t.Fatalf("init: %v; output %q", err, stdout.String()) |
| + } |
| + w.Close() |
| + want := fmt.Sprintf("cwd=%s arg=launch-value sid-is-pid=true", cmd.Dir) |
| + if got := strings.TrimSpace(stdout.String()); got != want { |
| + t.Errorf("tenant said %q, want %q", got, want) |
| + } |
| + b, _ := io.ReadAll(r) |
| + if string(b) != "fd3 reached\n" { |
| + t.Errorf("fd 3 carried %q", b) |
| + } |
| +} |
| + |
| +// A process orphaned inside the namespace is the init's to reap. The |
| +// grandchild must be reparented to the init (it is a subreaper, and PID 1 |
| +// in production) and must not stay a zombie after it exits. |
| +func TestInitReapsOrphans(t *testing.T) { |
| + cmd, out := startInit(t, "orphan", nil) |
| + line := readLine(t, out, 10*time.Second) |
| + var gpid int |
| + if _, err := fmt.Sscanf(line, "grandchild %d", &gpid); err != nil { |
| + t.Fatalf("tenant said %q", line) |
| + } |
| + initPid := cmd.Process.Pid |
| + adopted := false |
| + deadline := time.Now().Add(10 * time.Second) |
| + for time.Now().Before(deadline) { |
| + state, ppid, ok := procStat(gpid) |
| + if !ok { |
| + break // reaped |
| + } |
| + if ppid == initPid { |
| + adopted = true |
| + } |
| + if state == "Z" && time.Now().After(deadline.Add(-7*time.Second)) { |
| + t.Fatalf("grandchild %d is a zombie of %d and nobody reaps it", gpid, ppid) |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + if _, _, ok := procStat(gpid); ok { |
| + t.Fatalf("grandchild %d still present", gpid) |
| + } |
| + if !adopted { |
| + t.Fatalf("grandchild %d was never the init's (%d) child", gpid, initPid) |
| + } |
| + // The tenant itself is still running: reaping an orphan is not the |
| + // tenant's exit. |
| + if err := cmd.Process.Signal(syscall.Signal(0)); err != nil { |
| + t.Fatalf("init gone after reaping an orphan: %v", err) |
| + } |
| +} |
| + |
| +// procStat returns the state and parent of pid from /proc/<pid>/stat. |
| +func procStat(pid int) (state string, ppid int, ok bool) { |
| + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) |
| + if err != nil { |
| + return "", 0, false |
| + } |
| + s := string(b) |
| + i := strings.LastIndexByte(s, ')') |
| + if i < 0 { |
| + return "", 0, false |
| + } |
| + f := strings.Fields(s[i+1:]) |
| + if len(f) < 2 { |
| + return "", 0, false |
| + } |
| + ppid, _ = strconv.Atoi(f[1]) |
| + return f[0], ppid, true |
| +} |
| + |
| +// The Landlock scope applied by the init holds in the tenant: the scope is |
| +// on the thread that forks, and a domain is inherited across fork and |
| +// exec. The unscoped run is the control, proving the dial can succeed. |
| +func TestInitTenantKeepsTheAbstractSocketScope(t *testing.T) { |
| + abi, _, e := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, 0, 0, unix.LANDLOCK_CREATE_RULESET_VERSION) |
| + if e != 0 || abi < landlockScopeABI { |
| + t.Skipf("no Landlock scope on this kernel (abi %d, %v)", abi, e) |
| + } |
| + name := "@carlos-init-test-" + randomName() |
| + ln, err := net.Listen("unix", name) |
| + if err != nil { |
| + t.Skipf("abstract listen: %v", err) |
| + } |
| + defer ln.Close() |
| + go func() { |
| + for { |
| + c, err := ln.Accept() |
| + if err != nil { |
| + return |
| + } |
| + c.Close() |
| + } |
| + }() |
| + for scope, want := range map[string]string{"0": "dial: connected", "1": "dial: EPERM"} { |
| + cmd, out := startInit(t, "dial", nil, ieArgEnv+"="+name, ieScopeEnv+"="+scope) |
| + if got := readLine(t, out, 10*time.Second); got != want { |
| + t.Errorf("scope=%s: tenant said %q, want %q", scope, got, want) |
| + } |
| + if code, sig := waitExit(t, cmd, 10*time.Second); code != 0 { |
| + t.Errorf("scope=%s: init exit = %d (signal %v)", scope, code, sig) |
| + } |
| + } |
| +} |
| + |
| +// The finding itself (#126, dev fleet): in a PID namespace, as under |
| +// PrivatePIDs=yes, the first process is init and has no default signal |
| +// actions. A Go tenant with no SIGTERM handler in that slot does not die |
| +// by SIGTERM. Through the init it is the init's child and does, promptly. Needs an |
| +// unprivileged user namespace; skipped where there is none. |
| +func TestInitStopsATenantWithNoHandlerInAPIDNamespace(t *testing.T) { |
| + userPIDNS := func() *syscall.SysProcAttr { |
| + return &syscall.SysProcAttr{ |
| + Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWPID, |
| + UidMappings: []syscall.SysProcIDMap{{ContainerID: os.Getuid(), HostID: os.Getuid(), Size: 1}}, |
| + GidMappings: []syscall.SysProcIDMap{{ContainerID: os.Getgid(), HostID: os.Getgid(), Size: 1}}, |
| + } |
| + } |
| + |
| + t.Run("tenant as PID 1", func(t *testing.T) { |
| + self, _ := os.Executable() |
| + cmd := exec.Command(self) |
| + cmd.Env = append(os.Environ(), ieHelperEnv+"=no-handler") |
| + cmd.SysProcAttr = userPIDNS() |
| + cmd.Stderr = io.Discard // the runtime's own complaint, if any |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := cmd.Start(); err != nil { |
| + t.Skipf("no unprivileged user and PID namespace here: %v", err) |
| + } |
| + defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }() |
| + if line := readLine(t, bufio.NewReader(out), 10*time.Second); !strings.HasPrefix(line, "ready pid=1 ") { |
| + t.Fatalf("tenant said %q, want it at PID 1", line) |
| + } |
| + _ = cmd.Process.Signal(syscall.SIGTERM) |
| + done := make(chan struct{}) |
| + go func() { _ = cmd.Wait(); close(done) }() |
| + select { |
| + case <-done: |
| + ws := cmd.ProcessState.Sys().(syscall.WaitStatus) |
| + if ws.Signaled() && ws.Signal() == syscall.SIGTERM { |
| + t.Skip("this kernel delivered SIGTERM's default action to a namespace init") |
| + } |
| + t.Logf("as PID 1 the tenant did not die by SIGTERM: %v (the bug the init fixes)", cmd.ProcessState) |
| + case <-time.After(2 * time.Second): |
| + t.Logf("as PID 1 the tenant ignored SIGTERM (the bug the init fixes)") |
| + } |
| + }) |
| + |
| + t.Run("through the init", func(t *testing.T) { |
| + cmd, out := startInit(t, "no-handler", userPIDNS()) |
| + // Not PID 2 as such: the init's own threads take PIDs in the |
| + // namespace too. What matters is that the tenant is not PID 1. |
| + line := readLine(t, out, 10*time.Second) |
| + var pid, ppid int |
| + if _, err := fmt.Sscanf(line, "ready pid=%d ppid=%d", &pid, &ppid); err != nil || pid == 1 || ppid != 1 { |
| + t.Fatalf("tenant said %q, want a child of the init at PID 1", line) |
| + } |
| + start := time.Now() |
| + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, sig := waitExit(t, cmd, 5*time.Second) |
| + if code != 128+int(syscall.SIGTERM) { |
| + t.Errorf("init exit = %d (signal %v), want 143", code, sig) |
| + } |
| + t.Logf("stopped in %v", time.Since(start)) |
| + }) |
| +} |
diff --git a/internal/instanceexec/scope_linux.go b/internal/instanceexec/scope_linux.go| index 557a8f303..6ed398668 100644 |
| --- a/internal/instanceexec/scope_linux.go |
| +++ b/internal/instanceexec/scope_linux.go |
| @@ -28,7 +28,8 @@ const landlockScopeABI = 6 |
| // other's. |
| // |
| // Landlock restricts a thread, not a process, so the caller must hold |
| -// runtime.LockOSThread and either exec or let the thread die locked. |
| +// runtime.LockOSThread and then exec, fork (the child inherits the |
| +// domain), or let the thread die locked. |
| // Nothing here needs privilege: no_new_privs (which the unit already sets |
| // with NoNewPrivileges=) is the only precondition. |
| func ScopeAbstractUnix() error { |
| @@ -54,17 +55,6 @@ func ScopeAbstractUnix() error { |
| return nil |
| } |
| -// scopedExec is the trampoline's exec: scope, then exec on the same |
| -// thread. A scope that cannot be applied refuses the exec; the probe has |
| -// already failed readiness on such a box, so no nested wake gets here. |
| -func scopedExec(argv0 string, argv, env []string) error { |
| - runtime.LockOSThread() // never unlocked: the thread execs or the process exits |
| - if err := ScopeAbstractUnix(); err != nil { |
| - return fmt.Errorf("not starting without the abstract socket scope: %w", err) |
| - } |
| - return syscall.Exec(argv0, argv, env) |
| -} |
| - |
| var errAbstractReached = errors.New("connected to an abstract socket bound outside the scope") |
| // realAbstractCheck proves the scope on this kernel, end to end: it binds |
diff --git a/internal/instanceexec/scope_other.go b/internal/instanceexec/scope_other.go| index 9176634f2..6d68fca9b 100644 |
| --- a/internal/instanceexec/scope_other.go |
| +++ b/internal/instanceexec/scope_other.go |
| @@ -5,7 +5,7 @@ package instanceexec |
| import "errors" |
| func ScopeAbstractUnix() error { return errors.ErrUnsupported } |
| -func scopedExec(string, []string, []string) error { return errors.ErrUnsupported } |
| +func scopedInit(string, []string, []string) error { return errors.ErrUnsupported } |
| func realAbstractCheck() error { return errors.ErrUnsupported } |
| func ipcNamespaceInode() (uint64, error) { return 0, errors.ErrUnsupported } |
| func ioWeightEnforced(string) bool { return false } |