feat(guest): add init (guest PID 1) and the stub executor
- commit
- d85c6abd05abbd537a7fb7615839c9d1cc719d0b
- author
- brice · · 2026-10-12 15:35 UTC
- parent
- a743879
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +1040 -0
A internal/guest/cmdline.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package guest is the code that runs inside a sandbox: `ermite-runner init` | |
| 4 | + | // (the guest PID 1) and `ermite-runner exec` (the executor). | |
| 5 | + | package guest | |
| 6 | + | ||
| 7 | + | import "strings" | |
| 8 | + | ||
| 9 | + | // ParseCmdline splits a kernel command line into key=value pairs; a bare word | |
| 10 | + | // has the value "". The last occurrence of a key wins. Values may be quoted | |
| 11 | + | // with double quotes, as the kernel allows. | |
| 12 | + | func ParseCmdline(s string) map[string]string { | |
| 13 | + | out := map[string]string{} | |
| 14 | + | for _, f := range splitCmdline(strings.TrimSpace(s)) { | |
| 15 | + | k, v, _ := strings.Cut(f, "=") | |
| 16 | + | out[k] = strings.Trim(v, `"`) | |
| 17 | + | } | |
| 18 | + | return out | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func splitCmdline(s string) []string { | |
| 22 | + | var out []string | |
| 23 | + | var b strings.Builder | |
| 24 | + | inQuote := false | |
| 25 | + | for _, r := range s { | |
| 26 | + | switch { | |
| 27 | + | case r == '"': | |
| 28 | + | inQuote = !inQuote | |
| 29 | + | b.WriteRune(r) | |
| 30 | + | case (r == ' ' || r == '\t' || r == '\n') && !inQuote: | |
| 31 | + | if b.Len() > 0 { | |
| 32 | + | out = append(out, b.String()) | |
| 33 | + | b.Reset() | |
| 34 | + | } | |
| 35 | + | default: | |
| 36 | + | b.WriteRune(r) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | if b.Len() > 0 { | |
| 40 | + | out = append(out, b.String()) | |
| 41 | + | } | |
| 42 | + | return out | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package guest is the code that runs inside a sandbox: `ermite-runner init` | |||
| 4 | + | // (the guest PID 1) and `ermite-runner exec` (the executor). | |||
| 5 | + | package guest | |||
| 6 | + | ||||
| 7 | + | import "strings" | |||
| 8 | + | ||||
| 9 | + | // ParseCmdline splits a kernel command line into key=value pairs; a bare word | |||
| 10 | + | // has the value "". The last occurrence of a key wins. Values may be quoted | |||
| 11 | + | // with double quotes, as the kernel allows. | |||
| 12 | + | func ParseCmdline(s string) map[string]string { | |||
| 13 | + | out := map[string]string{} | |||
| 14 | + | for _, f := range splitCmdline(strings.TrimSpace(s)) { | |||
| 15 | + | k, v, _ := strings.Cut(f, "=") | |||
| 16 | + | out[k] = strings.Trim(v, `"`) | |||
| 17 | + | } | |||
| 18 | + | return out | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func splitCmdline(s string) []string { | |||
| 22 | + | var out []string | |||
| 23 | + | var b strings.Builder | |||
| 24 | + | inQuote := false | |||
| 25 | + | for _, r := range s { | |||
| 26 | + | switch { | |||
| 27 | + | case r == '"': | |||
| 28 | + | inQuote = !inQuote | |||
| 29 | + | b.WriteRune(r) | |||
| 30 | + | case (r == ' ' || r == '\t' || r == '\n') && !inQuote: | |||
| 31 | + | if b.Len() > 0 { | |||
| 32 | + | out = append(out, b.String()) | |||
| 33 | + | b.Reset() | |||
| 34 | + | } | |||
| 35 | + | default: | |||
| 36 | + | b.WriteRune(r) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | if b.Len() > 0 { | |||
| 40 | + | out = append(out, b.String()) | |||
| 41 | + | } | |||
| 42 | + | return out | |||
| 43 | + | } | |||
A internal/guest/connect.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Target is where the executor connects back to: "unix:/path" (container) or | |
| 18 | + | // "vsock:PORT" (vm; the host is CID 2). | |
| 19 | + | type Target struct { | |
| 20 | + | Network string // "unix" or "vsock" | |
| 21 | + | Addr string // socket path, or the vsock port | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ParseTarget parses "unix:/path" or "vsock:port". | |
| 25 | + | func ParseTarget(s string) (Target, error) { | |
| 26 | + | kind, rest, ok := strings.Cut(s, ":") | |
| 27 | + | if !ok || rest == "" { | |
| 28 | + | return Target{}, fmt.Errorf("connect target %q: want unix:PATH or vsock:PORT", s) | |
| 29 | + | } | |
| 30 | + | switch kind { | |
| 31 | + | case "unix": | |
| 32 | + | return Target{Network: "unix", Addr: rest}, nil | |
| 33 | + | case "vsock": | |
| 34 | + | if p, err := strconv.ParseUint(rest, 10, 32); err != nil || p == 0 { | |
| 35 | + | return Target{}, fmt.Errorf("connect target %q: bad vsock port", s) | |
| 36 | + | } | |
| 37 | + | return Target{Network: "vsock", Addr: rest}, nil | |
| 38 | + | } | |
| 39 | + | return Target{}, fmt.Errorf("connect target %q: unknown kind %q", s, kind) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (t Target) String() string { return t.Network + ":" + t.Addr } | |
| 43 | + | ||
| 44 | + | // Dial connects to the host, retrying until ctx ends or timeout elapses: the | |
| 45 | + | // host listens before the sandbox starts, but a vsock device may take a moment. | |
| 46 | + | func (t Target) Dial(ctx context.Context, timeout time.Duration) (io.ReadWriteCloser, error) { | |
| 47 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 48 | + | defer cancel() | |
| 49 | + | var last error | |
| 50 | + | for { | |
| 51 | + | var c io.ReadWriteCloser | |
| 52 | + | var err error | |
| 53 | + | if t.Network == "unix" { | |
| 54 | + | var d net.Dialer | |
| 55 | + | c, err = d.DialContext(ctx, "unix", t.Addr) | |
| 56 | + | } else { | |
| 57 | + | c, err = dialVsock(t.Addr) | |
| 58 | + | } | |
| 59 | + | if err == nil { | |
| 60 | + | return c, nil | |
| 61 | + | } | |
| 62 | + | last = err | |
| 63 | + | select { | |
| 64 | + | case <-ctx.Done(): | |
| 65 | + | return nil, fmt.Errorf("connect to %s: %w (last error: %v)", t, ctx.Err(), last) | |
| 66 | + | case <-time.After(100 * time.Millisecond): | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // vsockHostCID is the well-known CID of the host. | |
| 72 | + | const vsockHostCID = 2 | |
| 73 | + | ||
| 74 | + | func dialVsock(port string) (io.ReadWriteCloser, error) { | |
| 75 | + | p, _ := strconv.ParseUint(port, 10, 32) | |
| 76 | + | fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |
| 77 | + | if err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | // A blocking connect is fine: a Firecracker vsock either connects to the | |
| 81 | + | // host listener at once or fails. | |
| 82 | + | if err := unix.Connect(fd, &unix.SockaddrVM{CID: vsockHostCID, Port: uint32(p)}); err != nil { | |
| 83 | + | _ = unix.Close(fd) | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | return newFDConn(fd) | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "net" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Target is where the executor connects back to: "unix:/path" (container) or | |||
| 18 | + | // "vsock:PORT" (vm; the host is CID 2). | |||
| 19 | + | type Target struct { | |||
| 20 | + | Network string // "unix" or "vsock" | |||
| 21 | + | Addr string // socket path, or the vsock port | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ParseTarget parses "unix:/path" or "vsock:port". | |||
| 25 | + | func ParseTarget(s string) (Target, error) { | |||
| 26 | + | kind, rest, ok := strings.Cut(s, ":") | |||
| 27 | + | if !ok || rest == "" { | |||
| 28 | + | return Target{}, fmt.Errorf("connect target %q: want unix:PATH or vsock:PORT", s) | |||
| 29 | + | } | |||
| 30 | + | switch kind { | |||
| 31 | + | case "unix": | |||
| 32 | + | return Target{Network: "unix", Addr: rest}, nil | |||
| 33 | + | case "vsock": | |||
| 34 | + | if p, err := strconv.ParseUint(rest, 10, 32); err != nil || p == 0 { | |||
| 35 | + | return Target{}, fmt.Errorf("connect target %q: bad vsock port", s) | |||
| 36 | + | } | |||
| 37 | + | return Target{Network: "vsock", Addr: rest}, nil | |||
| 38 | + | } | |||
| 39 | + | return Target{}, fmt.Errorf("connect target %q: unknown kind %q", s, kind) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (t Target) String() string { return t.Network + ":" + t.Addr } | |||
| 43 | + | ||||
| 44 | + | // Dial connects to the host, retrying until ctx ends or timeout elapses: the | |||
| 45 | + | // host listens before the sandbox starts, but a vsock device may take a moment. | |||
| 46 | + | func (t Target) Dial(ctx context.Context, timeout time.Duration) (io.ReadWriteCloser, error) { | |||
| 47 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |||
| 48 | + | defer cancel() | |||
| 49 | + | var last error | |||
| 50 | + | for { | |||
| 51 | + | var c io.ReadWriteCloser | |||
| 52 | + | var err error | |||
| 53 | + | if t.Network == "unix" { | |||
| 54 | + | var d net.Dialer | |||
| 55 | + | c, err = d.DialContext(ctx, "unix", t.Addr) | |||
| 56 | + | } else { | |||
| 57 | + | c, err = dialVsock(t.Addr) | |||
| 58 | + | } | |||
| 59 | + | if err == nil { | |||
| 60 | + | return c, nil | |||
| 61 | + | } | |||
| 62 | + | last = err | |||
| 63 | + | select { | |||
| 64 | + | case <-ctx.Done(): | |||
| 65 | + | return nil, fmt.Errorf("connect to %s: %w (last error: %v)", t, ctx.Err(), last) | |||
| 66 | + | case <-time.After(100 * time.Millisecond): | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // vsockHostCID is the well-known CID of the host. | |||
| 72 | + | const vsockHostCID = 2 | |||
| 73 | + | ||||
| 74 | + | func dialVsock(port string) (io.ReadWriteCloser, error) { | |||
| 75 | + | p, _ := strconv.ParseUint(port, 10, 32) | |||
| 76 | + | fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |||
| 77 | + | if err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | // A blocking connect is fine: a Firecracker vsock either connects to the | |||
| 81 | + | // host listener at once or fails. | |||
| 82 | + | if err := unix.Connect(fd, &unix.SockaddrVM{CID: vsockHostCID, Port: uint32(p)}); err != nil { | |||
| 83 | + | _ = unix.Close(fd) | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | return newFDConn(fd) | |||
| 87 | + | } | |||
A internal/guest/exec.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "sync" | |
| 13 | + | "syscall" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 17 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // DefaultScript is what the stub executor runs when the host sends no script. | |
| 21 | + | const DefaultScript = "uname -a; id; cat /etc/os-release; docker info" | |
| 22 | + | ||
| 23 | + | // ExecConfig configures the executor. | |
| 24 | + | type ExecConfig struct { | |
| 25 | + | Target Target | |
| 26 | + | Sandbox string // "vm" or "container", reported in Hello | |
| 27 | + | Version string | |
| 28 | + | // Shell runs the script as `Shell -c script`; default /bin/sh. | |
| 29 | + | Shell string | |
| 30 | + | // ConnectTimeout bounds the connection attempts (default 30 s). | |
| 31 | + | ConnectTimeout time.Duration | |
| 32 | + | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | |
| 33 | + | RunTimeout time.Duration | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the | |
| 37 | + | // host, receives one Run, runs the script with the shell, streams stdout and | |
| 38 | + | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | |
| 39 | + | // it reported. A Cancel frame sends SIGINT to the script's process group. | |
| 40 | + | func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | |
| 41 | + | if cfg.Shell == "" { | |
| 42 | + | cfg.Shell = "/bin/sh" | |
| 43 | + | } | |
| 44 | + | if cfg.ConnectTimeout == 0 { | |
| 45 | + | cfg.ConnectTimeout = 30 * time.Second | |
| 46 | + | } | |
| 47 | + | if cfg.RunTimeout == 0 { | |
| 48 | + | cfg.RunTimeout = 60 * time.Second | |
| 49 | + | } | |
| 50 | + | raw, err := cfg.Target.Dial(ctx, cfg.ConnectTimeout) | |
| 51 | + | if err != nil { | |
| 52 | + | return 1, err | |
| 53 | + | } | |
| 54 | + | defer func() { _ = raw.Close() }() | |
| 55 | + | return serve(ctx, raw, cfg) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, error) { | |
| 59 | + | conn := execstream.NewConn(rw) | |
| 60 | + | host, _ := os.Hostname() | |
| 61 | + | if err := conn.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{ | |
| 62 | + | Version: cfg.Version, Sandbox: cfg.Sandbox, Hostname: host, | |
| 63 | + | }}}); err != nil { | |
| 64 | + | return 1, fmt.Errorf("send hello: %w", err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Wait for Run, bounded: a host that never answers must not pin the sandbox. | |
| 68 | + | type recv struct { | |
| 69 | + | f *execpb.Frame | |
| 70 | + | err error | |
| 71 | + | } | |
| 72 | + | first := make(chan recv, 1) | |
| 73 | + | go func() { f, err := conn.Recv(); first <- recv{f, err} }() | |
| 74 | + | var run *execpb.Run | |
| 75 | + | select { | |
| 76 | + | case r := <-first: | |
| 77 | + | if r.err != nil { | |
| 78 | + | return 1, fmt.Errorf("waiting for run: %w", r.err) | |
| 79 | + | } | |
| 80 | + | if run = r.f.GetRun(); run == nil { | |
| 81 | + | return 1, errors.New("first frame from host is not run") | |
| 82 | + | } | |
| 83 | + | case <-time.After(cfg.RunTimeout): | |
| 84 | + | return 1, errors.New("timed out waiting for the host's run frame") | |
| 85 | + | case <-ctx.Done(): | |
| 86 | + | return 1, ctx.Err() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | script := run.GetScript() | |
| 90 | + | if script == "" { | |
| 91 | + | script = DefaultScript | |
| 92 | + | } | |
| 93 | + | cmd := exec.Command(cfg.Shell, "-c", script) | |
| 94 | + | cmd.Env = os.Environ() | |
| 95 | + | for k, v := range run.GetEnv() { | |
| 96 | + | cmd.Env = append(cmd.Env, k+"="+v) | |
| 97 | + | } | |
| 98 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 99 | + | stdout, err := cmd.StdoutPipe() | |
| 100 | + | if err != nil { | |
| 101 | + | return sendExit(conn, 1, err) | |
| 102 | + | } | |
| 103 | + | stderr, err := cmd.StderrPipe() | |
| 104 | + | if err != nil { | |
| 105 | + | return sendExit(conn, 1, err) | |
| 106 | + | } | |
| 107 | + | if err := cmd.Start(); err != nil { | |
| 108 | + | return sendExit(conn, 127, fmt.Errorf("start %s: %w", cfg.Shell, err)) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Cancel frames arrive while the script runs. | |
| 112 | + | go func() { | |
| 113 | + | for { | |
| 114 | + | f, err := conn.Recv() | |
| 115 | + | if err != nil { | |
| 116 | + | // Host went away: stop the script rather than run unobserved. | |
| 117 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | if f.GetCancel() != nil { | |
| 121 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | }() | |
| 125 | + | ||
| 126 | + | var wg sync.WaitGroup | |
| 127 | + | pump := func(r io.Reader, kind execpb.StreamKind) { | |
| 128 | + | defer wg.Done() | |
| 129 | + | buf := make([]byte, execstream.ChunkSize) | |
| 130 | + | for { | |
| 131 | + | n, err := r.Read(buf) | |
| 132 | + | if n > 0 { | |
| 133 | + | _ = conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{ | |
| 134 | + | Stream: kind, Data: append([]byte(nil), buf[:n]...), UnixNano: time.Now().UnixNano(), | |
| 135 | + | }}}) | |
| 136 | + | } | |
| 137 | + | if err != nil { | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | wg.Add(2) | |
| 143 | + | go pump(stdout, execpb.StreamKind_STREAM_KIND_STDOUT) | |
| 144 | + | go pump(stderr, execpb.StreamKind_STREAM_KIND_STDERR) | |
| 145 | + | wg.Wait() // pipes must be drained before Wait closes them | |
| 146 | + | waitErr := cmd.Wait() | |
| 147 | + | code := 0 | |
| 148 | + | var ee *exec.ExitError | |
| 149 | + | switch { | |
| 150 | + | case waitErr == nil: | |
| 151 | + | case errors.As(waitErr, &ee): | |
| 152 | + | code = ee.ExitCode() | |
| 153 | + | if code < 0 { // killed by a signal: 128+signal like a shell | |
| 154 | + | if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() { | |
| 155 | + | code = 128 + int(ws.Signal()) | |
| 156 | + | } else { | |
| 157 | + | code = 1 | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | default: | |
| 161 | + | return sendExit(conn, 1, waitErr) | |
| 162 | + | } | |
| 163 | + | return sendExit(conn, code, nil) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func sendExit(c *execstream.Conn, code int, cause error) (int, error) { | |
| 167 | + | e := &execpb.Exit{Code: int32(code)} | |
| 168 | + | if cause != nil { | |
| 169 | + | e.Error = cause.Error() | |
| 170 | + | } | |
| 171 | + | if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: e}}); err != nil { | |
| 172 | + | return code, fmt.Errorf("send exit: %w", err) | |
| 173 | + | } | |
| 174 | + | return code, nil | |
| 175 | + | } | |
| @@ -0,0 +1,175 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "sync" | |||
| 13 | + | "syscall" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 17 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // DefaultScript is what the stub executor runs when the host sends no script. | |||
| 21 | + | const DefaultScript = "uname -a; id; cat /etc/os-release; docker info" | |||
| 22 | + | ||||
| 23 | + | // ExecConfig configures the executor. | |||
| 24 | + | type ExecConfig struct { | |||
| 25 | + | Target Target | |||
| 26 | + | Sandbox string // "vm" or "container", reported in Hello | |||
| 27 | + | Version string | |||
| 28 | + | // Shell runs the script as `Shell -c script`; default /bin/sh. | |||
| 29 | + | Shell string | |||
| 30 | + | // ConnectTimeout bounds the connection attempts (default 30 s). | |||
| 31 | + | ConnectTimeout time.Duration | |||
| 32 | + | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | |||
| 33 | + | RunTimeout time.Duration | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the | |||
| 37 | + | // host, receives one Run, runs the script with the shell, streams stdout and | |||
| 38 | + | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | |||
| 39 | + | // it reported. A Cancel frame sends SIGINT to the script's process group. | |||
| 40 | + | func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | |||
| 41 | + | if cfg.Shell == "" { | |||
| 42 | + | cfg.Shell = "/bin/sh" | |||
| 43 | + | } | |||
| 44 | + | if cfg.ConnectTimeout == 0 { | |||
| 45 | + | cfg.ConnectTimeout = 30 * time.Second | |||
| 46 | + | } | |||
| 47 | + | if cfg.RunTimeout == 0 { | |||
| 48 | + | cfg.RunTimeout = 60 * time.Second | |||
| 49 | + | } | |||
| 50 | + | raw, err := cfg.Target.Dial(ctx, cfg.ConnectTimeout) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return 1, err | |||
| 53 | + | } | |||
| 54 | + | defer func() { _ = raw.Close() }() | |||
| 55 | + | return serve(ctx, raw, cfg) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, error) { | |||
| 59 | + | conn := execstream.NewConn(rw) | |||
| 60 | + | host, _ := os.Hostname() | |||
| 61 | + | if err := conn.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{ | |||
| 62 | + | Version: cfg.Version, Sandbox: cfg.Sandbox, Hostname: host, | |||
| 63 | + | }}}); err != nil { | |||
| 64 | + | return 1, fmt.Errorf("send hello: %w", err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Wait for Run, bounded: a host that never answers must not pin the sandbox. | |||
| 68 | + | type recv struct { | |||
| 69 | + | f *execpb.Frame | |||
| 70 | + | err error | |||
| 71 | + | } | |||
| 72 | + | first := make(chan recv, 1) | |||
| 73 | + | go func() { f, err := conn.Recv(); first <- recv{f, err} }() | |||
| 74 | + | var run *execpb.Run | |||
| 75 | + | select { | |||
| 76 | + | case r := <-first: | |||
| 77 | + | if r.err != nil { | |||
| 78 | + | return 1, fmt.Errorf("waiting for run: %w", r.err) | |||
| 79 | + | } | |||
| 80 | + | if run = r.f.GetRun(); run == nil { | |||
| 81 | + | return 1, errors.New("first frame from host is not run") | |||
| 82 | + | } | |||
| 83 | + | case <-time.After(cfg.RunTimeout): | |||
| 84 | + | return 1, errors.New("timed out waiting for the host's run frame") | |||
| 85 | + | case <-ctx.Done(): | |||
| 86 | + | return 1, ctx.Err() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | script := run.GetScript() | |||
| 90 | + | if script == "" { | |||
| 91 | + | script = DefaultScript | |||
| 92 | + | } | |||
| 93 | + | cmd := exec.Command(cfg.Shell, "-c", script) | |||
| 94 | + | cmd.Env = os.Environ() | |||
| 95 | + | for k, v := range run.GetEnv() { | |||
| 96 | + | cmd.Env = append(cmd.Env, k+"="+v) | |||
| 97 | + | } | |||
| 98 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 99 | + | stdout, err := cmd.StdoutPipe() | |||
| 100 | + | if err != nil { | |||
| 101 | + | return sendExit(conn, 1, err) | |||
| 102 | + | } | |||
| 103 | + | stderr, err := cmd.StderrPipe() | |||
| 104 | + | if err != nil { | |||
| 105 | + | return sendExit(conn, 1, err) | |||
| 106 | + | } | |||
| 107 | + | if err := cmd.Start(); err != nil { | |||
| 108 | + | return sendExit(conn, 127, fmt.Errorf("start %s: %w", cfg.Shell, err)) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Cancel frames arrive while the script runs. | |||
| 112 | + | go func() { | |||
| 113 | + | for { | |||
| 114 | + | f, err := conn.Recv() | |||
| 115 | + | if err != nil { | |||
| 116 | + | // Host went away: stop the script rather than run unobserved. | |||
| 117 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | if f.GetCancel() != nil { | |||
| 121 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | }() | |||
| 125 | + | ||||
| 126 | + | var wg sync.WaitGroup | |||
| 127 | + | pump := func(r io.Reader, kind execpb.StreamKind) { | |||
| 128 | + | defer wg.Done() | |||
| 129 | + | buf := make([]byte, execstream.ChunkSize) | |||
| 130 | + | for { | |||
| 131 | + | n, err := r.Read(buf) | |||
| 132 | + | if n > 0 { | |||
| 133 | + | _ = conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{ | |||
| 134 | + | Stream: kind, Data: append([]byte(nil), buf[:n]...), UnixNano: time.Now().UnixNano(), | |||
| 135 | + | }}}) | |||
| 136 | + | } | |||
| 137 | + | if err != nil { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | wg.Add(2) | |||
| 143 | + | go pump(stdout, execpb.StreamKind_STREAM_KIND_STDOUT) | |||
| 144 | + | go pump(stderr, execpb.StreamKind_STREAM_KIND_STDERR) | |||
| 145 | + | wg.Wait() // pipes must be drained before Wait closes them | |||
| 146 | + | waitErr := cmd.Wait() | |||
| 147 | + | code := 0 | |||
| 148 | + | var ee *exec.ExitError | |||
| 149 | + | switch { | |||
| 150 | + | case waitErr == nil: | |||
| 151 | + | case errors.As(waitErr, &ee): | |||
| 152 | + | code = ee.ExitCode() | |||
| 153 | + | if code < 0 { // killed by a signal: 128+signal like a shell | |||
| 154 | + | if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() { | |||
| 155 | + | code = 128 + int(ws.Signal()) | |||
| 156 | + | } else { | |||
| 157 | + | code = 1 | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | default: | |||
| 161 | + | return sendExit(conn, 1, waitErr) | |||
| 162 | + | } | |||
| 163 | + | return sendExit(conn, code, nil) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func sendExit(c *execstream.Conn, code int, cause error) (int, error) { | |||
| 167 | + | e := &execpb.Exit{Code: int32(code)} | |||
| 168 | + | if cause != nil { | |||
| 169 | + | e.Error = cause.Error() | |||
| 170 | + | } | |||
| 171 | + | if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: e}}); err != nil { | |||
| 172 | + | return code, fmt.Errorf("send exit: %w", err) | |||
| 173 | + | } | |||
| 174 | + | return code, nil | |||
| 175 | + | } | |||
A internal/guest/fdconn.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | ||
| 9 | + | "golang.org/x/sys/unix" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // newFDConn wraps a connected stream socket (net.FileConn does not know | |
| 13 | + | // AF_VSOCK) as a ReadWriteCloser registered with the runtime poller. | |
| 14 | + | func newFDConn(fd int) (io.ReadWriteCloser, error) { | |
| 15 | + | if err := unix.SetNonblock(fd, true); err != nil { | |
| 16 | + | _ = unix.Close(fd) | |
| 17 | + | return nil, err | |
| 18 | + | } | |
| 19 | + | return os.NewFile(uintptr(fd), "vsock"), nil | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | ||||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // newFDConn wraps a connected stream socket (net.FileConn does not know | |||
| 13 | + | // AF_VSOCK) as a ReadWriteCloser registered with the runtime poller. | |||
| 14 | + | func newFDConn(fd int) (io.ReadWriteCloser, error) { | |||
| 15 | + | if err := unix.SetNonblock(fd, true); err != nil { | |||
| 16 | + | _ = unix.Close(fd) | |||
| 17 | + | return nil, err | |||
| 18 | + | } | |||
| 19 | + | return os.NewFile(uintptr(fd), "vsock"), nil | |||
| 20 | + | } | |||
A internal/guest/guest_test.go +261 -0
| @@ -0,0 +1,261 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net" | |
| 8 | + | "path/filepath" | |
| 9 | + | "reflect" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 16 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestParseCmdline(t *testing.T) { | |
| 20 | + | got := ParseCmdline("console=ttyS0 ro ermite.connect=vsock:1024 x=\"a b\" ermite.gw=10.0.0.1 ermite.gw=10.0.0.9 -- init\n") | |
| 21 | + | want := map[string]string{"console": "ttyS0", "ro": "", "ermite.connect": "vsock:1024", "x": "a b", "ermite.gw": "10.0.0.9", "--": "", "init": ""} | |
| 22 | + | if !reflect.DeepEqual(got, want) { | |
| 23 | + | t.Fatalf("got %v", got) | |
| 24 | + | } | |
| 25 | + | if len(ParseCmdline("")) != 0 { | |
| 26 | + | t.Fatal("empty command line") | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestParseTarget(t *testing.T) { | |
| 31 | + | for in, want := range map[string]Target{ | |
| 32 | + | "unix:/run/ermite/exec.sock": {"unix", "/run/ermite/exec.sock"}, | |
| 33 | + | "vsock:1024": {"vsock", "1024"}, | |
| 34 | + | } { | |
| 35 | + | got, err := ParseTarget(in) | |
| 36 | + | if err != nil || got != want || got.String() != in { | |
| 37 | + | t.Errorf("%q: %v %v", in, got, err) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | for _, in := range []string{"", "unix", "unix:", "vsock:0", "vsock:abc", "tcp:1.2.3.4:5", "vsock:99999999999"} { | |
| 41 | + | if _, err := ParseTarget(in); err == nil { | |
| 42 | + | t.Errorf("%q accepted", in) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // host plays the host process on a unix socket and runs one session. | |
| 48 | + | func host(t *testing.T, script string, ctxFn func(context.Context) context.Context) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) { | |
| 49 | + | t.Helper() | |
| 50 | + | sock := filepath.Join(t.TempDir(), "s") | |
| 51 | + | ln, err := net.Listen("unix", sock) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Skip(err) | |
| 54 | + | } | |
| 55 | + | defer func() { _ = ln.Close() }() | |
| 56 | + | type res struct { | |
| 57 | + | code int | |
| 58 | + | err error | |
| 59 | + | } | |
| 60 | + | done := make(chan res, 1) | |
| 61 | + | go func() { | |
| 62 | + | c, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second}) | |
| 63 | + | done <- res{c, err} | |
| 64 | + | }() | |
| 65 | + | conn, err := ln.Accept() | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | defer func() { _ = conn.Close() }() | |
| 70 | + | ctx := context.Background() | |
| 71 | + | if ctxFn != nil { | |
| 72 | + | ctx = ctxFn(ctx) | |
| 73 | + | } | |
| 74 | + | var so, se strings.Builder | |
| 75 | + | hello, exit, err = execstream.Drive(ctx, conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, func(o *execpb.Output) { | |
| 76 | + | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | |
| 77 | + | se.Write(o.Data) | |
| 78 | + | } else { | |
| 79 | + | so.Write(o.Data) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | if err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | r := <-done | |
| 86 | + | return so.String(), se.String(), exit, hello, r.code, r.err | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestExecStreamsOutputAndExitCode(t *testing.T) { | |
| 90 | + | out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, nil) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | if out != "out-line\nfrom-host\n" || errOut != "err-line\n" { | |
| 95 | + | t.Fatalf("out=%q err=%q", out, errOut) | |
| 96 | + | } | |
| 97 | + | if exit.Code != 3 || code != 3 || exit.Error != "" { | |
| 98 | + | t.Fatalf("exit=%v code=%d", exit, code) | |
| 99 | + | } | |
| 100 | + | if hello.Sandbox != "container" || hello.Version != "test" || hello.Hostname == "" { | |
| 101 | + | t.Fatalf("hello=%v", hello) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | |
| 106 | + | // The default script is the CI-1 stub: uname, id, os-release, docker info. | |
| 107 | + | // docker may be absent here: only the layout of the output is checked. | |
| 108 | + | out, errOut, exit, _, _, _ := host(t, "", nil) | |
| 109 | + | if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") { | |
| 110 | + | t.Fatalf("uname/id missing: out=%q err=%q", out, errOut) | |
| 111 | + | } | |
| 112 | + | if exit == nil { | |
| 113 | + | t.Fatal("no exit frame") | |
| 114 | + | } | |
| 115 | + | if !strings.Contains(DefaultScript, "docker info") || !strings.Contains(DefaultScript, "os-release") { | |
| 116 | + | t.Fatal("default script must run what the plan says") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | |
| 121 | + | out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, nil) | |
| 122 | + | if len(out) != 300000 || exit.Code != 0 { | |
| 123 | + | t.Fatalf("len=%d exit=%v", len(out), exit) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestExecCancelSendsSIGINT(t *testing.T) { | |
| 128 | + | start := time.Now() | |
| 129 | + | out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, func(ctx context.Context) context.Context { | |
| 130 | + | c, cancel := context.WithCancel(ctx) | |
| 131 | + | time.AfterFunc(500*time.Millisecond, cancel) | |
| 132 | + | return c | |
| 133 | + | }) | |
| 134 | + | if !strings.Contains(out, "got-int") || exit.Code != 130 { | |
| 135 | + | t.Fatalf("out=%q exit=%v", out, exit) | |
| 136 | + | } | |
| 137 | + | if time.Since(start) > 5*time.Second { | |
| 138 | + | t.Fatal("cancellation too slow") | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestExecKilledBySignalReports128PlusSignal(t *testing.T) { | |
| 143 | + | _, _, exit, _, _, _ := host(t, `kill -TERM $$`, nil) | |
| 144 | + | if exit.Code != 128+int32(syscall.SIGTERM) { | |
| 145 | + | t.Fatalf("exit=%v", exit) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestExecMissingShell(t *testing.T) { | |
| 150 | + | sock := filepath.Join(t.TempDir(), "s") | |
| 151 | + | ln, err := net.Listen("unix", sock) | |
| 152 | + | if err != nil { | |
| 153 | + | t.Skip(err) | |
| 154 | + | } | |
| 155 | + | defer func() { _ = ln.Close() }() | |
| 156 | + | go func() { | |
| 157 | + | c, _ := ln.Accept() | |
| 158 | + | defer func() { _ = c.Close() }() | |
| 159 | + | _, exit, _ := execstream.Drive(context.Background(), c, &execpb.Run{Script: "true"}, nil) | |
| 160 | + | if exit == nil || exit.Code != 127 || exit.Error == "" { | |
| 161 | + | t.Errorf("want 127 with an error message, got %v", exit) | |
| 162 | + | } | |
| 163 | + | }() | |
| 164 | + | code, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/nonexistent/sh", RunTimeout: 3 * time.Second}) | |
| 165 | + | if code != 127 { | |
| 166 | + | t.Fatalf("code=%d", code) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestExecHostGoneKillsTheScript(t *testing.T) { | |
| 171 | + | sock := filepath.Join(t.TempDir(), "s") | |
| 172 | + | ln, err := net.Listen("unix", sock) | |
| 173 | + | if err != nil { | |
| 174 | + | t.Skip(err) | |
| 175 | + | } | |
| 176 | + | defer func() { _ = ln.Close() }() | |
| 177 | + | done := make(chan int, 1) | |
| 178 | + | go func() { | |
| 179 | + | c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/bin/sh", RunTimeout: 3 * time.Second}) | |
| 180 | + | done <- c | |
| 181 | + | }() | |
| 182 | + | conn, _ := ln.Accept() | |
| 183 | + | g := execstream.NewConn(conn) | |
| 184 | + | _, _ = g.Recv() // hello | |
| 185 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Script: "sleep 60"}}}) | |
| 186 | + | time.Sleep(200 * time.Millisecond) | |
| 187 | + | _ = conn.Close() // the host died | |
| 188 | + | select { | |
| 189 | + | case code := <-done: | |
| 190 | + | if code == 0 { | |
| 191 | + | t.Fatalf("code=%d", code) | |
| 192 | + | } | |
| 193 | + | case <-time.After(10 * time.Second): | |
| 194 | + | t.Fatal("the executor kept running after the host went away") | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestExecConnectTimeout(t *testing.T) { | |
| 199 | + | start := time.Now() | |
| 200 | + | code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", filepath.Join(t.TempDir(), "nobody")}, ConnectTimeout: 300 * time.Millisecond}) | |
| 201 | + | if code == 0 || err == nil || time.Since(start) > 3*time.Second { | |
| 202 | + | t.Fatalf("code=%d err=%v after %v", code, err, time.Since(start)) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestExecRunTimeout(t *testing.T) { | |
| 207 | + | sock := filepath.Join(t.TempDir(), "s") | |
| 208 | + | ln, err := net.Listen("unix", sock) | |
| 209 | + | if err != nil { | |
| 210 | + | t.Skip(err) | |
| 211 | + | } | |
| 212 | + | defer func() { _ = ln.Close() }() | |
| 213 | + | go func() { c, _ := ln.Accept(); time.Sleep(2 * time.Second); _ = c.Close() }() | |
| 214 | + | code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 200 * time.Millisecond}) | |
| 215 | + | if code == 0 || err == nil || !strings.Contains(err.Error(), "run frame") { | |
| 216 | + | t.Fatalf("code=%d err=%v", code, err) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestInitInContainerSkipsVMSetup(t *testing.T) { | |
| 221 | + | // In a container (not a VM's PID 1) Init must not mount anything: with a | |
| 222 | + | // command line that says "vm" but a pid that is not 1 it behaves as a | |
| 223 | + | // container, runs the payload and returns the executor's code. | |
| 224 | + | sock := filepath.Join(t.TempDir(), "s") | |
| 225 | + | ln, err := net.Listen("unix", sock) | |
| 226 | + | if err != nil { | |
| 227 | + | t.Skip(err) | |
| 228 | + | } | |
| 229 | + | defer func() { _ = ln.Close() }() | |
| 230 | + | go func() { | |
| 231 | + | c, err := ln.Accept() | |
| 232 | + | if err != nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | defer func() { _ = c.Close() }() | |
| 236 | + | _, _, _ = execstream.Drive(context.Background(), c, &execpb.Run{Script: "exit 0"}, nil) | |
| 237 | + | }() | |
| 238 | + | code := Init(context.Background(), InitConfig{ | |
| 239 | + | Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock, | |
| 240 | + | Getpid: func() int { return 4242 }, | |
| 241 | + | ExecArgs: []string{"/bin/sh", "-c", "exit 5"}, | |
| 242 | + | DockerTimeout: time.Millisecond, | |
| 243 | + | SkipDocker: true, | |
| 244 | + | }) | |
| 245 | + | // ExecArgs is a stand-in for the executor: its exit code is Init's. | |
| 246 | + | if code != 5 { | |
| 247 | + | t.Fatalf("code=%d", code) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestInitWithoutTargetFails(t *testing.T) { | |
| 252 | + | if code := Init(context.Background(), InitConfig{Cmdline: "ermite.sandbox=container", Getpid: func() int { return 77 }, ExecArgs: []string{"/bin/true"}, SkipDocker: true}); code == 0 { | |
| 253 | + | t.Fatal("init without a connect target must fail") | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestWriteResolvConfNeedsGateway(t *testing.T) { | |
| 258 | + | if err := writeResolvConf(""); err == nil { | |
| 259 | + | t.Fatal("empty gateway accepted") | |
| 260 | + | } | |
| 261 | + | } | |
| @@ -0,0 +1,261 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "reflect" | |||
| 10 | + | "strings" | |||
| 11 | + | "syscall" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 16 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestParseCmdline(t *testing.T) { | |||
| 20 | + | got := ParseCmdline("console=ttyS0 ro ermite.connect=vsock:1024 x=\"a b\" ermite.gw=10.0.0.1 ermite.gw=10.0.0.9 -- init\n") | |||
| 21 | + | want := map[string]string{"console": "ttyS0", "ro": "", "ermite.connect": "vsock:1024", "x": "a b", "ermite.gw": "10.0.0.9", "--": "", "init": ""} | |||
| 22 | + | if !reflect.DeepEqual(got, want) { | |||
| 23 | + | t.Fatalf("got %v", got) | |||
| 24 | + | } | |||
| 25 | + | if len(ParseCmdline("")) != 0 { | |||
| 26 | + | t.Fatal("empty command line") | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestParseTarget(t *testing.T) { | |||
| 31 | + | for in, want := range map[string]Target{ | |||
| 32 | + | "unix:/run/ermite/exec.sock": {"unix", "/run/ermite/exec.sock"}, | |||
| 33 | + | "vsock:1024": {"vsock", "1024"}, | |||
| 34 | + | } { | |||
| 35 | + | got, err := ParseTarget(in) | |||
| 36 | + | if err != nil || got != want || got.String() != in { | |||
| 37 | + | t.Errorf("%q: %v %v", in, got, err) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | for _, in := range []string{"", "unix", "unix:", "vsock:0", "vsock:abc", "tcp:1.2.3.4:5", "vsock:99999999999"} { | |||
| 41 | + | if _, err := ParseTarget(in); err == nil { | |||
| 42 | + | t.Errorf("%q accepted", in) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // host plays the host process on a unix socket and runs one session. | |||
| 48 | + | func host(t *testing.T, script string, ctxFn func(context.Context) context.Context) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) { | |||
| 49 | + | t.Helper() | |||
| 50 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 51 | + | ln, err := net.Listen("unix", sock) | |||
| 52 | + | if err != nil { | |||
| 53 | + | t.Skip(err) | |||
| 54 | + | } | |||
| 55 | + | defer func() { _ = ln.Close() }() | |||
| 56 | + | type res struct { | |||
| 57 | + | code int | |||
| 58 | + | err error | |||
| 59 | + | } | |||
| 60 | + | done := make(chan res, 1) | |||
| 61 | + | go func() { | |||
| 62 | + | c, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second}) | |||
| 63 | + | done <- res{c, err} | |||
| 64 | + | }() | |||
| 65 | + | conn, err := ln.Accept() | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | defer func() { _ = conn.Close() }() | |||
| 70 | + | ctx := context.Background() | |||
| 71 | + | if ctxFn != nil { | |||
| 72 | + | ctx = ctxFn(ctx) | |||
| 73 | + | } | |||
| 74 | + | var so, se strings.Builder | |||
| 75 | + | hello, exit, err = execstream.Drive(ctx, conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, func(o *execpb.Output) { | |||
| 76 | + | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | |||
| 77 | + | se.Write(o.Data) | |||
| 78 | + | } else { | |||
| 79 | + | so.Write(o.Data) | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 82 | + | if err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | r := <-done | |||
| 86 | + | return so.String(), se.String(), exit, hello, r.code, r.err | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestExecStreamsOutputAndExitCode(t *testing.T) { | |||
| 90 | + | out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, nil) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | if out != "out-line\nfrom-host\n" || errOut != "err-line\n" { | |||
| 95 | + | t.Fatalf("out=%q err=%q", out, errOut) | |||
| 96 | + | } | |||
| 97 | + | if exit.Code != 3 || code != 3 || exit.Error != "" { | |||
| 98 | + | t.Fatalf("exit=%v code=%d", exit, code) | |||
| 99 | + | } | |||
| 100 | + | if hello.Sandbox != "container" || hello.Version != "test" || hello.Hostname == "" { | |||
| 101 | + | t.Fatalf("hello=%v", hello) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | |||
| 106 | + | // The default script is the CI-1 stub: uname, id, os-release, docker info. | |||
| 107 | + | // docker may be absent here: only the layout of the output is checked. | |||
| 108 | + | out, errOut, exit, _, _, _ := host(t, "", nil) | |||
| 109 | + | if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") { | |||
| 110 | + | t.Fatalf("uname/id missing: out=%q err=%q", out, errOut) | |||
| 111 | + | } | |||
| 112 | + | if exit == nil { | |||
| 113 | + | t.Fatal("no exit frame") | |||
| 114 | + | } | |||
| 115 | + | if !strings.Contains(DefaultScript, "docker info") || !strings.Contains(DefaultScript, "os-release") { | |||
| 116 | + | t.Fatal("default script must run what the plan says") | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | |||
| 121 | + | out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, nil) | |||
| 122 | + | if len(out) != 300000 || exit.Code != 0 { | |||
| 123 | + | t.Fatalf("len=%d exit=%v", len(out), exit) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestExecCancelSendsSIGINT(t *testing.T) { | |||
| 128 | + | start := time.Now() | |||
| 129 | + | out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, func(ctx context.Context) context.Context { | |||
| 130 | + | c, cancel := context.WithCancel(ctx) | |||
| 131 | + | time.AfterFunc(500*time.Millisecond, cancel) | |||
| 132 | + | return c | |||
| 133 | + | }) | |||
| 134 | + | if !strings.Contains(out, "got-int") || exit.Code != 130 { | |||
| 135 | + | t.Fatalf("out=%q exit=%v", out, exit) | |||
| 136 | + | } | |||
| 137 | + | if time.Since(start) > 5*time.Second { | |||
| 138 | + | t.Fatal("cancellation too slow") | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestExecKilledBySignalReports128PlusSignal(t *testing.T) { | |||
| 143 | + | _, _, exit, _, _, _ := host(t, `kill -TERM $$`, nil) | |||
| 144 | + | if exit.Code != 128+int32(syscall.SIGTERM) { | |||
| 145 | + | t.Fatalf("exit=%v", exit) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestExecMissingShell(t *testing.T) { | |||
| 150 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 151 | + | ln, err := net.Listen("unix", sock) | |||
| 152 | + | if err != nil { | |||
| 153 | + | t.Skip(err) | |||
| 154 | + | } | |||
| 155 | + | defer func() { _ = ln.Close() }() | |||
| 156 | + | go func() { | |||
| 157 | + | c, _ := ln.Accept() | |||
| 158 | + | defer func() { _ = c.Close() }() | |||
| 159 | + | _, exit, _ := execstream.Drive(context.Background(), c, &execpb.Run{Script: "true"}, nil) | |||
| 160 | + | if exit == nil || exit.Code != 127 || exit.Error == "" { | |||
| 161 | + | t.Errorf("want 127 with an error message, got %v", exit) | |||
| 162 | + | } | |||
| 163 | + | }() | |||
| 164 | + | code, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/nonexistent/sh", RunTimeout: 3 * time.Second}) | |||
| 165 | + | if code != 127 { | |||
| 166 | + | t.Fatalf("code=%d", code) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestExecHostGoneKillsTheScript(t *testing.T) { | |||
| 171 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 172 | + | ln, err := net.Listen("unix", sock) | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Skip(err) | |||
| 175 | + | } | |||
| 176 | + | defer func() { _ = ln.Close() }() | |||
| 177 | + | done := make(chan int, 1) | |||
| 178 | + | go func() { | |||
| 179 | + | c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/bin/sh", RunTimeout: 3 * time.Second}) | |||
| 180 | + | done <- c | |||
| 181 | + | }() | |||
| 182 | + | conn, _ := ln.Accept() | |||
| 183 | + | g := execstream.NewConn(conn) | |||
| 184 | + | _, _ = g.Recv() // hello | |||
| 185 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Script: "sleep 60"}}}) | |||
| 186 | + | time.Sleep(200 * time.Millisecond) | |||
| 187 | + | _ = conn.Close() // the host died | |||
| 188 | + | select { | |||
| 189 | + | case code := <-done: | |||
| 190 | + | if code == 0 { | |||
| 191 | + | t.Fatalf("code=%d", code) | |||
| 192 | + | } | |||
| 193 | + | case <-time.After(10 * time.Second): | |||
| 194 | + | t.Fatal("the executor kept running after the host went away") | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestExecConnectTimeout(t *testing.T) { | |||
| 199 | + | start := time.Now() | |||
| 200 | + | code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", filepath.Join(t.TempDir(), "nobody")}, ConnectTimeout: 300 * time.Millisecond}) | |||
| 201 | + | if code == 0 || err == nil || time.Since(start) > 3*time.Second { | |||
| 202 | + | t.Fatalf("code=%d err=%v after %v", code, err, time.Since(start)) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestExecRunTimeout(t *testing.T) { | |||
| 207 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 208 | + | ln, err := net.Listen("unix", sock) | |||
| 209 | + | if err != nil { | |||
| 210 | + | t.Skip(err) | |||
| 211 | + | } | |||
| 212 | + | defer func() { _ = ln.Close() }() | |||
| 213 | + | go func() { c, _ := ln.Accept(); time.Sleep(2 * time.Second); _ = c.Close() }() | |||
| 214 | + | code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 200 * time.Millisecond}) | |||
| 215 | + | if code == 0 || err == nil || !strings.Contains(err.Error(), "run frame") { | |||
| 216 | + | t.Fatalf("code=%d err=%v", code, err) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestInitInContainerSkipsVMSetup(t *testing.T) { | |||
| 221 | + | // In a container (not a VM's PID 1) Init must not mount anything: with a | |||
| 222 | + | // command line that says "vm" but a pid that is not 1 it behaves as a | |||
| 223 | + | // container, runs the payload and returns the executor's code. | |||
| 224 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 225 | + | ln, err := net.Listen("unix", sock) | |||
| 226 | + | if err != nil { | |||
| 227 | + | t.Skip(err) | |||
| 228 | + | } | |||
| 229 | + | defer func() { _ = ln.Close() }() | |||
| 230 | + | go func() { | |||
| 231 | + | c, err := ln.Accept() | |||
| 232 | + | if err != nil { | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | defer func() { _ = c.Close() }() | |||
| 236 | + | _, _, _ = execstream.Drive(context.Background(), c, &execpb.Run{Script: "exit 0"}, nil) | |||
| 237 | + | }() | |||
| 238 | + | code := Init(context.Background(), InitConfig{ | |||
| 239 | + | Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock, | |||
| 240 | + | Getpid: func() int { return 4242 }, | |||
| 241 | + | ExecArgs: []string{"/bin/sh", "-c", "exit 5"}, | |||
| 242 | + | DockerTimeout: time.Millisecond, | |||
| 243 | + | SkipDocker: true, | |||
| 244 | + | }) | |||
| 245 | + | // ExecArgs is a stand-in for the executor: its exit code is Init's. | |||
| 246 | + | if code != 5 { | |||
| 247 | + | t.Fatalf("code=%d", code) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestInitWithoutTargetFails(t *testing.T) { | |||
| 252 | + | if code := Init(context.Background(), InitConfig{Cmdline: "ermite.sandbox=container", Getpid: func() int { return 77 }, ExecArgs: []string{"/bin/true"}, SkipDocker: true}); code == 0 { | |||
| 253 | + | t.Fatal("init without a connect target must fail") | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestWriteResolvConfNeedsGateway(t *testing.T) { | |||
| 258 | + | if err := writeResolvConf(""); err == nil { | |||
| 259 | + | t.Fatal("empty gateway accepted") | |||
| 260 | + | } | |||
| 261 | + | } | |||
A internal/guest/init.go +251 -0
| @@ -0,0 +1,251 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | "syscall" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-runner/internal/sandbox" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // InitConfig configures Init. The zero value reads the real system; tests | |
| 20 | + | // replace the fields. | |
| 21 | + | type InitConfig struct { | |
| 22 | + | Version string | |
| 23 | + | // Cmdline is the kernel command line; empty reads /proc/cmdline. | |
| 24 | + | Cmdline string | |
| 25 | + | // Getpid reports the process id; Init only acts as a VM's PID 1 when it is 1. | |
| 26 | + | Getpid func() int | |
| 27 | + | // DockerTimeout bounds the wait for the Docker socket (default 60 s). | |
| 28 | + | DockerTimeout time.Duration | |
| 29 | + | // SkipDocker does not start dockerd (tests, images without Docker). | |
| 30 | + | SkipDocker bool | |
| 31 | + | // ExecArgs is the executor command line; default | |
| 32 | + | // [GuestExecDir/GuestExecName exec]. | |
| 33 | + | ExecArgs []string | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Init is `ermite-runner init`: the entrypoint of every sandbox. | |
| 37 | + | // | |
| 38 | + | // In a micro-VM it is the kernel's init (PID 1, root = the read-only exec disk, | |
| 39 | + | // /dev/vdb = shared base image, /dev/vdc = per-job scratch disk): it mounts the | |
| 40 | + | // pseudo filesystems, sets the clock and the DNS server, builds an overlayfs | |
| 41 | + | // root (lower = base, upper = scratch) and pivots into it, then does what a | |
| 42 | + | // container does. | |
| 43 | + | // | |
| 44 | + | // In a container the root and the pseudo filesystems are ready: it skips all of | |
| 45 | + | // that and only prepares cgroups. Then, in both: start dockerd with its data | |
| 46 | + | // root on the scratch space, wait for its socket, run the executor | |
| 47 | + | // (`ermite-runner exec`) as a child, reap orphans, stop dockerd, and finish by | |
| 48 | + | // powering the VM off, or exiting with the executor's code in a container. | |
| 49 | + | func Init(ctx context.Context, cfg InitConfig) int { | |
| 50 | + | log := zerowrap.FromCtx(ctx) | |
| 51 | + | if cfg.Getpid == nil { | |
| 52 | + | cfg.Getpid = os.Getpid | |
| 53 | + | } | |
| 54 | + | if cfg.DockerTimeout == 0 { | |
| 55 | + | cfg.DockerTimeout = 60 * time.Second | |
| 56 | + | } | |
| 57 | + | if cfg.ExecArgs == nil { | |
| 58 | + | cfg.ExecArgs = []string{sandbox.GuestExecDir + "/" + sandbox.GuestExecName, "exec"} | |
| 59 | + | } | |
| 60 | + | cmdline := cfg.Cmdline | |
| 61 | + | if cmdline == "" { | |
| 62 | + | b, err := os.ReadFile("/proc/cmdline") | |
| 63 | + | if err != nil && cfg.Getpid() == 1 { | |
| 64 | + | // A VM's init starts with nothing mounted: /proc is needed to read | |
| 65 | + | // the kernel command line that says what we are. setupVM reuses it. | |
| 66 | + | if merr := unix.Mount("proc", "/proc", "proc", 0, ""); merr == nil { | |
| 67 | + | b, err = os.ReadFile("/proc/cmdline") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if err == nil { | |
| 71 | + | cmdline = string(b) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | args := ParseCmdline(cmdline) | |
| 75 | + | inVM := args[sandbox.CmdlineSandbox] == string(sandbox.VM) && cfg.Getpid() == 1 | |
| 76 | + | ||
| 77 | + | mode := string(sandbox.Container) | |
| 78 | + | if inVM { | |
| 79 | + | mode = string(sandbox.VM) | |
| 80 | + | if err := setupVM(log, args); err != nil { | |
| 81 | + | log.Error().Err(err).Msg("init: vm setup failed") | |
| 82 | + | powerOff() | |
| 83 | + | return 1 | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | code := runPayload(ctx, cfg, mode, args) | |
| 87 | + | if inVM { | |
| 88 | + | powerOff() | |
| 89 | + | } | |
| 90 | + | return code | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // defaultPath is the PATH of a sandbox whose init was started by the kernel | |
| 94 | + | // with an empty environment. | |
| 95 | + | const defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" | |
| 96 | + | ||
| 97 | + | func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[string]string) int { | |
| 98 | + | log := zerowrap.FromCtx(ctx) | |
| 99 | + | if os.Getenv("PATH") == "" { | |
| 100 | + | _ = os.Setenv("PATH", defaultPath) | |
| 101 | + | } | |
| 102 | + | if os.Getenv("HOME") == "" { | |
| 103 | + | _ = os.Setenv("HOME", "/root") | |
| 104 | + | } | |
| 105 | + | if mode == string(sandbox.Container) { | |
| 106 | + | if err := nestCgroups(); err != nil { | |
| 107 | + | log.Warn().Err(err).Msg("init: cgroup setup (docker inside may not start)") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | target := args[sandbox.CmdlineConnect] | |
| 111 | + | if target == "" { | |
| 112 | + | target = os.Getenv("ERMITE_CONNECT") | |
| 113 | + | } | |
| 114 | + | if _, err := ParseTarget(target); err != nil { | |
| 115 | + | log.Error().Err(err).Msg("init: no executor target") | |
| 116 | + | return 1 | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | var dockerPid int | |
| 120 | + | if _, err := os.Stat("/usr/bin/dockerd"); err == nil && !cfg.SkipDocker { | |
| 121 | + | _ = os.MkdirAll(sandbox.GuestScratch+"/docker", 0o711) | |
| 122 | + | pid, err := spawn([]string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker", | |
| 123 | + | "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"}, nil) | |
| 124 | + | if err != nil { | |
| 125 | + | log.Error().Err(err).Msg("init: start dockerd") | |
| 126 | + | } else { | |
| 127 | + | dockerPid = pid | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | |
| 132 | + | "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)) | |
| 133 | + | if err != nil { | |
| 134 | + | log.Error().Err(err).Msg("init: start executor") | |
| 135 | + | return 1 | |
| 136 | + | } | |
| 137 | + | // Docker is awaited in the background: the executor starts right away and | |
| 138 | + | // the stub script's `docker info` waits for the socket itself. | |
| 139 | + | if dockerPid != 0 { | |
| 140 | + | go waitSocket(ctx, "/var/run/docker.sock", cfg.DockerTimeout) | |
| 141 | + | } | |
| 142 | + | code := reapUntil(execPid) | |
| 143 | + | if dockerPid != 0 { | |
| 144 | + | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | |
| 145 | + | deadline := time.Now().Add(5 * time.Second) | |
| 146 | + | for time.Now().Before(deadline) { | |
| 147 | + | if pid, _ := wait4NoHang(dockerPid); pid == dockerPid { | |
| 148 | + | break | |
| 149 | + | } | |
| 150 | + | time.Sleep(100 * time.Millisecond) | |
| 151 | + | } | |
| 152 | + | _ = syscall.Kill(dockerPid, syscall.SIGKILL) | |
| 153 | + | } | |
| 154 | + | return code | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func waitSocket(ctx context.Context, path string, timeout time.Duration) { | |
| 158 | + | log := zerowrap.FromCtx(ctx) | |
| 159 | + | deadline := time.Now().Add(timeout) | |
| 160 | + | for time.Now().Before(deadline) { | |
| 161 | + | if _, err := os.Stat(path); err == nil { | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | time.Sleep(200 * time.Millisecond) | |
| 165 | + | } | |
| 166 | + | log.Warn().Str("socket", path).Msg("init: docker socket did not appear") | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // spawn starts argv[0] as a child (own process group) without os/exec, so that | |
| 170 | + | // reapUntil is the only waiter and orphans can be reaped safely. | |
| 171 | + | func spawn(argv, env []string) (int, error) { | |
| 172 | + | if env == nil { | |
| 173 | + | env = os.Environ() | |
| 174 | + | } | |
| 175 | + | return syscall.ForkExec(argv[0], argv, &syscall.ProcAttr{ | |
| 176 | + | Env: env, | |
| 177 | + | Files: []uintptr{0, 1, 2}, | |
| 178 | + | Sys: &syscall.SysProcAttr{Setpgid: true}, | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // reapUntil waits for every child (orphans included) and returns when pid | |
| 183 | + | // exits, with its exit code (128+signal when killed). | |
| 184 | + | func reapUntil(pid int) int { | |
| 185 | + | for { | |
| 186 | + | var ws syscall.WaitStatus | |
| 187 | + | got, err := syscall.Wait4(-1, &ws, 0, nil) | |
| 188 | + | if err == syscall.EINTR { | |
| 189 | + | continue | |
| 190 | + | } | |
| 191 | + | if err != nil { // ECHILD: no child left, the executor included | |
| 192 | + | return 1 | |
| 193 | + | } | |
| 194 | + | if got == pid { | |
| 195 | + | switch { | |
| 196 | + | case ws.Exited(): | |
| 197 | + | return ws.ExitStatus() | |
| 198 | + | case ws.Signaled(): | |
| 199 | + | return 128 + int(ws.Signal()) | |
| 200 | + | } | |
| 201 | + | return 1 | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func wait4NoHang(pid int) (int, error) { | |
| 207 | + | var ws syscall.WaitStatus | |
| 208 | + | return syscall.Wait4(pid, &ws, syscall.WNOHANG, nil) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func powerOff() { | |
| 212 | + | unix.Sync() | |
| 213 | + | // On x86 Firecracker has no ACPI power off: a reboot makes the VMM exit | |
| 214 | + | // (kernel cmdline reboot=k). On arm64 PSCI handles power off. | |
| 215 | + | _ = unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART) | |
| 216 | + | _ = unix.Reboot(unix.LINUX_REBOOT_CMD_POWER_OFF) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // nestCgroups prepares cgroup v2 for a nested dockerd in a container: a cgroup | |
| 220 | + | // with member processes cannot delegate controllers to children, so move every | |
| 221 | + | // process to an "init" child and enable the controllers (like the upstream | |
| 222 | + | // docker-in-docker entrypoint). No-op without cgroup v2. | |
| 223 | + | func nestCgroups() error { | |
| 224 | + | const root = "/sys/fs/cgroup" | |
| 225 | + | ctl, err := os.ReadFile(root + "/cgroup.controllers") | |
| 226 | + | if err != nil { | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | if err := os.MkdirAll(root+"/init", 0o755); err != nil { | |
| 230 | + | return err | |
| 231 | + | } | |
| 232 | + | procs, err := os.ReadFile(root + "/cgroup.procs") | |
| 233 | + | if err != nil { | |
| 234 | + | return err | |
| 235 | + | } | |
| 236 | + | for _, p := range strings.Fields(string(procs)) { | |
| 237 | + | // Processes may exit meanwhile: ignore per-process errors. | |
| 238 | + | _ = os.WriteFile(root+"/init/cgroup.procs", []byte(p), 0o644) | |
| 239 | + | } | |
| 240 | + | var enable []string | |
| 241 | + | for _, c := range strings.Fields(string(ctl)) { | |
| 242 | + | enable = append(enable, "+"+c) | |
| 243 | + | } | |
| 244 | + | if len(enable) == 0 { | |
| 245 | + | return nil | |
| 246 | + | } | |
| 247 | + | if err := os.WriteFile(root+"/cgroup.subtree_control", []byte(strings.Join(enable, " ")), 0o644); err != nil { | |
| 248 | + | return fmt.Errorf("enable controllers: %w", err) | |
| 249 | + | } | |
| 250 | + | return nil | |
| 251 | + | } | |
| @@ -0,0 +1,251 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "strings" | |||
| 10 | + | "syscall" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // InitConfig configures Init. The zero value reads the real system; tests | |||
| 20 | + | // replace the fields. | |||
| 21 | + | type InitConfig struct { | |||
| 22 | + | Version string | |||
| 23 | + | // Cmdline is the kernel command line; empty reads /proc/cmdline. | |||
| 24 | + | Cmdline string | |||
| 25 | + | // Getpid reports the process id; Init only acts as a VM's PID 1 when it is 1. | |||
| 26 | + | Getpid func() int | |||
| 27 | + | // DockerTimeout bounds the wait for the Docker socket (default 60 s). | |||
| 28 | + | DockerTimeout time.Duration | |||
| 29 | + | // SkipDocker does not start dockerd (tests, images without Docker). | |||
| 30 | + | SkipDocker bool | |||
| 31 | + | // ExecArgs is the executor command line; default | |||
| 32 | + | // [GuestExecDir/GuestExecName exec]. | |||
| 33 | + | ExecArgs []string | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Init is `ermite-runner init`: the entrypoint of every sandbox. | |||
| 37 | + | // | |||
| 38 | + | // In a micro-VM it is the kernel's init (PID 1, root = the read-only exec disk, | |||
| 39 | + | // /dev/vdb = shared base image, /dev/vdc = per-job scratch disk): it mounts the | |||
| 40 | + | // pseudo filesystems, sets the clock and the DNS server, builds an overlayfs | |||
| 41 | + | // root (lower = base, upper = scratch) and pivots into it, then does what a | |||
| 42 | + | // container does. | |||
| 43 | + | // | |||
| 44 | + | // In a container the root and the pseudo filesystems are ready: it skips all of | |||
| 45 | + | // that and only prepares cgroups. Then, in both: start dockerd with its data | |||
| 46 | + | // root on the scratch space, wait for its socket, run the executor | |||
| 47 | + | // (`ermite-runner exec`) as a child, reap orphans, stop dockerd, and finish by | |||
| 48 | + | // powering the VM off, or exiting with the executor's code in a container. | |||
| 49 | + | func Init(ctx context.Context, cfg InitConfig) int { | |||
| 50 | + | log := zerowrap.FromCtx(ctx) | |||
| 51 | + | if cfg.Getpid == nil { | |||
| 52 | + | cfg.Getpid = os.Getpid | |||
| 53 | + | } | |||
| 54 | + | if cfg.DockerTimeout == 0 { | |||
| 55 | + | cfg.DockerTimeout = 60 * time.Second | |||
| 56 | + | } | |||
| 57 | + | if cfg.ExecArgs == nil { | |||
| 58 | + | cfg.ExecArgs = []string{sandbox.GuestExecDir + "/" + sandbox.GuestExecName, "exec"} | |||
| 59 | + | } | |||
| 60 | + | cmdline := cfg.Cmdline | |||
| 61 | + | if cmdline == "" { | |||
| 62 | + | b, err := os.ReadFile("/proc/cmdline") | |||
| 63 | + | if err != nil && cfg.Getpid() == 1 { | |||
| 64 | + | // A VM's init starts with nothing mounted: /proc is needed to read | |||
| 65 | + | // the kernel command line that says what we are. setupVM reuses it. | |||
| 66 | + | if merr := unix.Mount("proc", "/proc", "proc", 0, ""); merr == nil { | |||
| 67 | + | b, err = os.ReadFile("/proc/cmdline") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if err == nil { | |||
| 71 | + | cmdline = string(b) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | args := ParseCmdline(cmdline) | |||
| 75 | + | inVM := args[sandbox.CmdlineSandbox] == string(sandbox.VM) && cfg.Getpid() == 1 | |||
| 76 | + | ||||
| 77 | + | mode := string(sandbox.Container) | |||
| 78 | + | if inVM { | |||
| 79 | + | mode = string(sandbox.VM) | |||
| 80 | + | if err := setupVM(log, args); err != nil { | |||
| 81 | + | log.Error().Err(err).Msg("init: vm setup failed") | |||
| 82 | + | powerOff() | |||
| 83 | + | return 1 | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | code := runPayload(ctx, cfg, mode, args) | |||
| 87 | + | if inVM { | |||
| 88 | + | powerOff() | |||
| 89 | + | } | |||
| 90 | + | return code | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // defaultPath is the PATH of a sandbox whose init was started by the kernel | |||
| 94 | + | // with an empty environment. | |||
| 95 | + | const defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" | |||
| 96 | + | ||||
| 97 | + | func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[string]string) int { | |||
| 98 | + | log := zerowrap.FromCtx(ctx) | |||
| 99 | + | if os.Getenv("PATH") == "" { | |||
| 100 | + | _ = os.Setenv("PATH", defaultPath) | |||
| 101 | + | } | |||
| 102 | + | if os.Getenv("HOME") == "" { | |||
| 103 | + | _ = os.Setenv("HOME", "/root") | |||
| 104 | + | } | |||
| 105 | + | if mode == string(sandbox.Container) { | |||
| 106 | + | if err := nestCgroups(); err != nil { | |||
| 107 | + | log.Warn().Err(err).Msg("init: cgroup setup (docker inside may not start)") | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | target := args[sandbox.CmdlineConnect] | |||
| 111 | + | if target == "" { | |||
| 112 | + | target = os.Getenv("ERMITE_CONNECT") | |||
| 113 | + | } | |||
| 114 | + | if _, err := ParseTarget(target); err != nil { | |||
| 115 | + | log.Error().Err(err).Msg("init: no executor target") | |||
| 116 | + | return 1 | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | var dockerPid int | |||
| 120 | + | if _, err := os.Stat("/usr/bin/dockerd"); err == nil && !cfg.SkipDocker { | |||
| 121 | + | _ = os.MkdirAll(sandbox.GuestScratch+"/docker", 0o711) | |||
| 122 | + | pid, err := spawn([]string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker", | |||
| 123 | + | "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"}, nil) | |||
| 124 | + | if err != nil { | |||
| 125 | + | log.Error().Err(err).Msg("init: start dockerd") | |||
| 126 | + | } else { | |||
| 127 | + | dockerPid = pid | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | |||
| 132 | + | "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)) | |||
| 133 | + | if err != nil { | |||
| 134 | + | log.Error().Err(err).Msg("init: start executor") | |||
| 135 | + | return 1 | |||
| 136 | + | } | |||
| 137 | + | // Docker is awaited in the background: the executor starts right away and | |||
| 138 | + | // the stub script's `docker info` waits for the socket itself. | |||
| 139 | + | if dockerPid != 0 { | |||
| 140 | + | go waitSocket(ctx, "/var/run/docker.sock", cfg.DockerTimeout) | |||
| 141 | + | } | |||
| 142 | + | code := reapUntil(execPid) | |||
| 143 | + | if dockerPid != 0 { | |||
| 144 | + | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | |||
| 145 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 146 | + | for time.Now().Before(deadline) { | |||
| 147 | + | if pid, _ := wait4NoHang(dockerPid); pid == dockerPid { | |||
| 148 | + | break | |||
| 149 | + | } | |||
| 150 | + | time.Sleep(100 * time.Millisecond) | |||
| 151 | + | } | |||
| 152 | + | _ = syscall.Kill(dockerPid, syscall.SIGKILL) | |||
| 153 | + | } | |||
| 154 | + | return code | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func waitSocket(ctx context.Context, path string, timeout time.Duration) { | |||
| 158 | + | log := zerowrap.FromCtx(ctx) | |||
| 159 | + | deadline := time.Now().Add(timeout) | |||
| 160 | + | for time.Now().Before(deadline) { | |||
| 161 | + | if _, err := os.Stat(path); err == nil { | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | time.Sleep(200 * time.Millisecond) | |||
| 165 | + | } | |||
| 166 | + | log.Warn().Str("socket", path).Msg("init: docker socket did not appear") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // spawn starts argv[0] as a child (own process group) without os/exec, so that | |||
| 170 | + | // reapUntil is the only waiter and orphans can be reaped safely. | |||
| 171 | + | func spawn(argv, env []string) (int, error) { | |||
| 172 | + | if env == nil { | |||
| 173 | + | env = os.Environ() | |||
| 174 | + | } | |||
| 175 | + | return syscall.ForkExec(argv[0], argv, &syscall.ProcAttr{ | |||
| 176 | + | Env: env, | |||
| 177 | + | Files: []uintptr{0, 1, 2}, | |||
| 178 | + | Sys: &syscall.SysProcAttr{Setpgid: true}, | |||
| 179 | + | }) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // reapUntil waits for every child (orphans included) and returns when pid | |||
| 183 | + | // exits, with its exit code (128+signal when killed). | |||
| 184 | + | func reapUntil(pid int) int { | |||
| 185 | + | for { | |||
| 186 | + | var ws syscall.WaitStatus | |||
| 187 | + | got, err := syscall.Wait4(-1, &ws, 0, nil) | |||
| 188 | + | if err == syscall.EINTR { | |||
| 189 | + | continue | |||
| 190 | + | } | |||
| 191 | + | if err != nil { // ECHILD: no child left, the executor included | |||
| 192 | + | return 1 | |||
| 193 | + | } | |||
| 194 | + | if got == pid { | |||
| 195 | + | switch { | |||
| 196 | + | case ws.Exited(): | |||
| 197 | + | return ws.ExitStatus() | |||
| 198 | + | case ws.Signaled(): | |||
| 199 | + | return 128 + int(ws.Signal()) | |||
| 200 | + | } | |||
| 201 | + | return 1 | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func wait4NoHang(pid int) (int, error) { | |||
| 207 | + | var ws syscall.WaitStatus | |||
| 208 | + | return syscall.Wait4(pid, &ws, syscall.WNOHANG, nil) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func powerOff() { | |||
| 212 | + | unix.Sync() | |||
| 213 | + | // On x86 Firecracker has no ACPI power off: a reboot makes the VMM exit | |||
| 214 | + | // (kernel cmdline reboot=k). On arm64 PSCI handles power off. | |||
| 215 | + | _ = unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART) | |||
| 216 | + | _ = unix.Reboot(unix.LINUX_REBOOT_CMD_POWER_OFF) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // nestCgroups prepares cgroup v2 for a nested dockerd in a container: a cgroup | |||
| 220 | + | // with member processes cannot delegate controllers to children, so move every | |||
| 221 | + | // process to an "init" child and enable the controllers (like the upstream | |||
| 222 | + | // docker-in-docker entrypoint). No-op without cgroup v2. | |||
| 223 | + | func nestCgroups() error { | |||
| 224 | + | const root = "/sys/fs/cgroup" | |||
| 225 | + | ctl, err := os.ReadFile(root + "/cgroup.controllers") | |||
| 226 | + | if err != nil { | |||
| 227 | + | return nil | |||
| 228 | + | } | |||
| 229 | + | if err := os.MkdirAll(root+"/init", 0o755); err != nil { | |||
| 230 | + | return err | |||
| 231 | + | } | |||
| 232 | + | procs, err := os.ReadFile(root + "/cgroup.procs") | |||
| 233 | + | if err != nil { | |||
| 234 | + | return err | |||
| 235 | + | } | |||
| 236 | + | for _, p := range strings.Fields(string(procs)) { | |||
| 237 | + | // Processes may exit meanwhile: ignore per-process errors. | |||
| 238 | + | _ = os.WriteFile(root+"/init/cgroup.procs", []byte(p), 0o644) | |||
| 239 | + | } | |||
| 240 | + | var enable []string | |||
| 241 | + | for _, c := range strings.Fields(string(ctl)) { | |||
| 242 | + | enable = append(enable, "+"+c) | |||
| 243 | + | } | |||
| 244 | + | if len(enable) == 0 { | |||
| 245 | + | return nil | |||
| 246 | + | } | |||
| 247 | + | if err := os.WriteFile(root+"/cgroup.subtree_control", []byte(strings.Join(enable, " ")), 0o644); err != nil { | |||
| 248 | + | return fmt.Errorf("enable controllers: %w", err) | |||
| 249 | + | } | |||
| 250 | + | return nil | |||
| 251 | + | } | |||
A internal/guest/vmsetup.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite-runner/internal/sandbox" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Devices of the micro-VM, in the order the host attaches them (virtio-blk). | |
| 18 | + | const ( | |
| 19 | + | devBase = "/dev/vdb" // shared read-only base image (ext4) | |
| 20 | + | devScratch = "/dev/vdc" // per-job scratch disk (ext4) | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // KernelArg names of the clock and DNS settings (not part of the contract | |
| 24 | + | // constants because only init reads them). | |
| 25 | + | const ( | |
| 26 | + | cmdlineTime = "ermite.time" // host unix time at boot, seconds | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // setupVM turns the minimal boot root (the exec disk) into the job's root: | |
| 30 | + | // overlayfs of base + scratch, with the exec disk bound read-only at | |
| 31 | + | // /opt/ermite, then pivot_root into it and mount the pseudo filesystems. | |
| 32 | + | func setupVM(log zerowrap.Logger, args map[string]string) error { | |
| 33 | + | // Pseudo filesystems of the boot root. Their mountpoints exist on the exec | |
| 34 | + | // disk (the root is read-only). | |
| 35 | + | for _, m := range []struct{ src, dst, typ string }{ | |
| 36 | + | {"proc", "/proc", "proc"}, | |
| 37 | + | {"sysfs", "/sys", "sysfs"}, | |
| 38 | + | {"devtmpfs", "/dev", "devtmpfs"}, | |
| 39 | + | } { | |
| 40 | + | // proc is mounted by Init (to read the command line) and devtmpfs by the | |
| 41 | + | // kernel (DEVTMPFS_MOUNT): mount only what is missing. | |
| 42 | + | if isMounted(m.dst) { | |
| 43 | + | continue | |
| 44 | + | } | |
| 45 | + | if err := mount(m.src, m.dst, m.typ, 0, ""); err != nil { | |
| 46 | + | return err | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | // Firecracker has no RTC on x86: the guest clock starts at the epoch, which | |
| 50 | + | // breaks TLS. The host passes its time on the command line. | |
| 51 | + | if v, ok := args[cmdlineTime]; ok { | |
| 52 | + | if sec, err := strconv.ParseInt(v, 10, 64); err == nil && sec > 1_600_000_000 { | |
| 53 | + | if err := unix.ClockSettime(unix.CLOCK_REALTIME, &unix.Timespec{Sec: sec}); err != nil { | |
| 54 | + | log.Warn().Err(err).Msg("init: set clock") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if err := mount(devBase, "/mnt/base", "ext4", unix.MS_RDONLY, ""); err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | if err := mount(devScratch, "/mnt/scratch", "ext4", unix.MS_NOATIME, ""); err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | for _, d := range []string{"/mnt/scratch/upper", "/mnt/scratch/work", "/mnt/scratch/docker"} { | |
| 66 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | opts := "lowerdir=/mnt/base,upperdir=/mnt/scratch/upper,workdir=/mnt/scratch/work" | |
| 71 | + | if err := mount("overlay", "/mnt/newroot", "overlay", 0, opts); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | // The new root needs these mountpoints; they live in the (writable) upper. | |
| 75 | + | for _, d := range []string{"/mnt/newroot/proc", "/mnt/newroot/sys", "/mnt/newroot/dev", | |
| 76 | + | "/mnt/newroot/run", "/mnt/newroot/tmp", "/mnt/newroot/scratch", "/mnt/newroot/.oldroot", | |
| 77 | + | "/mnt/newroot" + sandbox.GuestExecDir} { | |
| 78 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | // Scratch and the exec disk stay reachable after the pivot. | |
| 83 | + | if err := mount("/mnt/scratch", "/mnt/newroot"+sandbox.GuestScratch, "", unix.MS_BIND, ""); err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | if err := mount("/", "/mnt/newroot"+sandbox.GuestExecDir, "", unix.MS_BIND, ""); err != nil { | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | if err := mount("", "/mnt/newroot"+sandbox.GuestExecDir, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, ""); err != nil { | |
| 90 | + | return err | |
| 91 | + | } | |
| 92 | + | for _, d := range []string{"/proc", "/sys", "/dev"} { | |
| 93 | + | if err := mount(d, "/mnt/newroot"+d, "", unix.MS_MOVE, ""); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | if err := unix.Chdir("/mnt/newroot"); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | if err := unix.PivotRoot(".", ".oldroot"); err != nil { | |
| 101 | + | return fmt.Errorf("pivot_root: %w", err) | |
| 102 | + | } | |
| 103 | + | if err := unix.Chroot("."); err != nil { | |
| 104 | + | return err | |
| 105 | + | } | |
| 106 | + | if err := unix.Chdir("/"); err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | _ = unix.Unmount("/.oldroot", unix.MNT_DETACH) | |
| 110 | + | ||
| 111 | + | for _, m := range []struct { | |
| 112 | + | src, dst, typ string | |
| 113 | + | flags uintptr | |
| 114 | + | data string | |
| 115 | + | }{ | |
| 116 | + | {"devpts", "/dev/pts", "devpts", 0, "gid=5,mode=620,ptmxmode=666"}, | |
| 117 | + | {"tmpfs", "/dev/shm", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, ""}, | |
| 118 | + | {"tmpfs", "/run", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, "mode=755"}, | |
| 119 | + | {"tmpfs", "/tmp", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, "mode=1777"}, | |
| 120 | + | {"cgroup2", "/sys/fs/cgroup", "cgroup2", unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC, ""}, | |
| 121 | + | } { | |
| 122 | + | if err := os.MkdirAll(m.dst, 0o755); err != nil { | |
| 123 | + | return err | |
| 124 | + | } | |
| 125 | + | if err := mount(m.src, m.dst, m.typ, m.flags, m.data); err != nil { | |
| 126 | + | // devpts and shm failures are not fatal for the stub. | |
| 127 | + | log.Warn().Err(err).Str("target", m.dst).Msg("init: mount") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | _ = os.Chmod("/tmp", 0o1777) | |
| 131 | + | ||
| 132 | + | gw := args[sandbox.CmdlineGateway] | |
| 133 | + | if err := writeResolvConf(gw); err != nil { | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | _ = os.WriteFile("/etc/hostname", []byte("ermite-job\n"), 0o644) | |
| 137 | + | _ = os.WriteFile("/etc/hosts", []byte("127.0.0.1 localhost ermite-job\n"), 0o644) | |
| 138 | + | _ = unix.Sethostname([]byte("ermite-job")) | |
| 139 | + | if err := loUp(); err != nil { | |
| 140 | + | log.Warn().Err(err).Msg("init: loopback") | |
| 141 | + | } | |
| 142 | + | // IPv6 is disabled on the kernel command line (ipv6.disable=1); this is a | |
| 143 | + | // second line of defence when the module is built in but not disabled. | |
| 144 | + | _ = os.WriteFile("/proc/sys/net/ipv6/conf/all/disable_ipv6", []byte("1\n"), 0o644) | |
| 145 | + | return nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // writeResolvConf points the guest at the gateway, where the runner's DNS | |
| 149 | + | // forwarder listens. /etc/resolv.conf may be a dangling symlink in the image | |
| 150 | + | // (systemd-resolved stub): replace it. | |
| 151 | + | func writeResolvConf(gw string) error { | |
| 152 | + | if gw == "" { | |
| 153 | + | return fmt.Errorf("kernel command line has no %s", sandbox.CmdlineGateway) | |
| 154 | + | } | |
| 155 | + | _ = os.Remove("/etc/resolv.conf") | |
| 156 | + | return os.WriteFile("/etc/resolv.conf", []byte("nameserver "+gw+"\noptions timeout:2 attempts:2\n"), 0o644) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func mount(src, dst, typ string, flags uintptr, data string) error { | |
| 160 | + | if err := os.MkdirAll(dst, 0o755); err != nil && !os.IsExist(err) { | |
| 161 | + | // Read-only boot root: the mountpoint must already exist. | |
| 162 | + | if _, serr := os.Stat(dst); serr != nil { | |
| 163 | + | return fmt.Errorf("mountpoint %s: %w", dst, err) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | if err := unix.Mount(src, dst, typ, flags, data); err != nil { | |
| 167 | + | return fmt.Errorf("mount %s on %s (%s): %w", src, dst, strings.TrimSpace(typ), err) | |
| 168 | + | } | |
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // loUp brings the loopback interface up (the kernel leaves it down). | |
| 173 | + | func loUp() error { | |
| 174 | + | fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0) | |
| 175 | + | if err != nil { | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | defer func() { _ = unix.Close(fd) }() | |
| 179 | + | ifr, err := unix.NewIfreq("lo") | |
| 180 | + | if err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | if err := unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr); err != nil { | |
| 184 | + | return err | |
| 185 | + | } | |
| 186 | + | ifr.SetUint16(ifr.Uint16() | unix.IFF_UP) | |
| 187 | + | return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // isMounted reports whether path is a mount point listed in /proc/self/mountinfo. | |
| 191 | + | func isMounted(path string) bool { | |
| 192 | + | b, err := os.ReadFile("/proc/self/mountinfo") | |
| 193 | + | if err != nil { | |
| 194 | + | return false | |
| 195 | + | } | |
| 196 | + | for _, line := range strings.Split(string(b), "\n") { | |
| 197 | + | f := strings.Fields(line) | |
| 198 | + | if len(f) > 4 && f[4] == path { | |
| 199 | + | return true | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | return false | |
| 203 | + | } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Devices of the micro-VM, in the order the host attaches them (virtio-blk). | |||
| 18 | + | const ( | |||
| 19 | + | devBase = "/dev/vdb" // shared read-only base image (ext4) | |||
| 20 | + | devScratch = "/dev/vdc" // per-job scratch disk (ext4) | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // KernelArg names of the clock and DNS settings (not part of the contract | |||
| 24 | + | // constants because only init reads them). | |||
| 25 | + | const ( | |||
| 26 | + | cmdlineTime = "ermite.time" // host unix time at boot, seconds | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // setupVM turns the minimal boot root (the exec disk) into the job's root: | |||
| 30 | + | // overlayfs of base + scratch, with the exec disk bound read-only at | |||
| 31 | + | // /opt/ermite, then pivot_root into it and mount the pseudo filesystems. | |||
| 32 | + | func setupVM(log zerowrap.Logger, args map[string]string) error { | |||
| 33 | + | // Pseudo filesystems of the boot root. Their mountpoints exist on the exec | |||
| 34 | + | // disk (the root is read-only). | |||
| 35 | + | for _, m := range []struct{ src, dst, typ string }{ | |||
| 36 | + | {"proc", "/proc", "proc"}, | |||
| 37 | + | {"sysfs", "/sys", "sysfs"}, | |||
| 38 | + | {"devtmpfs", "/dev", "devtmpfs"}, | |||
| 39 | + | } { | |||
| 40 | + | // proc is mounted by Init (to read the command line) and devtmpfs by the | |||
| 41 | + | // kernel (DEVTMPFS_MOUNT): mount only what is missing. | |||
| 42 | + | if isMounted(m.dst) { | |||
| 43 | + | continue | |||
| 44 | + | } | |||
| 45 | + | if err := mount(m.src, m.dst, m.typ, 0, ""); err != nil { | |||
| 46 | + | return err | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | // Firecracker has no RTC on x86: the guest clock starts at the epoch, which | |||
| 50 | + | // breaks TLS. The host passes its time on the command line. | |||
| 51 | + | if v, ok := args[cmdlineTime]; ok { | |||
| 52 | + | if sec, err := strconv.ParseInt(v, 10, 64); err == nil && sec > 1_600_000_000 { | |||
| 53 | + | if err := unix.ClockSettime(unix.CLOCK_REALTIME, &unix.Timespec{Sec: sec}); err != nil { | |||
| 54 | + | log.Warn().Err(err).Msg("init: set clock") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | if err := mount(devBase, "/mnt/base", "ext4", unix.MS_RDONLY, ""); err != nil { | |||
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | if err := mount(devScratch, "/mnt/scratch", "ext4", unix.MS_NOATIME, ""); err != nil { | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | for _, d := range []string{"/mnt/scratch/upper", "/mnt/scratch/work", "/mnt/scratch/docker"} { | |||
| 66 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | opts := "lowerdir=/mnt/base,upperdir=/mnt/scratch/upper,workdir=/mnt/scratch/work" | |||
| 71 | + | if err := mount("overlay", "/mnt/newroot", "overlay", 0, opts); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | // The new root needs these mountpoints; they live in the (writable) upper. | |||
| 75 | + | for _, d := range []string{"/mnt/newroot/proc", "/mnt/newroot/sys", "/mnt/newroot/dev", | |||
| 76 | + | "/mnt/newroot/run", "/mnt/newroot/tmp", "/mnt/newroot/scratch", "/mnt/newroot/.oldroot", | |||
| 77 | + | "/mnt/newroot" + sandbox.GuestExecDir} { | |||
| 78 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | // Scratch and the exec disk stay reachable after the pivot. | |||
| 83 | + | if err := mount("/mnt/scratch", "/mnt/newroot"+sandbox.GuestScratch, "", unix.MS_BIND, ""); err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | if err := mount("/", "/mnt/newroot"+sandbox.GuestExecDir, "", unix.MS_BIND, ""); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | if err := mount("", "/mnt/newroot"+sandbox.GuestExecDir, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, ""); err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | for _, d := range []string{"/proc", "/sys", "/dev"} { | |||
| 93 | + | if err := mount(d, "/mnt/newroot"+d, "", unix.MS_MOVE, ""); err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | if err := unix.Chdir("/mnt/newroot"); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | if err := unix.PivotRoot(".", ".oldroot"); err != nil { | |||
| 101 | + | return fmt.Errorf("pivot_root: %w", err) | |||
| 102 | + | } | |||
| 103 | + | if err := unix.Chroot("."); err != nil { | |||
| 104 | + | return err | |||
| 105 | + | } | |||
| 106 | + | if err := unix.Chdir("/"); err != nil { | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | _ = unix.Unmount("/.oldroot", unix.MNT_DETACH) | |||
| 110 | + | ||||
| 111 | + | for _, m := range []struct { | |||
| 112 | + | src, dst, typ string | |||
| 113 | + | flags uintptr | |||
| 114 | + | data string | |||
| 115 | + | }{ | |||
| 116 | + | {"devpts", "/dev/pts", "devpts", 0, "gid=5,mode=620,ptmxmode=666"}, | |||
| 117 | + | {"tmpfs", "/dev/shm", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, ""}, | |||
| 118 | + | {"tmpfs", "/run", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, "mode=755"}, | |||
| 119 | + | {"tmpfs", "/tmp", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, "mode=1777"}, | |||
| 120 | + | {"cgroup2", "/sys/fs/cgroup", "cgroup2", unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC, ""}, | |||
| 121 | + | } { | |||
| 122 | + | if err := os.MkdirAll(m.dst, 0o755); err != nil { | |||
| 123 | + | return err | |||
| 124 | + | } | |||
| 125 | + | if err := mount(m.src, m.dst, m.typ, m.flags, m.data); err != nil { | |||
| 126 | + | // devpts and shm failures are not fatal for the stub. | |||
| 127 | + | log.Warn().Err(err).Str("target", m.dst).Msg("init: mount") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | _ = os.Chmod("/tmp", 0o1777) | |||
| 131 | + | ||||
| 132 | + | gw := args[sandbox.CmdlineGateway] | |||
| 133 | + | if err := writeResolvConf(gw); err != nil { | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | _ = os.WriteFile("/etc/hostname", []byte("ermite-job\n"), 0o644) | |||
| 137 | + | _ = os.WriteFile("/etc/hosts", []byte("127.0.0.1 localhost ermite-job\n"), 0o644) | |||
| 138 | + | _ = unix.Sethostname([]byte("ermite-job")) | |||
| 139 | + | if err := loUp(); err != nil { | |||
| 140 | + | log.Warn().Err(err).Msg("init: loopback") | |||
| 141 | + | } | |||
| 142 | + | // IPv6 is disabled on the kernel command line (ipv6.disable=1); this is a | |||
| 143 | + | // second line of defence when the module is built in but not disabled. | |||
| 144 | + | _ = os.WriteFile("/proc/sys/net/ipv6/conf/all/disable_ipv6", []byte("1\n"), 0o644) | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // writeResolvConf points the guest at the gateway, where the runner's DNS | |||
| 149 | + | // forwarder listens. /etc/resolv.conf may be a dangling symlink in the image | |||
| 150 | + | // (systemd-resolved stub): replace it. | |||
| 151 | + | func writeResolvConf(gw string) error { | |||
| 152 | + | if gw == "" { | |||
| 153 | + | return fmt.Errorf("kernel command line has no %s", sandbox.CmdlineGateway) | |||
| 154 | + | } | |||
| 155 | + | _ = os.Remove("/etc/resolv.conf") | |||
| 156 | + | return os.WriteFile("/etc/resolv.conf", []byte("nameserver "+gw+"\noptions timeout:2 attempts:2\n"), 0o644) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func mount(src, dst, typ string, flags uintptr, data string) error { | |||
| 160 | + | if err := os.MkdirAll(dst, 0o755); err != nil && !os.IsExist(err) { | |||
| 161 | + | // Read-only boot root: the mountpoint must already exist. | |||
| 162 | + | if _, serr := os.Stat(dst); serr != nil { | |||
| 163 | + | return fmt.Errorf("mountpoint %s: %w", dst, err) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | if err := unix.Mount(src, dst, typ, flags, data); err != nil { | |||
| 167 | + | return fmt.Errorf("mount %s on %s (%s): %w", src, dst, strings.TrimSpace(typ), err) | |||
| 168 | + | } | |||
| 169 | + | return nil | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // loUp brings the loopback interface up (the kernel leaves it down). | |||
| 173 | + | func loUp() error { | |||
| 174 | + | fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0) | |||
| 175 | + | if err != nil { | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | defer func() { _ = unix.Close(fd) }() | |||
| 179 | + | ifr, err := unix.NewIfreq("lo") | |||
| 180 | + | if err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | if err := unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr); err != nil { | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | ifr.SetUint16(ifr.Uint16() | unix.IFF_UP) | |||
| 187 | + | return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // isMounted reports whether path is a mount point listed in /proc/self/mountinfo. | |||
| 191 | + | func isMounted(path string) bool { | |||
| 192 | + | b, err := os.ReadFile("/proc/self/mountinfo") | |||
| 193 | + | if err != nil { | |||
| 194 | + | return false | |||
| 195 | + | } | |||
| 196 | + | for _, line := range strings.Split(string(b), "\n") { | |||
| 197 | + | f := strings.Fields(line) | |||
| 198 | + | if len(f) > 4 && f[4] == path { | |||
| 199 | + | return true | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | return false | |||
| 203 | + | } | |||