detached 2f75a56

fix(sandbox/vm): jailer cgroup v2 limits and PID namespace, rate limiters, keep network while the VMM may run, abortable boot, reap by --id, Lchown/fchown

commit
2f75a567ab084fc5eee50d32b4091e58f7b74a50
author
brice · · 2026-10-12 16:10 UTC
parent
1905b39
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +803 -172
M internal/sandbox/vm/api.go +50 -7
@@ -32,16 +32,59 @@ type bootSource struct {
3232 }
3333
3434 type drive struct {
35- DriveID string `json:"drive_id"`
36- PathOnHost string `json:"path_on_host"`
37- IsRootDevice bool `json:"is_root_device"`
38- IsReadOnly bool `json:"is_read_only"`
35+ DriveID string `json:"drive_id"`
36+ PathOnHost string `json:"path_on_host"`
37+ IsRootDevice bool `json:"is_root_device"`
38+ IsReadOnly bool `json:"is_read_only"`
39+ RateLimiter *rateLimiter `json:"rate_limiter,omitempty"`
3940 }
4041
4142 type networkInterface struct {
42- IfaceID string `json:"iface_id"`
43- HostDevName string `json:"host_dev_name"`
44- GuestMAC string `json:"guest_mac"`
43+ IfaceID string `json:"iface_id"`
44+ HostDevName string `json:"host_dev_name"`
45+ GuestMAC string `json:"guest_mac"`
46+ RxRateLimiter *rateLimiter `json:"rx_rate_limiter,omitempty"`
47+ TxRateLimiter *rateLimiter `json:"tx_rate_limiter,omitempty"`
48+}
49+
50+// tokenBucket is Firecracker's rate limiter bucket: Size tokens (bytes or
51+// operations) are refilled every RefillTime milliseconds.
52+type tokenBucket struct {
53+ Size int64 `json:"size"`
54+ OneTimeBurst int64 `json:"one_time_burst,omitempty"`
55+ RefillTime int64 `json:"refill_time"`
56+}
57+
58+type rateLimiter struct {
59+ Bandwidth *tokenBucket `json:"bandwidth,omitempty"`
60+ Ops *tokenBucket `json:"ops,omitempty"`
61+}
62+
63+// diskLimiter limits a disk to bandwidthMiB MiB/s and iops operations/s (zero
64+// disables a limit; both zero means no limiter). A one-second burst is allowed
65+// at the start so that short bursts (a git checkout) are not slowed down.
66+func diskLimiter(bandwidthMiB, iops int) *rateLimiter {
67+ var rl rateLimiter
68+ if bandwidthMiB > 0 {
69+ b := int64(bandwidthMiB) << 20
70+ rl.Bandwidth = &tokenBucket{Size: b, OneTimeBurst: b, RefillTime: 1000}
71+ }
72+ if iops > 0 {
73+ rl.Ops = &tokenBucket{Size: int64(iops), OneTimeBurst: int64(iops), RefillTime: 1000}
74+ }
75+ if rl.Bandwidth == nil && rl.Ops == nil {
76+ return nil
77+ }
78+ return &rl
79+}
80+
81+// netLimiter limits one direction of a network interface to mbit Mbit/s.
82+func netLimiter(mbit int) *rateLimiter {
83+ if mbit <= 0 {
84+ return nil
85+ }
86+ b := int64(mbit) * 1_000_000 / 8
87+ return &rateLimiter{Bandwidth: &tokenBucket{Size: b, OneTimeBurst: b, RefillTime: 1000}}
4588 }
4689
4790 type vsockDevice struct {
@@ -32,16 +32,59 @@ type bootSource struct {
32 }32 }
33 33
34 type drive struct {34 type drive struct {
35- DriveID string `json:"drive_id"`35+ DriveID string `json:"drive_id"`
36- PathOnHost string `json:"path_on_host"`36+ PathOnHost string `json:"path_on_host"`
37- IsRootDevice bool `json:"is_root_device"`37+ IsRootDevice bool `json:"is_root_device"`
38- IsReadOnly bool `json:"is_read_only"`38+ IsReadOnly bool `json:"is_read_only"`
39+ RateLimiter *rateLimiter `json:"rate_limiter,omitempty"`
39 }40 }
40 41
41 type networkInterface struct {42 type networkInterface struct {
42- IfaceID string `json:"iface_id"`43+ IfaceID string `json:"iface_id"`
43- HostDevName string `json:"host_dev_name"`44+ HostDevName string `json:"host_dev_name"`
44- GuestMAC string `json:"guest_mac"`45+ GuestMAC string `json:"guest_mac"`
46+ RxRateLimiter *rateLimiter `json:"rx_rate_limiter,omitempty"`
47+ TxRateLimiter *rateLimiter `json:"tx_rate_limiter,omitempty"`
48+}
49+
50+// tokenBucket is Firecracker's rate limiter bucket: Size tokens (bytes or
51+// operations) are refilled every RefillTime milliseconds.
52+type tokenBucket struct {
53+ Size int64 `json:"size"`
54+ OneTimeBurst int64 `json:"one_time_burst,omitempty"`
55+ RefillTime int64 `json:"refill_time"`
56+}
57+
58+type rateLimiter struct {
59+ Bandwidth *tokenBucket `json:"bandwidth,omitempty"`
60+ Ops *tokenBucket `json:"ops,omitempty"`
61+}
62+
63+// diskLimiter limits a disk to bandwidthMiB MiB/s and iops operations/s (zero
64+// disables a limit; both zero means no limiter). A one-second burst is allowed
65+// at the start so that short bursts (a git checkout) are not slowed down.
66+func diskLimiter(bandwidthMiB, iops int) *rateLimiter {
67+ var rl rateLimiter
68+ if bandwidthMiB > 0 {
69+ b := int64(bandwidthMiB) << 20
70+ rl.Bandwidth = &tokenBucket{Size: b, OneTimeBurst: b, RefillTime: 1000}
71+ }
72+ if iops > 0 {
73+ rl.Ops = &tokenBucket{Size: int64(iops), OneTimeBurst: int64(iops), RefillTime: 1000}
74+ }
75+ if rl.Bandwidth == nil && rl.Ops == nil {
76+ return nil
77+ }
78+ return &rl
79+}
80+
81+// netLimiter limits one direction of a network interface to mbit Mbit/s.
82+func netLimiter(mbit int) *rateLimiter {
83+ if mbit <= 0 {
84+ return nil
85+ }
86+ b := int64(mbit) * 1_000_000 / 8
87+ return &rateLimiter{Bandwidth: &tokenBucket{Size: b, OneTimeBurst: b, RefillTime: 1000}}
45 }88 }
46 89
47 type vsockDevice struct {90 type vsockDevice struct {
M internal/sandbox/vm/driver.go +283 -119
@@ -20,12 +20,12 @@ import (
2020 "os"
2121 "os/exec"
2222 "path/filepath"
23+ "strconv"
2324 "sync"
2425 "syscall"
2526 "time"
2627
2728 "github.com/bnema/zerowrap"
28- "golang.org/x/sys/unix"
2929
3030 "bnema.dev/ermite-runner/internal/cmdrun"
3131 "bnema.dev/ermite-runner/internal/netpolicy"
@@ -36,8 +36,11 @@ import (
3636 // Options are the testable knobs of the driver.
3737 type Options struct {
3838 Run cmdrun.Runner
39- // ProcDir is the /proc used to find orphan Firecracker processes.
39+ // ProcDir is the /proc used to find orphan VMM processes.
4040 ProcDir string
41+ // CgroupRoot is the cgroup v2 mount, where the jailer creates one cgroup per
42+ // VM (default /sys/fs/cgroup).
43+ CgroupRoot string
4144 // BootTimeout bounds boot + init + executor connection (default 90 s).
4245 BootTimeout time.Duration
4346 // Net overrides the network plumbing (tests).
@@ -45,8 +48,17 @@ type Options struct {
4548 // FirecrackerArgs are extra arguments for Firecracker (tests use them to
4649 // select the fake).
4750 FirecrackerArgs []string
51+ // FreeSpace overrides statfs for the free-space warning (tests).
52+ FreeSpace func(string) (int64, error)
53+ // kill overrides killAndWait (tests simulate a VMM that cannot be killed).
54+ kill func(pid int, timeout time.Duration) bool
4855 }
4956
57+// vmmOverheadMiB is what the Firecracker process itself may use on top of the
58+// guest memory (device emulation, the API server, the seccomp filter, stacks):
59+// it is added to memory.max so that the guest, not the VMM, is what gets limited.
60+const vmmOverheadMiB = 256
61+
5062 // Driver implements sandbox.Driver for micro-VMs.
5163 type Driver struct {
5264 cfg sandbox.Config
@@ -56,14 +68,15 @@ type Driver struct {
5668 execDisk string
5769 store store
5870 net netsetup.Plumbing
59- lock *os.File
71+ lock *sandbox.HostLock
6072
6173 mu sync.Mutex
6274 slots *sandbox.Slots
6375 }
6476
65-// New builds the driver: resolves binaries, builds the exec disk, takes the
66-// state-directory lock and enables IPv4 forwarding. Run Reap before New sandboxes.
77+// New builds the driver: takes the host-wide lock, resolves binaries, builds the
78+// exec disk and enables IPv4 forwarding (a host-wide sysctl that stays on: see
79+// the README). Run Reap before New sandboxes.
6780 func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) {
6881 if opt.Run == nil {
6982 opt.Run = cmdrun.Exec{}
@@ -71,9 +84,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
7184 if opt.ProcDir == "" {
7285 opt.ProcDir = "/proc"
7386 }
87+ if opt.CgroupRoot == "" {
88+ opt.CgroupRoot = "/sys/fs/cgroup"
89+ }
7490 if opt.BootTimeout == 0 {
7591 opt.BootTimeout = 90 * time.Second
7692 }
93+ if opt.kill == nil {
94+ opt.kill = killAndWait
95+ }
7796 d := &Driver{cfg: cfg, opt: opt, slots: sandbox.NewSlots(cfg.Capacity), store: store{dir: filepath.Join(cfg.StateDir, "vms")}}
7897
7998 var err error
@@ -95,7 +114,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
95114 if err := os.MkdirAll(cfg.StateDir, 0o755); err != nil {
96115 return nil, fmt.Errorf("state directory: %w", err)
97116 }
98- if err := d.acquireLock(); err != nil {
117+ if d.lock, err = sandbox.AcquireHostLock(cfg.LockFile); err != nil {
99118 return nil, err
100119 }
101120 fail := func(err error) (*Driver, error) { _ = d.Close(); return nil, err }
@@ -107,6 +126,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
107126 }
108127 }
109128 }
129+ sandbox.WarnIfLowSpace(ctx, cfg.StateDir, cfg.Capacity, cfg.JobDiskMiB, opt.FreeSpace)
110130 if d.execDisk, err = ensureExecDisk(ctx, opt.Run, exe, cfg.StateDir); err != nil {
111131 return fail(err)
112132 }
@@ -138,44 +158,33 @@ func resolveBin(configured, name string) (string, error) {
138158 return abs, nil
139159 }
140160
141-func (d *Driver) acquireLock() error {
142- f, err := os.OpenFile(filepath.Join(d.cfg.StateDir, "vm.lock"), os.O_CREATE|os.O_RDWR, 0o600)
143- if err != nil {
144- return err
145- }
146- if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
147- _ = f.Close()
148- return fmt.Errorf("another ermite-runner already uses state directory %s (reaping would destroy its VMs): %w", d.cfg.StateDir, err)
149- }
150- d.lock = f
151- return nil
152-}
153-
154161 // Mode implements sandbox.Driver.
155162 func (d *Driver) Mode() sandbox.Mode { return sandbox.VM }
156163
157-// Close releases the lock and the DNS listeners.
164+// Isolation implements sandbox.Driver.
165+func (d *Driver) Isolation() sandbox.Isolation { return sandbox.IsolationVM }
166+
167+// Close releases the host lock and the DNS listeners.
158168 func (d *Driver) Close() error {
159169 if d.net != nil {
160170 d.net.Close()
161171 }
162- if d.lock != nil {
163- err := d.lock.Close()
164- d.lock = nil
165- return err
166- }
167- return nil
172+ return d.lock.Release()
168173 }
169174
170175 // New implements sandbox.Driver.
171176 func (d *Driver) New() (sandbox.Sandbox, error) {
177+ id, err := sandbox.NewID()
178+ if err != nil {
179+ return nil, err
180+ }
172181 d.mu.Lock()
173182 defer d.mu.Unlock()
174183 slot, err := d.slots.Acquire()
175184 if err != nil {
176185 return nil, err
177186 }
178- return &vmSandbox{d: d, slot: slot}, nil
187+ return &vmSandbox{d: d, slot: slot, id: id}, nil
179188 }
180189
181190 func (d *Driver) release(slot int) {
@@ -184,14 +193,26 @@ func (d *Driver) release(slot int) {
184193 d.mu.Unlock()
185194 }
186195
196+func (d *Driver) markLeaked(slot int) {
197+ d.mu.Lock()
198+ d.slots.MarkLeaked(slot)
199+ d.mu.Unlock()
200+}
201+
187202 // vmSandbox is one VM, single use.
188203 type vmSandbox struct {
189204 d *Driver
190205 slot int
206+ id string
207+ gate sandbox.BootGate
208+
209+ mu sync.Mutex
210+ started bool
211+ stopped bool
212+ // leakErr is the error of a cleanup that left something behind; later Stop
213+ // calls return it again.
214+ leakErr error
191215
192- mu sync.Mutex
193- started bool
194- stopped bool
195216 rec record
196217 lay layout
197218 cmd *exec.Cmd
@@ -204,10 +225,20 @@ type vmSandbox struct {
204225 attached bool
205226 }
206227
228+// ID implements sandbox.Sandbox.
229+func (s *vmSandbox) ID() string { return s.id }
230+
207231 func (s *vmSandbox) uid() int { return s.d.cfg.UIDBase + s.slot }
208232
209233 // Start implements sandbox.Sandbox.
210234 func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
235+ // A Stop from another goroutine aborts the boot through the gate.
236+ bootCtx, err := s.gate.Begin(ctx)
237+ if err != nil {
238+ return nil, fmt.Errorf("vm sandbox: %w", err)
239+ }
240+ defer s.gate.End()
241+
211242 s.mu.Lock()
212243 defer s.mu.Unlock()
213244 if s.started || s.stopped {
@@ -218,18 +249,13 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
218249 d := s.d
219250 spec = d.cfg.Resolve(spec)
220251
221- id, err := sandbox.NewID()
222- if err != nil {
223- s.releaseLocked()
224- return nil, err
225- }
226252 jailed := !d.cfg.JailerOff
227253 runUID := s.uid()
228254 if !jailed {
229255 runUID = os.Getuid()
230256 }
231- s.lay = newLayout(d.cfg.StateDir, filepath.Base(d.fcPath), id, jailed, runUID)
232- s.rec = record{ID: id, Slot: s.slot, Job: spec.JobID, Dir: s.lay.dir}
257+ s.lay = newLayout(d.cfg.StateDir, filepath.Base(d.fcPath), s.id, jailed, runUID)
258+ s.rec = record{ID: s.id, Slot: s.slot, Job: spec.JobID, Dir: s.lay.dir}
233259 // The record exists before the first resource, so any crash from here on is
234260 // reapable.
235261 if err := d.store.save(s.rec); err != nil {
@@ -237,7 +263,7 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
237263 return nil, fmt.Errorf("write state record: %w", err)
238264 }
239265
240- conn, err := s.boot(ctx, spec, id)
266+ conn, err := s.boot(bootCtx, spec)
241267 if err != nil {
242268 console := ""
243269 if s.console != nil {
@@ -249,98 +275,140 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
249275 }
250276 return nil, errors.Join(err, cerr)
251277 }
252- log.Info().Str("vm", id).Int("slot", s.slot).Str("job", spec.JobID).Msg("micro-VM running")
278+ log.Info().Str("vm", s.id).Int("slot", s.slot).Str("job", spec.JobID).Msg("micro-VM running")
253279 return conn, nil
254280 }
255281
256-func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec, id string) (sandbox.Conn, error) {
257- d := s.d
258- netw, err := netpolicy.Subnet(d.cfg.Network, s.slot)
282+// boot runs the stages in order; each stage records what it created so that
283+// cleanupLocked can undo exactly that.
284+func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
285+ netw, err := netpolicy.Subnet(s.d.cfg.Network, s.slot)
286+ if err != nil {
287+ return nil, err
288+ }
289+ if err := s.bootNetwork(ctx, netw); err != nil {
290+ return nil, err
291+ }
292+ api, err := s.bootVMM(ctx, spec)
293+ if err != nil {
294+ return nil, err
295+ }
296+ files, err := s.bootFiles(ctx, spec)
297+ if err != nil {
298+ return nil, err
299+ }
300+ accepted, err := s.bootVsock()
259301 if err != nil {
260302 return nil, err
261303 }
304+ if err := api.configure(ctx, s.vmConfig(spec, netw, files)); err != nil {
305+ return nil, err
306+ }
307+ return s.waitExecutor(ctx, accepted)
308+}
262309
263- // Network first: the TAP must exist when Firecracker opens it.
310+// bootNetwork creates the TAP (it must exist when Firecracker opens it) and
311+// installs the firewall policy and the DNS listener.
312+func (s *vmSandbox) bootNetwork(ctx context.Context, netw netpolicy.Net) error {
313+ d := s.d
264314 s.tap, s.gateway = tapName(s.slot), netw.Gateway
265315 s.rec.Tap, s.rec.Gate = s.tap, s.gateway.String()
266316 if err := d.store.save(s.rec); err != nil {
267- return nil, err
317+ return err
268318 }
269319 if err := createTap(ctx, d.opt.Run, s.tap, s.lay.uid, s.gateway); err != nil {
270- return nil, err
320+ return err
271321 }
272322 s.attached = true // from here Detach is idempotent and always safe
273- if err := d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest}); err != nil {
274- return nil, err
275- }
323+ return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest})
324+}
276325
277- // The VMM process.
326+// bootVMM starts the jailer (or Firecracker) and waits for its API.
327+func (s *vmSandbox) bootVMM(ctx context.Context, spec sandbox.JobSpec) (*apiClient, error) {
278328 apiSock := s.lay.host("api.sock")
279329 if s.lay.jailed {
280330 apiSock = s.lay.host("run/firecracker.socket")
281331 } else if err := os.MkdirAll(s.lay.root, 0o700); err != nil {
282332 return nil, err
283333 }
284- if err := s.startVMM(id, apiSock); err != nil {
334+ if err := s.startVMM(spec, apiSock); err != nil {
285335 return nil, err
286336 }
287337 api := newAPIClient(apiSock)
288338 if err := s.waitAPI(ctx, api, apiSock); err != nil {
289339 return nil, err
290340 }
341+ return api, nil
342+}
291343
292- // Files: the process only opens them when configured below.
293- execDisk, err := s.lay.share(d.execDisk, "exec.ext4")
294- if err != nil {
295- return nil, err
296- }
297- baseDisk, err := s.lay.share(d.cfg.BaseImagePath(), "base.ext4")
298- if err != nil {
299- return nil, err
344+// vmFiles are the paths Firecracker is given.
345+type vmFiles struct{ execDisk, baseDisk, kernel string }
346+
347+// bootFiles makes the shared files available and creates the scratch disk.
348+func (s *vmSandbox) bootFiles(ctx context.Context, spec sandbox.JobSpec) (vmFiles, error) {
349+ d := s.d
350+ var f vmFiles
351+ var err error
352+ if f.execDisk, err = s.lay.share(d.execDisk, "exec.ext4"); err != nil {
353+ return f, err
300354 }
301- kernel, err := s.lay.share(d.cfg.KernelPath(), "kernel")
302- if err != nil {
303- return nil, err
355+ if f.baseDisk, err = s.lay.share(d.cfg.BaseImagePath(), "base.ext4"); err != nil {
356+ return f, err
304357 }
305- if err := makeScratch(ctx, d.opt.Run, s.lay.host("scratch.ext4"), spec.DiskMiB); err != nil {
306- return nil, err
358+ if f.kernel, err = s.lay.share(d.cfg.KernelPath(), "kernel"); err != nil {
359+ return f, err
307360 }
308- if err := s.lay.own("scratch.ext4"); err != nil {
309- return nil, err
361+ owner := -1
362+ if s.lay.jailed {
363+ owner = s.lay.uid
310364 }
365+ return f, makeScratch(ctx, d.opt.Run, s.lay.host("scratch.ext4"), spec.DiskMiB, owner)
366+}
311367
312- // The host side of the vsock stream: Firecracker connects to
313- // <uds_path>_<port> when the guest connects to the host CID.
314- udsName := "v.sock"
315- ln, err := net.Listen("unix", s.lay.host(udsName)+fmt.Sprintf("_%d", sandbox.VsockPort))
368+// bootVsock listens on the host side of the vsock stream: Firecracker connects
369+// to <uds_path>_<port> when the guest connects to the host CID.
370+func (s *vmSandbox) bootVsock() (<-chan acceptResult, error) {
371+ path := s.lay.host(udsName) + fmt.Sprintf("_%d", sandbox.VsockPort)
372+ ln, err := net.Listen("unix", path)
316373 if err != nil {
317374 return nil, fmt.Errorf("listen vsock socket: %w", err)
318375 }
319376 s.listener = ln
320377 if s.lay.jailed {
321- if err := os.Chown(s.lay.host(udsName+fmt.Sprintf("_%d", sandbox.VsockPort)), s.lay.uid, s.lay.uid); err != nil {
378+ if err := os.Lchown(path, s.lay.uid, s.lay.uid); err != nil {
322379 return nil, err
323380 }
324381 }
325382 accepted := make(chan acceptResult, 1)
326383 go func() { c, err := ln.Accept(); accepted <- acceptResult{c, err} }()
384+ return accepted, nil
385+}
386+
387+const udsName = "v.sock"
327388
328- err = api.configure(ctx, vmConfig{
389+func (s *vmSandbox) vmConfig(spec sandbox.JobSpec, netw netpolicy.Net, f vmFiles) vmConfig {
390+ cfg := s.d.cfg
391+ return vmConfig{
329392 Machine: machineConfig{VcpuCount: spec.CPUs, MemSizeMiB: spec.MemoryMiB},
330- Boot: bootSource{KernelImagePath: kernel, BootArgs: bootArgs(netw.Guest, netw.Gateway, time.Now().Unix())},
393+ Boot: bootSource{KernelImagePath: f.kernel, BootArgs: bootArgs(netw.Guest, netw.Gateway, time.Now().Unix())},
331394 Drives: []drive{
332- {DriveID: "rootfs", PathOnHost: execDisk, IsRootDevice: true, IsReadOnly: true},
333- {DriveID: "base", PathOnHost: baseDisk, IsReadOnly: true},
334- {DriveID: "scratch", PathOnHost: s.lay.fc("scratch.ext4")},
395+ {DriveID: "rootfs", PathOnHost: f.execDisk, IsRootDevice: true, IsReadOnly: true},
396+ {DriveID: "base", PathOnHost: f.baseDisk, IsReadOnly: true},
397+ {DriveID: "scratch", PathOnHost: s.lay.fc("scratch.ext4"), RateLimiter: diskLimiter(cfg.DiskBandwidthMiB, cfg.DiskIOPS)},
398+ },
399+ Net: networkInterface{
400+ IfaceID: "eth0", HostDevName: s.tap, GuestMAC: macFor(s.slot),
401+ RxRateLimiter: netLimiter(cfg.NetMbit), TxRateLimiter: netLimiter(cfg.NetMbit),
335402 },
336- Net: networkInterface{IfaceID: "eth0", HostDevName: s.tap, GuestMAC: macFor(s.slot)},
337403 Vsock: vsockDevice{GuestCID: 3, UDSPath: s.lay.fc(udsName)},
338- })
339- if err != nil {
340- return nil, err
341404 }
405+}
342406
343- timer := time.NewTimer(d.opt.BootTimeout)
407+// waitExecutor waits for the guest's executor to connect. Only ctx (the caller's
408+// context, or Stop through the boot gate) and the boot timeout end the wait: a
409+// boot cannot be aborted any other way.
410+func (s *vmSandbox) waitExecutor(ctx context.Context, accepted <-chan acceptResult) (sandbox.Conn, error) {
411+ timer := time.NewTimer(s.d.opt.BootTimeout)
344412 defer timer.Stop()
345413 select {
346414 case r := <-accepted:
@@ -352,9 +420,9 @@ func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec, id string) (
352420 case <-s.exited:
353421 return nil, errors.New("firecracker exited before the executor connected")
354422 case <-timer.C:
355- return nil, fmt.Errorf("executor did not connect within %s", d.opt.BootTimeout)
423+ return nil, fmt.Errorf("executor did not connect within %s", s.d.opt.BootTimeout)
356424 case <-ctx.Done():
357- return nil, ctx.Err()
425+ return nil, context.Cause(ctx)
358426 }
359427 }
360428
@@ -363,18 +431,38 @@ type acceptResult struct {
363431 err error
364432 }
365433
366-func (s *vmSandbox) startVMM(id, apiSock string) error {
434+// jailerArgs builds the jailer command line (after the binary name).
435+//
436+// - cgroup v2 with memory.max (guest memory + VMM overhead) and pids.max (a
437+// VMM needs a handful of threads per vCPU): a runaway or compromised VMM
438+// cannot take the host's memory or fork-bomb it;
439+// - a new PID namespace: the VMM cannot see or signal host processes;
440+// - no-file: the descriptor budget of the VMM.
441+func jailerArgs(jailer, fc, id string, uid int, chrootBase string, spec sandbox.JobSpec, extra []string) []string {
442+ memBytes := (int64(spec.MemoryMiB) + vmmOverheadMiB) << 20
443+ pids := 32 + 8*spec.CPUs
444+ args := []string{
445+ "--id", id, "--exec-file", fc,
446+ "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(uid),
447+ "--chroot-base-dir", chrootBase,
448+ "--cgroup-version", "2",
449+ "--cgroup", "memory.max=" + strconv.FormatInt(memBytes, 10),
450+ "--cgroup", "pids.max=" + strconv.Itoa(pids),
451+ "--new-pid-ns",
452+ "--resource-limit", "no-file=4096",
453+ "--",
454+ }
455+ return append(args, extra...)
456+}
457+
458+func (s *vmSandbox) startVMM(spec sandbox.JobSpec, apiSock string) error {
367459 d := s.d
368460 var cmd *exec.Cmd
369461 if s.lay.jailed {
370- args := []string{"--id", id, "--exec-file", d.fcPath,
371- "--uid", fmt.Sprint(s.uid()), "--gid", fmt.Sprint(s.uid()),
372- "--chroot-base-dir", jailBase(d.cfg.StateDir),
373- "--resource-limit", "no-file=4096",
374- "--"}
375- cmd = exec.Command(d.jailer, append(args, d.opt.FirecrackerArgs...)...)
462+ args := jailerArgs(d.jailer, d.fcPath, s.id, s.uid(), jailBase(d.cfg.StateDir), spec, d.opt.FirecrackerArgs)
463+ cmd = exec.Command(d.jailer, args...)
376464 } else {
377- args := append([]string{"--id", id, "--api-sock", apiSock}, d.opt.FirecrackerArgs...)
465+ args := append([]string{"--id", s.id, "--api-sock", apiSock}, d.opt.FirecrackerArgs...)
378466 cmd = exec.Command(d.fcPath, args...)
379467 }
380468 s.console = newRing(16 << 10)
@@ -385,11 +473,36 @@ func (s *vmSandbox) startVMM(id, apiSock string) error {
385473 }
386474 s.cmd = cmd
387475 s.exited = make(chan struct{})
388- go func() { _ = cmd.Wait(); close(s.exited) }()
476+ jailed := s.lay.jailed
477+ go func() {
478+ _ = cmd.Wait()
479+ // With --new-pid-ns the jailer may exit as soon as it has started the
480+ // VMM in its namespace: the VMM is then gone only when its own pid is.
481+ if jailed {
482+ s.awaitVMMGone()
483+ }
484+ close(s.exited)
485+ }()
389486 s.rec.PID = cmd.Process.Pid
390487 return s.d.store.save(s.rec)
391488 }
392489
490+// awaitVMMGone blocks until no process of this VM's id is left.
491+func (s *vmSandbox) awaitVMMGone() {
492+ for {
493+ alive := false
494+ for pid, id := range vmmProcs(s.d.opt.ProcDir) {
495+ if id == s.id && pidAlive(pid) {
496+ alive = true
497+ }
498+ }
499+ if !alive {
500+ return
501+ }
502+ time.Sleep(50 * time.Millisecond)
503+ }
504+}
505+
393506 func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) error {
394507 deadline := time.Now().Add(10 * time.Second)
395508 for {
@@ -400,7 +513,7 @@ func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) er
400513 case <-s.exited:
401514 return errors.New("firecracker exited before its API was ready")
402515 case <-ctx.Done():
403- return ctx.Err()
516+ return context.Cause(ctx)
404517 case <-time.After(25 * time.Millisecond):
405518 }
406519 if time.Now().After(deadline) {
@@ -411,13 +524,14 @@ func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) er
411524
412525 // Stop implements sandbox.Sandbox.
413526 func (s *vmSandbox) Stop(ctx context.Context) error {
527+ // Abort a boot in progress first: Start holds s.mu for its whole duration.
528+ s.gate.Abort()
414529 s.mu.Lock()
415530 defer s.mu.Unlock()
416531 if s.stopped {
417- return nil
532+ return s.leakErr
418533 }
419534 if !s.started {
420- s.stopped = true
421535 s.releaseLocked()
422536 return nil
423537 }
@@ -429,10 +543,31 @@ func (s *vmSandbox) releaseLocked() {
429543 s.d.release(s.slot)
430544 }
431545
432-// cleanupLocked removes everything the VM created, in dependency order, and
433-// keeps going after an error. The state record and the slot are released only
434-// when everything is gone, so that a half-failed cleanup is retried by Reap
435-// rather than forgotten and the slot (hence TAP name) is not reused meanwhile.
546+// vmmPIDs returns every host process of this VM: the launched command and
547+// whatever the /proc scan finds with this id.
548+func (s *vmSandbox) vmmPIDs() []int {
549+ set := map[int]bool{}
550+ if s.cmd != nil && s.cmd.Process != nil {
551+ set[s.cmd.Process.Pid] = true
552+ }
553+ for pid, id := range vmmProcs(s.d.opt.ProcDir) {
554+ if id == s.id {
555+ set[pid] = true
556+ }
557+ }
558+ var out []int
559+ for pid := range set {
560+ out = append(out, pid)
561+ }
562+ return out
563+}
564+
565+// cleanupLocked removes everything the VM created, in dependency order. Steps
566+// that need the VMM to be dead (network device, firewall rules, files, cgroup)
567+// are skipped when it could not be killed: removing the TAP or the policy under
568+// a VM that may still run would break the isolation it relies on, so the
569+// sandbox is reported as leaked (sandbox.ErrLeaked), its slot stays taken, and
570+// Reap finishes the job.
436571 func (s *vmSandbox) cleanupLocked(ctx context.Context) error {
437572 log := zerowrap.FromCtx(ctx)
438573 d := s.d
@@ -445,42 +580,60 @@ func (s *vmSandbox) cleanupLocked(ctx context.Context) error {
445580 _ = s.listener.Close()
446581 s.listener = nil
447582 }
583+ dead := true
448584 if s.cmd != nil && s.cmd.Process != nil {
449- killAndWait(s.cmd.Process.Pid, 5*time.Second)
585+ _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL) // the whole group
586+ }
587+ for _, pid := range s.vmmPIDs() {
588+ if !d.opt.kill(pid, 5*time.Second) {
589+ dead = false
590+ errs = append(errs, fmt.Errorf("vmm process %d did not exit", pid))
591+ }
592+ }
593+ if dead && s.exited != nil {
450594 select {
451595 case <-s.exited:
452596 case <-time.After(5 * time.Second):
453- errs = append(errs, fmt.Errorf("firecracker pid %d did not exit", s.cmd.Process.Pid))
597+ dead = false
598+ errs = append(errs, errors.New("vmm exit was not observed"))
454599 }
455600 }
456- if s.attached {
457- if err := d.net.Detach(ctx, s.tap, s.gateway); err != nil {
458- errs = append(errs, err)
601+ if dead {
602+ if s.attached {
603+ if err := d.net.Detach(ctx, s.tap, s.gateway); err != nil {
604+ errs = append(errs, err)
605+ }
606+ if err := deleteTap(ctx, d.opt.Run, s.tap); err != nil {
607+ errs = append(errs, err)
608+ } else {
609+ s.attached = false
610+ }
459611 }
460- if err := deleteTap(ctx, d.opt.Run, s.tap); err != nil {
461- errs = append(errs, err)
462- } else {
463- s.attached = false
612+ if s.lay.dir != "" {
613+ if err := removeAll(s.lay.dir); err != nil {
614+ errs = append(errs, err)
615+ }
464616 }
465- }
466- if s.lay.dir != "" {
467- if err := removeAll(s.lay.dir); err != nil {
468- errs = append(errs, err)
617+ if s.lay.jailed {
618+ if err := removeCgroup(d.opt.CgroupRoot, filepath.Base(d.fcPath), s.id); err != nil {
619+ errs = append(errs, err)
620+ }
469621 }
470- }
471- if len(errs) == 0 {
472- if err := d.store.remove(s.rec.ID); err != nil {
473- errs = append(errs, err)
622+ if len(errs) == 0 {
623+ if err := d.store.remove(s.id); err != nil {
624+ errs = append(errs, err)
625+ }
474626 }
475627 }
476628 if len(errs) == 0 {
477629 s.releaseLocked()
478630 return nil
479631 }
480- s.stopped = true // do not retry from here; Reap at the next start will
481- err := errors.Join(errs...)
482- log.Error().Err(err).Str("vm", s.rec.ID).Msg("micro-VM cleanup incomplete; the next runner start will retry")
483- return err
632+ s.stopped = true
633+ d.markLeaked(s.slot)
634+ s.leakErr = fmt.Errorf("%w: vm %s: %w", sandbox.ErrLeaked, s.id, errors.Join(errs...))
635+ log.Error().Err(s.leakErr).Str("vm", s.id).Msg("micro-VM cleanup incomplete; call Reap to finish it")
636+ return s.leakErr
484637 }
485638
486639 // removeAll is os.RemoveAll that also reports a directory that is still there.
@@ -493,3 +646,14 @@ func removeAll(dir string) error {
493646 }
494647 return nil
495648 }
649+
650+// removeCgroup removes the cgroup the jailer created for a VM
651+// (<root>/<parent>/<id>, parent = the exec file name). The jailer never removes
652+// it. A missing cgroup is fine; a non-empty one fails (rmdir semantics).
653+func removeCgroup(root, parent, id string) error {
654+ err := os.Remove(filepath.Join(root, parent, id))
655+ if err == nil || errors.Is(err, os.ErrNotExist) {
656+ return nil
657+ }
658+ return fmt.Errorf("remove cgroup %s: %w", filepath.Join(root, parent, id), err)
659+}
@@ -20,12 +20,12 @@ import (
20 "os"20 "os"
21 "os/exec"21 "os/exec"
22 "path/filepath"22 "path/filepath"
23+ "strconv"
23 "sync"24 "sync"
24 "syscall"25 "syscall"
25 "time"26 "time"
26 27
27 "github.com/bnema/zerowrap"28 "github.com/bnema/zerowrap"
28- "golang.org/x/sys/unix"
29 29
30 "bnema.dev/ermite-runner/internal/cmdrun"30 "bnema.dev/ermite-runner/internal/cmdrun"
31 "bnema.dev/ermite-runner/internal/netpolicy"31 "bnema.dev/ermite-runner/internal/netpolicy"
@@ -36,8 +36,11 @@ import (
36 // Options are the testable knobs of the driver.36 // Options are the testable knobs of the driver.
37 type Options struct {37 type Options struct {
38 Run cmdrun.Runner38 Run cmdrun.Runner
39- // ProcDir is the /proc used to find orphan Firecracker processes.39+ // ProcDir is the /proc used to find orphan VMM processes.
40 ProcDir string40 ProcDir string
41+ // CgroupRoot is the cgroup v2 mount, where the jailer creates one cgroup per
42+ // VM (default /sys/fs/cgroup).
43+ CgroupRoot string
41 // BootTimeout bounds boot + init + executor connection (default 90 s).44 // BootTimeout bounds boot + init + executor connection (default 90 s).
42 BootTimeout time.Duration45 BootTimeout time.Duration
43 // Net overrides the network plumbing (tests).46 // Net overrides the network plumbing (tests).
@@ -45,8 +48,17 @@ type Options struct {
45 // FirecrackerArgs are extra arguments for Firecracker (tests use them to48 // FirecrackerArgs are extra arguments for Firecracker (tests use them to
46 // select the fake).49 // select the fake).
47 FirecrackerArgs []string50 FirecrackerArgs []string
51+ // FreeSpace overrides statfs for the free-space warning (tests).
52+ FreeSpace func(string) (int64, error)
53+ // kill overrides killAndWait (tests simulate a VMM that cannot be killed).
54+ kill func(pid int, timeout time.Duration) bool
48 }55 }
49 56
57+// vmmOverheadMiB is what the Firecracker process itself may use on top of the
58+// guest memory (device emulation, the API server, the seccomp filter, stacks):
59+// it is added to memory.max so that the guest, not the VMM, is what gets limited.
60+const vmmOverheadMiB = 256
61+
50 // Driver implements sandbox.Driver for micro-VMs.62 // Driver implements sandbox.Driver for micro-VMs.
51 type Driver struct {63 type Driver struct {
52 cfg sandbox.Config64 cfg sandbox.Config
@@ -56,14 +68,15 @@ type Driver struct {
56 execDisk string68 execDisk string
57 store store69 store store
58 net netsetup.Plumbing70 net netsetup.Plumbing
59- lock *os.File71+ lock *sandbox.HostLock
60 72
61 mu sync.Mutex73 mu sync.Mutex
62 slots *sandbox.Slots74 slots *sandbox.Slots
63 }75 }
64 76
65-// New builds the driver: resolves binaries, builds the exec disk, takes the77+// New builds the driver: takes the host-wide lock, resolves binaries, builds the
66-// state-directory lock and enables IPv4 forwarding. Run Reap before New sandboxes.78+// exec disk and enables IPv4 forwarding (a host-wide sysctl that stays on: see
79+// the README). Run Reap before New sandboxes.
67 func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) {80 func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) {
68 if opt.Run == nil {81 if opt.Run == nil {
69 opt.Run = cmdrun.Exec{}82 opt.Run = cmdrun.Exec{}
@@ -71,9 +84,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
71 if opt.ProcDir == "" {84 if opt.ProcDir == "" {
72 opt.ProcDir = "/proc"85 opt.ProcDir = "/proc"
73 }86 }
87+ if opt.CgroupRoot == "" {
88+ opt.CgroupRoot = "/sys/fs/cgroup"
89+ }
74 if opt.BootTimeout == 0 {90 if opt.BootTimeout == 0 {
75 opt.BootTimeout = 90 * time.Second91 opt.BootTimeout = 90 * time.Second
76 }92 }
93+ if opt.kill == nil {
94+ opt.kill = killAndWait
95+ }
77 d := &Driver{cfg: cfg, opt: opt, slots: sandbox.NewSlots(cfg.Capacity), store: store{dir: filepath.Join(cfg.StateDir, "vms")}}96 d := &Driver{cfg: cfg, opt: opt, slots: sandbox.NewSlots(cfg.Capacity), store: store{dir: filepath.Join(cfg.StateDir, "vms")}}
78 97
79 var err error98 var err error
@@ -95,7 +114,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
95 if err := os.MkdirAll(cfg.StateDir, 0o755); err != nil {114 if err := os.MkdirAll(cfg.StateDir, 0o755); err != nil {
96 return nil, fmt.Errorf("state directory: %w", err)115 return nil, fmt.Errorf("state directory: %w", err)
97 }116 }
98- if err := d.acquireLock(); err != nil {117+ if d.lock, err = sandbox.AcquireHostLock(cfg.LockFile); err != nil {
99 return nil, err118 return nil, err
100 }119 }
101 fail := func(err error) (*Driver, error) { _ = d.Close(); return nil, err }120 fail := func(err error) (*Driver, error) { _ = d.Close(); return nil, err }
@@ -107,6 +126,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
107 }126 }
108 }127 }
109 }128 }
129+ sandbox.WarnIfLowSpace(ctx, cfg.StateDir, cfg.Capacity, cfg.JobDiskMiB, opt.FreeSpace)
110 if d.execDisk, err = ensureExecDisk(ctx, opt.Run, exe, cfg.StateDir); err != nil {130 if d.execDisk, err = ensureExecDisk(ctx, opt.Run, exe, cfg.StateDir); err != nil {
111 return fail(err)131 return fail(err)
112 }132 }
@@ -138,44 +158,33 @@ func resolveBin(configured, name string) (string, error) {
138 return abs, nil158 return abs, nil
139 }159 }
140 160
141-func (d *Driver) acquireLock() error {
142- f, err := os.OpenFile(filepath.Join(d.cfg.StateDir, "vm.lock"), os.O_CREATE|os.O_RDWR, 0o600)
143- if err != nil {
144- return err
145- }
146- if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
147- _ = f.Close()
148- return fmt.Errorf("another ermite-runner already uses state directory %s (reaping would destroy its VMs): %w", d.cfg.StateDir, err)
149- }
150- d.lock = f
151- return nil
152-}
153-
154 // Mode implements sandbox.Driver.161 // Mode implements sandbox.Driver.
155 func (d *Driver) Mode() sandbox.Mode { return sandbox.VM }162 func (d *Driver) Mode() sandbox.Mode { return sandbox.VM }
156 163
157-// Close releases the lock and the DNS listeners.164+// Isolation implements sandbox.Driver.
165+func (d *Driver) Isolation() sandbox.Isolation { return sandbox.IsolationVM }
166+
167+// Close releases the host lock and the DNS listeners.
158 func (d *Driver) Close() error {168 func (d *Driver) Close() error {
159 if d.net != nil {169 if d.net != nil {
160 d.net.Close()170 d.net.Close()
161 }171 }
162- if d.lock != nil {172+ return d.lock.Release()
163- err := d.lock.Close()
164- d.lock = nil
165- return err
166- }
167- return nil
168 }173 }
169 174
170 // New implements sandbox.Driver.175 // New implements sandbox.Driver.
171 func (d *Driver) New() (sandbox.Sandbox, error) {176 func (d *Driver) New() (sandbox.Sandbox, error) {
177+ id, err := sandbox.NewID()
178+ if err != nil {
179+ return nil, err
180+ }
172 d.mu.Lock()181 d.mu.Lock()
173 defer d.mu.Unlock()182 defer d.mu.Unlock()
174 slot, err := d.slots.Acquire()183 slot, err := d.slots.Acquire()
175 if err != nil {184 if err != nil {
176 return nil, err185 return nil, err
177 }186 }
178- return &vmSandbox{d: d, slot: slot}, nil187+ return &vmSandbox{d: d, slot: slot, id: id}, nil
179 }188 }
180 189
181 func (d *Driver) release(slot int) {190 func (d *Driver) release(slot int) {
@@ -184,14 +193,26 @@ func (d *Driver) release(slot int) {
184 d.mu.Unlock()193 d.mu.Unlock()
185 }194 }
186 195
196+func (d *Driver) markLeaked(slot int) {
197+ d.mu.Lock()
198+ d.slots.MarkLeaked(slot)
199+ d.mu.Unlock()
200+}
201+
187 // vmSandbox is one VM, single use.202 // vmSandbox is one VM, single use.
188 type vmSandbox struct {203 type vmSandbox struct {
189 d *Driver204 d *Driver
190 slot int205 slot int
206+ id string
207+ gate sandbox.BootGate
208+
209+ mu sync.Mutex
210+ started bool
211+ stopped bool
212+ // leakErr is the error of a cleanup that left something behind; later Stop
213+ // calls return it again.
214+ leakErr error
191 215
192- mu sync.Mutex
193- started bool
194- stopped bool
195 rec record216 rec record
196 lay layout217 lay layout
197 cmd *exec.Cmd218 cmd *exec.Cmd
@@ -204,10 +225,20 @@ type vmSandbox struct {
204 attached bool225 attached bool
205 }226 }
206 227
228+// ID implements sandbox.Sandbox.
229+func (s *vmSandbox) ID() string { return s.id }
230+
207 func (s *vmSandbox) uid() int { return s.d.cfg.UIDBase + s.slot }231 func (s *vmSandbox) uid() int { return s.d.cfg.UIDBase + s.slot }
208 232
209 // Start implements sandbox.Sandbox.233 // Start implements sandbox.Sandbox.
210 func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {234 func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
235+ // A Stop from another goroutine aborts the boot through the gate.
236+ bootCtx, err := s.gate.Begin(ctx)
237+ if err != nil {
238+ return nil, fmt.Errorf("vm sandbox: %w", err)
239+ }
240+ defer s.gate.End()
241+
211 s.mu.Lock()242 s.mu.Lock()
212 defer s.mu.Unlock()243 defer s.mu.Unlock()
213 if s.started || s.stopped {244 if s.started || s.stopped {
@@ -218,18 +249,13 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
218 d := s.d249 d := s.d
219 spec = d.cfg.Resolve(spec)250 spec = d.cfg.Resolve(spec)
220 251
221- id, err := sandbox.NewID()
222- if err != nil {
223- s.releaseLocked()
224- return nil, err
225- }
226 jailed := !d.cfg.JailerOff252 jailed := !d.cfg.JailerOff
227 runUID := s.uid()253 runUID := s.uid()
228 if !jailed {254 if !jailed {
229 runUID = os.Getuid()255 runUID = os.Getuid()
230 }256 }
231- s.lay = newLayout(d.cfg.StateDir, filepath.Base(d.fcPath), id, jailed, runUID)257+ s.lay = newLayout(d.cfg.StateDir, filepath.Base(d.fcPath), s.id, jailed, runUID)
232- s.rec = record{ID: id, Slot: s.slot, Job: spec.JobID, Dir: s.lay.dir}258+ s.rec = record{ID: s.id, Slot: s.slot, Job: spec.JobID, Dir: s.lay.dir}
233 // The record exists before the first resource, so any crash from here on is259 // The record exists before the first resource, so any crash from here on is
234 // reapable.260 // reapable.
235 if err := d.store.save(s.rec); err != nil {261 if err := d.store.save(s.rec); err != nil {
@@ -237,7 +263,7 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
237 return nil, fmt.Errorf("write state record: %w", err)263 return nil, fmt.Errorf("write state record: %w", err)
238 }264 }
239 265
240- conn, err := s.boot(ctx, spec, id)266+ conn, err := s.boot(bootCtx, spec)
241 if err != nil {267 if err != nil {
242 console := ""268 console := ""
243 if s.console != nil {269 if s.console != nil {
@@ -249,98 +275,140 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
249 }275 }
250 return nil, errors.Join(err, cerr)276 return nil, errors.Join(err, cerr)
251 }277 }
252- log.Info().Str("vm", id).Int("slot", s.slot).Str("job", spec.JobID).Msg("micro-VM running")278+ log.Info().Str("vm", s.id).Int("slot", s.slot).Str("job", spec.JobID).Msg("micro-VM running")
253 return conn, nil279 return conn, nil
254 }280 }
255 281
256-func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec, id string) (sandbox.Conn, error) {282+// boot runs the stages in order; each stage records what it created so that
257- d := s.d283+// cleanupLocked can undo exactly that.
258- netw, err := netpolicy.Subnet(d.cfg.Network, s.slot)284+func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
285+ netw, err := netpolicy.Subnet(s.d.cfg.Network, s.slot)
286+ if err != nil {
287+ return nil, err
288+ }
289+ if err := s.bootNetwork(ctx, netw); err != nil {
290+ return nil, err
291+ }
292+ api, err := s.bootVMM(ctx, spec)
293+ if err != nil {
294+ return nil, err
295+ }
296+ files, err := s.bootFiles(ctx, spec)
297+ if err != nil {
298+ return nil, err
299+ }
300+ accepted, err := s.bootVsock()
259 if err != nil {301 if err != nil {
260 return nil, err302 return nil, err
261 }303 }
304+ if err := api.configure(ctx, s.vmConfig(spec, netw, files)); err != nil {
305+ return nil, err
306+ }
307+ return s.waitExecutor(ctx, accepted)
308+}
262 309
263- // Network first: the TAP must exist when Firecracker opens it.310+// bootNetwork creates the TAP (it must exist when Firecracker opens it) and
311+// installs the firewall policy and the DNS listener.
312+func (s *vmSandbox) bootNetwork(ctx context.Context, netw netpolicy.Net) error {
313+ d := s.d
264 s.tap, s.gateway = tapName(s.slot), netw.Gateway314 s.tap, s.gateway = tapName(s.slot), netw.Gateway
265 s.rec.Tap, s.rec.Gate = s.tap, s.gateway.String()315 s.rec.Tap, s.rec.Gate = s.tap, s.gateway.String()
266 if err := d.store.save(s.rec); err != nil {316 if err := d.store.save(s.rec); err != nil {
267- return nil, err317+ return err
268 }318 }
269 if err := createTap(ctx, d.opt.Run, s.tap, s.lay.uid, s.gateway); err != nil {319 if err := createTap(ctx, d.opt.Run, s.tap, s.lay.uid, s.gateway); err != nil {
270- return nil, err320+ return err
271 }321 }
272 s.attached = true // from here Detach is idempotent and always safe322 s.attached = true // from here Detach is idempotent and always safe
273- if err := d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest}); err != nil {323+ return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest})
274- return nil, err324+}
275- }
276 325
277- // The VMM process.326+// bootVMM starts the jailer (or Firecracker) and waits for its API.
327+func (s *vmSandbox) bootVMM(ctx context.Context, spec sandbox.JobSpec) (*apiClient, error) {
278 apiSock := s.lay.host("api.sock")328 apiSock := s.lay.host("api.sock")
279 if s.lay.jailed {329 if s.lay.jailed {
280 apiSock = s.lay.host("run/firecracker.socket")330 apiSock = s.lay.host("run/firecracker.socket")
281 } else if err := os.MkdirAll(s.lay.root, 0o700); err != nil {331 } else if err := os.MkdirAll(s.lay.root, 0o700); err != nil {
282 return nil, err332 return nil, err
283 }333 }
284- if err := s.startVMM(id, apiSock); err != nil {334+ if err := s.startVMM(spec, apiSock); err != nil {
285 return nil, err335 return nil, err
286 }336 }
287 api := newAPIClient(apiSock)337 api := newAPIClient(apiSock)
288 if err := s.waitAPI(ctx, api, apiSock); err != nil {338 if err := s.waitAPI(ctx, api, apiSock); err != nil {
289 return nil, err339 return nil, err
290 }340 }
341+ return api, nil
342+}
291 343
292- // Files: the process only opens them when configured below.344+// vmFiles are the paths Firecracker is given.
293- execDisk, err := s.lay.share(d.execDisk, "exec.ext4")345+type vmFiles struct{ execDisk, baseDisk, kernel string }
294- if err != nil {346+
295- return nil, err347+// bootFiles makes the shared files available and creates the scratch disk.
296- }348+func (s *vmSandbox) bootFiles(ctx context.Context, spec sandbox.JobSpec) (vmFiles, error) {
297- baseDisk, err := s.lay.share(d.cfg.BaseImagePath(), "base.ext4")349+ d := s.d
298- if err != nil {350+ var f vmFiles
299- return nil, err351+ var err error
352+ if f.execDisk, err = s.lay.share(d.execDisk, "exec.ext4"); err != nil {
353+ return f, err
300 }354 }
301- kernel, err := s.lay.share(d.cfg.KernelPath(), "kernel")355+ if f.baseDisk, err = s.lay.share(d.cfg.BaseImagePath(), "base.ext4"); err != nil {
302- if err != nil {356+ return f, err
303- return nil, err
304 }357 }
305- if err := makeScratch(ctx, d.opt.Run, s.lay.host("scratch.ext4"), spec.DiskMiB); err != nil {358+ if f.kernel, err = s.lay.share(d.cfg.KernelPath(), "kernel"); err != nil {
306- return nil, err359+ return f, err
307 }360 }
308- if err := s.lay.own("scratch.ext4"); err != nil {361+ owner := -1
309- return nil, err362+ if s.lay.jailed {
363+ owner = s.lay.uid
310 }364 }
365+ return f, makeScratch(ctx, d.opt.Run, s.lay.host("scratch.ext4"), spec.DiskMiB, owner)
366+}
311 367
312- // The host side of the vsock stream: Firecracker connects to368+// bootVsock listens on the host side of the vsock stream: Firecracker connects
313- // <uds_path>_<port> when the guest connects to the host CID.369+// to <uds_path>_<port> when the guest connects to the host CID.
314- udsName := "v.sock"370+func (s *vmSandbox) bootVsock() (<-chan acceptResult, error) {
315- ln, err := net.Listen("unix", s.lay.host(udsName)+fmt.Sprintf("_%d", sandbox.VsockPort))371+ path := s.lay.host(udsName) + fmt.Sprintf("_%d", sandbox.VsockPort)
372+ ln, err := net.Listen("unix", path)
316 if err != nil {373 if err != nil {
317 return nil, fmt.Errorf("listen vsock socket: %w", err)374 return nil, fmt.Errorf("listen vsock socket: %w", err)
318 }375 }
319 s.listener = ln376 s.listener = ln
320 if s.lay.jailed {377 if s.lay.jailed {
321- if err := os.Chown(s.lay.host(udsName+fmt.Sprintf("_%d", sandbox.VsockPort)), s.lay.uid, s.lay.uid); err != nil {378+ if err := os.Lchown(path, s.lay.uid, s.lay.uid); err != nil {
322 return nil, err379 return nil, err
323 }380 }
324 }381 }
325 accepted := make(chan acceptResult, 1)382 accepted := make(chan acceptResult, 1)
326 go func() { c, err := ln.Accept(); accepted <- acceptResult{c, err} }()383 go func() { c, err := ln.Accept(); accepted <- acceptResult{c, err} }()
384+ return accepted, nil
385+}
386+
387+const udsName = "v.sock"
327 388
328- err = api.configure(ctx, vmConfig{389+func (s *vmSandbox) vmConfig(spec sandbox.JobSpec, netw netpolicy.Net, f vmFiles) vmConfig {
390+ cfg := s.d.cfg
391+ return vmConfig{
329 Machine: machineConfig{VcpuCount: spec.CPUs, MemSizeMiB: spec.MemoryMiB},392 Machine: machineConfig{VcpuCount: spec.CPUs, MemSizeMiB: spec.MemoryMiB},
330- Boot: bootSource{KernelImagePath: kernel, BootArgs: bootArgs(netw.Guest, netw.Gateway, time.Now().Unix())},393+ Boot: bootSource{KernelImagePath: f.kernel, BootArgs: bootArgs(netw.Guest, netw.Gateway, time.Now().Unix())},
331 Drives: []drive{394 Drives: []drive{
332- {DriveID: "rootfs", PathOnHost: execDisk, IsRootDevice: true, IsReadOnly: true},395+ {DriveID: "rootfs", PathOnHost: f.execDisk, IsRootDevice: true, IsReadOnly: true},
333- {DriveID: "base", PathOnHost: baseDisk, IsReadOnly: true},396+ {DriveID: "base", PathOnHost: f.baseDisk, IsReadOnly: true},
334- {DriveID: "scratch", PathOnHost: s.lay.fc("scratch.ext4")},397+ {DriveID: "scratch", PathOnHost: s.lay.fc("scratch.ext4"), RateLimiter: diskLimiter(cfg.DiskBandwidthMiB, cfg.DiskIOPS)},
398+ },
399+ Net: networkInterface{
400+ IfaceID: "eth0", HostDevName: s.tap, GuestMAC: macFor(s.slot),
401+ RxRateLimiter: netLimiter(cfg.NetMbit), TxRateLimiter: netLimiter(cfg.NetMbit),
335 },402 },
336- Net: networkInterface{IfaceID: "eth0", HostDevName: s.tap, GuestMAC: macFor(s.slot)},
337 Vsock: vsockDevice{GuestCID: 3, UDSPath: s.lay.fc(udsName)},403 Vsock: vsockDevice{GuestCID: 3, UDSPath: s.lay.fc(udsName)},
338- })
339- if err != nil {
340- return nil, err
341 }404 }
405+}
342 406
343- timer := time.NewTimer(d.opt.BootTimeout)407+// waitExecutor waits for the guest's executor to connect. Only ctx (the caller's
408+// context, or Stop through the boot gate) and the boot timeout end the wait: a
409+// boot cannot be aborted any other way.
410+func (s *vmSandbox) waitExecutor(ctx context.Context, accepted <-chan acceptResult) (sandbox.Conn, error) {
411+ timer := time.NewTimer(s.d.opt.BootTimeout)
344 defer timer.Stop()412 defer timer.Stop()
345 select {413 select {
346 case r := <-accepted:414 case r := <-accepted:
@@ -352,9 +420,9 @@ func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec, id string) (
352 case <-s.exited:420 case <-s.exited:
353 return nil, errors.New("firecracker exited before the executor connected")421 return nil, errors.New("firecracker exited before the executor connected")
354 case <-timer.C:422 case <-timer.C:
355- return nil, fmt.Errorf("executor did not connect within %s", d.opt.BootTimeout)423+ return nil, fmt.Errorf("executor did not connect within %s", s.d.opt.BootTimeout)
356 case <-ctx.Done():424 case <-ctx.Done():
357- return nil, ctx.Err()425+ return nil, context.Cause(ctx)
358 }426 }
359 }427 }
360 428
@@ -363,18 +431,38 @@ type acceptResult struct {
363 err error431 err error
364 }432 }
365 433
366-func (s *vmSandbox) startVMM(id, apiSock string) error {434+// jailerArgs builds the jailer command line (after the binary name).
435+//
436+// - cgroup v2 with memory.max (guest memory + VMM overhead) and pids.max (a
437+// VMM needs a handful of threads per vCPU): a runaway or compromised VMM
438+// cannot take the host's memory or fork-bomb it;
439+// - a new PID namespace: the VMM cannot see or signal host processes;
440+// - no-file: the descriptor budget of the VMM.
441+func jailerArgs(jailer, fc, id string, uid int, chrootBase string, spec sandbox.JobSpec, extra []string) []string {
442+ memBytes := (int64(spec.MemoryMiB) + vmmOverheadMiB) << 20
443+ pids := 32 + 8*spec.CPUs
444+ args := []string{
445+ "--id", id, "--exec-file", fc,
446+ "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(uid),
447+ "--chroot-base-dir", chrootBase,
448+ "--cgroup-version", "2",
449+ "--cgroup", "memory.max=" + strconv.FormatInt(memBytes, 10),
450+ "--cgroup", "pids.max=" + strconv.Itoa(pids),
451+ "--new-pid-ns",
452+ "--resource-limit", "no-file=4096",
453+ "--",
454+ }
455+ return append(args, extra...)
456+}
457+
458+func (s *vmSandbox) startVMM(spec sandbox.JobSpec, apiSock string) error {
367 d := s.d459 d := s.d
368 var cmd *exec.Cmd460 var cmd *exec.Cmd
369 if s.lay.jailed {461 if s.lay.jailed {
370- args := []string{"--id", id, "--exec-file", d.fcPath,462+ args := jailerArgs(d.jailer, d.fcPath, s.id, s.uid(), jailBase(d.cfg.StateDir), spec, d.opt.FirecrackerArgs)
371- "--uid", fmt.Sprint(s.uid()), "--gid", fmt.Sprint(s.uid()),463+ cmd = exec.Command(d.jailer, args...)
372- "--chroot-base-dir", jailBase(d.cfg.StateDir),
373- "--resource-limit", "no-file=4096",
374- "--"}
375- cmd = exec.Command(d.jailer, append(args, d.opt.FirecrackerArgs...)...)
376 } else {464 } else {
377- args := append([]string{"--id", id, "--api-sock", apiSock}, d.opt.FirecrackerArgs...)465+ args := append([]string{"--id", s.id, "--api-sock", apiSock}, d.opt.FirecrackerArgs...)
378 cmd = exec.Command(d.fcPath, args...)466 cmd = exec.Command(d.fcPath, args...)
379 }467 }
380 s.console = newRing(16 << 10)468 s.console = newRing(16 << 10)
@@ -385,11 +473,36 @@ func (s *vmSandbox) startVMM(id, apiSock string) error {
385 }473 }
386 s.cmd = cmd474 s.cmd = cmd
387 s.exited = make(chan struct{})475 s.exited = make(chan struct{})
388- go func() { _ = cmd.Wait(); close(s.exited) }()476+ jailed := s.lay.jailed
477+ go func() {
478+ _ = cmd.Wait()
479+ // With --new-pid-ns the jailer may exit as soon as it has started the
480+ // VMM in its namespace: the VMM is then gone only when its own pid is.
481+ if jailed {
482+ s.awaitVMMGone()
483+ }
484+ close(s.exited)
485+ }()
389 s.rec.PID = cmd.Process.Pid486 s.rec.PID = cmd.Process.Pid
390 return s.d.store.save(s.rec)487 return s.d.store.save(s.rec)
391 }488 }
392 489
490+// awaitVMMGone blocks until no process of this VM's id is left.
491+func (s *vmSandbox) awaitVMMGone() {
492+ for {
493+ alive := false
494+ for pid, id := range vmmProcs(s.d.opt.ProcDir) {
495+ if id == s.id && pidAlive(pid) {
496+ alive = true
497+ }
498+ }
499+ if !alive {
500+ return
501+ }
502+ time.Sleep(50 * time.Millisecond)
503+ }
504+}
505+
393 func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) error {506 func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) error {
394 deadline := time.Now().Add(10 * time.Second)507 deadline := time.Now().Add(10 * time.Second)
395 for {508 for {
@@ -400,7 +513,7 @@ func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) er
400 case <-s.exited:513 case <-s.exited:
401 return errors.New("firecracker exited before its API was ready")514 return errors.New("firecracker exited before its API was ready")
402 case <-ctx.Done():515 case <-ctx.Done():
403- return ctx.Err()516+ return context.Cause(ctx)
404 case <-time.After(25 * time.Millisecond):517 case <-time.After(25 * time.Millisecond):
405 }518 }
406 if time.Now().After(deadline) {519 if time.Now().After(deadline) {
@@ -411,13 +524,14 @@ func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) er
411 524
412 // Stop implements sandbox.Sandbox.525 // Stop implements sandbox.Sandbox.
413 func (s *vmSandbox) Stop(ctx context.Context) error {526 func (s *vmSandbox) Stop(ctx context.Context) error {
527+ // Abort a boot in progress first: Start holds s.mu for its whole duration.
528+ s.gate.Abort()
414 s.mu.Lock()529 s.mu.Lock()
415 defer s.mu.Unlock()530 defer s.mu.Unlock()
416 if s.stopped {531 if s.stopped {
417- return nil532+ return s.leakErr
418 }533 }
419 if !s.started {534 if !s.started {
420- s.stopped = true
421 s.releaseLocked()535 s.releaseLocked()
422 return nil536 return nil
423 }537 }
@@ -429,10 +543,31 @@ func (s *vmSandbox) releaseLocked() {
429 s.d.release(s.slot)543 s.d.release(s.slot)
430 }544 }
431 545
432-// cleanupLocked removes everything the VM created, in dependency order, and546+// vmmPIDs returns every host process of this VM: the launched command and
433-// keeps going after an error. The state record and the slot are released only547+// whatever the /proc scan finds with this id.
434-// when everything is gone, so that a half-failed cleanup is retried by Reap548+func (s *vmSandbox) vmmPIDs() []int {
435-// rather than forgotten and the slot (hence TAP name) is not reused meanwhile.549+ set := map[int]bool{}
550+ if s.cmd != nil && s.cmd.Process != nil {
551+ set[s.cmd.Process.Pid] = true
552+ }
553+ for pid, id := range vmmProcs(s.d.opt.ProcDir) {
554+ if id == s.id {
555+ set[pid] = true
556+ }
557+ }
558+ var out []int
559+ for pid := range set {
560+ out = append(out, pid)
561+ }
562+ return out
563+}
564+
565+// cleanupLocked removes everything the VM created, in dependency order. Steps
566+// that need the VMM to be dead (network device, firewall rules, files, cgroup)
567+// are skipped when it could not be killed: removing the TAP or the policy under
568+// a VM that may still run would break the isolation it relies on, so the
569+// sandbox is reported as leaked (sandbox.ErrLeaked), its slot stays taken, and
570+// Reap finishes the job.
436 func (s *vmSandbox) cleanupLocked(ctx context.Context) error {571 func (s *vmSandbox) cleanupLocked(ctx context.Context) error {
437 log := zerowrap.FromCtx(ctx)572 log := zerowrap.FromCtx(ctx)
438 d := s.d573 d := s.d
@@ -445,42 +580,60 @@ func (s *vmSandbox) cleanupLocked(ctx context.Context) error {
445 _ = s.listener.Close()580 _ = s.listener.Close()
446 s.listener = nil581 s.listener = nil
447 }582 }
583+ dead := true
448 if s.cmd != nil && s.cmd.Process != nil {584 if s.cmd != nil && s.cmd.Process != nil {
449- killAndWait(s.cmd.Process.Pid, 5*time.Second)585+ _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL) // the whole group
586+ }
587+ for _, pid := range s.vmmPIDs() {
588+ if !d.opt.kill(pid, 5*time.Second) {
589+ dead = false
590+ errs = append(errs, fmt.Errorf("vmm process %d did not exit", pid))
591+ }
592+ }
593+ if dead && s.exited != nil {
450 select {594 select {
451 case <-s.exited:595 case <-s.exited:
452 case <-time.After(5 * time.Second):596 case <-time.After(5 * time.Second):
453- errs = append(errs, fmt.Errorf("firecracker pid %d did not exit", s.cmd.Process.Pid))597+ dead = false
598+ errs = append(errs, errors.New("vmm exit was not observed"))
454 }599 }
455 }600 }
456- if s.attached {601+ if dead {
457- if err := d.net.Detach(ctx, s.tap, s.gateway); err != nil {602+ if s.attached {
458- errs = append(errs, err)603+ if err := d.net.Detach(ctx, s.tap, s.gateway); err != nil {
604+ errs = append(errs, err)
605+ }
606+ if err := deleteTap(ctx, d.opt.Run, s.tap); err != nil {
607+ errs = append(errs, err)
608+ } else {
609+ s.attached = false
610+ }
459 }611 }
460- if err := deleteTap(ctx, d.opt.Run, s.tap); err != nil {612+ if s.lay.dir != "" {
461- errs = append(errs, err)613+ if err := removeAll(s.lay.dir); err != nil {
462- } else {614+ errs = append(errs, err)
463- s.attached = false615+ }
464 }616 }
465- }617+ if s.lay.jailed {
466- if s.lay.dir != "" {618+ if err := removeCgroup(d.opt.CgroupRoot, filepath.Base(d.fcPath), s.id); err != nil {
467- if err := removeAll(s.lay.dir); err != nil {619+ errs = append(errs, err)
468- errs = append(errs, err)620+ }
469 }621 }
470- }622+ if len(errs) == 0 {
471- if len(errs) == 0 {623+ if err := d.store.remove(s.id); err != nil {
472- if err := d.store.remove(s.rec.ID); err != nil {624+ errs = append(errs, err)
473- errs = append(errs, err)625+ }
474 }626 }
475 }627 }
476 if len(errs) == 0 {628 if len(errs) == 0 {
477 s.releaseLocked()629 s.releaseLocked()
478 return nil630 return nil
479 }631 }
480- s.stopped = true // do not retry from here; Reap at the next start will632+ s.stopped = true
481- err := errors.Join(errs...)633+ d.markLeaked(s.slot)
482- log.Error().Err(err).Str("vm", s.rec.ID).Msg("micro-VM cleanup incomplete; the next runner start will retry")634+ s.leakErr = fmt.Errorf("%w: vm %s: %w", sandbox.ErrLeaked, s.id, errors.Join(errs...))
483- return err635+ log.Error().Err(s.leakErr).Str("vm", s.id).Msg("micro-VM cleanup incomplete; call Reap to finish it")
636+ return s.leakErr
484 }637 }
485 638
486 // removeAll is os.RemoveAll that also reports a directory that is still there.639 // removeAll is os.RemoveAll that also reports a directory that is still there.
@@ -493,3 +646,14 @@ func removeAll(dir string) error {
493 }646 }
494 return nil647 return nil
495 }648 }
649+
650+// removeCgroup removes the cgroup the jailer created for a VM
651+// (<root>/<parent>/<id>, parent = the exec file name). The jailer never removes
652+// it. A missing cgroup is fine; a non-empty one fails (rmdir semantics).
653+func removeCgroup(root, parent, id string) error {
654+ err := os.Remove(filepath.Join(root, parent, id))
655+ if err == nil || errors.Is(err, os.ErrNotExist) {
656+ return nil
657+ }
658+ return fmt.Errorf("remove cgroup %s: %w", filepath.Join(root, parent, id), err)
659+}
M internal/sandbox/vm/images.go +11 -2
@@ -10,6 +10,7 @@ import (
1010 "io"
1111 "os"
1212 "path/filepath"
13+ "syscall"
1314
1415 "bnema.dev/ermite-runner/internal/cmdrun"
1516 "bnema.dev/ermite-runner/internal/sandbox"
@@ -83,11 +84,19 @@ func ensureExecDisk(ctx context.Context, run cmdrun.Runner, exe, stateDir string
8384 }
8485
8586 // makeScratch creates the per-job sparse scratch disk of sizeMiB and formats it.
86-func makeScratch(ctx context.Context, run cmdrun.Runner, path string, sizeMiB int) error {
87- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
87+// The file is created exclusively without following symlinks and handed to
88+// owner (fchown on the open descriptor, never by path) when owner >= 0.
89+func makeScratch(ctx context.Context, run cmdrun.Runner, path string, sizeMiB, owner int) error {
90+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600)
8891 if err != nil {
8992 return err
9093 }
94+ if owner >= 0 {
95+ if err := f.Chown(owner, owner); err != nil {
96+ _ = f.Close()
97+ return fmt.Errorf("chown scratch disk: %w", err)
98+ }
99+ }
91100 if err := f.Truncate(int64(sizeMiB) << 20); err != nil {
92101 _ = f.Close()
93102 return err
@@ -10,6 +10,7 @@ import (
10 "io"10 "io"
11 "os"11 "os"
12 "path/filepath"12 "path/filepath"
13+ "syscall"
13 14
14 "bnema.dev/ermite-runner/internal/cmdrun"15 "bnema.dev/ermite-runner/internal/cmdrun"
15 "bnema.dev/ermite-runner/internal/sandbox"16 "bnema.dev/ermite-runner/internal/sandbox"
@@ -83,11 +84,19 @@ func ensureExecDisk(ctx context.Context, run cmdrun.Runner, exe, stateDir string
83 }84 }
84 85
85 // makeScratch creates the per-job sparse scratch disk of sizeMiB and formats it.86 // makeScratch creates the per-job sparse scratch disk of sizeMiB and formats it.
86-func makeScratch(ctx context.Context, run cmdrun.Runner, path string, sizeMiB int) error {87+// The file is created exclusively without following symlinks and handed to
87- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)88+// owner (fchown on the open descriptor, never by path) when owner >= 0.
89+func makeScratch(ctx context.Context, run cmdrun.Runner, path string, sizeMiB, owner int) error {
90+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600)
88 if err != nil {91 if err != nil {
89 return err92 return err
90 }93 }
94+ if owner >= 0 {
95+ if err := f.Chown(owner, owner); err != nil {
96+ _ = f.Close()
97+ return fmt.Errorf("chown scratch disk: %w", err)
98+ }
99+ }
91 if err := f.Truncate(int64(sizeMiB) << 20); err != nil {100 if err := f.Truncate(int64(sizeMiB) << 20); err != nil {
92 _ = f.Close()101 _ = f.Close()
93 return err102 return err
M internal/sandbox/vm/layout.go +3 -1
@@ -69,11 +69,13 @@ func (l layout) share(src, name string) (string, error) {
6969 }
7070
7171 // own hands a per-job file in the jail to the jailed uid; no-op without jailer.
72+// It never follows a symlink (Lchown): the jail is writable by the jailed uid
73+// once Firecracker runs, so a link planted there must not redirect the chown.
7274 func (l layout) own(name string) error {
7375 if !l.jailed {
7476 return nil
7577 }
76- return os.Chown(l.host(name), l.uid, l.uid)
78+ return os.Lchown(l.host(name), l.uid, l.uid)
7779 }
7880
7981 // checkReadable verifies that other users can read the shared file, which the
@@ -69,11 +69,13 @@ func (l layout) share(src, name string) (string, error) {
69 }69 }
70 70
71 // own hands a per-job file in the jail to the jailed uid; no-op without jailer.71 // own hands a per-job file in the jail to the jailed uid; no-op without jailer.
72+// It never follows a symlink (Lchown): the jail is writable by the jailed uid
73+// once Firecracker runs, so a link planted there must not redirect the chown.
72 func (l layout) own(name string) error {74 func (l layout) own(name string) error {
73 if !l.jailed {75 if !l.jailed {
74 return nil76 return nil
75 }77 }
76- return os.Chown(l.host(name), l.uid, l.uid)78+ return os.Lchown(l.host(name), l.uid, l.uid)
77 }79 }
78 80
79 // checkReadable verifies that other users can read the shared file, which the81 // checkReadable verifies that other users can read the shared file, which the
M internal/sandbox/vm/proc.go +64 -23
@@ -15,11 +15,15 @@ import (
1515 "bnema.dev/ermite-runner/internal/sandbox"
1616 )
1717
18-// firecrackerProcs scans a /proc tree for Firecracker processes started by this
19-// runner: argv[0] is "firecracker" and "--id" is followed by one of our ids
20-// (see newID). Processes of other users of Firecracker are never matched. It
21-// finds orphans even when the state directory was lost.
22-func firecrackerProcs(procDir string) map[int]string {
18+// vmmProcs scans a /proc tree for processes started by this runner for a VM:
19+// the Firecracker process and the jailer. A process is ours when its command
20+// line has "--id <id>" with an id of the form sandbox.NewID produces and also
21+// "--api-sock" (Firecracker) or "--exec-file" (jailer). The program name is not
22+// looked at: the binary may be renamed or versioned (firecracker-v1.17), and a
23+// jailer in a PID namespace leaves a Firecracker whose argv[0] is whatever the
24+// jailer chose. Processes of other users of Firecracker (other ids) are never
25+// matched. It finds orphans even when the state directory was lost.
26+func vmmProcs(procDir string) map[int]string {
2327 out := map[int]string{}
2428 ents, err := os.ReadDir(procDir)
2529 if err != nil {
@@ -34,17 +38,57 @@ func firecrackerProcs(procDir string) map[int]string {
3438 if err != nil || len(b) == 0 {
3539 continue
3640 }
37- args := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")
38- if filepath.Base(args[0]) != "firecracker" {
39- continue
41+ if id := vmIDFromCmdline(strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")); id != "" {
42+ out[pid] = id
4043 }
41- for i := 1; i+1 < len(args); i++ {
42- if args[i] == "--id" && sandbox.ValidID(args[i+1]) {
43- out[pid] = args[i+1]
44+ }
45+ return out
46+}
47+
48+// vmIDFromCmdline returns the VM id of a command line, or "".
49+func vmIDFromCmdline(args []string) string {
50+ var id string
51+ var marker bool
52+ for i := 1; i < len(args); i++ {
53+ switch args[i] {
54+ case "--id":
55+ if i+1 < len(args) && sandbox.ValidID(args[i+1]) {
56+ id = args[i+1]
4457 }
58+ case "--api-sock", "--exec-file":
59+ marker = true
4560 }
4661 }
47- return out
62+ if marker {
63+ return id
64+ }
65+ return ""
66+}
67+
68+// readPidFile returns the pid the jailer wrote for a VM run in a PID namespace.
69+func readPidFile(path string) int {
70+ b, err := os.ReadFile(path)
71+ if err != nil {
72+ return 0
73+ }
74+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
75+ if err != nil || pid <= 1 {
76+ return 0
77+ }
78+ return pid
79+}
80+
81+// pidAlive reports whether pid exists and is not a zombie.
82+func pidAlive(pid int) bool {
83+ if err := syscall.Kill(pid, 0); err != nil {
84+ return false
85+ }
86+ if b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil {
87+ if i := bytes.LastIndexByte(b, ')'); i >= 0 && i+2 < len(b) && b[i+2] == 'Z' {
88+ return false
89+ }
90+ }
91+ return true
4892 }
4993
5094 // ring keeps the last N bytes written to it: the VMM's stdout/stderr, which
@@ -74,20 +118,17 @@ func (r *ring) String() string {
74118 return string(bytes.TrimSpace(r.buf))
75119 }
76120
77-// killAndWait sends SIGKILL to pid and waits until it is gone (zombies of
78-// children of this process are reaped by their own Wait).
79-func killAndWait(pid int, timeout time.Duration) {
121+// killAndWait sends SIGKILL to pid and waits until it is gone (or a zombie that
122+// its parent will reap). It reports whether the process is gone.
123+func killAndWait(pid int, timeout time.Duration) bool {
80124 _ = syscall.Kill(pid, syscall.SIGKILL)
81125 deadline := time.Now().Add(timeout)
82- for time.Now().Before(deadline) {
83- if err := syscall.Kill(pid, 0); err != nil {
84- return
126+ for {
127+ if !pidAlive(pid) {
128+ return true
85129 }
86- // A zombie child still answers kill(0); check its state.
87- if b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil {
88- if i := bytes.LastIndexByte(b, ')'); i >= 0 && i+2 < len(b) && b[i+2] == 'Z' {
89- return
90- }
130+ if time.Now().After(deadline) {
131+ return false
91132 }
92133 time.Sleep(20 * time.Millisecond)
93134 }
@@ -15,11 +15,15 @@ import (
15 "bnema.dev/ermite-runner/internal/sandbox"15 "bnema.dev/ermite-runner/internal/sandbox"
16 )16 )
17 17
18-// firecrackerProcs scans a /proc tree for Firecracker processes started by this18+// vmmProcs scans a /proc tree for processes started by this runner for a VM:
19-// runner: argv[0] is "firecracker" and "--id" is followed by one of our ids19+// the Firecracker process and the jailer. A process is ours when its command
20-// (see newID). Processes of other users of Firecracker are never matched. It20+// line has "--id <id>" with an id of the form sandbox.NewID produces and also
21-// finds orphans even when the state directory was lost.21+// "--api-sock" (Firecracker) or "--exec-file" (jailer). The program name is not
22-func firecrackerProcs(procDir string) map[int]string {22+// looked at: the binary may be renamed or versioned (firecracker-v1.17), and a
23+// jailer in a PID namespace leaves a Firecracker whose argv[0] is whatever the
24+// jailer chose. Processes of other users of Firecracker (other ids) are never
25+// matched. It finds orphans even when the state directory was lost.
26+func vmmProcs(procDir string) map[int]string {
23 out := map[int]string{}27 out := map[int]string{}
24 ents, err := os.ReadDir(procDir)28 ents, err := os.ReadDir(procDir)
25 if err != nil {29 if err != nil {
@@ -34,17 +38,57 @@ func firecrackerProcs(procDir string) map[int]string {
34 if err != nil || len(b) == 0 {38 if err != nil || len(b) == 0 {
35 continue39 continue
36 }40 }
37- args := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")41+ if id := vmIDFromCmdline(strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")); id != "" {
38- if filepath.Base(args[0]) != "firecracker" {42+ out[pid] = id
39- continue
40 }43 }
41- for i := 1; i+1 < len(args); i++ {44+ }
42- if args[i] == "--id" && sandbox.ValidID(args[i+1]) {45+ return out
43- out[pid] = args[i+1]46+}
47+
48+// vmIDFromCmdline returns the VM id of a command line, or "".
49+func vmIDFromCmdline(args []string) string {
50+ var id string
51+ var marker bool
52+ for i := 1; i < len(args); i++ {
53+ switch args[i] {
54+ case "--id":
55+ if i+1 < len(args) && sandbox.ValidID(args[i+1]) {
56+ id = args[i+1]
44 }57 }
58+ case "--api-sock", "--exec-file":
59+ marker = true
45 }60 }
46 }61 }
47- return out62+ if marker {
63+ return id
64+ }
65+ return ""
66+}
67+
68+// readPidFile returns the pid the jailer wrote for a VM run in a PID namespace.
69+func readPidFile(path string) int {
70+ b, err := os.ReadFile(path)
71+ if err != nil {
72+ return 0
73+ }
74+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
75+ if err != nil || pid <= 1 {
76+ return 0
77+ }
78+ return pid
79+}
80+
81+// pidAlive reports whether pid exists and is not a zombie.
82+func pidAlive(pid int) bool {
83+ if err := syscall.Kill(pid, 0); err != nil {
84+ return false
85+ }
86+ if b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil {
87+ if i := bytes.LastIndexByte(b, ')'); i >= 0 && i+2 < len(b) && b[i+2] == 'Z' {
88+ return false
89+ }
90+ }
91+ return true
48 }92 }
49 93
50 // ring keeps the last N bytes written to it: the VMM's stdout/stderr, which94 // ring keeps the last N bytes written to it: the VMM's stdout/stderr, which
@@ -74,20 +118,17 @@ func (r *ring) String() string {
74 return string(bytes.TrimSpace(r.buf))118 return string(bytes.TrimSpace(r.buf))
75 }119 }
76 120
77-// killAndWait sends SIGKILL to pid and waits until it is gone (zombies of121+// killAndWait sends SIGKILL to pid and waits until it is gone (or a zombie that
78-// children of this process are reaped by their own Wait).122+// its parent will reap). It reports whether the process is gone.
79-func killAndWait(pid int, timeout time.Duration) {123+func killAndWait(pid int, timeout time.Duration) bool {
80 _ = syscall.Kill(pid, syscall.SIGKILL)124 _ = syscall.Kill(pid, syscall.SIGKILL)
81 deadline := time.Now().Add(timeout)125 deadline := time.Now().Add(timeout)
82- for time.Now().Before(deadline) {126+ for {
83- if err := syscall.Kill(pid, 0); err != nil {127+ if !pidAlive(pid) {
84- return128+ return true
85 }129 }
86- // A zombie child still answers kill(0); check its state.130+ if time.Now().After(deadline) {
87- if b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil {131+ return false
88- if i := bytes.LastIndexByte(b, ')'); i >= 0 && i+2 < len(b) && b[i+2] == 'Z' {
89- return
90- }
91 }132 }
92 time.Sleep(20 * time.Millisecond)133 time.Sleep(20 * time.Millisecond)
93 }134 }
M internal/sandbox/vm/reap.go +49 -14
@@ -8,6 +8,8 @@ import (
88 "fmt"
99 "os"
1010 "path/filepath"
11+ "strconv"
12+ "strings"
1113 "time"
1214
1315 "github.com/bnema/zerowrap"
@@ -17,32 +19,43 @@ import (
1719 )
1820
1921 // Reap implements sandbox.Driver. It removes what a previous run of the runner
20-// left: Firecracker processes (by state record and by scanning /proc for our
21-// "--id erXXXXXXXX"), TAP devices named ertap<N>, the nftables table, and VM
22-// directories. It relies on the state-directory lock taken in New: no other
23-// runner instance can own what it removes.
22+// left: VMM processes (Firecracker and jailer: matched by "--id <our id>" in
23+// their command line, never by program name), TAP devices named ertap<N>, the
24+// nftables table, VM directories and jailer cgroups. It also finishes the
25+// cleanup of sandboxes that reported ErrLeaked and frees their slots. It relies
26+// on the host-wide lock taken in New: no other runner can own what it removes.
27+// It returns ErrBusy while a sandbox is running.
2428 func (d *Driver) Reap(ctx context.Context) error {
2529 log := zerowrap.FromCtx(ctx)
30+ d.mu.Lock()
31+ busy := d.slots.Busy()
32+ d.mu.Unlock()
33+ if busy > 0 {
34+ return fmt.Errorf("%w (%d running)", sandbox.ErrBusy, busy)
35+ }
2636 var errs []error
2737
2838 recs, err := d.store.list()
2939 if err != nil {
3040 errs = append(errs, err)
3141 }
32- procs := firecrackerProcs(d.opt.ProcDir)
42+ procs := vmmProcs(d.opt.ProcDir)
3343 killed := map[int]bool{}
3444 killProc := func(pid int, id string) {
3545 if killed[pid] {
3646 return
3747 }
3848 killed[pid] = true
39- log.Warn().Int("pid", pid).Str("vm", id).Msg("reaping leftover firecracker process")
40- killAndWait(pid, 5*time.Second)
49+ log.Warn().Int("pid", pid).Str("vm", id).Msg("reaping leftover VMM process")
50+ if !d.opt.kill(pid, 5*time.Second) {
51+ errs = append(errs, fmt.Errorf("vmm process %d (%s) did not exit", pid, id))
52+ }
4153 }
42- // 1. Processes: by record, but only if the pid is still one of ours (pids
43- // are reused), then any other orphan found in /proc.
54+ // 1. Processes. A recorded pid is only trusted when /proc/<pid>/cmdline still
55+ // carries this record's id (pids are reused); then every other process of
56+ // ours found in /proc, which also covers a lost state directory.
4457 for _, r := range recs {
45- if r.PID > 0 && procs[r.PID] == r.ID {
58+ if r.PID > 0 && cmdlineID(d.opt.ProcDir, r.PID) == r.ID {
4659 killProc(r.PID, r.ID)
4760 }
4861 }
@@ -65,10 +78,8 @@ func (d *Driver) Reap(ctx context.Context) error {
6578 }
6679 }
6780
68- // 3. Files: directories of recorded VMs, then any directory with one of our
69- // ids that has no record (crash before the record was written is
70- // impossible, but a deleted state dir is not).
71- // The jailer's chroot base has one directory per exec-file name.
81+ // 3. Files and cgroups. The jailer's chroot base has one directory per
82+ // exec-file name.
7283 parents := []string{filepath.Join(d.cfg.StateDir, "vm")}
7384 jails, _ := os.ReadDir(filepath.Join(d.cfg.StateDir, "jail"))
7485 for _, j := range jails {
@@ -87,6 +98,15 @@ func (d *Driver) Reap(ctx context.Context) error {
8798 }
8899 }
89100 }
101+ cg := filepath.Join(d.opt.CgroupRoot, filepath.Base(d.fcPath))
102+ cents, _ := os.ReadDir(cg)
103+ for _, e := range cents {
104+ if e.IsDir() && sandbox.ValidID(e.Name()) {
105+ if err := removeCgroup(d.opt.CgroupRoot, filepath.Base(d.fcPath), e.Name()); err != nil {
106+ errs = append(errs, err)
107+ }
108+ }
109+ }
90110 if len(errs) == 0 {
91111 for _, r := range recs {
92112 if err := d.store.remove(r.ID); err != nil {
@@ -94,5 +114,20 @@ func (d *Driver) Reap(ctx context.Context) error {
94114 }
95115 }
96116 }
117+ if len(errs) == 0 {
118+ d.mu.Lock()
119+ d.slots.ReleaseLeaked()
120+ d.mu.Unlock()
121+ }
97122 return errors.Join(errs...)
98123 }
124+
125+// cmdlineID returns the VM id in the command line of pid, or "" (not running,
126+// not ours, or recycled for another program).
127+func cmdlineID(procDir string, pid int) string {
128+ b, err := os.ReadFile(filepath.Join(procDir, strconv.Itoa(pid), "cmdline"))
129+ if err != nil || len(b) == 0 {
130+ return ""
131+ }
132+ return vmIDFromCmdline(strings.Split(strings.TrimRight(string(b), "\x00"), "\x00"))
133+}
@@ -8,6 +8,8 @@ import (
8 "fmt"8 "fmt"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11+ "strconv"
12+ "strings"
11 "time"13 "time"
12 14
13 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -17,32 +19,43 @@ import (
17 )19 )
18 20
19 // Reap implements sandbox.Driver. It removes what a previous run of the runner21 // Reap implements sandbox.Driver. It removes what a previous run of the runner
20-// left: Firecracker processes (by state record and by scanning /proc for our22+// left: VMM processes (Firecracker and jailer: matched by "--id <our id>" in
21-// "--id erXXXXXXXX"), TAP devices named ertap<N>, the nftables table, and VM23+// their command line, never by program name), TAP devices named ertap<N>, the
22-// directories. It relies on the state-directory lock taken in New: no other24+// nftables table, VM directories and jailer cgroups. It also finishes the
23-// runner instance can own what it removes.25+// cleanup of sandboxes that reported ErrLeaked and frees their slots. It relies
26+// on the host-wide lock taken in New: no other runner can own what it removes.
27+// It returns ErrBusy while a sandbox is running.
24 func (d *Driver) Reap(ctx context.Context) error {28 func (d *Driver) Reap(ctx context.Context) error {
25 log := zerowrap.FromCtx(ctx)29 log := zerowrap.FromCtx(ctx)
30+ d.mu.Lock()
31+ busy := d.slots.Busy()
32+ d.mu.Unlock()
33+ if busy > 0 {
34+ return fmt.Errorf("%w (%d running)", sandbox.ErrBusy, busy)
35+ }
26 var errs []error36 var errs []error
27 37
28 recs, err := d.store.list()38 recs, err := d.store.list()
29 if err != nil {39 if err != nil {
30 errs = append(errs, err)40 errs = append(errs, err)
31 }41 }
32- procs := firecrackerProcs(d.opt.ProcDir)42+ procs := vmmProcs(d.opt.ProcDir)
33 killed := map[int]bool{}43 killed := map[int]bool{}
34 killProc := func(pid int, id string) {44 killProc := func(pid int, id string) {
35 if killed[pid] {45 if killed[pid] {
36 return46 return
37 }47 }
38 killed[pid] = true48 killed[pid] = true
39- log.Warn().Int("pid", pid).Str("vm", id).Msg("reaping leftover firecracker process")49+ log.Warn().Int("pid", pid).Str("vm", id).Msg("reaping leftover VMM process")
40- killAndWait(pid, 5*time.Second)50+ if !d.opt.kill(pid, 5*time.Second) {
51+ errs = append(errs, fmt.Errorf("vmm process %d (%s) did not exit", pid, id))
52+ }
41 }53 }
42- // 1. Processes: by record, but only if the pid is still one of ours (pids54+ // 1. Processes. A recorded pid is only trusted when /proc/<pid>/cmdline still
43- // are reused), then any other orphan found in /proc.55+ // carries this record's id (pids are reused); then every other process of
56+ // ours found in /proc, which also covers a lost state directory.
44 for _, r := range recs {57 for _, r := range recs {
45- if r.PID > 0 && procs[r.PID] == r.ID {58+ if r.PID > 0 && cmdlineID(d.opt.ProcDir, r.PID) == r.ID {
46 killProc(r.PID, r.ID)59 killProc(r.PID, r.ID)
47 }60 }
48 }61 }
@@ -65,10 +78,8 @@ func (d *Driver) Reap(ctx context.Context) error {
65 }78 }
66 }79 }
67 80
68- // 3. Files: directories of recorded VMs, then any directory with one of our81+ // 3. Files and cgroups. The jailer's chroot base has one directory per
69- // ids that has no record (crash before the record was written is82+ // exec-file name.
70- // impossible, but a deleted state dir is not).
71- // The jailer's chroot base has one directory per exec-file name.
72 parents := []string{filepath.Join(d.cfg.StateDir, "vm")}83 parents := []string{filepath.Join(d.cfg.StateDir, "vm")}
73 jails, _ := os.ReadDir(filepath.Join(d.cfg.StateDir, "jail"))84 jails, _ := os.ReadDir(filepath.Join(d.cfg.StateDir, "jail"))
74 for _, j := range jails {85 for _, j := range jails {
@@ -87,6 +98,15 @@ func (d *Driver) Reap(ctx context.Context) error {
87 }98 }
88 }99 }
89 }100 }
101+ cg := filepath.Join(d.opt.CgroupRoot, filepath.Base(d.fcPath))
102+ cents, _ := os.ReadDir(cg)
103+ for _, e := range cents {
104+ if e.IsDir() && sandbox.ValidID(e.Name()) {
105+ if err := removeCgroup(d.opt.CgroupRoot, filepath.Base(d.fcPath), e.Name()); err != nil {
106+ errs = append(errs, err)
107+ }
108+ }
109+ }
90 if len(errs) == 0 {110 if len(errs) == 0 {
91 for _, r := range recs {111 for _, r := range recs {
92 if err := d.store.remove(r.ID); err != nil {112 if err := d.store.remove(r.ID); err != nil {
@@ -94,5 +114,20 @@ func (d *Driver) Reap(ctx context.Context) error {
94 }114 }
95 }115 }
96 }116 }
117+ if len(errs) == 0 {
118+ d.mu.Lock()
119+ d.slots.ReleaseLeaked()
120+ d.mu.Unlock()
121+ }
97 return errors.Join(errs...)122 return errors.Join(errs...)
98 }123 }
124+
125+// cmdlineID returns the VM id in the command line of pid, or "" (not running,
126+// not ours, or recycled for another program).
127+func cmdlineID(procDir string, pid int) string {
128+ b, err := os.ReadFile(filepath.Join(procDir, strconv.Itoa(pid), "cmdline"))
129+ if err != nil || len(b) == 0 {
130+ return ""
131+ }
132+ return vmIDFromCmdline(strings.Split(strings.TrimRight(string(b), "\x00"), "\x00"))
133+}
M internal/sandbox/vm/vm_test.go +343 -6
@@ -3,6 +3,7 @@
33 package vm
44
55 import (
6+ "bytes"
67 "context"
78 "encoding/json"
89 "errors"
@@ -20,6 +21,8 @@ import (
2021 "testing"
2122 "time"
2223
24+ "github.com/bnema/zerowrap"
25+
2326 "bnema.dev/ermite-runner/internal/cmdrun"
2427 "bnema.dev/ermite-runner/internal/execstream"
2528 "bnema.dev/ermite-runner/internal/execstream/execpb"
@@ -108,7 +111,10 @@ type env struct {
108111 }
109112
110113 // newEnv prepares a state dir, fake binaries, kernel and images.
111-func newEnv(t *testing.T, mode string) *env {
114+func newEnv(t *testing.T, mode string) *env { return newEnvNamed(t, mode, "firecracker") }
115+
116+// newEnvNamed runs the fake VMM under another program name (argv[0]).
117+func newEnvNamed(t *testing.T, mode, argv0 string) *env {
112118 t.Helper()
113119 bash, err := exec.LookPath("bash")
114120 if err != nil {
@@ -118,7 +124,7 @@ func newEnv(t *testing.T, mode string) *env {
118124 e := &env{taps: map[string]bool{}, plumb: sandboxtest.NewPlumbing(), log: filepath.Join(dir, "fc.log")}
119125 self, _ := os.Executable()
120126 fc := filepath.Join(dir, "firecracker")
121- script := fmt.Sprintf("#!%s\nexport FC_HELPER=1 FC_MODE=%q FC_LOG=%q\nexec -a firecracker %q \"$@\"\n", bash, mode, e.log, self)
127+ script := fmt.Sprintf("#!%s\nexport FC_HELPER=1 FC_MODE=%q FC_LOG=%q\nexec -a %q %q \"$@\"\n", bash, mode, e.log, argv0, self)
122128 if err := os.WriteFile(fc, []byte(script), 0o755); err != nil {
123129 t.Fatal(err)
124130 }
@@ -133,6 +139,7 @@ func newEnv(t *testing.T, mode string) *env {
133139 Isolation: sandbox.VM, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: img,
134140 JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000, KVMPath: "/dev/null",
135141 Network: mustPrefix("10.200.0.0/16"), Firecracker: fc, JailerOff: true, Executable: exe, DNSPort: 0,
142+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 1000, DiskBandwidthMiB: 400, DiskIOPS: 20000, NetMbit: 1000,
136143 }
137144 e.run = &cmdrun.Fake{Handle: func(c cmdrun.Call) ([]byte, error) {
138145 e.mu.Lock()
@@ -437,8 +444,7 @@ func TestReapAfterRunnerCrash(t *testing.T) {
437444 t.Fatal("fake VM not running")
438445 }
439446 // The runner dies: its lock goes away, nothing else is cleaned.
440- _ = d1.lock.Close()
441- d1.lock = nil
447+ _ = d1.lock.Release()
442448 _ = conn.Close()
443449 if e.tapCount() != 1 {
444450 t.Fatal("setup: no TAP")
@@ -506,7 +512,7 @@ func TestSecondRunnerOnSameStateDirRefused(t *testing.T) {
506512 e := newEnv(t, "")
507513 _ = e.driver(t)
508514 _, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb})
509- if err == nil || !strings.Contains(err.Error(), "another ermite-runner") {
515+ if err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
510516 t.Fatalf("got %v", err)
511517 }
512518 }
@@ -646,7 +652,7 @@ func TestFirecrackerProcsScan(t *testing.T) {
646652 mk("103", "bash\x00--id\x00er00112233\x00") // not firecracker
647653 mk("1", "firecracker\x00--id\x00er99999999\x00") // pid 1 is never touched
648654 mk("abc", "")
649- got := firecrackerProcs(proc)
655+ got := vmmProcs(proc)
650656 if len(got) != 2 || got[100] != "er00112233" || got[101] != "er44556677" {
651657 t.Fatalf("%v", got)
652658 }
@@ -682,3 +688,334 @@ func TestAPIErrorsSurfaceFirecrackerFault(t *testing.T) {
682688
683689 func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }
684690 func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }
691+
692+// C3: processes are matched by "--id <our id>" in their command line, not by the
693+// program name: a renamed or versioned Firecracker binary is still found and
694+// reaped, and a recycled pid is never killed on the strength of a record alone.
695+func TestReapFindsRenamedVMMByID(t *testing.T) {
696+ e := newEnvNamed(t, "", "fc-renamed-1.17")
697+ d1 := e.driver(t)
698+ sb, _ := d1.New()
699+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
700+ if err != nil {
701+ t.Fatal(err)
702+ }
703+ runStub(t, conn)
704+ pid := sb.(*vmSandbox).rec.PID
705+ if got := cmdlineID("/proc", pid); got != sb.ID() {
706+ t.Fatalf("cmdlineID=%q want %q (argv[0] is %q)", got, sb.ID(), "fc-renamed-1.17")
707+ }
708+ _ = d1.lock.Release()
709+ _ = conn.Close()
710+ d2 := e.driver(t)
711+ if err := d2.Reap(context.Background()); err != nil {
712+ t.Fatal(err)
713+ }
714+ if pidAlive(pid) {
715+ t.Fatalf("a VMM with a renamed binary survived Reap (pid %d)", pid)
716+ }
717+}
718+
719+func TestVMIDFromCmdline(t *testing.T) {
720+ for name, tc := range map[string]struct {
721+ args []string
722+ want string
723+ }{
724+ "firecracker": {[]string{"firecracker", "--id", "er00112233", "--api-sock", "/x"}, "er00112233"},
725+ "jailer": {[]string{"jailer", "--id", "er00112233", "--exec-file", "/fc", "--uid", "1"}, "er00112233"},
726+ "renamed": {[]string{"anything", "--api-sock", "/x", "--id", "er44556677"}, "er44556677"},
727+ "foreign id": {[]string{"firecracker", "--id", "my-vm", "--api-sock", "/x"}, ""},
728+ "no marker": {[]string{"python", "--id", "er00112233"}, ""},
729+ "id without value": {[]string{"firecracker", "--api-sock", "/x", "--id"}, ""},
730+ "path-like id": {[]string{"firecracker", "--id", "er../../../x", "--api-sock", "/x"}, ""},
731+ } {
732+ if got := vmIDFromCmdline(tc.args); got != tc.want {
733+ t.Errorf("%s: got %q want %q", name, got, tc.want)
734+ }
735+ }
736+}
737+
738+func TestReapDoesNotTrustARecycledPid(t *testing.T) {
739+ e := newEnv(t, "")
740+ d := e.driver(t)
741+ // A record pointing at this very test process: its command line carries no
742+ // VM id, so it must not be killed.
743+ _ = d.store.save(record{ID: "er00112233", Slot: 0, PID: os.Getpid()})
744+ if err := d.Reap(context.Background()); err != nil {
745+ t.Fatal(err)
746+ }
747+ if !pidAlive(os.Getpid()) {
748+ t.Fatal("Reap killed a process on the strength of a stale record")
749+ }
750+}
751+
752+// C5 + C8: when the VMM cannot be killed, the TAP, the firewall policy and the
753+// files are left alone (the VM may still be running), the error wraps ErrLeaked,
754+// the slot stays taken, and Reap (with a working kill) finishes the cleanup and
755+// frees the slot.
756+func TestStopKeepsNetworkWhenTheVMMSurvives(t *testing.T) {
757+ e := newEnv(t, "")
758+ stubborn := true
759+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 5 * time.Second,
760+ kill: func(pid int, to time.Duration) bool {
761+ if stubborn {
762+ return false
763+ }
764+ return killAndWait(pid, to)
765+ }})
766+ if err != nil {
767+ t.Fatal(err)
768+ }
769+ defer func() { _ = d.Close() }()
770+ sb, _ := d.New()
771+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
772+ if err != nil {
773+ t.Fatal(err)
774+ }
775+ runStub(t, conn)
776+
777+ err = sb.Stop(context.Background())
778+ if !errors.Is(err, sandbox.ErrLeaked) {
779+ t.Fatalf("Stop must report ErrLeaked, got %v", err)
780+ }
781+ if e.tapCount() != 1 || e.plumb.Live() != 1 {
782+ t.Fatalf("the TAP and the policy must stay while the VMM may run: taps=%d attached=%d", e.tapCount(), e.plumb.Live())
783+ }
784+ if recs, _ := d.store.list(); len(recs) != 1 {
785+ t.Fatalf("the state record must stay for Reap: %v", recs)
786+ }
787+ if err := sb.Stop(context.Background()); !errors.Is(err, sandbox.ErrLeaked) {
788+ t.Fatalf("a second Stop must keep reporting the leak, got %v", err)
789+ }
790+ if d.slots.InUse() != 1 {
791+ t.Fatal("the slot of a leaked sandbox stays taken")
792+ }
793+ // Other jobs keep running on the free slot; the leaked one is never reused.
794+ other, err := d.New()
795+ if err != nil {
796+ t.Fatal(err)
797+ }
798+ if other.(*vmSandbox).slot == sb.(*vmSandbox).slot {
799+ t.Fatal("a leaked slot was handed out again")
800+ }
801+ // Reap refuses while a sandbox is running...
802+ if err := d.Reap(context.Background()); !errors.Is(err, sandbox.ErrBusy) {
803+ t.Fatalf("Reap with a handed-out sandbox: %v", err)
804+ }
805+ _ = other.Stop(context.Background())
806+ // ...and finishes the job once idle.
807+ stubborn = false
808+ if err := d.Reap(context.Background()); err != nil {
809+ t.Fatal(err)
810+ }
811+ if left := e.leftovers(t); len(left) != 0 {
812+ t.Fatalf("leftovers after Reap: %v", left)
813+ }
814+ if d.slots.InUse() != 0 {
815+ t.Fatal("Reap must free the slot of the leaked sandbox")
816+ }
817+}
818+
819+// C7: Stop from another goroutine aborts a boot in progress at once, instead of
820+// waiting for the boot timeout, and leaves nothing behind.
821+func TestStopAbortsABootInProgress(t *testing.T) {
822+ e := newEnv(t, "hang")
823+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: time.Minute})
824+ if err != nil {
825+ t.Fatal(err)
826+ }
827+ defer func() { _ = d.Close() }()
828+ sb, _ := d.New()
829+ started := make(chan error, 1)
830+ go func() {
831+ _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
832+ started <- err
833+ }()
834+ time.Sleep(700 * time.Millisecond) // the fake is now waiting for the executor
835+ t0 := time.Now()
836+ if err := sb.Stop(context.Background()); err != nil {
837+ t.Fatal(err)
838+ }
839+ select {
840+ case err := <-started:
841+ if !errors.Is(err, sandbox.ErrAborted) {
842+ t.Fatalf("Start must report that Stop aborted it, got %v", err)
843+ }
844+ case <-time.After(10 * time.Second):
845+ t.Fatal("Start was not aborted by Stop")
846+ }
847+ if time.Since(t0) > 8*time.Second {
848+ t.Fatalf("Stop took %v", time.Since(t0))
849+ }
850+ if left := e.leftovers(t); len(left) != 0 {
851+ t.Fatalf("leftovers: %v", left)
852+ }
853+ // Stop before Start: Start must refuse.
854+ sb2, _ := d.New()
855+ _ = sb2.Stop(context.Background())
856+ if _, err := sb2.Start(context.Background(), sandbox.JobSpec{}); err == nil {
857+ t.Fatal("Start after Stop accepted")
858+ }
859+}
860+
861+// C16: the sandbox exposes its id, which names its files.
862+func TestSandboxIDNamesItsFiles(t *testing.T) {
863+ e := newEnv(t, "")
864+ d := e.driver(t)
865+ sb, _ := d.New()
866+ if !sandbox.ValidID(sb.ID()) {
867+ t.Fatalf("id %q", sb.ID())
868+ }
869+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
870+ if err != nil {
871+ t.Fatal(err)
872+ }
873+ runStub(t, conn)
874+ defer func() { _ = sb.Stop(context.Background()) }()
875+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "vm", sb.ID())); err != nil {
876+ t.Fatalf("the VM directory must be named after the sandbox id: %v", err)
877+ }
878+ if d.Isolation() != sandbox.IsolationVM || d.Mode() != sandbox.VM {
879+ t.Fatal("isolation")
880+ }
881+}
882+
883+// S7: cgroup v2 limits, a new PID namespace, and the jailer's own options.
884+func TestJailerArgs(t *testing.T) {
885+ args := jailerArgs("/usr/bin/jailer", "/usr/bin/firecracker", "er00112233", 900003, "/var/lib/er/jail",
886+ sandbox.JobSpec{CPUs: 4, MemoryMiB: 2048}, nil)
887+ line := " " + strings.Join(args, " ") + " "
888+ for _, want := range []string{
889+ " --id er00112233 ", " --exec-file /usr/bin/firecracker ", " --uid 900003 ", " --gid 900003 ",
890+ " --chroot-base-dir /var/lib/er/jail ", " --cgroup-version 2 ", " --new-pid-ns ",
891+ " --cgroup memory.max=" + fmt.Sprint((2048+vmmOverheadMiB)<<20) + " ",
892+ " --cgroup pids.max=64 ", " --resource-limit no-file=4096 ", " -- ",
893+ } {
894+ if !strings.Contains(line, want) {
895+ t.Errorf("jailer args lack %q: %s", want, line)
896+ }
897+ }
898+ if args[len(args)-1] != "--" {
899+ t.Errorf("the jailer separator must come last when there are no extra args: %v", args)
900+ }
901+ more := jailerArgs("j", "f", "er00112233", 1, "/b", sandbox.JobSpec{CPUs: 1, MemoryMiB: 512}, []string{"--x"})
902+ if more[len(more)-2] != "--" || more[len(more)-1] != "--x" {
903+ t.Errorf("extra args follow the separator: %v", more)
904+ }
905+}
906+
907+// S7: sane rate limiters on the scratch disk and both directions of eth0, off
908+// when configured to zero, and only on the writable disk.
909+func TestRateLimitersInTheVMConfig(t *testing.T) {
910+ e := newEnv(t, "")
911+ d := e.driver(t)
912+ sb, _ := d.New()
913+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
914+ if err != nil {
915+ t.Fatal(err)
916+ }
917+ runStub(t, conn)
918+ defer func() { _ = sb.Stop(context.Background()) }()
919+ b, _ := os.ReadFile(e.log)
920+ log := string(b)
921+ for _, want := range []string{
922+ `"rate_limiter":{"bandwidth":{"size":419430400,"one_time_burst":419430400,"refill_time":1000},"ops":{"size":20000,"one_time_burst":20000,"refill_time":1000}}`,
923+ `"rx_rate_limiter":{"bandwidth":{"size":125000000,"one_time_burst":125000000,"refill_time":1000}}`,
924+ `"tx_rate_limiter":{"bandwidth":{"size":125000000,"one_time_burst":125000000,"refill_time":1000}}`,
925+ } {
926+ if !strings.Contains(log, want) {
927+ t.Errorf("API log lacks %s\n%s", want, log)
928+ }
929+ }
930+ if n := strings.Count(log, `"rate_limiter"`); n != 1 {
931+ t.Errorf("only the scratch drive is rate limited, got %d rate_limiter fields", n)
932+ }
933+ if diskLimiter(0, 0) != nil || netLimiter(0) != nil {
934+ t.Error("zero must disable the limiters")
935+ }
936+ if l := diskLimiter(0, 100); l.Bandwidth != nil || l.Ops == nil {
937+ t.Errorf("%+v", l)
938+ }
939+}
940+
941+// S8: the free-space check warns, and only warns.
942+func TestNewWarnsOnLowFreeSpace(t *testing.T) {
943+ e := newEnv(t, "")
944+ var buf bytes.Buffer
945+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
946+ d, err := New(ctx, e.cfg, Options{Run: e.run, Net: e.plumb, FreeSpace: func(string) (int64, error) { return 1, nil }})
947+ if err != nil {
948+ t.Fatalf("low space must not fail New: %v", err)
949+ }
950+ defer func() { _ = d.Close() }()
951+ if !strings.Contains(buf.String(), "less free space") {
952+ t.Fatalf("no warning: %s", buf.String())
953+ }
954+}
955+
956+// S11: the scratch disk is created without following symlinks and chowned
957+// through its descriptor.
958+func TestMakeScratchRefusesSymlinksAndExistingFiles(t *testing.T) {
959+ dir := t.TempDir()
960+ target := filepath.Join(dir, "victim")
961+ _ = os.WriteFile(target, []byte("precious"), 0o600)
962+ link := filepath.Join(dir, "scratch.ext4")
963+ if err := os.Symlink(target, link); err != nil {
964+ t.Fatal(err)
965+ }
966+ run := &cmdrun.Fake{}
967+ if err := makeScratch(context.Background(), run, link, 8, -1); err == nil {
968+ t.Fatal("a planted symlink must be refused")
969+ }
970+ if b, _ := os.ReadFile(target); string(b) != "precious" {
971+ t.Fatalf("the symlink target was modified: %q", b)
972+ }
973+ if len(run.Calls) != 0 {
974+ t.Fatalf("mkfs must not run: %v", run.Lines())
975+ }
976+ ok := filepath.Join(dir, "fresh.ext4")
977+ if err := makeScratch(context.Background(), run, ok, 8, os.Getuid()); err != nil {
978+ t.Fatal(err)
979+ }
980+ if st, err := os.Stat(ok); err != nil || st.Size() != 8<<20 || st.Mode().Perm() != 0o600 {
981+ t.Fatalf("%v %v", st, err)
982+ }
983+ if err := makeScratch(context.Background(), run, ok, 8, -1); err == nil {
984+ t.Fatal("an existing file must be refused (O_EXCL)")
985+ }
986+}
987+
988+func TestOwnUsesLchown(t *testing.T) {
989+ dir := t.TempDir()
990+ l := newLayout(dir, "firecracker", "er00112233", true, os.Getuid())
991+ _ = os.MkdirAll(l.root, 0o700)
992+ target := filepath.Join(dir, "victim")
993+ _ = os.WriteFile(target, nil, 0o600)
994+ if err := os.Symlink(target, l.host("link")); err != nil {
995+ t.Fatal(err)
996+ }
997+ if err := l.own("link"); err != nil {
998+ t.Fatalf("Lchown on a link must act on the link itself: %v", err)
999+ }
1000+ if err := l.own("missing"); err == nil {
1001+ t.Fatal("a missing file is an error")
1002+ }
1003+ if u := newLayout(dir, "f", "er00112233", false, 1); u.own("anything") != nil {
1004+ t.Fatal("no-op without jailer")
1005+ }
1006+}
1007+
1008+func TestRemoveCgroup(t *testing.T) {
1009+ root := t.TempDir()
1010+ _ = os.MkdirAll(filepath.Join(root, "firecracker", "er00112233"), 0o755)
1011+ if err := removeCgroup(root, "firecracker", "er00112233"); err != nil {
1012+ t.Fatal(err)
1013+ }
1014+ if err := removeCgroup(root, "firecracker", "er00112233"); err != nil {
1015+ t.Fatalf("a missing cgroup is fine: %v", err)
1016+ }
1017+ _ = os.MkdirAll(filepath.Join(root, "firecracker", "er44556677", "child"), 0o755)
1018+ if err := removeCgroup(root, "firecracker", "er44556677"); err == nil {
1019+ t.Fatal("a non-empty cgroup cannot be removed and must be reported")
1020+ }
1021+}
@@ -3,6 +3,7 @@
3 package vm3 package vm
4 4
5 import (5 import (
6+ "bytes"
6 "context"7 "context"
7 "encoding/json"8 "encoding/json"
8 "errors"9 "errors"
@@ -20,6 +21,8 @@ import (
20 "testing"21 "testing"
21 "time"22 "time"
22 23
24+ "github.com/bnema/zerowrap"
25+
23 "bnema.dev/ermite-runner/internal/cmdrun"26 "bnema.dev/ermite-runner/internal/cmdrun"
24 "bnema.dev/ermite-runner/internal/execstream"27 "bnema.dev/ermite-runner/internal/execstream"
25 "bnema.dev/ermite-runner/internal/execstream/execpb"28 "bnema.dev/ermite-runner/internal/execstream/execpb"
@@ -108,7 +111,10 @@ type env struct {
108 }111 }
109 112
110 // newEnv prepares a state dir, fake binaries, kernel and images.113 // newEnv prepares a state dir, fake binaries, kernel and images.
111-func newEnv(t *testing.T, mode string) *env {114+func newEnv(t *testing.T, mode string) *env { return newEnvNamed(t, mode, "firecracker") }
115+
116+// newEnvNamed runs the fake VMM under another program name (argv[0]).
117+func newEnvNamed(t *testing.T, mode, argv0 string) *env {
112 t.Helper()118 t.Helper()
113 bash, err := exec.LookPath("bash")119 bash, err := exec.LookPath("bash")
114 if err != nil {120 if err != nil {
@@ -118,7 +124,7 @@ func newEnv(t *testing.T, mode string) *env {
118 e := &env{taps: map[string]bool{}, plumb: sandboxtest.NewPlumbing(), log: filepath.Join(dir, "fc.log")}124 e := &env{taps: map[string]bool{}, plumb: sandboxtest.NewPlumbing(), log: filepath.Join(dir, "fc.log")}
119 self, _ := os.Executable()125 self, _ := os.Executable()
120 fc := filepath.Join(dir, "firecracker")126 fc := filepath.Join(dir, "firecracker")
121- script := fmt.Sprintf("#!%s\nexport FC_HELPER=1 FC_MODE=%q FC_LOG=%q\nexec -a firecracker %q \"$@\"\n", bash, mode, e.log, self)127+ script := fmt.Sprintf("#!%s\nexport FC_HELPER=1 FC_MODE=%q FC_LOG=%q\nexec -a %q %q \"$@\"\n", bash, mode, e.log, argv0, self)
122 if err := os.WriteFile(fc, []byte(script), 0o755); err != nil {128 if err := os.WriteFile(fc, []byte(script), 0o755); err != nil {
123 t.Fatal(err)129 t.Fatal(err)
124 }130 }
@@ -133,6 +139,7 @@ func newEnv(t *testing.T, mode string) *env {
133 Isolation: sandbox.VM, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: img,139 Isolation: sandbox.VM, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: img,
134 JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000, KVMPath: "/dev/null",140 JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000, KVMPath: "/dev/null",
135 Network: mustPrefix("10.200.0.0/16"), Firecracker: fc, JailerOff: true, Executable: exe, DNSPort: 0,141 Network: mustPrefix("10.200.0.0/16"), Firecracker: fc, JailerOff: true, Executable: exe, DNSPort: 0,
142+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 1000, DiskBandwidthMiB: 400, DiskIOPS: 20000, NetMbit: 1000,
136 }143 }
137 e.run = &cmdrun.Fake{Handle: func(c cmdrun.Call) ([]byte, error) {144 e.run = &cmdrun.Fake{Handle: func(c cmdrun.Call) ([]byte, error) {
138 e.mu.Lock()145 e.mu.Lock()
@@ -437,8 +444,7 @@ func TestReapAfterRunnerCrash(t *testing.T) {
437 t.Fatal("fake VM not running")444 t.Fatal("fake VM not running")
438 }445 }
439 // The runner dies: its lock goes away, nothing else is cleaned.446 // The runner dies: its lock goes away, nothing else is cleaned.
440- _ = d1.lock.Close()447+ _ = d1.lock.Release()
441- d1.lock = nil
442 _ = conn.Close()448 _ = conn.Close()
443 if e.tapCount() != 1 {449 if e.tapCount() != 1 {
444 t.Fatal("setup: no TAP")450 t.Fatal("setup: no TAP")
@@ -506,7 +512,7 @@ func TestSecondRunnerOnSameStateDirRefused(t *testing.T) {
506 e := newEnv(t, "")512 e := newEnv(t, "")
507 _ = e.driver(t)513 _ = e.driver(t)
508 _, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb})514 _, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb})
509- if err == nil || !strings.Contains(err.Error(), "another ermite-runner") {515+ if err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
510 t.Fatalf("got %v", err)516 t.Fatalf("got %v", err)
511 }517 }
512 }518 }
@@ -646,7 +652,7 @@ func TestFirecrackerProcsScan(t *testing.T) {
646 mk("103", "bash\x00--id\x00er00112233\x00") // not firecracker652 mk("103", "bash\x00--id\x00er00112233\x00") // not firecracker
647 mk("1", "firecracker\x00--id\x00er99999999\x00") // pid 1 is never touched653 mk("1", "firecracker\x00--id\x00er99999999\x00") // pid 1 is never touched
648 mk("abc", "")654 mk("abc", "")
649- got := firecrackerProcs(proc)655+ got := vmmProcs(proc)
650 if len(got) != 2 || got[100] != "er00112233" || got[101] != "er44556677" {656 if len(got) != 2 || got[100] != "er00112233" || got[101] != "er44556677" {
651 t.Fatalf("%v", got)657 t.Fatalf("%v", got)
652 }658 }
@@ -682,3 +688,334 @@ func TestAPIErrorsSurfaceFirecrackerFault(t *testing.T) {
682 688
683 func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }689 func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }
684 func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }690 func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }
691+
692+// C3: processes are matched by "--id <our id>" in their command line, not by the
693+// program name: a renamed or versioned Firecracker binary is still found and
694+// reaped, and a recycled pid is never killed on the strength of a record alone.
695+func TestReapFindsRenamedVMMByID(t *testing.T) {
696+ e := newEnvNamed(t, "", "fc-renamed-1.17")
697+ d1 := e.driver(t)
698+ sb, _ := d1.New()
699+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
700+ if err != nil {
701+ t.Fatal(err)
702+ }
703+ runStub(t, conn)
704+ pid := sb.(*vmSandbox).rec.PID
705+ if got := cmdlineID("/proc", pid); got != sb.ID() {
706+ t.Fatalf("cmdlineID=%q want %q (argv[0] is %q)", got, sb.ID(), "fc-renamed-1.17")
707+ }
708+ _ = d1.lock.Release()
709+ _ = conn.Close()
710+ d2 := e.driver(t)
711+ if err := d2.Reap(context.Background()); err != nil {
712+ t.Fatal(err)
713+ }
714+ if pidAlive(pid) {
715+ t.Fatalf("a VMM with a renamed binary survived Reap (pid %d)", pid)
716+ }
717+}
718+
719+func TestVMIDFromCmdline(t *testing.T) {
720+ for name, tc := range map[string]struct {
721+ args []string
722+ want string
723+ }{
724+ "firecracker": {[]string{"firecracker", "--id", "er00112233", "--api-sock", "/x"}, "er00112233"},
725+ "jailer": {[]string{"jailer", "--id", "er00112233", "--exec-file", "/fc", "--uid", "1"}, "er00112233"},
726+ "renamed": {[]string{"anything", "--api-sock", "/x", "--id", "er44556677"}, "er44556677"},
727+ "foreign id": {[]string{"firecracker", "--id", "my-vm", "--api-sock", "/x"}, ""},
728+ "no marker": {[]string{"python", "--id", "er00112233"}, ""},
729+ "id without value": {[]string{"firecracker", "--api-sock", "/x", "--id"}, ""},
730+ "path-like id": {[]string{"firecracker", "--id", "er../../../x", "--api-sock", "/x"}, ""},
731+ } {
732+ if got := vmIDFromCmdline(tc.args); got != tc.want {
733+ t.Errorf("%s: got %q want %q", name, got, tc.want)
734+ }
735+ }
736+}
737+
738+func TestReapDoesNotTrustARecycledPid(t *testing.T) {
739+ e := newEnv(t, "")
740+ d := e.driver(t)
741+ // A record pointing at this very test process: its command line carries no
742+ // VM id, so it must not be killed.
743+ _ = d.store.save(record{ID: "er00112233", Slot: 0, PID: os.Getpid()})
744+ if err := d.Reap(context.Background()); err != nil {
745+ t.Fatal(err)
746+ }
747+ if !pidAlive(os.Getpid()) {
748+ t.Fatal("Reap killed a process on the strength of a stale record")
749+ }
750+}
751+
752+// C5 + C8: when the VMM cannot be killed, the TAP, the firewall policy and the
753+// files are left alone (the VM may still be running), the error wraps ErrLeaked,
754+// the slot stays taken, and Reap (with a working kill) finishes the cleanup and
755+// frees the slot.
756+func TestStopKeepsNetworkWhenTheVMMSurvives(t *testing.T) {
757+ e := newEnv(t, "")
758+ stubborn := true
759+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 5 * time.Second,
760+ kill: func(pid int, to time.Duration) bool {
761+ if stubborn {
762+ return false
763+ }
764+ return killAndWait(pid, to)
765+ }})
766+ if err != nil {
767+ t.Fatal(err)
768+ }
769+ defer func() { _ = d.Close() }()
770+ sb, _ := d.New()
771+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
772+ if err != nil {
773+ t.Fatal(err)
774+ }
775+ runStub(t, conn)
776+
777+ err = sb.Stop(context.Background())
778+ if !errors.Is(err, sandbox.ErrLeaked) {
779+ t.Fatalf("Stop must report ErrLeaked, got %v", err)
780+ }
781+ if e.tapCount() != 1 || e.plumb.Live() != 1 {
782+ t.Fatalf("the TAP and the policy must stay while the VMM may run: taps=%d attached=%d", e.tapCount(), e.plumb.Live())
783+ }
784+ if recs, _ := d.store.list(); len(recs) != 1 {
785+ t.Fatalf("the state record must stay for Reap: %v", recs)
786+ }
787+ if err := sb.Stop(context.Background()); !errors.Is(err, sandbox.ErrLeaked) {
788+ t.Fatalf("a second Stop must keep reporting the leak, got %v", err)
789+ }
790+ if d.slots.InUse() != 1 {
791+ t.Fatal("the slot of a leaked sandbox stays taken")
792+ }
793+ // Other jobs keep running on the free slot; the leaked one is never reused.
794+ other, err := d.New()
795+ if err != nil {
796+ t.Fatal(err)
797+ }
798+ if other.(*vmSandbox).slot == sb.(*vmSandbox).slot {
799+ t.Fatal("a leaked slot was handed out again")
800+ }
801+ // Reap refuses while a sandbox is running...
802+ if err := d.Reap(context.Background()); !errors.Is(err, sandbox.ErrBusy) {
803+ t.Fatalf("Reap with a handed-out sandbox: %v", err)
804+ }
805+ _ = other.Stop(context.Background())
806+ // ...and finishes the job once idle.
807+ stubborn = false
808+ if err := d.Reap(context.Background()); err != nil {
809+ t.Fatal(err)
810+ }
811+ if left := e.leftovers(t); len(left) != 0 {
812+ t.Fatalf("leftovers after Reap: %v", left)
813+ }
814+ if d.slots.InUse() != 0 {
815+ t.Fatal("Reap must free the slot of the leaked sandbox")
816+ }
817+}
818+
819+// C7: Stop from another goroutine aborts a boot in progress at once, instead of
820+// waiting for the boot timeout, and leaves nothing behind.
821+func TestStopAbortsABootInProgress(t *testing.T) {
822+ e := newEnv(t, "hang")
823+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: time.Minute})
824+ if err != nil {
825+ t.Fatal(err)
826+ }
827+ defer func() { _ = d.Close() }()
828+ sb, _ := d.New()
829+ started := make(chan error, 1)
830+ go func() {
831+ _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
832+ started <- err
833+ }()
834+ time.Sleep(700 * time.Millisecond) // the fake is now waiting for the executor
835+ t0 := time.Now()
836+ if err := sb.Stop(context.Background()); err != nil {
837+ t.Fatal(err)
838+ }
839+ select {
840+ case err := <-started:
841+ if !errors.Is(err, sandbox.ErrAborted) {
842+ t.Fatalf("Start must report that Stop aborted it, got %v", err)
843+ }
844+ case <-time.After(10 * time.Second):
845+ t.Fatal("Start was not aborted by Stop")
846+ }
847+ if time.Since(t0) > 8*time.Second {
848+ t.Fatalf("Stop took %v", time.Since(t0))
849+ }
850+ if left := e.leftovers(t); len(left) != 0 {
851+ t.Fatalf("leftovers: %v", left)
852+ }
853+ // Stop before Start: Start must refuse.
854+ sb2, _ := d.New()
855+ _ = sb2.Stop(context.Background())
856+ if _, err := sb2.Start(context.Background(), sandbox.JobSpec{}); err == nil {
857+ t.Fatal("Start after Stop accepted")
858+ }
859+}
860+
861+// C16: the sandbox exposes its id, which names its files.
862+func TestSandboxIDNamesItsFiles(t *testing.T) {
863+ e := newEnv(t, "")
864+ d := e.driver(t)
865+ sb, _ := d.New()
866+ if !sandbox.ValidID(sb.ID()) {
867+ t.Fatalf("id %q", sb.ID())
868+ }
869+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
870+ if err != nil {
871+ t.Fatal(err)
872+ }
873+ runStub(t, conn)
874+ defer func() { _ = sb.Stop(context.Background()) }()
875+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "vm", sb.ID())); err != nil {
876+ t.Fatalf("the VM directory must be named after the sandbox id: %v", err)
877+ }
878+ if d.Isolation() != sandbox.IsolationVM || d.Mode() != sandbox.VM {
879+ t.Fatal("isolation")
880+ }
881+}
882+
883+// S7: cgroup v2 limits, a new PID namespace, and the jailer's own options.
884+func TestJailerArgs(t *testing.T) {
885+ args := jailerArgs("/usr/bin/jailer", "/usr/bin/firecracker", "er00112233", 900003, "/var/lib/er/jail",
886+ sandbox.JobSpec{CPUs: 4, MemoryMiB: 2048}, nil)
887+ line := " " + strings.Join(args, " ") + " "
888+ for _, want := range []string{
889+ " --id er00112233 ", " --exec-file /usr/bin/firecracker ", " --uid 900003 ", " --gid 900003 ",
890+ " --chroot-base-dir /var/lib/er/jail ", " --cgroup-version 2 ", " --new-pid-ns ",
891+ " --cgroup memory.max=" + fmt.Sprint((2048+vmmOverheadMiB)<<20) + " ",
892+ " --cgroup pids.max=64 ", " --resource-limit no-file=4096 ", " -- ",
893+ } {
894+ if !strings.Contains(line, want) {
895+ t.Errorf("jailer args lack %q: %s", want, line)
896+ }
897+ }
898+ if args[len(args)-1] != "--" {
899+ t.Errorf("the jailer separator must come last when there are no extra args: %v", args)
900+ }
901+ more := jailerArgs("j", "f", "er00112233", 1, "/b", sandbox.JobSpec{CPUs: 1, MemoryMiB: 512}, []string{"--x"})
902+ if more[len(more)-2] != "--" || more[len(more)-1] != "--x" {
903+ t.Errorf("extra args follow the separator: %v", more)
904+ }
905+}
906+
907+// S7: sane rate limiters on the scratch disk and both directions of eth0, off
908+// when configured to zero, and only on the writable disk.
909+func TestRateLimitersInTheVMConfig(t *testing.T) {
910+ e := newEnv(t, "")
911+ d := e.driver(t)
912+ sb, _ := d.New()
913+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
914+ if err != nil {
915+ t.Fatal(err)
916+ }
917+ runStub(t, conn)
918+ defer func() { _ = sb.Stop(context.Background()) }()
919+ b, _ := os.ReadFile(e.log)
920+ log := string(b)
921+ for _, want := range []string{
922+ `"rate_limiter":{"bandwidth":{"size":419430400,"one_time_burst":419430400,"refill_time":1000},"ops":{"size":20000,"one_time_burst":20000,"refill_time":1000}}`,
923+ `"rx_rate_limiter":{"bandwidth":{"size":125000000,"one_time_burst":125000000,"refill_time":1000}}`,
924+ `"tx_rate_limiter":{"bandwidth":{"size":125000000,"one_time_burst":125000000,"refill_time":1000}}`,
925+ } {
926+ if !strings.Contains(log, want) {
927+ t.Errorf("API log lacks %s\n%s", want, log)
928+ }
929+ }
930+ if n := strings.Count(log, `"rate_limiter"`); n != 1 {
931+ t.Errorf("only the scratch drive is rate limited, got %d rate_limiter fields", n)
932+ }
933+ if diskLimiter(0, 0) != nil || netLimiter(0) != nil {
934+ t.Error("zero must disable the limiters")
935+ }
936+ if l := diskLimiter(0, 100); l.Bandwidth != nil || l.Ops == nil {
937+ t.Errorf("%+v", l)
938+ }
939+}
940+
941+// S8: the free-space check warns, and only warns.
942+func TestNewWarnsOnLowFreeSpace(t *testing.T) {
943+ e := newEnv(t, "")
944+ var buf bytes.Buffer
945+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
946+ d, err := New(ctx, e.cfg, Options{Run: e.run, Net: e.plumb, FreeSpace: func(string) (int64, error) { return 1, nil }})
947+ if err != nil {
948+ t.Fatalf("low space must not fail New: %v", err)
949+ }
950+ defer func() { _ = d.Close() }()
951+ if !strings.Contains(buf.String(), "less free space") {
952+ t.Fatalf("no warning: %s", buf.String())
953+ }
954+}
955+
956+// S11: the scratch disk is created without following symlinks and chowned
957+// through its descriptor.
958+func TestMakeScratchRefusesSymlinksAndExistingFiles(t *testing.T) {
959+ dir := t.TempDir()
960+ target := filepath.Join(dir, "victim")
961+ _ = os.WriteFile(target, []byte("precious"), 0o600)
962+ link := filepath.Join(dir, "scratch.ext4")
963+ if err := os.Symlink(target, link); err != nil {
964+ t.Fatal(err)
965+ }
966+ run := &cmdrun.Fake{}
967+ if err := makeScratch(context.Background(), run, link, 8, -1); err == nil {
968+ t.Fatal("a planted symlink must be refused")
969+ }
970+ if b, _ := os.ReadFile(target); string(b) != "precious" {
971+ t.Fatalf("the symlink target was modified: %q", b)
972+ }
973+ if len(run.Calls) != 0 {
974+ t.Fatalf("mkfs must not run: %v", run.Lines())
975+ }
976+ ok := filepath.Join(dir, "fresh.ext4")
977+ if err := makeScratch(context.Background(), run, ok, 8, os.Getuid()); err != nil {
978+ t.Fatal(err)
979+ }
980+ if st, err := os.Stat(ok); err != nil || st.Size() != 8<<20 || st.Mode().Perm() != 0o600 {
981+ t.Fatalf("%v %v", st, err)
982+ }
983+ if err := makeScratch(context.Background(), run, ok, 8, -1); err == nil {
984+ t.Fatal("an existing file must be refused (O_EXCL)")
985+ }
986+}
987+
988+func TestOwnUsesLchown(t *testing.T) {
989+ dir := t.TempDir()
990+ l := newLayout(dir, "firecracker", "er00112233", true, os.Getuid())
991+ _ = os.MkdirAll(l.root, 0o700)
992+ target := filepath.Join(dir, "victim")
993+ _ = os.WriteFile(target, nil, 0o600)
994+ if err := os.Symlink(target, l.host("link")); err != nil {
995+ t.Fatal(err)
996+ }
997+ if err := l.own("link"); err != nil {
998+ t.Fatalf("Lchown on a link must act on the link itself: %v", err)
999+ }
1000+ if err := l.own("missing"); err == nil {
1001+ t.Fatal("a missing file is an error")
1002+ }
1003+ if u := newLayout(dir, "f", "er00112233", false, 1); u.own("anything") != nil {
1004+ t.Fatal("no-op without jailer")
1005+ }
1006+}
1007+
1008+func TestRemoveCgroup(t *testing.T) {
1009+ root := t.TempDir()
1010+ _ = os.MkdirAll(filepath.Join(root, "firecracker", "er00112233"), 0o755)
1011+ if err := removeCgroup(root, "firecracker", "er00112233"); err != nil {
1012+ t.Fatal(err)
1013+ }
1014+ if err := removeCgroup(root, "firecracker", "er00112233"); err != nil {
1015+ t.Fatalf("a missing cgroup is fine: %v", err)
1016+ }
1017+ _ = os.MkdirAll(filepath.Join(root, "firecracker", "er44556677", "child"), 0o755)
1018+ if err := removeCgroup(root, "firecracker", "er44556677"); err == nil {
1019+ t.Fatal("a non-empty cgroup cannot be removed and must be reported")
1020+ }
1021+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close